Send a Claude slash command as the last content block (#5240)

Claude Code expands a slash command only when it is the last content
block of the user message. buildAgentPrompt orders blocks as chat
history, typed text, images, then rendered attachments, so a "/command"
sent alongside a pasted issue link or a screenshot reached the model as
literal text: the command never ran, and the model either called the
Skill tool on its own or answered without it.

toSdkUserMessage now moves the typed block to the end when it parses as
a slash command. Attachments rendered as text always carry a mimeType,
so a quoted "/command" inside chat history is never mistaken for the
invocation. Message order is unchanged when the typed text is not a
slash command.

Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Joe Corkerton <joecorkerton@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-09-23 06:45:57 +02:00
committed by GitHub
co-authored by paseo-bot[bot] Joe Corkerton
parent 8269a0ea78
commit 3eaf0be0b2
2 changed files with 113 additions and 1 deletions
@@ -17,7 +17,14 @@ import {
} from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
import type { AgentSession, AgentTimelineItem, AgentStreamEvent } from "../../agent-sdk-types.js";
import type {
AgentPromptInput,
AgentSession,
AgentTimelineItem,
AgentStreamEvent,
} from "../../agent-sdk-types.js";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js";
interface TestClaudeSession {
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
@@ -866,6 +873,97 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
async function captureSdkUserMessage(prompt: AgentPromptInput): Promise<SDKUserMessage> {
const { queryFactory, queryMock } = createQueryMock();
let resolveSent: ((message: SDKUserMessage) => void) | null = null;
const sent = new Promise<SDKUserMessage>((resolve) => {
resolveSent = resolve;
});
queryFactory.mockImplementation((input: { prompt: AsyncIterable<SDKUserMessage> }) => {
void (async () => {
for await (const message of input.prompt) {
resolveSent?.(message);
break;
}
})();
return queryMock;
});
const session = await new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
try {
await session.startTurn(prompt);
return await sent;
} finally {
await session.close();
}
}
const issueAttachment: AgentAttachment = {
type: "forge_issue",
mimeType: "application/paseo-forge-issue",
forge: "github",
number: 12,
title: "Fake issue for QA",
url: "https://example.invalid/acme/app/issues/12",
body: "This is an attached issue body.",
};
// Claude Code expands a slash command only when it is the last content block of the user
// message, so an auto-attached issue or a pasted screenshot must not be appended after it.
test("sends a typed slash command last when an attachment follows it", async () => {
const message = await captureSdkUserMessage(
buildAgentPrompt("/hello please", undefined, [issueAttachment]),
);
expect(message.message.content).toEqual([
{ type: "text", text: renderPromptAttachmentAsText(issueAttachment) },
{ type: "text", text: "/hello please" },
]);
});
test("sends a typed slash command last when an image follows it", async () => {
const message = await captureSdkUserMessage(
buildAgentPrompt("/hello please", [{ data: "aGk=", mimeType: "image/png" }], undefined),
);
expect(message.message.content).toEqual([
{ type: "image", source: { type: "base64", media_type: "image/png", data: "aGk=" } },
{ type: "text", text: "/hello please" },
]);
});
test("keeps typed text before attachments when it is not a slash command", async () => {
const message = await captureSdkUserMessage(
buildAgentPrompt("please look at this issue", undefined, [issueAttachment]),
);
expect(message.message.content).toEqual([
{ type: "text", text: "please look at this issue" },
{ type: "text", text: renderPromptAttachmentAsText(issueAttachment) },
]);
});
test("moves the typed slash command, not an attachment that reads like one", async () => {
const chatHistory: AgentAttachment = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "/earlier command quoted from another chat",
};
const message = await captureSdkUserMessage(
buildAgentPrompt("/hello please", undefined, [chatHistory, issueAttachment]),
);
expect(message.message.content).toEqual([
{ type: "text", text: chatHistory.text },
{ type: "text", text: renderPromptAttachmentAsText(issueAttachment) },
{ type: "text", text: "/hello please" },
]);
});
test("maps Ultracode to xhigh effort and Claude ultracode settings", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
@@ -3374,9 +3374,19 @@ class ClaudeAgentSession implements AgentSession {
};
}
> = [];
// Claude Code expands a slash command only when it is the last content block of the user
// message. buildAgentPrompt places the typed text ahead of images and rendered attachments,
// so a "/command" sent with a pasted link or a screenshot would otherwise reach the model as
// literal text instead of the command it names.
let typedSlashCommandIndex = -1;
if (Array.isArray(prompt)) {
for (const chunk of prompt) {
if (chunk.type === "text") {
// Attachments rendered as text always carry a mimeType; the composer's typed text does
// not. Only the typed block can be a slash command the user meant to invoke.
if (!("mimeType" in chunk) && this.parseSlashCommandInput(chunk.text)) {
typedSlashCommandIndex = content.length;
}
content.push({ type: "text", text: chunk.text });
} else if (chunk.type === "image") {
if (isImageMimeType(chunk.mimeType)) {
@@ -3396,6 +3406,10 @@ class ClaudeAgentSession implements AgentSession {
} else {
content.push({ type: "text", text: prompt });
}
if (typedSlashCommandIndex >= 0 && typedSlashCommandIndex < content.length - 1) {
const [slashCommand] = content.splice(typedSlashCommandIndex, 1);
content.push(slashCommand);
}
const messageId = randomUUID();
this.rememberUserMessageId(messageId);