History

Send Claude image attachments as inline image blocks

- Read image attachments from the server state dir - Validate supported Claude image MIME types before sending - Add coverage for embedding attachment bytes in user prompts

Julius Marminge committed 8 months ago

Commit 004b6fe1 parent e41830f2 files · +214 −33Browse files

Unified · Split

2 files
  1. apps/server/src/provider/Layers/ClaudeAdapter.test.ts +102 −14
  2. apps/server/src/provider/Layers/ClaudeAdapter.ts +112 −19
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+102−14
@@ -1,3 +1,8 @@
1+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2+import os from "node:os";
3+import path from "node:path";
4+
5+import * as NodeServices from "@effect/platform-node/NodeServices";
16 import type {
27 Options as ClaudeQueryOptions,
38 PermissionMode,
@@ -7,8 +12,10 @@ import type { · 3 unmodified lines
712 } from "@anthropic-ai/claude-agent-sdk";
813 import { ApprovalRequestId, ProviderItemId, ThreadId } from "@t3tools/contracts";
914 import { assert, describe, it } from "@effect/vitest";
10−import { Effect, Fiber, Random, Stream } from "effect";
15+import { Effect, Fiber, Layer, Random, Stream } from "effect";
1116
17+import { attachmentRelativePath } from "../../attachmentStore.ts";
18+import { ServerConfig } from "../../config.ts";
1219 import { ProviderAdapterValidationError } from "../Errors.ts";
1320 import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
1421 import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";
@@ -93,21 +100,12 @@ class FakeClaudeQuery implements AsyncIterable<SDKMessage> { · 78 unmodified lines
93100 }
94101 }
95102
96−interface Harness {
97− readonly layer: ReturnType<typeof makeClaudeAdapterLive>;
98− readonly query: FakeClaudeQuery;
99− readonly getLastCreateQueryInput: () =>
100− | {
101− readonly prompt: AsyncIterable<SDKUserMessage>;
102− readonly options: ClaudeQueryOptions;
103− }
104− | undefined;
105−}
106−
107103 function makeHarness(config?: {
108104 readonly nativeEventLogPath?: string;
109105 readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"];
110−}): Harness {
106+ readonly cwd?: string;
107+ readonly stateDir?: string;
108+}) {
111109 const query = new FakeClaudeQuery();
112110 let createInput:
113111 | {
@@ -134,7 +132,15 @@ function makeHarness(config?: { · 20 unmodified lines
134132 };
135133
136134 return {
137− layer: makeClaudeAdapterLive(adapterOptions),
135+ layer: makeClaudeAdapterLive(adapterOptions).pipe(
136+ Layer.provideMerge(
137+ ServerConfig.layerTest(
138+ config?.cwd ?? "/tmp/claude-adapter-test",
139+ config?.stateDir ?? "/tmp",
140+ ),
141+ ),
142+ Layer.provideMerge(NodeServices.layer),
143+ ),
138144 query,
139145 getLastCreateQueryInput: () => createInput,
140146 };
@@ -178,6 +184,24 @@ async function readFirstPromptText( · 37 unmodified lines
178184 return content.text;
179185 }
180186
187+async function readFirstPromptMessage(
188+ input:
189+ | {
190+ readonly prompt: AsyncIterable<SDKUserMessage>;
191+ }
192+ | undefined,
193+): Promise<SDKUserMessage | undefined> {
194+ const iterator = input?.prompt[Symbol.asyncIterator]();
195+ if (!iterator) {
196+ return undefined;
197+ }
198+ const next = await iterator.next();
199+ if (next.done) {
200+ return undefined;
201+ }
202+ return next.value;
203+}
204+
181205 const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1");
182206 const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume");
183207
@@ -461,6 +485,70 @@ describe("ClaudeAdapterLive", () => { · 277 unmodified lines
461485 );
462486 });
463487
488+ it.effect("embeds image attachments in Claude user messages", () => {
489+ const stateDir = mkdtempSync(path.join(os.tmpdir(), "claude-attachments-"));
490+ const harness = makeHarness({
491+ cwd: "/tmp/project-claude-attachments",
492+ stateDir,
493+ });
494+ return Effect.gen(function* () {
495+ yield* Effect.addFinalizer(() =>
496+ Effect.sync(() =>
497+ rmSync(stateDir, {
498+ recursive: true,
499+ force: true,
500+ }),
501+ ),
502+ );
503+
504+ const adapter = yield* ClaudeAdapter;
505+
506+ const attachment = {
507+ type: "image" as const,
508+ id: "thread-claude-attachment-12345678-1234-1234-1234-123456789abc",
509+ name: "diagram.png",
510+ mimeType: "image/png",
511+ sizeBytes: 4,
512+ };
513+ const attachmentPath = path.join(stateDir, "attachments", attachmentRelativePath(attachment));
514+ mkdirSync(path.dirname(attachmentPath), { recursive: true });
515+ writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4]));
516+
517+ const session = yield* adapter.startSession({
518+ threadId: THREAD_ID,
519+ provider: "claudeAgent",
520+ runtimeMode: "full-access",
521+ });
522+
523+ yield* adapter.sendTurn({
524+ threadId: session.threadId,
525+ input: "What's in this image?",
526+ attachments: [attachment],
527+ });
528+
529+ const createInput = harness.getLastCreateQueryInput();
530+ const promptMessage = yield* Effect.promise(() => readFirstPromptMessage(createInput));
531+ assert.isDefined(promptMessage);
532+ assert.deepEqual(promptMessage?.message.content, [
533+ {
534+ type: "text",
535+ text: "What's in this image?",
536+ },
537+ {
538+ type: "image",
539+ source: {
540+ type: "base64",
541+ media_type: "image/png",
542+ data: "AQIDBA==",
543+ },
544+ },
545+ ]);
546+ }).pipe(
547+ Effect.provideService(Random.Random, makeDeterministicRandomService()),
548+ Effect.provide(harness.layer),
549+ );
550+ });
551+
464552 it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => {
465553 const harness = makeHarness();
466554 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+112−19
@@ -46,8 +46,21 @@ import { · 45 unmodified lines
4646 supportsClaudeThinkingToggle,
4747 supportsClaudeUltrathinkKeyword,
4848 } from "@t3tools/shared/model";
49−import { Cause, DateTime, Deferred, Effect, Layer, Queue, Random, Ref, Stream } from "effect";
50−
49+import {
50+ Cause,
51+ DateTime,
52+ Deferred,
53+ Effect,
54+ FileSystem,
55+ Layer,
56+ Queue,
57+ Random,
58+ Ref,
59+ Stream,
60+} from "effect";
61+
62+import { resolveAttachmentPath } from "../../attachmentStore.ts";
63+import { ServerConfig } from "../../config.ts";
5164 import {
5265 ProviderAdapterProcessError,
5366 ProviderAdapterRequestError,
@@ -363,21 +376,14 @@ function titleForTool(itemType: CanonicalItemType): string { · 309 unmodified lines
363376 }
364377 }
365378
366−function buildUserMessage(input: ProviderSendTurnInput): SDKUserMessage {
367− const fragments: string[] = [];
368−
369− if (input.input && input.input.trim().length > 0) {
370− fragments.push(input.input.trim());
371− }
372−
373− for (const attachment of input.attachments ?? []) {
374− if (attachment.type === "image") {
375− fragments.push(
376− `Attached image: ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes).`,
377− );
378− }
379− }
379+const SUPPORTED_CLAUDE_IMAGE_MIME_TYPES = new Set([
380+ "image/gif",
381+ "image/jpeg",
382+ "image/png",
383+ "image/webp",
384+]);
380385
386+function buildPromptText(input: ProviderSendTurnInput): string {
381387 const requestedEffort = resolveReasoningEffortForProvider(
382388 "claudeAgent",
383389 input.modelOptions?.claudeAgent?.effort ?? null,
@@ -389,19 +395,101 @@ function buildUserMessage(input: ProviderSendTurnInput): SDKUserMessage { · 5 unmodified lines
389395 : requestedEffort && supportedEffortOptions.includes(requestedEffort)
390396 ? requestedEffort
391397 : null;
392− const text = applyClaudePromptEffortPrefix(fragments.join("\n\n"), promptEffort);
398+ return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort);
399+}
393400
401+function buildUserMessage(input: {
402+ readonly sdkContent: Array<Record<string, unknown>>;
403+}): SDKUserMessage {
394404 return {
395405 type: "user",
396406 session_id: "",
397407 parent_tool_use_id: null,
398408 message: {
399409 role: "user",
400− content: [{ type: "text", text }],
410+ content: input.sdkContent,
401411 },
402412 } as SDKUserMessage;
403413 }
404414
415+function buildClaudeImageContentBlock(input: {
416+ readonly mimeType: string;
417+ readonly bytes: Uint8Array;
418+}): Record<string, unknown> {
419+ return {
420+ type: "image",
421+ source: {
422+ type: "base64",
423+ media_type: input.mimeType,
424+ data: Buffer.from(input.bytes).toString("base64"),
425+ },
426+ };
427+}
428+
429+function buildUserMessageEffect(
430+ input: ProviderSendTurnInput,
431+ dependencies: {
432+ readonly fileSystem: FileSystem.FileSystem;
433+ readonly stateDir: string;
434+ },
435+): Effect.Effect<SDKUserMessage, ProviderAdapterRequestError> {
436+ return Effect.gen(function* () {
437+ const text = buildPromptText(input);
438+ const sdkContent: Array<Record<string, unknown>> = [];
439+
440+ if (text.length > 0) {
441+ sdkContent.push({ type: "text", text });
442+ }
443+
444+ for (const attachment of input.attachments ?? []) {
445+ if (attachment.type !== "image") {
446+ continue;
447+ }
448+
449+ if (!SUPPORTED_CLAUDE_IMAGE_MIME_TYPES.has(attachment.mimeType)) {
450+ return yield* new ProviderAdapterRequestError({
451+ provider: PROVIDER,
452+ method: "turn/start",
453+ detail: `Unsupported Claude image attachment type '${attachment.mimeType}'.`,
454+ });
455+ }
456+
457+ const attachmentPath = resolveAttachmentPath({
458+ stateDir: dependencies.stateDir,
459+ attachment,
460+ });
461+ if (!attachmentPath) {
462+ return yield* new ProviderAdapterRequestError({
463+ provider: PROVIDER,
464+ method: "turn/start",
465+ detail: `Invalid attachment id '${attachment.id}'.`,
466+ });
467+ }
468+
469+ const bytes = yield* dependencies.fileSystem.readFile(attachmentPath).pipe(
470+ Effect.mapError(
471+ (cause) =>
472+ new ProviderAdapterRequestError({
473+ provider: PROVIDER,
474+ method: "turn/start",
475+ detail: toMessage(cause, "Failed to read attachment file."),
476+ cause,
477+ }),
478+ ),
479+ );
480+
481+ sdkContent.push(
482+ buildClaudeImageContentBlock({
483+ mimeType: attachment.mimeType,
484+ bytes,
485+ }),
486+ );
487+ }
488+
489+ return buildUserMessage({ sdkContent });
490+ });
491+}
492+
405493 function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus {
406494 if (result.subtype === "success") {
407495 return "completed";
@@ -699,6 +787,8 @@ function sdkNativeItemId(message: SDKMessage): string | undefined { · 291 unmodified lines
699787
700788 function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
701789 return Effect.gen(function* () {
790+ const fileSystem = yield* FileSystem.FileSystem;
791+ const serverConfig = yield* ServerConfig;
702792 const nativeEventLogger =
703793 options?.nativeEventLogger ??
704794 (options?.nativeEventLogPath !== undefined
@@ -2571,7 +2661,10 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 1866 unmodified lines
25712661 providerRefs: {},
25722662 });
25732663
2574− const message = buildUserMessage(input);
2664+ const message = yield* buildUserMessageEffect(input, {
2665+ fileSystem,
2666+ stateDir: serverConfig.stateDir,
2667+ });
25752668
25762669 yield* Queue.offer(context.promptQueue, {
25772670 type: "message",

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t