| ... | @@ -272,6 +272,30 @@ async function readFirstPromptMessage( | ... | @@ -272,6 +272,30 @@ async function readFirstPromptMessage( |
| 272 | return next.value; | 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 | const THREAD_ID = ThreadId.make("thread-claude-1"); | 299 | const THREAD_ID = ThreadId.make("thread-claude-1"); |
| 276 | const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); | 300 | const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); |
| 277 | const SYNTHETIC_SUBAGENT_MODEL = "claude-synthetic-subagent[expanded]"; | 301 | const SYNTHETIC_SUBAGENT_MODEL = "claude-synthetic-subagent[expanded]"; |
| ... | @@ -792,10 +816,100 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -792,10 +816,100 @@ describe("ClaudeAdapterLive", () => { |
| 792 | const promptMessage = yield* Effect.promise(() => readFirstPromptMessage(createInput)); | 816 | const promptMessage = yield* Effect.promise(() => readFirstPromptMessage(createInput)); |
| 793 | assert.isDefined(promptMessage); | 817 | assert.isDefined(promptMessage); |
| 794 | assert.deepEqual(promptMessage?.message.content, [ | 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 | type: "text", | 828 | type: "text", |
| 797 | text: "What's in this image?", | 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 | type: "image", | 914 | type: "image", |
| 801 | source: { | 915 | source: { |
| ... | @@ -804,7 +918,12 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -804,7 +918,12 @@ describe("ClaudeAdapterLive", () => { |
| 804 | data: "AQIDBA==", | 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 | }).pipe( | 927 | }).pipe( |
| 809 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | 928 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| 810 | Effect.provide(harness.layer), | 929 | Effect.provide(harness.layer), |