diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 46831be05..bd692f074 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed user-invoked skills not identifying themselves as the invoked skill or exposing their skill directory for relative path resolution across typed, steered, follow-up, interrupted/resumed, ACP, and RPC skill command paths. + ## [16.2.8] - 2026-06-30 ### Added diff --git a/packages/coding-agent/src/extensibility/skills.ts b/packages/coding-agent/src/extensibility/skills.ts index 0743f4bda..49f628e80 100644 --- a/packages/coding-agent/src/extensibility/skills.ts +++ b/packages/coding-agent/src/extensibility/skills.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; -import { getProjectDir } from "@oh-my-pi/pi-utils"; +import { getProjectDir, prompt } from "@oh-my-pi/pi-utils"; import { isValidManagedSkillName, MANAGED_SKILLS_PROVIDER_ID, @@ -11,6 +11,8 @@ import type { SourceMeta } from "../capability/types"; import type { SkillsSettings } from "../config/settings"; import { type Skill as CapabilitySkill, loadCapability } from "../discovery"; import { compareSkillOrder, scanSkillsFromDir } from "../discovery/helpers"; +import autoloadTemplate from "../prompts/skills/autoload.md" with { type: "text" }; +import userInvocationTemplate from "../prompts/skills/user-invocation.md" with { type: "text" }; import type { SkillPromptDetails } from "../session/messages"; import { expandTilde } from "../tools/path-utils"; export interface Skill { @@ -384,18 +386,39 @@ export function getSkillSlashCommandName(skill: Pick): string { return `skill:${skill.name}`; } +export type SkillInvocationKind = "user" | "autoload"; + export async function buildSkillPromptMessage( - skill: Pick, + skill: Pick, args: string, + invocation: SkillInvocationKind = "user", ): Promise { const content = await Bun.file(skill.filePath).text(); const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim(); - const metaLines = [`Skill: ${skill.filePath}`]; const trimmedArgs = args.trim(); - if (trimmedArgs) { - metaLines.push(`User: ${trimmedArgs}`); + let message: string; + if (invocation === "user") { + // User-invoked skills announce themselves and expose their skill directory + // so the model resolves the skill's own relative paths (scripts/, templates/). + message = prompt + .render(userInvocationTemplate, { + name: skill.name, + body, + baseDir: skill.baseDir, + userArgs: trimmedArgs || undefined, + }) + .trim(); + } else { + // Autoload skills are hidden, non-user context — they MUST NOT claim the + // user invoked them; this keeps the minimal provenance-only format. + message = prompt + .render(autoloadTemplate, { + body, + filePath: skill.filePath, + userArgs: trimmedArgs || undefined, + }) + .trim(); } - const message = `${body}\n\n---\n\n${metaLines.join("\n")}`; return { message, details: { diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 19b499d05..e48803304 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -845,7 +845,7 @@ export class AcpAgent implements Agent { if (!skill) { return false; } - const built = await buildSkillPromptMessage(skill, args); + const built = await buildSkillPromptMessage(skill, args, "user"); await record.session.promptCustomMessage({ customType: SKILL_PROMPT_MESSAGE_TYPE, content: built.message, diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 98e446646..106bb9278 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -1160,21 +1160,6 @@ export class CommandController { this.ctx.showStatus(formatShakeSummary(result)); } - async handleSkillCommand(skillPath: string, args: string): Promise { - try { - const content = await Bun.file(skillPath).text(); - const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim(); - const metaLines = [`Skill: ${skillPath}`]; - if (args) { - metaLines.push(`User: ${args}`); - } - const message = `${body}\n\n---\n\n${metaLines.join("\n")}`; - await this.ctx.session.prompt(message); - } catch (err) { - this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`); - } - } - async executeCompaction( customInstructionsOrOptions?: string | CompactOptions, isAuto = false, diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index e62de17ee..3b05a650f 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -65,6 +65,7 @@ import type { ExtensionWidgetOptions, } from "../extensibility/extensions"; import type { CompactOptions } from "../extensibility/extensions/types"; +import type { Skill } from "../extensibility/skills"; import { loadSlashCommands } from "../extensibility/slash-commands"; import { type GuidedGoalMessage, runGuidedGoalTurn } from "../goals/guided-setup"; import type { Goal, GoalModeState } from "../goals/state"; @@ -478,7 +479,7 @@ export class InteractiveMode implements InteractiveModeContext { lastStatusSpacer: Spacer | undefined = undefined; lastStatusText: Text | undefined = undefined; fileSlashCommands: Set = new Set(); - skillCommands: Map = new Map(); + skillCommands: Map = new Map(); oauthManualInput: OAuthManualInputManager = new OAuthManualInputManager(); collabHost?: CollabHost; collabGuest?: CollabGuestLink; @@ -687,7 +688,7 @@ export class InteractiveMode implements InteractiveModeContext { if (settings.get("skills.enableSkillCommands")) { for (const skill of this.session.skills) { const commandName = `skill:${skill.name}`; - this.skillCommands.set(commandName, skill.filePath); + this.skillCommands.set(commandName, skill); skillCommandList.push({ name: commandName, description: skill.description }); } } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 6c38803f7..1191e1693 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -94,7 +94,7 @@ export async function tryRunRpcSkillCommand( const skillName = commandName.slice("skill:".length); const skill = session.skills.find(candidate => candidate.name === skillName); if (!skill) return false; - const built = await buildSkillPromptMessage(skill, args); + const built = await buildSkillPromptMessage(skill, args, "user"); await session.promptCustomMessage({ customType: SKILL_PROMPT_MESSAGE_TYPE, content: built.message, diff --git a/packages/coding-agent/src/modes/skill-command.ts b/packages/coding-agent/src/modes/skill-command.ts index d8feaaaf2..70a72b2b8 100644 --- a/packages/coding-agent/src/modes/skill-command.ts +++ b/packages/coding-agent/src/modes/skill-command.ts @@ -1,4 +1,5 @@ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; +import { buildSkillPromptMessage } from "../extensibility/skills"; import { type CustomMessage, SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../session/messages"; import type { InteractiveModeContext } from "./types"; @@ -61,32 +62,19 @@ export async function buildSkillCommandPrompt( ): Promise { const parsed = parseSkillCommand(text); if (!parsed) return undefined; - const skillPath = ctx.skillCommands.get(parsed.commandName); - if (!skillPath) return undefined; + const skill = ctx.skillCommands.get(parsed.commandName); + if (!skill) return undefined; - const content = await Bun.file(skillPath).text(); - const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim(); - const metaLines = [`Skill: ${skillPath}`]; - if (parsed.args) { - metaLines.push(`User: ${parsed.args}`); - } - const message = `${body}\n\n---\n\n${metaLines.join("\n")}`; - const textBlock: TextContent = { type: "text", text: message }; - const promptContent = images && images.length > 0 ? [textBlock, ...images] : message; - const skillName = parsed.commandName.slice("skill:".length); - const details: SkillPromptDetails = { - name: skillName || parsed.commandName, - path: skillPath, - args: parsed.args || undefined, - lineCount: body ? body.split("\n").length : 0, - }; + const built = await buildSkillPromptMessage(skill, parsed.args, "user"); + const textBlock: TextContent = { type: "text", text: built.message }; + const promptContent = images && images.length > 0 ? [textBlock, ...images] : built.message; return { message: { customType: SKILL_PROMPT_MESSAGE_TYPE, content: promptContent, display: true, - details, + details: built.details, attribution: "user", }, options: { streamingBehavior, queueChipText: text }, diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 9a5c5da28..7899ba9c1 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -14,6 +14,7 @@ import type { ExtensionWidgetOptions, } from "../extensibility/extensions"; import type { CompactOptions } from "../extensibility/extensions/types"; +import type { Skill } from "../extensibility/skills"; import type { MCPManager } from "../mcp"; import type { PlanApprovalDetails } from "../plan-mode/approved-plan"; import type { AgentSession } from "../session/agent-session"; @@ -206,7 +207,7 @@ export interface InteractiveModeContext { lastStatusSpacer: Spacer | undefined; lastStatusText: Text | undefined; fileSlashCommands: Set; - skillCommands: Map; + skillCommands: Map; oauthManualInput: OAuthManualInputManager; todoPhases: TodoPhase[]; diff --git a/packages/coding-agent/src/prompts/skills/autoload.md b/packages/coding-agent/src/prompts/skills/autoload.md new file mode 100644 index 000000000..56f9e4754 --- /dev/null +++ b/packages/coding-agent/src/prompts/skills/autoload.md @@ -0,0 +1,8 @@ +{{body}} + +--- + +Skill: {{filePath}} +{{#if userArgs}} +User: {{userArgs}} +{{/if}} \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/skills/user-invocation.md b/packages/coding-agent/src/prompts/skills/user-invocation.md new file mode 100644 index 000000000..daae88b6b --- /dev/null +++ b/packages/coding-agent/src/prompts/skills/user-invocation.md @@ -0,0 +1,11 @@ +[IMPORTANT: The user has invoked the "{{name}}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.] + +{{body}} + +--- + +[Skill directory: {{baseDir}}] +Resolve any relative paths in this skill (e.g. `scripts/foo.js`, `templates/config.yaml`) against that directory using its absolute path: read referenced assets and templates, and run scripts with the terminal tool when the skill's instructions call for it. +{{#if userArgs}} +User: {{userArgs}} +{{/if}} \ No newline at end of file diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 76bf4e034..f29c24a3b 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -2315,7 +2315,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise) if (options.autoloadSkills?.length) { for (const skill of options.autoloadSkills) { - const { message } = await buildSkillPromptMessage(skill, ""); + const { message } = await buildSkillPromptMessage(skill, "", "autoload"); await session.sendCustomMessage( { customType: SKILL_PROMPT_MESSAGE_TYPE, diff --git a/packages/coding-agent/test/acp-agent.test.ts b/packages/coding-agent/test/acp-agent.test.ts index ccdd1875f..16aa44b60 100644 --- a/packages/coding-agent/test/acp-agent.test.ts +++ b/packages/coding-agent/test/acp-agent.test.ts @@ -1410,7 +1410,8 @@ describe("ACP agent", () => { it("executes skill commands through custom skill messages", async () => { const harness = await createHarness(); const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] }); - const session = harness.findSession(created.sessionId)!; + const session = harness.findSession(created.sessionId); + if (!session) throw new Error("expected ACP session to exist after newSession"); const skillDir = path.join(harness.cwdA, ".skills", "sample"); const skillPath = path.join(skillDir, "SKILL.md"); await fs.promises.mkdir(skillDir, { recursive: true }); @@ -1433,10 +1434,14 @@ describe("ACP agent", () => { expect(session.promptCalls).toEqual([]); expect(session.customMessages).toHaveLength(1); - expect(session.customMessages[0]!.customType).toBe("skill-prompt"); - expect(session.customMessages[0]!.content).toContain("# Sample\nDo work."); - expect(session.customMessages[0]!.content).toContain(`Skill: ${skillPath}`); - expect(session.customMessages[0]!.content).toContain("User: extra context"); + const customMessage = session.customMessages[0]; + if (!customMessage) throw new Error("expected ACP skill prompt custom message"); + expect(customMessage.customType).toBe("skill-prompt"); + expect(customMessage.content).toContain("# Sample\nDo work."); + expect(customMessage.content).toContain('The user has invoked the "sample" skill'); + expect(customMessage.content).toContain(`[Skill directory: ${skillDir}]`); + expect(customMessage.content).toMatch(/[Rr]esolve any relative paths/); + expect(customMessage.content).toContain("User: extra context"); harness.abortController.abort(); await Bun.sleep(0); diff --git a/packages/coding-agent/test/input-controller-skill-queue.test.ts b/packages/coding-agent/test/input-controller-skill-queue.test.ts index 5c1cd711c..6e4bfc07b 100644 --- a/packages/coding-agent/test/input-controller-skill-queue.test.ts +++ b/packages/coding-agent/test/input-controller-skill-queue.test.ts @@ -8,10 +8,11 @@ import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import type { ImageContent } from "@oh-my-pi/pi-ai"; +import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import type { Skill } from "@oh-my-pi/pi-coding-agent/extensibility/skills"; import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller"; import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller"; import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; @@ -40,7 +41,7 @@ type PromptCustomMessage = Mock< ( message: { customType?: string; - content?: string | unknown[]; + content?: string | (TextContent | ImageContent)[]; display?: boolean; attribution?: string; details: SkillPromptDetails; @@ -49,14 +50,14 @@ type PromptCustomMessage = Mock< ) => Promise >; -async function writeSkillFile(dir: string, skillName: string, body: string): Promise { +async function writeSkillFile(dir: string, skillName: string, body: string): Promise { const skillPath = path.join(dir, `${skillName}.md`); await Bun.write(skillPath, `---\nname: ${skillName}\n---\n${body}\n`); - return skillPath; + return { name: skillName, description: "", filePath: skillPath, baseDir: dir, source: "test" }; } function createStubInputControllerContext(opts: { - skillCommands: Map; + skillCommands: Map; isStreaming: boolean; isCompacting?: boolean; }) { @@ -132,12 +133,12 @@ function createStubInputControllerContext(opts: { describe("InputController skill queue chip metadata", () => { let tempDir: TempDir; - let skillCommands: Map; + let skillCommands: Map; beforeEach(async () => { tempDir = TempDir.createSync("@pi-skill-queue-stub-"); - const skillPath = await writeSkillFile(tempDir.path(), "test-skill", "Do the thing."); - skillCommands = new Map([["skill:test-skill", skillPath]]); + const skill = await writeSkillFile(tempDir.path(), "test-skill", "Do the thing."); + skillCommands = new Map([["skill:test-skill", skill]]); }); afterEach(() => { @@ -260,7 +261,7 @@ describe("InputController skill queue chip metadata", () => { describe("compaction skill re-invocation", () => { let tempDir: TempDir; - let skillCommands: Map; + let skillCommands: Map; function firstPromptCustomCall(promptCustomMessage: PromptCustomMessage) { const call = promptCustomMessage.mock.calls[0]; @@ -299,8 +300,8 @@ describe("compaction skill re-invocation", () => { beforeEach(async () => { tempDir = TempDir.createSync("@pi-skill-compaction-stub-"); - const skillPath = await writeSkillFile(tempDir.path(), "test-skill", "Do the thing."); - skillCommands = new Map([["skill:test-skill", skillPath]]); + const skill = await writeSkillFile(tempDir.path(), "test-skill", "Do the thing."); + skillCommands = new Map([["skill:test-skill", skill]]); }); afterEach(() => { @@ -323,7 +324,17 @@ describe("compaction skill re-invocation", () => { if (!Array.isArray(message.content)) { throw new Error("expected queued skill prompt to preserve image content blocks"); } - expect(message.content[0]).toMatchObject({ type: "text", text: expect.stringContaining("Do the thing.") }); + const renderedText = message.content[0]; + if (renderedText?.type !== "text") { + throw new Error("expected first content block to be rendered skill text"); + } + // Bug fix contract: a re-invoked user skill identifies itself and exposes its + // skill directory so relative skill paths resolve after compaction. + expect(renderedText.text).toContain("Do the thing."); + expect(renderedText.text).toContain('The user has invoked the "test-skill" skill'); + expect(renderedText.text).toContain(`[Skill directory: ${tempDir.path()}]`); + expect(renderedText.text).toMatch(/[Rr]esolve any relative paths/); + expect(renderedText.text).toContain("User: arg1 arg2"); expect(message.content[1]).toEqual(image); expect(message.details).toMatchObject({ name: "test-skill", args: "arg1 arg2", lineCount: 1 }); expect(options).toEqual({ diff --git a/packages/coding-agent/test/rpc-skill-command.test.ts b/packages/coding-agent/test/rpc-skill-command.test.ts index 2b5348403..f5c4c799f 100644 --- a/packages/coding-agent/test/rpc-skill-command.test.ts +++ b/packages/coding-agent/test/rpc-skill-command.test.ts @@ -33,6 +33,9 @@ describe("tryRunRpcSkillCommand", () => { expect(handled).toEqual({ agentInvoked: true }); expect(message?.customType).toBe(SKILL_PROMPT_MESSAGE_TYPE); expect(message?.content).toContain("Review the supplied code carefully."); + expect(message?.content).toContain('The user has invoked the "reviewer" skill'); + expect(message?.content).toContain(`[Skill directory: ${dir}]`); + expect(message?.content).toMatch(/[Rr]esolve any relative paths/); expect(message?.content).toContain("User: focus on risks"); expect(message?.display).toBe(true); expect(message?.attribution).toBe("user"); diff --git a/packages/coding-agent/test/skill-prompt-message.test.ts b/packages/coding-agent/test/skill-prompt-message.test.ts new file mode 100644 index 000000000..90b001e15 --- /dev/null +++ b/packages/coding-agent/test/skill-prompt-message.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { buildSkillPromptMessage, type Skill } from "@oh-my-pi/pi-coding-agent/extensibility/skills"; +import { removeWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; + +async function createSkill(body: string): Promise<{ dir: string; skill: Skill }> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), `omp-skill-prompt-${Snowflake.next()}-`)); + const filePath = path.join(dir, "SKILL.md"); + await Bun.write(filePath, `---\nname: reviewer\ndescription: Review code\n---\n\n${body}\n`); + return { + dir, + skill: { + name: "reviewer", + description: "Review code", + filePath, + baseDir: dir, + source: "test", + }, + }; +} + +describe("buildSkillPromptMessage", () => { + test("defaults public skill prompt rendering to user-invoked bug-fix directory guidance", async () => { + const { dir, skill } = await createSkill("Review the supplied code carefully."); + try { + const built = await buildSkillPromptMessage(skill, "focus on risks"); + + expect(built.message).toContain("Review the supplied code carefully."); + expect(built.message).toContain('The user has invoked the "reviewer" skill'); + expect(built.message).toContain(`[Skill directory: ${dir}]`); + expect(built.message).toMatch(/[Rr]esolve any relative paths/); + expect(built.message).toContain("User: focus on risks"); + expect(built.details).toMatchObject({ + name: "reviewer", + path: skill.filePath, + args: "focus on risks", + lineCount: 1, + }); + } finally { + await removeWithRetries(dir); + } + }); + + test("keeps autoload skills on non-user minimal framing", async () => { + const { dir, skill } = await createSkill("Review silently loaded context."); + try { + const built = await buildSkillPromptMessage(skill, "", "autoload"); + + expect(built.message).toContain("Review silently loaded context."); + expect(built.message).toContain(`Skill: ${skill.filePath}`); + expect(built.message).not.toContain("The user has invoked"); + expect(built.message).not.toContain("[Skill directory:"); + expect(built.details).toMatchObject({ name: "reviewer", path: skill.filePath, lineCount: 1 }); + } finally { + await removeWithRetries(dir); + } + }); +});