From 1c01bdb5e799dcf4acc616335c88b380776f1973 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:27:29 +1000 Subject: [PATCH] fix: disambiguate inline skills from absolute paths --- docs/tools/slash-commands.md | 2 +- ...ply-inline-actions.skip-when-config-empty.test.ts | 4 ++-- src/skills/discovery/chat-command-invocation.ts | 4 ++-- src/skills/discovery/chat-commands.test.ts | 12 +++++++++--- ui/src/pages/chat/chat-view.test.ts | 2 +- .../chat/components/chat-composer-slash-menu.ts | 6 ++++-- 6 files changed, 19 insertions(+), 11 deletions(-) diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 4bafe7f0ba43..0254394aa01c 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -529,7 +529,7 @@ See [BTW side questions](/tools/btw) for the full behavior. - Command-only messages from allowlisted senders are handled immediately (bypass queue + model). - Inline shortcuts (`/help`, `/commands`, `/status`, `/whoami`) also work embedded in normal messages and are stripped before the model sees the remaining text. - In Control UI, selecting an inline shortcut runs it separately and removes only that token from the composer; the surrounding draft remains unsent. - - Skill commands can be embedded in normal messages (for example, `Please use /weather to check Sydney`). The skill marker selects the skill and the surrounding text becomes its input. + - Skill commands can be embedded in normal messages with a colon-terminated marker (for example, `Please use /weather: to check Sydney`). The skill marker selects the skill and the surrounding text becomes its input; bare root-style tokens such as `/weather` remain ordinary text unless selected in the Control UI. - Session-changing control commands such as `/reset` remain command-only. This prevents prose that merely mentions a command from executing it. - Unauthorized command-only messages are silently ignored; inline `/...` tokens are treated as plain text. diff --git a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts index e1f652a60355..eabc1896b2e5 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts @@ -761,7 +761,7 @@ describe("handleInlineActions", () => { it("rewrites a skill marker embedded in normal prose", async () => { const typing = createTypingController(); - const body = "Please use /office_hours to build me a deployment plan"; + const body = "Please use /office_hours: to build me a deployment plan"; const ctx = buildTestCtx({ Body: body, CommandBody: body }); const result = await handleInlineActions( @@ -795,7 +795,7 @@ describe("handleInlineActions", () => { it("keeps unauthorized inline skill markers as plain text", async () => { const typing = createTypingController(); - const body = "Please use /office_hours to build me a deployment plan"; + const body = "Please use /office_hours: to build me a deployment plan"; const ctx = buildTestCtx({ Body: body, CommandBody: body }); const result = await handleInlineActions( diff --git a/src/skills/discovery/chat-command-invocation.ts b/src/skills/discovery/chat-command-invocation.ts index f18e9261f13e..6e9aa671daa0 100644 --- a/src/skills/discovery/chat-command-invocation.ts +++ b/src/skills/discovery/chat-command-invocation.ts @@ -129,7 +129,7 @@ function resolveInlineSkillCommandInvocation(params: { skillCommands: SkillCommandSpec[]; }): { command: SkillCommandSpec; args?: string; inline: true } | null { const body = params.commandBodyNormalized; - const directPattern = /(?:^|\s)\/([^\s:]+)(?=$|\s|:)(?:\s*:\s*)?/giu; + const directPattern = /(?:^|\s)\/([^\s:]+)\s*:\s*/giu; for (const match of body.matchAll(directPattern)) { const rawName = match[1] ?? ""; if (normalizeOptionalLowercaseString(rawName) === "skill") { @@ -146,7 +146,7 @@ function resolveInlineSkillCommandInvocation(params: { return { command, args: args || undefined, inline: true }; } - const skillPattern = /(?:^|\s)\/skill(?=$|\s|:)(?:\s*:\s*|\s+)([^\s:]+)/giu; + const skillPattern = /(?:^|\s)\/skill\s*:\s*([^\s:]+)/giu; for (const match of body.matchAll(skillPattern)) { const command = findSkillCommand(params.skillCommands, match[1] ?? ""); if (!command || command.modelVisible === false || match.index === undefined) { diff --git a/src/skills/discovery/chat-commands.test.ts b/src/skills/discovery/chat-commands.test.ts index 5413a0a7efe6..d45cfc1a4fec 100644 --- a/src/skills/discovery/chat-commands.test.ts +++ b/src/skills/discovery/chat-commands.test.ts @@ -212,7 +212,7 @@ describe("resolveSkillCommandInvocation", () => { it("matches direct skill invocations embedded in a sentence", () => { const invocation = resolveSkillCommandInvocation({ - commandBodyNormalized: "Please use /demo_skill to do the thing", + commandBodyNormalized: "Please use /demo_skill: to do the thing", skillCommands: [{ name: "demo_skill", skillName: "demo-skill", description: "Demo" }], }); expect(invocation?.command.skillName).toBe("demo-skill"); @@ -222,7 +222,7 @@ describe("resolveSkillCommandInvocation", () => { it("matches /skill invocations embedded in a sentence", () => { const invocation = resolveSkillCommandInvocation({ - commandBodyNormalized: "Please ask /skill demo_skill about this", + commandBodyNormalized: "Please ask /skill:demo_skill about this", skillCommands: [{ name: "demo_skill", skillName: "demo-skill", description: "Demo" }], }); expect(invocation?.command.name).toBe("demo_skill"); @@ -230,7 +230,7 @@ describe("resolveSkillCommandInvocation", () => { expect(invocation?.inline).toBe(true); }); - it.each(["Please use /hidden_skill for this", "Please use /skill hidden_skill for this"])( + it.each(["Please use /hidden_skill: for this", "Please use /skill:hidden_skill for this"])( "does not resolve model-hidden inline skill invocations in %j", (commandBodyNormalized) => { expect( @@ -263,6 +263,12 @@ describe("resolveSkillCommandInvocation", () => { skillCommands, }), ).toBeNull(); + expect( + resolveSkillCommandInvocation({ + commandBodyNormalized: "Open /demo_skill please", + skillCommands, + }), + ).toBeNull(); }); it("normalizes /skill lookup names", () => { diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 3a69676d3347..3768e4717adc 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -3749,7 +3749,7 @@ describe("chat slash menu accessibility", () => { keydownComposer(container, "Enter"); expect(onSlashCommand).not.toHaveBeenCalled(); - expect(draft).toBe("Please use /weather "); + expect(draft).toBe("Please use /weather: "); }); it("does not offer session-changing commands in the middle of prose", () => { diff --git a/ui/src/pages/chat/components/chat-composer-slash-menu.ts b/ui/src/pages/chat/components/chat-composer-slash-menu.ts index 8d04a245d35d..3f5b5776c332 100644 --- a/ui/src/pages/chat/components/chat-composer-slash-menu.ts +++ b/ui/src/pages/chat/components/chat-composer-slash-menu.ts @@ -298,6 +298,7 @@ export function selectSlashCommand( requestUpdate: () => void, ) { const state = getChatComposerState(props.paneId); + const inlineReplacement = cmd.source === "skill" ? `/${cmd.name}:` : `/${cmd.name}`; if (beginInlineSlashArguments(cmd, props, state, requestUpdate)) { return; } @@ -314,7 +315,7 @@ export function selectSlashCommand( props.onSlashCommand(`/${cmd.name}`); return; } - if (commitInlineSlashSelection(`/${cmd.name}`, props, state)) { + if (commitInlineSlashSelection(inlineReplacement, props, state)) { state.slashMenuOpen = false; resetSlashMenuState(state); requestUpdate(); @@ -349,10 +350,11 @@ export function tabCompleteSlashCommand( requestUpdate: () => void, ) { const state = getChatComposerState(props.paneId); + const inlineReplacement = cmd.source === "skill" ? `/${cmd.name}:` : `/${cmd.name}`; if (beginInlineSlashArguments(cmd, props, state, requestUpdate)) { return; } - if (commitInlineSlashSelection(`/${cmd.name}`, props, state)) { + if (commitInlineSlashSelection(inlineReplacement, props, state)) { state.slashMenuOpen = false; resetSlashMenuState(state); requestUpdate();