authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-03 02:28:33-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 02:28:33-07:00
log18573d60aab1344bf124a044b01445086e910c4c
treeaf04e8f4e027f87a20d7060af3694e8e033a38f3
parent5d1b02cdeebff4b0b4f15a1e8425b06ae3cfe8d1
signature Signed by PGP key B5690EEEBB952194

fix(claude): expand slash commands when a message has attachments (#9122)

Co-authored-by: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com>

2 files changed, 128 insertions(+), 8 deletions(-)

apps/server/src/provider/Layers/ClaudeAdapter.test.ts+119
...@@ -272,6 +272,30 @@ async function readFirstPromptMessage(...@@ -272,6 +272,30 @@ async function readFirstPromptMessage(
272 return next.value;272 return next.value;
273}273}
274274
275/** Drains the first `count` queued prompts so consecutive turns can be compared. */
276async 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
275const THREAD_ID = ThreadId.make("thread-claude-1");299const THREAD_ID = ThreadId.make("thread-claude-1");
276const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume");300const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume");
277const SYNTHETIC_SUBAGENT_MODEL = "claude-synthetic-subagent[expanded]";301const 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),
apps/server/src/provider/Layers/ClaudeAdapter.ts+9-8
...@@ -1316,12 +1316,8 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (...@@ -1316,12 +1316,8 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (
1316 // therefore split into [leading text, "/name trailing text"] so the CLI1316 // therefore split into [leading text, "/name trailing text"] so the CLI
1317 // runs it natively and the prose around it survives. See ClaudeSkillDispatch.1317 // runs it natively and the prose around it survives. See ClaudeSkillDispatch.
1318 const dispatch = planClaudeSkillDispatch(text, dependencies.skillNames);1318 const dispatch = planClaudeSkillDispatch(text, dependencies.skillNames);
1319 if (dispatch) {1319 if (dispatch?.leadingText !== undefined) {
1320 if (dispatch.leadingText !== undefined) {1320 sdkContent.push({ type: "text", text: dispatch.leadingText });
1321 sdkContent.push({ type: "text", text: dispatch.leadingText });
1322 }
1323 } else if (text.length > 0) {
1324 sdkContent.push({ type: "text", text });
1325 }1321 }
13261322
1327 for (const attachment of input.attachments ?? []) {1323 for (const attachment of input.attachments ?? []) {
...@@ -1371,10 +1367,15 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (...@@ -1371,10 +1367,15 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (
1371 );1367 );
1372 }1368 }
13731369
1374 // Images go before the command block: a text block after them still1370 // The final text block goes last on purpose. The Claude CLI only reads a
1375 // expands, a command block followed by an image does not.1371 // streamed user message as a slash-command invocation when the last content
1372 // block is text; image blocks ahead of it ride along as preceding input.
1373 // Leading with the text made every image-carrying turn fall back to a plain
1374 // prompt, so a hand-typed `/skill args` reached the agent unexpanded.
1376 if (dispatch) {1375 if (dispatch) {
1377 sdkContent.push({ type: "text", text: dispatch.commandText });1376 sdkContent.push({ type: "text", text: dispatch.commandText });
1377 } else if (text.length > 0) {
1378 sdkContent.push({ type: "text", text });
1378 }1379 }
13791380
1380 return buildUserMessage({ sdkContent });1381 return buildUserMessage({ sdkContent });