Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
14 kB · 420 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";import { tmpdir } from "node:os";import { join } from "node:path";import { afterEach, beforeEach, describe, expect, it } from "vitest";import { AuthStorage } from "../src/core/auth-storage.js";import { ExtensionRunner } from "../src/core/extensions/runner.js";import { ModelRegistry } from "../src/core/model-registry.js";import { DefaultResourceLoader } from "../src/core/resource-loader.js";import { SessionManager } from "../src/core/session-manager.js";import { SettingsManager } from "../src/core/settings-manager.js";
describe("DefaultResourceLoader", () => { let tempDir: string; let agentDir: string; let cwd: string; let previousSerperApiKey: string | undefined;
beforeEach(() => { tempDir = join(tmpdir(), `rl-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); agentDir = join(tempDir, "agent"); cwd = join(tempDir, "project"); previousSerperApiKey = process.env.SERPER_API_KEY; delete process.env.SERPER_API_KEY; mkdirSync(agentDir, { recursive: true }); mkdirSync(cwd, { recursive: true }); });
afterEach(() => { if (previousSerperApiKey === undefined) { delete process.env.SERPER_API_KEY; } else { process.env.SERPER_API_KEY = previousSerperApiKey; } rmSync(tempDir, { recursive: true, force: true }); });
describe("resource precedence", () => { it("should prefer project resources over user on name collisions", async () => { const userPromptsDir = join(agentDir, "prompts"); const projectPromptsDir = join(cwd, ".prime", "agent", "prompts"); mkdirSync(userPromptsDir, { recursive: true }); mkdirSync(projectPromptsDir, { recursive: true }); const userPromptPath = join(userPromptsDir, "commit.md"); const projectPromptPath = join(projectPromptsDir, "commit.md"); writeFileSync(userPromptPath, "User prompt"); writeFileSync(projectPromptPath, "Project prompt");
const userSkillDir = join(agentDir, "skills", "collision-skill"); const projectSkillDir = join(cwd, ".prime", "agent", "skills", "collision-skill"); mkdirSync(userSkillDir, { recursive: true }); mkdirSync(projectSkillDir, { recursive: true }); const userSkillPath = join(userSkillDir, "SKILL.md"); const projectSkillPath = join(projectSkillDir, "SKILL.md"); writeFileSync( userSkillPath, `---name: collision-skilldescription: user---User skill`, ); writeFileSync( projectSkillPath, `---name: collision-skilldescription: project---Project skill`, );
const baseTheme = JSON.parse( readFileSync(join(process.cwd(), "src", "modes", "interactive", "theme", "dark.json"), "utf-8"), ) as { name: string; vars?: Record<string, string> }; baseTheme.name = "collision-theme"; const userThemePath = join(agentDir, "themes", "collision.json"); const projectThemePath = join(cwd, ".prime", "agent", "themes", "collision.json"); mkdirSync(join(agentDir, "themes"), { recursive: true }); mkdirSync(join(cwd, ".prime", "agent", "themes"), { recursive: true }); writeFileSync(userThemePath, JSON.stringify(baseTheme, null, 2)); if (baseTheme.vars) { baseTheme.vars.accent = "#ff00ff"; } writeFileSync(projectThemePath, JSON.stringify(baseTheme, null, 2));
const loader = new DefaultResourceLoader({ cwd, agentDir }); await loader.reload();
const prompt = loader.getPrompts().prompts.find((p) => p.name === "commit"); expect(prompt?.filePath).toBe(projectPromptPath);
const skill = loader.getSkills().skills.find((s) => s.name === "collision-skill"); expect(skill?.filePath).toBe(projectSkillPath);
const theme = loader.getThemes().themes.find((t) => t.name === "collision-theme"); expect(theme?.sourcePath).toBe(projectThemePath); });
it("should load symlinked user and project extensions once", async () => { const sharedExtDir = join(tempDir, "shared-extensions"); mkdirSync(sharedExtDir, { recursive: true }); writeFileSync( join(sharedExtDir, "shared.ts"), `export default function(pi) { pi.registerCommand("shared", { description: "shared command", handler: async () => {}, });}`, );
mkdirSync(agentDir, { recursive: true }); mkdirSync(join(cwd, ".prime", "agent"), { recursive: true }); symlinkSync(sharedExtDir, join(agentDir, "extensions"), "dir"); symlinkSync(sharedExtDir, join(cwd, ".prime", "agent", "extensions"), "dir");
const loader = new DefaultResourceLoader({ cwd, agentDir }); await loader.reload();
const extensionsResult = loader.getExtensions(); expect(extensionsResult.extensions).toHaveLength(1); expect(extensionsResult.errors).toEqual([]);
// mergePaths processes project paths before user paths, so the project // alias is the canonical survivor. expect(extensionsResult.extensions[0].path).toBe(join(cwd, ".prime", "agent", "extensions", "shared.ts")); });
it("should let a project skill override the bundled websearch skill", async () => { const projectSkillDir = join(cwd, ".prime", "agent", "skills", "websearch"); mkdirSync(projectSkillDir, { recursive: true }); writeFileSync( join(projectSkillDir, "SKILL.md"), `---name: websearchdescription: Project-specific web search override.---Project override.`, );
const loader = new DefaultResourceLoader({ cwd, agentDir }); await loader.reload();
const { skills, diagnostics } = loader.getSkills(); const websearch = skills.find((s) => s.name === "websearch"); expect(websearch).toBeDefined(); expect(websearch?.filePath).toBe(join(projectSkillDir, "SKILL.md")); expect(websearch?.kind).toBe("markdown"); expect(diagnostics.some((d) => d.type === "collision" && d.collision?.name === "websearch")).toBe(true); }); });
describe("extension conflict detection", () => { it("should keep both extensions loaded when command names collide", async () => { const userExtDir = join(agentDir, "extensions"); const projectExtDir = join(cwd, ".prime", "agent", "extensions"); mkdirSync(userExtDir, { recursive: true }); mkdirSync(projectExtDir, { recursive: true });
writeFileSync( join(projectExtDir, "project.ts"), `export default function(pi) { pi.registerCommand("deploy", { description: "project deploy", handler: async () => {}, }); pi.registerCommand("project-only", { description: "project only", handler: async () => {}, });}`, );
writeFileSync( join(userExtDir, "user.ts"), `export default function(pi) { pi.registerCommand("deploy", { description: "user deploy", handler: async () => {}, }); pi.registerCommand("user-only", { description: "user only", handler: async () => {}, });}`, );
const loader = new DefaultResourceLoader({ cwd, agentDir }); await loader.reload();
const extensionsResult = loader.getExtensions(); expect(extensionsResult.extensions).toHaveLength(2); expect(extensionsResult.errors.some((e) => e.error.includes('Command "/deploy" conflicts'))).toBe(false);
const sessionManager = SessionManager.inMemory(); const authStorage = AuthStorage.create(join(tempDir, "auth.json")); const modelRegistry = ModelRegistry.create(authStorage); const runner = new ExtensionRunner( extensionsResult.extensions, extensionsResult.runtime, cwd, sessionManager, modelRegistry, );
expect(runner.getCommand("deploy:1")?.description).toBe("project deploy"); expect(runner.getCommand("deploy:2")?.description).toBe("user deploy"); expect(runner.getCommand("project-only")?.description).toBe("project only"); expect(runner.getCommand("user-only")?.description).toBe("user only");
const commands = runner.getRegisteredCommands(); expect(commands.map((command) => command.invocationName)).toEqual([ "deploy:1", "project-only", "deploy:2", "user-only", ]); });
it("should detect tool conflicts between extensions", async () => { const ext1Dir = join(agentDir, "extensions", "ext1"); const ext2Dir = join(agentDir, "extensions", "ext2"); mkdirSync(ext1Dir, { recursive: true }); mkdirSync(ext2Dir, { recursive: true });
writeFileSync( join(ext1Dir, "index.ts"), `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";import { Type } from "typebox";export default function(pi: ExtensionAPI) { pi.registerTool({ name: "duplicate-tool", description: "First", parameters: Type.Object({}), execute: async () => ({ result: "1" }), });}`, );
writeFileSync( join(ext2Dir, "index.ts"), `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";import { Type } from "typebox";export default function(pi: ExtensionAPI) { pi.registerTool({ name: "duplicate-tool", description: "Second", parameters: Type.Object({}), execute: async () => ({ result: "2" }), });}`, );
const loader = new DefaultResourceLoader({ cwd, agentDir }); await loader.reload();
const { errors } = loader.getExtensions(); expect(errors.some((e) => e.error.includes("duplicate-tool") && e.error.includes("conflicts"))).toBe(true); });
it("should prefer explicit CLI extensions over discovered extensions when commands and tools conflict", async () => { const globalExtDir = join(agentDir, "extensions"); mkdirSync(globalExtDir, { recursive: true }); const explicitExtPath = join(tempDir, "explicit-extension.ts");
writeFileSync( join(globalExtDir, "global.ts"), `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";import { Type } from "typebox";export default function(pi: ExtensionAPI) { pi.registerTool({ name: "duplicate-tool", description: "global tool", parameters: Type.Object({}), execute: async () => ({ result: "global" }), }); pi.registerCommand("deploy", { description: "global command", handler: async () => {}, });}`, );
writeFileSync( explicitExtPath, `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";import { Type } from "typebox";export default function(pi: ExtensionAPI) { pi.registerTool({ name: "duplicate-tool", description: "explicit tool", parameters: Type.Object({}), execute: async () => ({ result: "explicit" }), }); pi.registerCommand("deploy", { description: "explicit command", handler: async () => {}, });}`, );
const loader = new DefaultResourceLoader({ cwd, agentDir, additionalExtensionPaths: [explicitExtPath], }); await loader.reload();
const extensionsResult = loader.getExtensions(); expect(extensionsResult.extensions[0]?.path).toBe(explicitExtPath);
const sessionManager = SessionManager.inMemory(); const authStorage = AuthStorage.create(join(tempDir, "auth-explicit.json")); const modelRegistry = ModelRegistry.create(authStorage); const runner = new ExtensionRunner( extensionsResult.extensions, extensionsResult.runtime, cwd, sessionManager, modelRegistry, );
expect(runner.getCommand("deploy:1")?.description).toBe("explicit command"); expect(runner.getCommand("deploy:2")?.description).toBe("global command"); expect(runner.getToolDefinition("duplicate-tool")?.description).toBe("explicit tool"); }); });});
describe("settings reload regressions", () => { let tempDir: string; let agentDir: string;
beforeEach(() => { tempDir = join(tmpdir(), `rl-settings-reload-${Date.now()}-${Math.random().toString(36).slice(2)}`); agentDir = join(tempDir, "agent"); mkdirSync(join(agentDir, "prompts"), { recursive: true }); });
afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); });
function inMemoryLoaderSettings() { return SettingsManager.inMemory({ defaultThinkingLevel: "high", images: { autoResize: false }, compaction: { enabled: false }, }); }
it("#2753: applies updated top-level prompt settings on reload after startup", async () => { writeFileSync(join(agentDir, "prompts", "test.md"), "Echo test prompt\n"); const settingsManager = SettingsManager.create(tempDir, agentDir); const loader = new DefaultResourceLoader({ cwd: tempDir, agentDir, settingsManager, noExtensions: true, noSkills: true, noThemes: true, }); await loader.reload(); expect(loader.getPrompts().prompts.map((prompt) => prompt.name)).toContain("test");
writeFileSync(join(agentDir, "settings.json"), `${JSON.stringify({ prompts: ["-prompts/test.md"] }, null, 2)}\n`); await loader.reload();
expect(settingsManager.getGlobalSettings().prompts).toEqual(["-prompts/test.md"]); expect(loader.getPrompts().prompts.map((prompt) => prompt.name)).not.toContain("test"); });
it.each([ { name: "a direct settings reload", reload: async (settingsManager: SettingsManager) => { await settingsManager.reload(); }, }, { name: "a resource loader reload", reload: async (settingsManager: SettingsManager, cwd: string, dir: string) => { await new DefaultResourceLoader({ cwd, agentDir: dir, settingsManager, noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, }).reload(); }, }, { name: "an unrelated setter, flush, and reload", reload: async (settingsManager: SettingsManager) => { settingsManager.setTheme("dark"); await settingsManager.flush(); await settingsManager.reload(); }, }, ])("#3616: in-memory settings survive $name", async ({ reload }) => { const settingsManager = inMemoryLoaderSettings();
await reload(settingsManager, tempDir, agentDir);
expect(settingsManager.getDefaultThinkingLevel()).toBe("high"); expect(settingsManager.getImageAutoResize()).toBe(false); expect(settingsManager.getCompactionEnabled()).toBe(false); expect(settingsManager.getGlobalSettings()).toMatchObject({ defaultThinkingLevel: "high", images: { autoResize: false }, compaction: { enabled: false }, }); });});