- 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 | import type { | 6 | import 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"; |
| 8 | import { ApprovalRequestId, ProviderItemId, ThreadId } from "@t3tools/contracts"; | 13 | import { ApprovalRequestId, ProviderItemId, ThreadId } from "@t3tools/contracts"; |
| 9 | import { assert, describe, it } from "@effect/vitest"; | 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 | import { ProviderAdapterValidationError } from "../Errors.ts"; | 19 | import { ProviderAdapterValidationError } from "../Errors.ts"; |
| 13 | import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; | 20 | import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; |
| 14 | import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; | 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 | function makeHarness(config?: { | 103 | function 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 | ||
| 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 | const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1"); | 205 | const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1"); |
| 182 | const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume"); | 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 | 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* () { |
| @@ -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"; |
| 49 | import { Cause, DateTime, Deferred, Effect, Layer, Queue, Random, Ref, Stream } from "effect"; | 49 | import { |
| 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 | |||
| 62 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | ||
| 63 | import { ServerConfig } from "../../config.ts"; | ||
| 51 | import { | 64 | import { |
| 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 | ||
| 366 | function buildUserMessage(input: ProviderSendTurnInput): SDKUserMessage { | 379 | const 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 | ||
| 386 | function 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 | ? requestedEffort | 396 | ? requestedEffort |
| 391 | : null; | 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 | 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 | ||
| 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 | function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus { | 493 | function 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 | ||
| 700 | function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { | 788 | function 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 !== undefined | 794 | (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", |