Files
oh-my-pi/packages/coding-agent/test/autolearn-tools-gating.test.ts
T
metaphorics 9662844853 feat(coding-agent): support the local memory backend in the learn tool
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.
2026-06-14 12:03:13 +09:00

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);
});
});