272 lines
10 KiB
TypeScript
272 lines
10 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;
|
|
/** Whether a triggerTurn dispatch actually starts a synthetic turn. */
|
|
turnStarts = true;
|
|
/** Force the dispatch to reject (models a failed send). */
|
|
failSend = false;
|
|
|
|
subscribe(listener: (event: AgentSessionEvent) => void): () => void {
|
|
this.listeners.push(listener);
|
|
return () => {};
|
|
}
|
|
|
|
async sendCustomMessage(message: CapturedNudge["message"], options?: CapturedNudge["options"]): Promise<boolean> {
|
|
if (this.failSend) throw new Error("send failed");
|
|
this.sent.push({ message, options });
|
|
// Mirror AgentSession: a turn starts only when triggerTurn is honored.
|
|
return options?.triggerTurn === true && this.turnStarts;
|
|
}
|
|
|
|
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 });
|
|
}
|
|
}
|
|
|
|
agentStart(): void {
|
|
this.emit({ type: "agent_start" });
|
|
}
|
|
|
|
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("does not inject a passive nudge into the conversation prefix", () => {
|
|
const session = new FakeSession();
|
|
install(session);
|
|
session.toolCalls(5);
|
|
session.agentEnd();
|
|
|
|
expect(session.sent).toHaveLength(0);
|
|
});
|
|
|
|
it("the auto-continue nudge is terminal — capture then stop, never assume approval (#3504)", () => {
|
|
// Regression: with autoContinue on, the synthetic capture turn carries
|
|
// the nudge as its only user-role payload. Without an explicit "stop /
|
|
// not a user reply / do not assume approval" contract, the agent reads
|
|
// its own unanswered prior question (e.g. "Want me to commit and
|
|
// push?") as accepted and continues — exactly the scenario in #3504.
|
|
const session = new FakeSession();
|
|
install(session, { "autolearn.autoContinue": true });
|
|
session.toolCalls(5);
|
|
session.agentEnd();
|
|
const body = String(session.sent[0]?.message.content);
|
|
// Frames the prompt as automated, not as the user's response.
|
|
expect(body).toMatch(/not a user reply|not from the user/i);
|
|
// Forbids inferring approval / acting on pending questions.
|
|
expect(body).toMatch(/not.*(approval|accept|pending|prior)/i);
|
|
// Demands a hard stop after capture, with no continuation.
|
|
expect(body).toMatch(/then stop\./i);
|
|
expect(body).toMatch(/do not.*(continue|resume|other tools)/i);
|
|
expect(body).toMatch(/wait for the user'?s next prompt/i);
|
|
});
|
|
|
|
it("does not nudge below the threshold", () => {
|
|
const session = new FakeSession();
|
|
install(session, { "autolearn.autoContinue": true });
|
|
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, { "autolearn.autoContinue": true });
|
|
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, { "autolearn.autoContinue": true });
|
|
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, { "autolearn.autoContinue": true });
|
|
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 auto-continuing 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({ "autolearn.autoContinue": true });
|
|
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("does not nudge during goal mode and leaks no suppression latch", () => {
|
|
const session = new FakeSession();
|
|
session.goalEnabled = true;
|
|
install(session, { "autolearn.autoContinue": true });
|
|
session.toolCalls(5);
|
|
session.agentEnd();
|
|
// Goal mode owns the continuation; auto-learn stays out of the loop.
|
|
expect(session.sent).toHaveLength(0);
|
|
// The skipped stop must not arm suppression for the next non-goal stop.
|
|
session.goalEnabled = false;
|
|
session.toolCalls(5);
|
|
session.agentEnd();
|
|
expect(session.sent).toHaveLength(1);
|
|
});
|
|
|
|
it("never nudges a turn that started in goal mode even if the goal ended mid-turn", () => {
|
|
const session = new FakeSession();
|
|
session.goalEnabled = true;
|
|
install(session, { "autolearn.autoContinue": true });
|
|
// The turn begins as a goal continuation...
|
|
session.agentStart();
|
|
session.toolCalls(5);
|
|
// ...then a `goal` tool completes/drops the goal mid-turn: the live flag is
|
|
// off by the time the turn stops, but this turn must still never be nudged.
|
|
session.goalEnabled = false;
|
|
session.agentEnd();
|
|
expect(session.sent).toHaveLength(0);
|
|
|
|
// The capture is per-turn: a fresh turn that did not start in goal mode
|
|
// nudges normally, proving the latch resets.
|
|
session.agentStart();
|
|
session.toolCalls(5);
|
|
session.agentEnd();
|
|
expect(session.sent).toHaveLength(1);
|
|
});
|
|
|
|
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("disarms suppression when the capture turn is deferred (not started)", async () => {
|
|
const session = new FakeSession();
|
|
// triggerTurn honored but downgraded to a queue: no synthetic agent_end.
|
|
session.turnStarts = false;
|
|
install(session, { "autolearn.autoContinue": true });
|
|
session.toolCalls(5);
|
|
session.agentEnd();
|
|
expect(session.sent).toHaveLength(1);
|
|
expect(session.sent[0]?.options?.triggerTurn).toBe(true);
|
|
await Bun.sleep(1); // flush the async disarm
|
|
// No turn ran, so the next real stop must still nudge.
|
|
session.toolCalls(5);
|
|
session.agentEnd();
|
|
expect(session.sent).toHaveLength(2);
|
|
});
|
|
|
|
it("disarms suppression when the capture-turn dispatch fails", async () => {
|
|
const session = new FakeSession();
|
|
session.failSend = true;
|
|
install(session, { "autolearn.autoContinue": true });
|
|
session.toolCalls(5);
|
|
session.agentEnd(); // dispatch rejects: armed, then disarmed in .catch
|
|
expect(session.sent).toHaveLength(0);
|
|
await Bun.sleep(1); // flush the async disarm
|
|
session.failSend = false;
|
|
session.toolCalls(5);
|
|
session.agentEnd();
|
|
expect(session.sent).toHaveLength(1);
|
|
});
|
|
|
|
it("respects a custom minToolCalls threshold", () => {
|
|
const session = new FakeSession();
|
|
install(session, { "autolearn.autoContinue": true, "autolearn.minToolCalls": 2 });
|
|
session.toolCalls(2);
|
|
session.agentEnd();
|
|
expect(session.sent).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("buildAutoLearnInstructions", () => {
|
|
it("returns null when manage_skill is not in the active tool set", () => {
|
|
expect(buildAutoLearnInstructions({ manageSkill: false, learn: false })).toBeNull();
|
|
// learn without manage_skill still yields no guidance (manage_skill gates it).
|
|
expect(buildAutoLearnInstructions({ manageSkill: false, learn: true })).toBeNull();
|
|
});
|
|
|
|
it("includes the learn addendum when the learn tool is present", () => {
|
|
const text = buildAutoLearnInstructions({ manageSkill: true, learn: true });
|
|
expect(text).toContain("manage_skill");
|
|
expect(text).toContain("long-term memory");
|
|
});
|
|
|
|
it("omits the learn addendum when only manage_skill is present", () => {
|
|
const text = buildAutoLearnInstructions({ manageSkill: true, learn: false });
|
|
expect(text).toContain("manage_skill");
|
|
expect(text).not.toContain("long-term memory");
|
|
});
|
|
});
|