fix: disambiguate inline skills from absolute paths

This commit is contained in:
Jesse Merhi
2026-08-13 02:27:29 +10:00
parent d4a0a7439f
commit 1c01bdb5e7
6 changed files with 19 additions and 11 deletions
+1 -1
View File
@@ -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.
@@ -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(
@@ -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) {
+9 -3
View File
@@ -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", () => {
+1 -1
View File
@@ -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", () => {
@@ -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();