fix(coding-agent): identify user-invoked skills and expose skill directory
User-invoked skills (typed /skill:, steered, follow-up, interrupted/ resumed via compaction, ACP, RPC) only appended a bare "Skill: <path>" line, so the model neither learned the user had invoked that specific skill nor where the skill directory was. Relative paths in skill bodies (scripts/, templates/) could not be resolved. Route all user-invoked paths through a self-identifying, baseDir-aware prompt template; keep hidden autoload skills on the minimal non-user format. Interactive skillCommands now carries the loaded Skill object instead of a bare path so baseDir flows through without reconstruction. The invocation kind defaults to "user" to keep buildSkillPromptMessage source-compatible. Op: correct Restores: spec:user-invoked-skill-prompt-self-identifies-and-exposes-skill-directory
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<Skill, "name">): string {
|
||||
return `skill:${skill.name}`;
|
||||
}
|
||||
|
||||
export type SkillInvocationKind = "user" | "autoload";
|
||||
|
||||
export async function buildSkillPromptMessage(
|
||||
skill: Pick<Skill, "name" | "filePath">,
|
||||
skill: Pick<Skill, "name" | "filePath" | "baseDir">,
|
||||
args: string,
|
||||
invocation: SkillInvocationKind = "user",
|
||||
): Promise<BuiltSkillPromptMessage> {
|
||||
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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1160,21 +1160,6 @@ export class CommandController {
|
||||
this.ctx.showStatus(formatShakeSummary(result));
|
||||
}
|
||||
|
||||
async handleSkillCommand(skillPath: string, args: string): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -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<string> = new Set();
|
||||
skillCommands: Map<string, string> = new Map();
|
||||
skillCommands: Map<string, Skill> = 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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<BuiltSkillCommandPrompt | undefined> {
|
||||
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 },
|
||||
|
||||
@@ -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<string>;
|
||||
skillCommands: Map<string, string>;
|
||||
skillCommands: Map<string, Skill>;
|
||||
oauthManualInput: OAuthManualInputManager;
|
||||
todoPhases: TodoPhase[];
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{{body}}
|
||||
|
||||
---
|
||||
|
||||
Skill: {{filePath}}
|
||||
{{#if userArgs}}
|
||||
User: {{userArgs}}
|
||||
{{/if}}
|
||||
@@ -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}}
|
||||
@@ -2315,7 +2315,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
// Autoload skills via sendCustomMessage (same mechanic as /skill:<name>)
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void>
|
||||
>;
|
||||
|
||||
async function writeSkillFile(dir: string, skillName: string, body: string): Promise<string> {
|
||||
async function writeSkillFile(dir: string, skillName: string, body: string): Promise<Skill> {
|
||||
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<string, string>;
|
||||
skillCommands: Map<string, Skill>;
|
||||
isStreaming: boolean;
|
||||
isCompacting?: boolean;
|
||||
}) {
|
||||
@@ -132,12 +133,12 @@ function createStubInputControllerContext(opts: {
|
||||
|
||||
describe("InputController skill queue chip metadata", () => {
|
||||
let tempDir: TempDir;
|
||||
let skillCommands: Map<string, string>;
|
||||
let skillCommands: Map<string, Skill>;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = TempDir.createSync("@pi-skill-queue-stub-");
|
||||
const skillPath = await writeSkillFile(tempDir.path(), "test-skill", "Do the thing.");
|
||||
skillCommands = new Map<string, string>([["skill:test-skill", skillPath]]);
|
||||
const skill = await writeSkillFile(tempDir.path(), "test-skill", "Do the thing.");
|
||||
skillCommands = new Map<string, Skill>([["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<string, string>;
|
||||
let skillCommands: Map<string, Skill>;
|
||||
|
||||
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<string, string>([["skill:test-skill", skillPath]]);
|
||||
const skill = await writeSkillFile(tempDir.path(), "test-skill", "Do the thing.");
|
||||
skillCommands = new Map<string, Skill>([["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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user