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);