9662844853
The `learn` tool previously required a `hindsight`/`mnemopi` backend. It now also works when `memory.backend` is `local` (the file-based rollout backend): lessons append to a `learned.md` under the project's memory root, kept separate from the consolidation artifacts so a consolidation pass never clobbers them, and are injected into future sessions alongside the memory summary. - memories: `saveLearnedLesson` (newest-first, deduped, count- and per-field size-capped, secret-redacted, injection-neutralized) with per-path write serialization; `buildMemoryToolDeveloperInstructions` reads `learned.md` and shares one injection budget with the summary; `redactSecrets` extended with GitHub/npm/Slack/Google token prefixes. - local backend: implements `save()`; status reports `writable: true`. - learn tool: `local` execute branch; `createIf`/`isToolAllowed`/auto-include and the standing guidance extended to `local`; local saves tier as a `write` approval. - read-path prompt: renders the learned-lessons block when present. - Lessons are injection-neutralized and secret-redacted on BOTH write and read (they render unescaped into the system prompt). Also moves the auto-learn CHANGELOG entry out of the released [15.12.6] section (a cherry-pick artifact) back under [Unreleased] and notes the local backend. Tests: local storage (format, dedup, cap, redaction incl. provider/delimiter- split tokens, concurrency), read-back (with/without summary, off-gating, raw hand-edited file), tool gating + write-approval tiering.
179 lines
6.7 KiB
TypeScript
179 lines
6.7 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { getManagedSkillsDir } from "@oh-my-pi/pi-coding-agent/autolearn/managed-skills";
|
|
import { type SettingPath, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import type { MnemopiSessionState } from "@oh-my-pi/pi-coding-agent/mnemopi/state";
|
|
import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
|
import { LearnTool } from "@oh-my-pi/pi-coding-agent/tools/learn";
|
|
import { ManageSkillTool } from "@oh-my-pi/pi-coding-agent/tools/manage-skill";
|
|
|
|
Bun.env.PI_PYTHON_SKIP_CHECK = "1";
|
|
|
|
function makeSession(
|
|
settingsOverrides: Partial<Record<SettingPath, unknown>> = {},
|
|
extra: Partial<ToolSession> = {},
|
|
): ToolSession {
|
|
return {
|
|
cwd: "/tmp/test",
|
|
hasUI: false,
|
|
skipPythonPreflight: true,
|
|
getSessionFile: () => null,
|
|
getSessionSpawns: () => "*",
|
|
settings: Settings.isolated(settingsOverrides),
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
describe("autolearn tool gating", () => {
|
|
afterEach(() => {
|
|
spyOn(os, "homedir").mockRestore();
|
|
});
|
|
|
|
it("offers neither tool by default (autolearn disabled)", async () => {
|
|
const names = (await createTools(makeSession())).map(t => t.name);
|
|
expect(names).not.toContain("learn");
|
|
expect(names).not.toContain("manage_skill");
|
|
});
|
|
|
|
it("offers manage_skill but not learn when enabled with no memory backend", async () => {
|
|
const names = (await createTools(makeSession({ "autolearn.enabled": true, "memory.backend": "off" }))).map(
|
|
t => t.name,
|
|
);
|
|
expect(names).toContain("manage_skill");
|
|
expect(names).not.toContain("learn");
|
|
});
|
|
|
|
it("offers both tools, marked essential, when enabled with a live backend", async () => {
|
|
const tools = await createTools(makeSession({ "autolearn.enabled": true, "memory.backend": "mnemopi" }));
|
|
const learn = tools.find(t => t.name === "learn");
|
|
const manage = tools.find(t => t.name === "manage_skill");
|
|
expect(learn).toBeDefined();
|
|
expect(manage).toBeDefined();
|
|
// loadMode "essential" is what keeps them active under tools.discoveryMode "all".
|
|
expect(learn?.loadMode).toBe("essential");
|
|
expect(manage?.loadMode).toBe("essential");
|
|
});
|
|
|
|
it("force-includes the tools into an explicit restricted toolNames list", async () => {
|
|
// A session created with autolearn on but a narrow tool list still gets the
|
|
// controller/guidance, so the tools the nudge points at must be present.
|
|
const withBackend = (
|
|
await createTools(makeSession({ "autolearn.enabled": true, "memory.backend": "mnemopi" }), ["read"])
|
|
).map(t => t.name);
|
|
expect(withBackend).toContain("manage_skill");
|
|
expect(withBackend).toContain("learn");
|
|
|
|
const noBackend = (
|
|
await createTools(makeSession({ "autolearn.enabled": true, "memory.backend": "off" }), ["read"])
|
|
).map(t => t.name);
|
|
expect(noBackend).toContain("manage_skill");
|
|
expect(noBackend).not.toContain("learn");
|
|
});
|
|
|
|
it("offers learn with the file-based local backend", async () => {
|
|
const names = (await createTools(makeSession({ "autolearn.enabled": true, "memory.backend": "local" }))).map(
|
|
t => t.name,
|
|
);
|
|
expect(names).toContain("learn");
|
|
expect(names).toContain("manage_skill");
|
|
|
|
// Force-included into an explicit restricted toolNames list too.
|
|
const restricted = (
|
|
await createTools(makeSession({ "autolearn.enabled": true, "memory.backend": "local" }), ["read"])
|
|
).map(t => t.name);
|
|
expect(restricted).toContain("learn");
|
|
});
|
|
});
|
|
|
|
describe("manage_skill execute", () => {
|
|
let tempHome: string;
|
|
|
|
beforeEach(async () => {
|
|
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "omp-manage-skill-"));
|
|
spyOn(os, "homedir").mockReturnValue(tempHome);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
spyOn(os, "homedir").mockRestore();
|
|
await fs.rm(tempHome, { recursive: true, force: true });
|
|
});
|
|
|
|
const tool = () => ManageSkillTool.createIf(makeSession({ "autolearn.enabled": true }))!;
|
|
|
|
it("create writes the managed SKILL.md; delete removes it", async () => {
|
|
const file = path.join(getManagedSkillsDir(), "demo", "SKILL.md");
|
|
await tool().execute("1", { action: "create", name: "demo", description: "When to demo.", body: "# Demo" });
|
|
expect(await Bun.file(file).exists()).toBe(true);
|
|
|
|
await tool().execute("2", { action: "delete", name: "demo" });
|
|
expect(await Bun.file(file).exists()).toBe(false);
|
|
});
|
|
|
|
it("rejects create without a body and delete of a missing skill", async () => {
|
|
await expect(tool().execute("3", { action: "create", name: "nobody", description: "d" })).rejects.toThrow(
|
|
/requires/,
|
|
);
|
|
await expect(tool().execute("4", { action: "delete", name: "absent" })).rejects.toThrow(/does not exist/);
|
|
});
|
|
});
|
|
|
|
describe("learn execute", () => {
|
|
let tempHome: string;
|
|
let remembered: string[];
|
|
|
|
function learnSession(): ToolSession {
|
|
const fakeState = {
|
|
sessionId: "sess-1",
|
|
session: { sessionManager: { getCwd: () => "/tmp/work" } },
|
|
rememberScoped: (memory: string) => {
|
|
remembered.push(memory);
|
|
return "mem-id";
|
|
},
|
|
};
|
|
return makeSession(
|
|
{ "autolearn.enabled": true, "memory.backend": "mnemopi" },
|
|
{ getMnemopiSessionState: () => fakeState as unknown as MnemopiSessionState },
|
|
);
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "omp-learn-"));
|
|
spyOn(os, "homedir").mockReturnValue(tempHome);
|
|
remembered = [];
|
|
});
|
|
|
|
afterEach(async () => {
|
|
spyOn(os, "homedir").mockRestore();
|
|
await fs.rm(tempHome, { recursive: true, force: true });
|
|
});
|
|
|
|
it("stores a lesson to memory without writing a skill when no skill payload", async () => {
|
|
await new LearnTool(learnSession()).execute("1", { memory: "Prefer Bun.file over readFileSync." });
|
|
expect(remembered).toEqual(["Prefer Bun.file over readFileSync."]);
|
|
// No managed skills written.
|
|
expect(await fs.readdir(getManagedSkillsDir()).catch(() => [])).toHaveLength(0);
|
|
});
|
|
|
|
it("stores a lesson AND mints a managed skill when a skill payload is given", async () => {
|
|
await new LearnTool(learnSession()).execute("2", {
|
|
memory: "Use the worker host entry pattern.",
|
|
skill: { action: "create", name: "worker-host", description: "Spawn workers.", body: "# Worker host" },
|
|
});
|
|
expect(remembered).toHaveLength(1);
|
|
expect(await Bun.file(path.join(getManagedSkillsDir(), "worker-host", "SKILL.md")).exists()).toBe(true);
|
|
});
|
|
|
|
it("surfaces a partial-outcome error when the skill name is invalid", async () => {
|
|
await expect(
|
|
new LearnTool(learnSession()).execute("3", {
|
|
memory: "lesson",
|
|
skill: { action: "create", name: "../evil", description: "d", body: "b" },
|
|
}),
|
|
).rejects.toThrow(/Lesson stored, but the managed skill could not be written/);
|
|
// The memory half still ran.
|
|
expect(remembered).toHaveLength(1);
|
|
});
|
|
});
|