Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand All @@ -29,15 +29,15 @@ 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",
});
});

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",
});
Expand All @@ -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",
});
Expand All @@ -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 ["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"]) {
Expand Down
14 changes: 7 additions & 7 deletions apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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;
Expand Down Expand Up @@ -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,
};
Expand Down
8 changes: 4 additions & 4 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1173,7 +1173,7 @@ describe("ClaudeAdapterLive", () => {
});
yield* adapter.sendTurn({
threadId: session.threadId,
input: "$review this screenshot",
input: "please $review this screenshot",
attachments: [attachment],
});

Expand All @@ -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()),
Expand Down
7 changes: 4 additions & 3 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1608,9 +1608,10 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (
const sdkContent: Array<Record<string, unknown>> = [];

// 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 });
Expand Down
Loading