Files
oh-my-pi/packages/coding-agent/test/agent-session-magic-keywords.test.ts
T
Slava Zavadsky b6a3862ebc fix(coding-agent): gate prompt tool mentions on session tool availability
System and mode prompts referenced tools that may be absent from the
session catalog, forcing the model to satisfy requirements it cannot
execute (generalizes #8139's browser-verification mismatch):

- system-prompt.md: browser verification now keys on the actual UI
  surface and available tools (browser/computer/TUI/CLI), with an
  explicit behavioral/smoke-test fallback when no runtime tool exists;
  todo workflow guidance and the AST-section grep hint are gated on
  tool presence; the auto-QA report_issue block additionally requires
  the write tool.
- project-prompt.md: workspace-tree drill-in and additional-roots tool
  hints only name tools present in the session.
- plan-mode-active.md: ask-tool directives get a prose fallback and
  scout-via-task dispatch is gated on the task tool (render site now
  passes askAvailable/taskAvailable).
- orchestrate-notice.md: the tool budget, verify gates, todo tracking,
  and inline-edit guidance are gated on tool presence; the notice is
  skipped entirely when the task tool is inactive (render fn takes the
  active tool list).

Adds regression tests for each gate; changelog entry.
2026-08-10 20:24:34 -04:00

230 lines
9.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { type } from "@oh-my-pi/omptype";
import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core";
import { Effort } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import * as autoThinkingClassifier from "@oh-my-pi/pi-coding-agent/auto-thinking/classifier";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { AUTO_THINKING } from "@oh-my-pi/pi-coding-agent/thinking";
import { removeWithRetries } from "@oh-my-pi/pi-utils";
const mockTaskTool: AgentTool = {
name: "task",
label: "Task",
description: "Mock task tool",
parameters: type({}),
execute: async () => ({ content: [{ type: "text" as const, text: "ok" }] }),
};
const mockEvalTool: AgentTool = {
name: "eval",
label: "Eval",
description: "Mock eval tool",
parameters: type({}),
execute: async () => ({ content: [{ type: "text" as const, text: "ok" }] }),
};
async function createMagicKeywordSession(
root: string,
tools: AgentTool[] = [mockTaskTool, mockEvalTool],
): Promise<{
session: AgentSession;
settings: Settings;
authStorage: AuthStorage;
}> {
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected bundled Claude Sonnet model");
const agent = new Agent({
initialState: {
model,
systemPrompt: ["Test"],
tools,
messages: [],
thinkingLevel: Effort.High,
},
});
const authStorage = await AuthStorage.create(path.join(root, "auth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(root, "models.yml"));
const settings = Settings.isolated();
const session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings,
modelRegistry,
});
return { session, settings, authStorage };
}
describe("AgentSession magic keyword settings", () => {
let root: string;
let session: AgentSession | undefined;
let authStorage: AuthStorage | undefined;
beforeEach(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-magic-keywords-"));
});
afterEach(async () => {
vi.restoreAllMocks();
if (session) await session.dispose();
authStorage?.close();
await removeWithRetries(root).catch(() => undefined);
session = undefined;
authStorage = undefined;
});
it("does not append magic keyword notices when disabled", async () => {
const created = await createMagicKeywordSession(root);
session = created.session;
authStorage = created.authStorage;
created.settings.set("magicKeywords.enabled", false);
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
await session.prompt("please workflowz this and ultrathink through it");
const promptMessages = promptSpy.mock.calls[0]![0] as unknown as Array<{ customType?: string }>;
expect(promptMessages.map(message => message.customType).filter(Boolean)).toEqual([]);
});
it("honors non-ultrathink per-keyword notice toggles", async () => {
const created = await createMagicKeywordSession(root);
session = created.session;
authStorage = created.authStorage;
created.settings.set("magicKeywords.orchestrate", false);
created.settings.set("magicKeywords.workflow", false);
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
await session.prompt("please orchestrate and workflowz this");
const promptMessages = promptSpy.mock.calls[0]![0] as unknown as Array<{ customType?: string }>;
expect(promptMessages.map(message => message.customType).filter(Boolean)).toEqual([]);
});
it("still appends enabled non-ultrathink notices", async () => {
const created = await createMagicKeywordSession(root);
session = created.session;
authStorage = created.authStorage;
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
await session.prompt("please orchestrate and workflowz this");
const promptMessages = promptSpy.mock.calls[0]![0] as unknown as Array<{ customType?: string }>;
expect(promptMessages.map(message => message.customType).filter(Boolean)).toEqual([
"orchestrate-notice",
"workflow-notice",
]);
});
it("renders the eval-specific workflowz notice", async () => {
const created = await createMagicKeywordSession(root);
session = created.session;
authStorage = created.authStorage;
created.settings.set("task.batch", false);
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
await session.prompt("please workflowz this");
const promptMessages = promptSpy.mock.calls[0]![0] as unknown as Array<{
content?: string;
customType?: string;
}>;
const notice = promptMessages.find(message => message.customType === "workflow-notice");
expect(notice?.customType).toBe("workflow-notice");
expect(notice?.content).toContain("`eval`");
expect(notice?.content).toContain("`parallel(thunks)`");
expect(notice?.content).toContain("**Python (`eval`, Python backend):**");
expect(notice?.content).toContain("**JavaScript (`eval`, JavaScript backend):**");
});
it("updates the workflowz notice when scout is disabled during the session", async () => {
const created = await createMagicKeywordSession(root);
session = created.session;
authStorage = created.authStorage;
created.settings.set("task.disabledAgents", ["scout"]);
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
await session.prompt("please workflowz this");
const promptMessages = promptSpy.mock.calls[0]![0] as unknown as Array<{ content?: string; customType?: string }>;
const notice = promptMessages.find(message => message.customType === "workflow-notice")?.content ?? "";
expect(notice.toLowerCase()).not.toContain("scout");
expect(notice).toContain("Explore inline FIRST");
});
it("skips workflowz notice when the task tool is inactive", async () => {
const created = await createMagicKeywordSession(root, []);
session = created.session;
authStorage = created.authStorage;
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
await session.prompt("please workflowz this");
const promptMessages = promptSpy.mock.calls[0]![0] as unknown as Array<{ customType?: string }>;
expect(promptMessages.map(message => message.customType).filter(Boolean)).toEqual([]);
});
it("skips orchestrate notice when the task tool is inactive", async () => {
const created = await createMagicKeywordSession(root, []);
session = created.session;
authStorage = created.authStorage;
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
await session.prompt("please orchestrate this");
const promptMessages = promptSpy.mock.calls[0]![0] as unknown as Array<{ customType?: string }>;
expect(promptMessages.map(message => message.customType).filter(Boolean)).toEqual([]);
});
it("skips workflowz notice when the eval tool is inactive", async () => {
const created = await createMagicKeywordSession(root, [mockTaskTool]);
session = created.session;
authStorage = created.authStorage;
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
await session.prompt("please workflowz this");
const promptMessages = promptSpy.mock.calls[0]![0] as unknown as Array<{ customType?: string }>;
expect(promptMessages.map(message => message.customType).filter(Boolean)).toEqual([]);
});
it("does not use a disabled ultrathink keyword to force auto thinking", async () => {
const created = await createMagicKeywordSession(root);
session = created.session;
authStorage = created.authStorage;
created.settings.set("magicKeywords.ultrathink", false);
vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
const classifierSpy = vi.spyOn(autoThinkingClassifier, "classifyDifficulty").mockResolvedValue(Effort.Low);
session.setThinkingLevel(AUTO_THINKING);
await session.prompt("ultrathink through the unsafe refactor");
expect(classifierSpy).toHaveBeenCalledTimes(1);
expect(session.thinkingLevel).toBe(Effort.Low);
expect(session.autoResolvedThinkingLevel()).toBe(Effort.Low);
});
it("queues the magic-keyword notice before the user message", async () => {
const created = await createMagicKeywordSession(root);
session = created.session;
authStorage = created.authStorage;
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
await session.prompt("ultrathink do the thing");
const promptMessages = promptSpy.mock.calls[0]![0] as unknown as Array<{ role?: string; customType?: string }>;
const noticeIdx = promptMessages.findIndex(m => m.customType === "ultrathink-notice");
const userIdx = promptMessages.findIndex(m => m.role === "user");
expect(noticeIdx).toBeGreaterThanOrEqual(0);
expect(userIdx).toBeGreaterThanOrEqual(0);
expect(noticeIdx).toBeLessThan(userIdx);
});
});