From 3eaf0be0b2c00b4b73e7d828ead3a1250f780a1f Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 06:45:57 +0200 Subject: [PATCH] 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] Co-authored-by: Joe Corkerton --- .../agent/providers/claude/agent.test.ts | 100 +++++++++++++++++- .../server/agent/providers/claude/agent.ts | 14 +++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 118e93067c..4a9538d00a 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -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 { + const { queryFactory, queryMock } = createQueryMock(); + let resolveSent: ((message: SDKUserMessage) => void) | null = null; + const sent = new Promise((resolve) => { + resolveSent = resolve; + }); + queryFactory.mockImplementation((input: { prompt: AsyncIterable }) => { + 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({ diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 496dfe2687..b23f58e244 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -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);