Files
oh-my-pi/packages/coding-agent/test/autolearn-controller.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

202 lines
6.9 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import { AutoLearnController, buildAutoLearnInstructions } from "@oh-my-pi/pi-coding-agent/autolearn/controller";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { AgentSession, AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
interface CapturedNudge {
message: { customType: string; content: string; display?: boolean; attribution?: string };
options?: { deliverAs?: string; triggerTurn?: boolean };
}
class FakeSession {
readonly listeners: Array<(event: AgentSessionEvent) => void> = [];
readonly sent: CapturedNudge[] = [];
planEnabled = false;
goalEnabled = false;
subscribe(listener: (event: AgentSessionEvent) => void): () => void {
this.listeners.push(listener);
return () => {};
}
async sendCustomMessage(message: CapturedNudge["message"], options?: CapturedNudge["options"]): Promise<void> {
this.sent.push({ message, options });
}
getPlanModeState(): { enabled: boolean } | undefined {
return this.planEnabled ? { enabled: true } : undefined;
}
getGoalModeState(): { enabled: boolean } | undefined {
return this.goalEnabled ? { enabled: true } : undefined;
}
emit(event: AgentSessionEvent): void {
for (const listener of [...this.listeners]) listener(event);
}
toolCalls(n: number): void {
for (let i = 0; i < n; i++) {
this.emit({ type: "tool_execution_end", toolCallId: `t${i}`, toolName: "read", result: null });
}
}
agentEnd(): void {
this.emit({ type: "agent_end", messages: [] });
}
}
function install(session: FakeSession, overrides: Record<string, unknown> = {}): Settings {
const settings = Settings.isolated({ "autolearn.enabled": true, ...overrides });
new AutoLearnController({ session: session as unknown as AgentSession, settings });
return settings;
}
describe("AutoLearnController", () => {
it("fires one passive nudge once the tool-call threshold is met", () => {
const session = new FakeSession();
install(session);
session.toolCalls(5);
session.agentEnd();
expect(session.sent).toHaveLength(1);
expect(session.sent[0]?.message.customType).toBe("autolearn-nudge");
expect(session.sent[0]?.message.display).toBe(false);
expect(session.sent[0]?.options?.deliverAs).toBe("nextTurn");
expect(session.sent[0]?.options?.triggerTurn).toBe(false);
});
it("does not nudge below the threshold", () => {
const session = new FakeSession();
install(session);
session.toolCalls(4);
session.agentEnd();
expect(session.sent).toHaveLength(0);
});
it("does not nudge during plan mode", () => {
const session = new FakeSession();
session.planEnabled = true;
install(session);
session.toolCalls(5);
session.agentEnd();
expect(session.sent).toHaveLength(0);
});
it("does not combine tool calls across separate sub-threshold turns", () => {
const session = new FakeSession();
install(session);
session.toolCalls(3);
session.agentEnd();
session.toolCalls(3);
session.agentEnd();
// Neither turn reached the threshold; the counter must not accumulate.
expect(session.sent).toHaveLength(0);
});
it("discards plan-mode tool calls instead of leaking them into the next turn", () => {
const session = new FakeSession();
session.planEnabled = true;
install(session);
session.toolCalls(5);
session.agentEnd(); // plan mode: no fire, counter reset
session.planEnabled = false;
session.toolCalls(1);
session.agentEnd(); // 1 < threshold -> no fire (no plan-mode leak)
expect(session.sent).toHaveLength(0);
});
it("stops nudging when autolearn is disabled mid-session", () => {
const session = new FakeSession();
// Enable via the global layer (not an isolated override) so the live flag
// can be flipped and the controller's fire-time re-check is exercised.
const settings = Settings.isolated({});
settings.set("autolearn.enabled", true);
new AutoLearnController({ session: session as unknown as AgentSession, settings });
session.toolCalls(5);
session.agentEnd();
expect(session.sent).toHaveLength(1); // fires while enabled
settings.set("autolearn.enabled", false);
session.toolCalls(5);
session.agentEnd();
expect(session.sent).toHaveLength(1); // no new nudge after disable
// The disabled stop must NOT leave its tool calls queued: re-enabling and
// doing a sub-threshold turn must not fire from leaked counts.
settings.set("autolearn.enabled", true);
session.toolCalls(1);
session.agentEnd();
expect(session.sent).toHaveLength(1);
});
it("downgrades autoContinue to a passive nudge during goal mode", () => {
const session = new FakeSession();
session.goalEnabled = true;
install(session, { "autolearn.autoContinue": true });
session.toolCalls(5);
session.agentEnd();
expect(session.sent).toHaveLength(1);
expect(session.sent[0]?.options?.triggerTurn).toBe(false);
// Passive => no suppression; the next qualifying stop fires again.
session.toolCalls(5);
session.agentEnd();
expect(session.sent).toHaveLength(2);
});
it("auto-runs a capture turn and suppresses exactly one follow-up agent_end", () => {
const session = new FakeSession();
install(session, { "autolearn.autoContinue": true });
session.toolCalls(5);
session.agentEnd();
expect(session.sent).toHaveLength(1);
expect(session.sent[0]?.options?.triggerTurn).toBe(true);
// The synthetic capture turn's agent_end is swallowed.
session.toolCalls(5);
session.agentEnd();
expect(session.sent).toHaveLength(1);
// Suppression is one-shot: the next qualifying stop fires again.
session.toolCalls(5);
session.agentEnd();
expect(session.sent).toHaveLength(2);
});
it("respects a custom minToolCalls threshold", () => {
const session = new FakeSession();
install(session, { "autolearn.minToolCalls": 2 });
session.toolCalls(2);
session.agentEnd();
expect(session.sent).toHaveLength(1);
});
});
describe("buildAutoLearnInstructions", () => {
it("returns null when auto-learn is disabled", () => {
expect(buildAutoLearnInstructions(Settings.isolated({ "autolearn.enabled": false }))).toBeNull();
});
it("includes the learn addendum when a memory backend is live", () => {
const text = buildAutoLearnInstructions(
Settings.isolated({ "autolearn.enabled": true, "memory.backend": "mnemopi" }),
);
expect(text).toContain("manage_skill");
expect(text).toContain("long-term memory");
});
it("includes the learn addendum for the file-based local backend", () => {
const text = buildAutoLearnInstructions(
Settings.isolated({ "autolearn.enabled": true, "memory.backend": "local" }),
);
expect(text).toContain("manage_skill");
expect(text).toContain("long-term memory");
});
it("omits the learn addendum when no memory backend is configured", () => {
const text = buildAutoLearnInstructions(
Settings.isolated({ "autolearn.enabled": true, "memory.backend": "off" }),
);
expect(text).toContain("manage_skill");
expect(text).not.toContain("long-term memory");
});
});