From c13852d0d2778b1663b716d7db6abd7dea445c8e Mon Sep 17 00:00:00 2001 From: BrickerP Date: Tue, 4 Aug 2026 22:42:30 +0800 Subject: [PATCH] fix(opencode): preserve user request for skill slash commands Skill slash commands were treated as command templates, so $ARGUMENTS/$N inside SKILL.md docs swallowed user args, or long skill bodies buried the request at the end. Wrap skill bodies without placeholder substitution and append a dedicated User request section. Closes #40463 --- packages/opencode/src/command/index.ts | 5 +- packages/opencode/src/session/prompt.ts | 54 +++++++++++++------ packages/opencode/test/session/prompt.test.ts | 50 +++++++++++++++++ 3 files changed, 91 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 057754cd9ef8..41b2acc34fc3 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -138,6 +138,9 @@ const layer = Layer.effect( name: item.name, description: item.description, source: "skill", + // Skill bodies are free-form markdown docs, not command templates. + // Placeholder substitution ($ARGUMENTS / $N) is applied by a fixed + // wrapper in SessionPrompt.command so docs cannot swallow user args. get template() { if (!dir) return item.content return [ @@ -147,7 +150,7 @@ const layer = Layer.effect( "Relative paths in this skill (e.g., scripts/, references/) are relative to this base directory.", ].join("\n") }, - hints: [], + hints: ["$ARGUMENTS"], } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index eb116f6b960f..a5050dc34253 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1373,25 +1373,45 @@ const layer = Layer.effect( const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) const templateCommand = yield* Effect.promise(async () => cmd.template) - const placeholders = templateCommand.match(placeholderRegex) ?? [] - let last = 0 - for (const item of placeholders) { - const value = Number(item.slice(1)) - if (value > last) last = value - } + let template: string + if (cmd.source === "skill") { + // Skills are free-form markdown, not command templates. Never run + // $ARGUMENTS / $N substitution against the skill body — docs often + // mention those tokens as examples and would swallow the real request. + // Keep the user request in a dedicated trailing section instead. + const request = input.arguments.trim() + template = [ + `Follow the skill instructions below for skill "${cmd.name}".`, + "", + ``, + templateCommand.trim(), + ``, + "", + "## User request", + request || + "(No additional user request was provided. Follow the skill instructions and ask if anything is unclear.)", + ].join("\n") + } else { + const placeholders = templateCommand.match(placeholderRegex) ?? [] + let last = 0 + for (const item of placeholders) { + const value = Number(item.slice(1)) + if (value > last) last = value + } - const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => { - const position = Number(index) - const argIndex = position - 1 - if (argIndex >= args.length) return "" - if (position === last) return args.slice(argIndex).join(" ") - return args[argIndex] - }) - const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS") - let template = withArgs.replaceAll("$ARGUMENTS", input.arguments) + const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => { + const position = Number(index) + const argIndex = position - 1 + if (argIndex >= args.length) return "" + if (position === last) return args.slice(argIndex).join(" ") + return args[argIndex] + }) + const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS") + template = withArgs.replaceAll("$ARGUMENTS", input.arguments) - if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) { - template = template + "\n\n" + input.arguments + if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) { + template = template + "\n\n" + input.arguments + } } const shellMatches = ConfigMarkdown.shell(template) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 491ad06aaf47..a52ae2ffdeef 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1776,6 +1776,56 @@ unix( 30_000, ) +it.instance( + "skill slash command preserves user request and does not substitute skill body placeholders", + () => + Effect.gen(function* () { + const { dir, llm } = yield* useServerConfig(providerCfg) + const skillDir = path.join(dir, ".opencode", "skill", "arg-skill") + yield* writeText( + path.join(skillDir, "SKILL.md"), + `--- +name: arg-skill +description: Skill that documents $ARGUMENTS without consuming them. +--- + +# Arg Skill + +Example docs mention Input: $ARGUMENTS and also $1. + +Do the real work from the user request section only. +`, + ) + + const { prompt, chat } = yield* boot() + yield* llm.text("done") + + const result = yield* prompt.command({ + sessionID: chat.id, + command: "arg-skill", + arguments: "please fix the login bug in src/auth.ts", + }) + + expect(result.info.role).toBe("assistant") + const sessions = yield* Session.Service + const messages = yield* sessions.messages({ sessionID: chat.id }) + const user = messages.find((message) => message.info.role === "user") + const text = user?.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n") + + expect(text).toContain('') + expect(text).toContain("Example docs mention Input: $ARGUMENTS and also $1.") + expect(text).toContain("## User request") + expect(text).toContain("please fix the login bug in src/auth.ts") + // Placeholder tokens inside the skill body must remain literal. + expect(text).not.toMatch(/Example docs mention Input: please fix the login bug/) + expect(text).not.toMatch(/Example docs mention Input: please/) + }), + 30_000, +) + unixNoLLMServer( "cancel interrupts shell and resolves cleanly", () =>