diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts index acb7809c3a0d..e7b951763781 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts @@ -11,9 +11,9 @@ describe("planClaudeSkillDispatch", () => { expect(planClaudeSkillDispatch("echo $HOME then $unknown", SKILLS)).toBeUndefined(); }); - it("moves a mid-prompt mention into a trailing slash command", () => { + it("preserves the complete request before a mid-prompt skill invocation", () => { expect(planClaudeSkillDispatch("ok, now $implement all the tickets", SKILLS)).toEqual({ - leadingText: "ok, now", + leadingText: "ok, now /implement all the tickets", commandText: "/implement all the tickets", skillName: "implement", }); @@ -29,7 +29,7 @@ describe("planClaudeSkillDispatch", () => { it("dispatches a known skill whose name begins with a digit", () => { expect(planClaudeSkillDispatch("use $2spec for this", SKILLS)).toEqual({ - leadingText: "use", + leadingText: "use /2spec for this", commandText: "/2spec for this", skillName: "2spec", }); @@ -37,7 +37,7 @@ describe("planClaudeSkillDispatch", () => { it("dispatches the last mention and rewrites earlier ones inline", () => { expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({ - leadingText: "/review the diff, then", + leadingText: "/review the diff, then /implement the fixes", commandText: "/implement the fixes", skillName: "implement", }); @@ -51,7 +51,7 @@ describe("planClaudeSkillDispatch", () => { SKILLS, ), ).toEqual({ - leadingText: "/review the diff, then", + leadingText: "/review the diff, then /implement the fixes", commandText: "/implement the fixes", skillName: "implement", }); @@ -68,6 +68,23 @@ describe("planClaudeSkillDispatch", () => { expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined(); }); + it.each([ + ["do not $implement fixes yet", "do not /implement fixes yet", "/implement fixes yet"], + ["before $implement", "before /implement", "/implement"], + [ + "please $implement the fixes\n\nThen report the result", + "please /implement the fixes\n\nThen report the result", + "/implement the fixes\n\nThen report the result", + ], + ["Ultrathink:\n$implement fixes", "Ultrathink:\n/implement fixes", "/implement fixes"], + ])("keeps the instruction intact for %s", (prompt, leadingText, commandText) => { + expect(planClaudeSkillDispatch(prompt, SKILLS)).toEqual({ + leadingText, + commandText, + skillName: "implement", + }); + }); + it("ignores currency amounts and compact monetary expressions", () => { const skillsWithCurrency = new Set([...SKILLS, "20", "20k", "100M", "1e6"]); for (const symbol of ["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"]) { diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts index 2dc106e85382..bd0c2c6a0bdc 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -18,8 +18,9 @@ * its Skill tool when it reads `/name` in the prompt, so earlier mentions * are rewritten to `/name` inline. * - * So one mention anywhere in the prompt becomes a guaranteed invocation, and - * the user's text on either side is kept in order. + * Native expansion moves command metadata ahead of earlier text blocks. For + * a mid-prompt mention, keep the complete request in an earlier block as well + * as the final invocation, so expansion cannot split the user's sentence. * * @module provider/Drivers/ClaudeSkillDispatch */ @@ -33,7 +34,7 @@ const SKILL_MENTION_PATTERN = /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; export interface ClaudeSkillDispatch { - /** Text before the dispatched mention, or `undefined` when it opens the prompt. */ + /** Complete request before the command block, or `undefined` for a leading invocation. */ readonly leadingText: string | undefined; /** `/name` plus the trailing text, ready to be the message's last text block. */ readonly commandText: string; @@ -63,17 +64,16 @@ export function planClaudeSkillDispatch( const leading = prompt.slice(0, last.start); const trailing = prompt.slice(last.end); - const leadingWithInlineSlashes = mentions - .slice(0, -1) + const requestWithInlineSlashes = mentions .reduceRight( (text, mention) => `${text.slice(0, mention.start)}/${mention.name}${text.slice(mention.end)}`, - leading, + prompt, ) .trimEnd(); return { - leadingText: leadingWithInlineSlashes.length > 0 ? leadingWithInlineSlashes : undefined, + leadingText: leading.trim().length > 0 ? requestWithInlineSlashes : undefined, commandText: `/${last.name}${trailing}`.trimEnd(), skillName: last.name, }; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index ca596e6501cf..0e312ac03b20 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1100,7 +1100,7 @@ describe("ClaudeAdapterLive", () => { it.effect("dispatches a $skill mention as a trailing slash command block", () => { // Claude Code only runs `/name` from the message's last text block, so a - // chip picked mid-prompt is moved there and the surrounding prose kept. + // chip picked mid-prompt needs the complete request in an earlier block. const homeDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-skills-home-")); NodeFS.mkdirSync(NodePath.join(homeDir, "skills", "implement"), { recursive: true }); NodeFS.writeFileSync( @@ -1129,7 +1129,7 @@ describe("ClaudeAdapterLive", () => { readFirstPromptMessage(harness.getLastCreateQueryInput()), ); assert.deepEqual(promptMessage?.message.content, [ - { type: "text", text: "ok, now" }, + { type: "text", text: "ok, now /implement all the tickets\nstart with auth" }, { type: "text", text: "/implement all the tickets\nstart with auth" }, ]); }).pipe( @@ -1173,7 +1173,7 @@ describe("ClaudeAdapterLive", () => { }); yield* adapter.sendTurn({ threadId: session.threadId, - input: "$review this screenshot", + input: "please $review this screenshot", attachments: [attachment], }); @@ -1184,7 +1184,7 @@ describe("ClaudeAdapterLive", () => { const blocks = promptMessage.message.content as Array<{ type: string; text?: string }>; assert.deepEqual( blocks.map((block) => (block.type === "text" ? block.text : block.type)), - ["image", "/review this screenshot"], + ["please /review this screenshot", "image", "/review this screenshot"], ); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 13645a33a05b..79d5dbb7d0d7 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1608,9 +1608,10 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( const sdkContent: Array> = []; // Claude Code expands a skill only from the LAST text block, and only when - // `/name` is its first character. A `$skill` chip anywhere in the prompt is - // therefore split into [leading text, "/name trailing text"] so the CLI - // runs it natively and the prose around it survives. See ClaudeSkillDispatch. + // `/name` is its first character. A `$skill` chip anywhere in the prompt + // therefore gets a trailing invocation. For a mid-prompt chip, an earlier + // block preserves the complete request through native skill expansion. + // See ClaudeSkillDispatch. const dispatch = planClaudeSkillDispatch(text, dependencies.skillNames); if (dispatch?.leadingText !== undefined) { sdkContent.push({ type: "text", text: dispatch.leadingText });