Files
Empryo/tests/mode-tool-gating.test.ts
PouiiroandSoulForge e5a216a38c fix(forge): mode inject latches on mode name + frozen turn-mode
- latch on mode NAME, not rendered text — plan mode no longer re-injects when context crosses the PLAN_LIGHT/PLAN_FULL 50% threshold mid-session
- banner uses the mode frozen at agent build (passed to prepareStep), matching the execution-time deny gate — no message/gate drift on mid-turn steering
- buildModeMessage(modeOverride?) renders the frozen mode instead of live state; mode change takes effect on next turn's rebuild
- tests: override-wins-over-live-state, override-default-null

Co-Authored-By: SoulForge <[email protected]>
2026-05-31 17:04:37 +02:00

143 lines
5.6 KiB
TypeScript

/**
* Mode tool gating — cache-stability + execution-time deny.
*
* Restricted modes (architect/socratic/challenge/plan) and plan-execution must
* send the FULL tool schema (byte-identical across modes) so the prompt-cache
* prefix (tools → system → messages) survives mode switches (discussion #85).
* Restriction is enforced at execution: a disallowed tool returns a deny result
* (error: mode_restricted) instead of running.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// No repo map needed — the gate is pure logic over the tool set. Disabling it
// keeps the background AST-summary worker from spinning up on the tmp dir
// (which logs disk-I/O noise on teardown). Must be set before ContextManager.
process.env.SOULFORGE_NO_REPOMAP = "1";
import { createForgeAgent } from "../src/core/agents/forge.js";
import { ContextManager } from "../src/core/context/manager.js";
let TMP: string;
beforeEach(() => {
TMP = mkdtempSync(join(tmpdir(), "mode-gate-"));
});
afterEach(() => {
rmSync(TMP, { recursive: true, force: true });
});
// biome-ignore lint/suspicious/noExplicitAny: minimal model stub for agent construction
const mockModel = (id: string) => ({ modelId: id, doGenerate: async () => ({}) }) as any;
type Agent = { tools: Record<string, { execute?: (...a: unknown[]) => unknown }> };
function makeAgent(forgeMode: "default" | "architect", planExecution = false): Agent {
return createForgeAgent({
model: mockModel("anthropic/claude-sonnet-4-6"),
contextManager: new ContextManager(TMP),
forgeMode,
planExecution,
}) as unknown as Agent;
}
function toolNames(agent: Agent): string[] {
return Object.keys(agent.tools).sort();
}
describe("mode tool gating", () => {
test("architect mode sends the SAME tool schema as default mode", () => {
expect(toolNames(makeAgent("architect"))).toEqual(toolNames(makeAgent("default")));
});
test("plan-execution sends the SAME tool schema as default mode", () => {
expect(toolNames(makeAgent("default", true))).toEqual(toolNames(makeAgent("default")));
});
test("disallowed tool (edit_file) denies at execution in architect mode", async () => {
const editTool = makeAgent("architect").tools.edit_file;
expect(editTool?.execute).toBeDefined();
const result = (await editTool!.execute!(
{ path: "x.ts", oldString: "a", newString: "b" },
{},
)) as { success: boolean; error?: string; output: string };
expect(result.success).toBe(false);
expect(result.error).toBe("mode_restricted");
expect(result.output).toContain("architect");
});
test("allowed tool (read) is NOT deny-stubbed in architect mode", () => {
const archRead = makeAgent("architect").tools.read;
expect(typeof archRead?.execute).toBe("function");
});
test("default mode does not deny-stub edit_file (distinct execute from architect's stub)", () => {
// The architect edit_file execute is the deny stub; the default one is the
// real tool. They must be different function references — proves default
// mode left edit_file untouched. Avoids running a real edit (which would hit
// the live repo-map worker) so the assertion stays a pure structural check.
const defaultEdit = makeAgent("default").tools.edit_file;
const architectEdit = makeAgent("architect").tools.edit_file;
expect(typeof defaultEdit?.execute).toBe("function");
expect(typeof architectEdit?.execute).toBe("function");
expect(defaultEdit?.execute).not.toBe(architectEdit?.execute);
});
});
describe("buildModeMessage — cache-stable mode injection", () => {
test("default mode returns null (absence of banner = default)", () => {
const cm = new ContextManager(TMP);
cm.setForgeMode("default");
expect(cm.buildModeMessage()).toBeNull();
});
test("architect mode returns banner + instructions", () => {
const cm = new ContextManager(TMP);
cm.setForgeMode("architect");
const msg = cm.buildModeMessage();
expect(msg).toContain("Active mode: ARCHITECT.");
expect(msg).toContain("ARCHITECT MODE");
});
test("plan mode returns banner + plan instructions", () => {
const cm = new ContextManager(TMP);
cm.setForgeMode("plan");
const msg = cm.buildModeMessage();
expect(msg).toContain("Active mode: PLAN.");
expect(msg).toContain("PLAN MODE");
});
test("message is byte-stable for an unchanged mode", () => {
const cm = new ContextManager(TMP);
cm.setForgeMode("architect");
expect(cm.buildModeMessage()).toBe(cm.buildModeMessage());
});
test("message changes when mode changes", () => {
const cm = new ContextManager(TMP);
cm.setForgeMode("architect");
const a = cm.buildModeMessage();
cm.setForgeMode("challenge");
const b = cm.buildModeMessage();
expect(a).not.toBe(b);
expect(b).toContain("Active mode: CHALLENGE.");
});
test("override wins over live state (frozen turn-mode, no mid-turn drift)", () => {
// The deny gate enforces the mode frozen at agent build. The banner must
// reflect that same frozen mode even if live state changed mid-turn.
const cm = new ContextManager(TMP);
cm.setForgeMode("default"); // live state = default
const msg = cm.buildModeMessage("architect"); // frozen = architect
expect(msg).toContain("Active mode: ARCHITECT.");
});
test("override default returns null even when live state is restricted", () => {
const cm = new ContextManager(TMP);
cm.setForgeMode("architect");
expect(cm.buildModeMessage("default")).toBeNull();
});
});