fix(coding-agent): resource collision precedence, user/project skills override package skills
Sort resolved resources by precedence rank (project settings > project auto > user settings > user auto > package) so first-wins collision logic produces correct results. Also reorder CLI paths to prepend instead of append. fixes #2781
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DefaultResourceLoader } from "../../../src/core/resource-loader.js";
|
||||
|
||||
describe("issue #2781 skill collision precedence: user skills should override package skills", () => {
|
||||
let tempDir: string;
|
||||
let agentDir: string;
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-2781-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
agentDir = join(tempDir, "agent");
|
||||
cwd = join(tempDir, "project");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createPackageWithSkill(name: string, description: string): string {
|
||||
const pkgDir = join(tempDir, `fake-package-${name}`);
|
||||
const skillDir = join(pkgDir, "skills", name);
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(pkgDir, "package.json"),
|
||||
JSON.stringify({ name: `fake-pkg-${name}`, version: "1.0.0", pi: { skills: [`skills/${name}`] } }, null, 2),
|
||||
);
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---\nname: ${name}\ndescription: ${description}\n---\nPackage skill content`,
|
||||
);
|
||||
return pkgDir;
|
||||
}
|
||||
|
||||
function createUserSkill(name: string, description: string): string {
|
||||
const skillDir = join(agentDir, "skills", name);
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
const skillPath = join(skillDir, "SKILL.md");
|
||||
writeFileSync(skillPath, `---\nname: ${name}\ndescription: ${description}\n---\nUser skill content`);
|
||||
return skillPath;
|
||||
}
|
||||
|
||||
function createProjectSkill(name: string, description: string): string {
|
||||
const skillDir = join(cwd, ".pi", "skills", name);
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
const skillPath = join(skillDir, "SKILL.md");
|
||||
writeFileSync(skillPath, `---\nname: ${name}\ndescription: ${description}\n---\nProject skill content`);
|
||||
return skillPath;
|
||||
}
|
||||
|
||||
function createSettingsWithPackage(pkgDir: string, scope: "user" | "project"): void {
|
||||
const settingsDir = scope === "user" ? agentDir : join(cwd, ".pi");
|
||||
mkdirSync(settingsDir, { recursive: true });
|
||||
writeFileSync(join(settingsDir, "settings.json"), JSON.stringify({ packages: [pkgDir] }, null, 2));
|
||||
}
|
||||
|
||||
it("user auto-discovered skill should override package skill with same name", async () => {
|
||||
const pkgDir = createPackageWithSkill("web-fetch", "Package web-fetch skill");
|
||||
const userSkillPath = createUserSkill("web-fetch", "User web-fetch override");
|
||||
createSettingsWithPackage(pkgDir, "user");
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
const { skills } = loader.getSkills();
|
||||
const webFetch = skills.find((s) => s.name === "web-fetch");
|
||||
expect(webFetch).toBeDefined();
|
||||
expect(webFetch!.filePath).toBe(userSkillPath);
|
||||
expect(webFetch!.description).toBe("User web-fetch override");
|
||||
});
|
||||
|
||||
it("project auto-discovered skill should override package skill with same name", async () => {
|
||||
const pkgDir = createPackageWithSkill("web-fetch", "Package web-fetch skill");
|
||||
const projectSkillPath = createProjectSkill("web-fetch", "Project web-fetch override");
|
||||
createSettingsWithPackage(pkgDir, "user");
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
const { skills } = loader.getSkills();
|
||||
const webFetch = skills.find((s) => s.name === "web-fetch");
|
||||
expect(webFetch).toBeDefined();
|
||||
expect(webFetch!.filePath).toBe(projectSkillPath);
|
||||
expect(webFetch!.description).toBe("Project web-fetch override");
|
||||
});
|
||||
|
||||
it("project skill should override user skill which should override package skill", async () => {
|
||||
const pkgDir = createPackageWithSkill("web-fetch", "Package web-fetch skill");
|
||||
createUserSkill("web-fetch", "User web-fetch override");
|
||||
const projectSkillPath = createProjectSkill("web-fetch", "Project web-fetch override");
|
||||
createSettingsWithPackage(pkgDir, "user");
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
const { skills } = loader.getSkills();
|
||||
const webFetch = skills.find((s) => s.name === "web-fetch");
|
||||
expect(webFetch).toBeDefined();
|
||||
expect(webFetch!.filePath).toBe(projectSkillPath);
|
||||
expect(webFetch!.description).toBe("Project web-fetch override");
|
||||
});
|
||||
|
||||
it("collision diagnostics should report package skill as loser when user skill wins", async () => {
|
||||
const pkgDir = createPackageWithSkill("web-fetch", "Package web-fetch skill");
|
||||
createUserSkill("web-fetch", "User web-fetch override");
|
||||
createSettingsWithPackage(pkgDir, "user");
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
const { diagnostics } = loader.getSkills();
|
||||
const collision = diagnostics.find((d) => d.type === "collision" && d.collision?.name === "web-fetch");
|
||||
expect(collision).toBeDefined();
|
||||
expect(collision!.collision!.loserPath).toContain("fake-package");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user