- 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
Unified · Split
| @@ -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"; | |
| 1 | 6 | import type { |
| 2 | 7 | Options as ClaudeQueryOptions, |
| 3 | 8 | PermissionMode, |
| @@ -7,8 +12,10 @@ import type { · 3 unmodified lines | ||
| 7 | 12 | } from "@anthropic-ai/claude-agent-sdk"; |
| 8 | 13 | import { ApprovalRequestId, ProviderItemId, ThreadId } from "@t3tools/contracts"; |
| 9 | 14 | import { assert, describe, it } from "@effect/vitest"; |
| 10 | −import { Effect, Fiber, Random, Stream } from "effect"; | |
| 15 | +import { Effect, Fiber, Layer, Random, Stream } from "effect"; | |
| 11 | 16 | |
| 17 | +import { attachmentRelativePath } from "../../attachmentStore.ts"; | |
| 18 | +import { ServerConfig } from "../../config.ts"; | |
| 12 | 19 | import { ProviderAdapterValidationError } from "../Errors.ts"; |
| 13 | 20 | import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; |
| 14 | 21 | import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; |
| @@ -93,21 +100,12 @@ class FakeClaudeQuery implements AsyncIterable<SDKMessage> { · 78 unmodified lines | ||
| 93 | 100 | } |
| 94 | 101 | } |
| 95 | 102 | |
| 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 | − | |
| 107 | 103 | function makeHarness(config?: { |
| 108 | 104 | readonly nativeEventLogPath?: string; |
| 109 | 105 | readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"]; |
| 110 | −}): Harness { | |
| 106 | + readonly cwd?: string; | |
| 107 | + readonly stateDir?: string; | |
| 108 | +}) { | |
| 111 | 109 | const query = new FakeClaudeQuery(); |
| 112 | 110 | let createInput: |
| 113 | 111 | | { |
| @@ -134,7 +132,15 @@ function makeHarness(config?: { · 20 unmodified lines | ||
| 134 | 132 | }; |
| 135 | 133 | |
| 136 | 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 | 144 | query, |
| 139 | 145 | getLastCreateQueryInput: () => createInput, |
| 140 | 146 | }; |
| @@ -178,6 +184,24 @@ async function readFirstPromptText( · 37 unmodified lines | ||
| 178 | 184 | return content.text; |
| 179 | 185 | } |
| 180 | 186 | |
| 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 | + | |
| 181 | 205 | const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1"); |
| 182 | 206 | const 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 | 552 | it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => { |
| 465 | 553 | const harness = makeHarness(); |
| 466 | 554 | return Effect.gen(function* () { |
| @@ -46,8 +46,21 @@ import { · 45 unmodified lines | ||
| 46 | 46 | supportsClaudeThinkingToggle, |
| 47 | 47 | supportsClaudeUltrathinkKeyword, |
| 48 | 48 | } 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"; | |
| 51 | 64 | import { |
| 52 | 65 | ProviderAdapterProcessError, |
| 53 | 66 | ProviderAdapterRequestError, |
| @@ -363,21 +376,14 @@ function titleForTool(itemType: CanonicalItemType): string { · 309 unmodified lines | ||
| 363 | 376 | } |
| 364 | 377 | } |
| 365 | 378 | |
| 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 | +]); | |
| 380 | 385 | |
| 386 | +function buildPromptText(input: ProviderSendTurnInput): string { | |
| 381 | 387 | const requestedEffort = resolveReasoningEffortForProvider( |
| 382 | 388 | "claudeAgent", |
| 383 | 389 | input.modelOptions?.claudeAgent?.effort ?? null, |
| @@ -389,19 +395,101 @@ function buildUserMessage(input: ProviderSendTurnInput): SDKUserMessage { · 5 unmodified lines | ||
| 389 | 395 | : requestedEffort && supportedEffortOptions.includes(requestedEffort) |
| 390 | 396 | ? requestedEffort |
| 391 | 397 | : null; |
| 392 | − const text = applyClaudePromptEffortPrefix(fragments.join("\n\n"), promptEffort); | |
| 398 | + return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); | |
| 399 | +} | |
| 393 | 400 | |
| 401 | +function buildUserMessage(input: { | |
| 402 | + readonly sdkContent: Array<Record<string, unknown>>; | |
| 403 | +}): SDKUserMessage { | |
| 394 | 404 | return { |
| 395 | 405 | type: "user", |
| 396 | 406 | session_id: "", |
| 397 | 407 | parent_tool_use_id: null, |
| 398 | 408 | message: { |
| 399 | 409 | role: "user", |
| 400 | − content: [{ type: "text", text }], | |
| 410 | + content: input.sdkContent, | |
| 401 | 411 | }, |
| 402 | 412 | } as SDKUserMessage; |
| 403 | 413 | } |
| 404 | 414 | |
| 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 | + | |
| 405 | 493 | function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus { |
| 406 | 494 | if (result.subtype === "success") { |
| 407 | 495 | return "completed"; |
| @@ -699,6 +787,8 @@ function sdkNativeItemId(message: SDKMessage): string | undefined { · 291 unmodified lines | ||
| 699 | 787 | |
| 700 | 788 | function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 701 | 789 | return Effect.gen(function* () { |
| 790 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 791 | + const serverConfig = yield* ServerConfig; | |
| 702 | 792 | const nativeEventLogger = |
| 703 | 793 | options?.nativeEventLogger ?? |
| 704 | 794 | (options?.nativeEventLogPath !== undefined |
| @@ -2571,7 +2661,10 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 1866 unmodified lines | ||
| 2571 | 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 | 2669 | yield* Queue.offer(context.promptQueue, { |
| 2577 | 2670 | type: "message", |