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 @@
1import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import * as NodeServices from "@effect/platform-node/NodeServices";
1import type {6import type {
2 Options as ClaudeQueryOptions,7 Options as ClaudeQueryOptions,
3 PermissionMode,8 PermissionMode,
@@ -7,8 +12,10 @@ import type { · 3 unmodified lines
7} from "@anthropic-ai/claude-agent-sdk";12} from "@anthropic-ai/claude-agent-sdk";
8import { ApprovalRequestId, ProviderItemId, ThreadId } from "@t3tools/contracts";13import { ApprovalRequestId, ProviderItemId, ThreadId } from "@t3tools/contracts";
9import { assert, describe, it } from "@effect/vitest";14import { assert, describe, it } from "@effect/vitest";
10import { Effect, Fiber, Random, Stream } from "effect";15import { Effect, Fiber, Layer, Random, Stream } from "effect";
11 16
17import { attachmentRelativePath } from "../../attachmentStore.ts";
18import { ServerConfig } from "../../config.ts";
12import { ProviderAdapterValidationError } from "../Errors.ts";19import { ProviderAdapterValidationError } from "../Errors.ts";
13import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";20import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
14import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";21import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";
@@ -93,21 +100,12 @@ class FakeClaudeQuery implements AsyncIterable<SDKMessage> { · 78 unmodified lines
93 }100 }
94}101}
95 102
96interface 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
107function makeHarness(config?: {103function makeHarness(config?: {
108 readonly nativeEventLogPath?: string;104 readonly nativeEventLogPath?: string;
109 readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"];105 readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"];
110}): Harness {106 readonly cwd?: string;
107 readonly stateDir?: string;
108}) {
111 const query = new FakeClaudeQuery();109 const query = new FakeClaudeQuery();
112 let createInput:110 let createInput:
113 | {111 | {
@@ -134,7 +132,15 @@ function makeHarness(config?: { · 20 unmodified lines
134 };132 };
135 133
136 return {134 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 ),
138 query,144 query,
139 getLastCreateQueryInput: () => createInput,145 getLastCreateQueryInput: () => createInput,
140 };146 };
@@ -178,6 +184,24 @@ async function readFirstPromptText( · 37 unmodified lines
178 return content.text;184 return content.text;
179}185}
180 186
187async 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
181const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1");205const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1");
182const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume");206const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume");
183 207
@@ -461,6 +485,70 @@ describe("ClaudeAdapterLive", () => { · 277 unmodified lines
461 );485 );
462 });486 });
463 487
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
464 it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => {552 it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => {
465 const harness = makeHarness();553 const harness = makeHarness();
466 return Effect.gen(function* () {554 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+112−19
@@ -46,8 +46,21 @@ import { · 45 unmodified lines
46 supportsClaudeThinkingToggle,46 supportsClaudeThinkingToggle,
47 supportsClaudeUltrathinkKeyword,47 supportsClaudeUltrathinkKeyword,
48} from "@t3tools/shared/model";48} from "@t3tools/shared/model";
49import { Cause, DateTime, Deferred, Effect, Layer, Queue, Random, Ref, Stream } from "effect";49import {
50 50 Cause,
51 DateTime,
52 Deferred,
53 Effect,
54 FileSystem,
55 Layer,
56 Queue,
57 Random,
58 Ref,
59 Stream,
60} from "effect";
61
62import { resolveAttachmentPath } from "../../attachmentStore.ts";
63import { ServerConfig } from "../../config.ts";
51import {64import {
52 ProviderAdapterProcessError,65 ProviderAdapterProcessError,
53 ProviderAdapterRequestError,66 ProviderAdapterRequestError,
@@ -363,21 +376,14 @@ function titleForTool(itemType: CanonicalItemType): string { · 309 unmodified lines
363 }376 }
364}377}
365 378
366function buildUserMessage(input: ProviderSendTurnInput): SDKUserMessage {379const SUPPORTED_CLAUDE_IMAGE_MIME_TYPES = new Set([
367 const fragments: string[] = [];380 "image/gif",
368 381 "image/jpeg",
369 if (input.input && input.input.trim().length > 0) {382 "image/png",
370 fragments.push(input.input.trim());383 "image/webp",
371 }384]);
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 }
380 385
386function buildPromptText(input: ProviderSendTurnInput): string {
381 const requestedEffort = resolveReasoningEffortForProvider(387 const requestedEffort = resolveReasoningEffortForProvider(
382 "claudeAgent",388 "claudeAgent",
383 input.modelOptions?.claudeAgent?.effort ?? null,389 input.modelOptions?.claudeAgent?.effort ?? null,
@@ -389,19 +395,101 @@ function buildUserMessage(input: ProviderSendTurnInput): SDKUserMessage { · 5 unmodified lines
389 : requestedEffort && supportedEffortOptions.includes(requestedEffort)395 : requestedEffort && supportedEffortOptions.includes(requestedEffort)
390 ? requestedEffort396 ? requestedEffort
391 : null;397 : null;
392 const text = applyClaudePromptEffortPrefix(fragments.join("\n\n"), promptEffort);398 return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort);
399}
393 400
401function buildUserMessage(input: {
402 readonly sdkContent: Array<Record<string, unknown>>;
403}): SDKUserMessage {
394 return {404 return {
395 type: "user",405 type: "user",
396 session_id: "",406 session_id: "",
397 parent_tool_use_id: null,407 parent_tool_use_id: null,
398 message: {408 message: {
399 role: "user",409 role: "user",
400 content: [{ type: "text", text }],410 content: input.sdkContent,
401 },411 },
402 } as SDKUserMessage;412 } as SDKUserMessage;
403}413}
404 414
415function 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
429function 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
405function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus {493function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus {
406 if (result.subtype === "success") {494 if (result.subtype === "success") {
407 return "completed";495 return "completed";
@@ -699,6 +787,8 @@ function sdkNativeItemId(message: SDKMessage): string | undefined { · 291 unmodified lines
699 787
700function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {788function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
701 return Effect.gen(function* () {789 return Effect.gen(function* () {
790 const fileSystem = yield* FileSystem.FileSystem;
791 const serverConfig = yield* ServerConfig;
702 const nativeEventLogger =792 const nativeEventLogger =
703 options?.nativeEventLogger ??793 options?.nativeEventLogger ??
704 (options?.nativeEventLogPath !== undefined794 (options?.nativeEventLogPath !== undefined
@@ -2571,7 +2661,10 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 1866 unmodified lines
2571 providerRefs: {},2661 providerRefs: {},
2572 });2662 });
2573 2663
2574 const message = buildUserMessage(input);2664 const message = yield* buildUserMessageEffect(input, {
2665 fileSystem,
2666 stateDir: serverConfig.stateDir,
2667 });
2575 2668
2576 yield* Queue.offer(context.promptQueue, {2669 yield* Queue.offer(context.promptQueue, {
2577 type: "message",2670 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