| ... | ... | @@ -272,6 +272,30 @@ async function readFirstPromptMessage( |
| 272 | 272 | return next.value; |
| 273 | 273 | } |
| 274 | 274 | |
| 275 | /** Drains the first `count` queued prompts so consecutive turns can be compared. */ |
| 276 | async function readPromptMessages( |
| 277 | input: |
| 278 | | { |
| 279 | readonly prompt: AsyncIterable<SDKUserMessage>; |
| 280 | } |
| 281 | | undefined, |
| 282 | count: number, |
| 283 | ): Promise<Array<SDKUserMessage>> { |
| 284 | const iterator = input?.prompt[Symbol.asyncIterator](); |
| 285 | if (!iterator) { |
| 286 | return []; |
| 287 | } |
| 288 | const messages: Array<SDKUserMessage> = []; |
| 289 | while (messages.length < count) { |
| 290 | const next = await iterator.next(); |
| 291 | if (next.done) { |
| 292 | break; |
| 293 | } |
| 294 | messages.push(next.value); |
| 295 | } |
| 296 | return messages; |
| 297 | } |
| 298 | |
| 275 | 299 | const THREAD_ID = ThreadId.make("thread-claude-1"); |
| 276 | 300 | const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); |
| 277 | 301 | const SYNTHETIC_SUBAGENT_MODEL = "claude-synthetic-subagent[expanded]"; |
| ... | ... | @@ -792,10 +816,100 @@ describe("ClaudeAdapterLive", () => { |
| 792 | 816 | const promptMessage = yield* Effect.promise(() => readFirstPromptMessage(createInput)); |
| 793 | 817 | assert.isDefined(promptMessage); |
| 794 | 818 | assert.deepEqual(promptMessage?.message.content, [ |
| 819 | { |
| 820 | type: "image", |
| 821 | source: { |
| 822 | type: "base64", |
| 823 | media_type: "image/png", |
| 824 | data: "AQIDBA==", |
| 825 | }, |
| 826 | }, |
| 795 | 827 | { |
| 796 | 828 | type: "text", |
| 797 | 829 | text: "What's in this image?", |
| 798 | 830 | }, |
| 831 | ]); |
| 832 | }).pipe( |
| 833 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| 834 | Effect.provide(harness.layer), |
| 835 | ); |
| 836 | }); |
| 837 | |
| 838 | // The Claude CLI reads a streamed user message as a slash-command invocation |
| 839 | // only when the final content block is text. Leading with the text block sent |
| 840 | // every image-carrying turn down the plain-prompt path, so `/skill args` |
| 841 | // reached the agent unexpanded with no error anywhere. |
| 842 | it.effect("puts the command text last so attachments do not suppress expansion", () => { |
| 843 | const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-attachments-")); |
| 844 | const harness = makeHarness({ |
| 845 | cwd: "/tmp/project-claude-command-attachments", |
| 846 | baseDir, |
| 847 | }); |
| 848 | return Effect.gen(function* () { |
| 849 | yield* Effect.addFinalizer(() => |
| 850 | Effect.sync(() => |
| 851 | NodeFS.rmSync(baseDir, { |
| 852 | recursive: true, |
| 853 | force: true, |
| 854 | }), |
| 855 | ), |
| 856 | ); |
| 857 | |
| 858 | const adapter = yield* ClaudeAdapter; |
| 859 | const { attachmentsDir } = yield* ServerConfig; |
| 860 | |
| 861 | const imageAttachment = { |
| 862 | type: "image" as const, |
| 863 | id: "thread-claude-attachment-22345678-1234-1234-1234-123456789abc", |
| 864 | name: "screenshot.png", |
| 865 | mimeType: "image/png", |
| 866 | sizeBytes: 4, |
| 867 | }; |
| 868 | const fileAttachment = { |
| 869 | type: "file" as const, |
| 870 | id: "thread-claude-attachment-32345678-1234-1234-1234-123456789abc", |
| 871 | name: "notes.pdf", |
| 872 | mimeType: "application/pdf", |
| 873 | sizeBytes: 4, |
| 874 | }; |
| 875 | for (const attachment of [imageAttachment, fileAttachment]) { |
| 876 | const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment)!); |
| 877 | NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true }); |
| 878 | NodeFS.writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4])); |
| 879 | } |
| 880 | |
| 881 | const session = yield* adapter.startSession({ |
| 882 | threadId: THREAD_ID, |
| 883 | provider: ProviderDriverKind.make("claudeAgent"), |
| 884 | runtimeMode: "full-access", |
| 885 | }); |
| 886 | |
| 887 | yield* adapter.sendTurn({ |
| 888 | threadId: session.threadId, |
| 889 | input: "/flow-patterns hello", |
| 890 | attachments: [], |
| 891 | }); |
| 892 | yield* adapter.sendTurn({ |
| 893 | threadId: session.threadId, |
| 894 | input: "/flow-patterns hello", |
| 895 | attachments: [imageAttachment], |
| 896 | }); |
| 897 | yield* adapter.sendTurn({ |
| 898 | threadId: session.threadId, |
| 899 | input: "/flow-patterns hello", |
| 900 | attachments: [fileAttachment], |
| 901 | }); |
| 902 | |
| 903 | const prompts = yield* Effect.promise(() => |
| 904 | readPromptMessages(harness.getLastCreateQueryInput(), 3), |
| 905 | ); |
| 906 | const commandBlock = { |
| 907 | type: "text" as const, |
| 908 | text: "/flow-patterns hello", |
| 909 | }; |
| 910 | |
| 911 | assert.deepEqual(prompts[0]?.message.content, [commandBlock]); |
| 912 | assert.deepEqual(prompts[1]?.message.content, [ |
| 799 | 913 | { |
| 800 | 914 | type: "image", |
| 801 | 915 | source: { |
| ... | ... | @@ -804,7 +918,12 @@ describe("ClaudeAdapterLive", () => { |
| 804 | 918 | data: "AQIDBA==", |
| 805 | 919 | }, |
| 806 | 920 | }, |
| 921 | commandBlock, |
| 807 | 922 | ]); |
| 923 | // Non-image attachments never become content blocks. Claude reaches them |
| 924 | // through the path line ProviderService writes into the prompt, so the |
| 925 | // text block stays last on its own. |
| 926 | assert.deepEqual(prompts[2]?.message.content, [commandBlock]); |
| 808 | 927 | }).pipe( |
| 809 | 928 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| 810 | 929 | Effect.provide(harness.layer), |