The bridge is constructed once, at session creation, and was handed the startup `cwd` by value. The session's own cwd moves under it — `/cd`, resume, branch restore all call `sessionManager.moveTo` — and the two frames that confine a path themselves (the native `delete`, and a `read_mcp_resource` carrying `download_path`) resolve against whichever cwd the bridge holds. So after a move the primary deleted or overwrote the relative path in the workspace the session had left, and reported success for the path the server actually named. The advisor bridge already passed a live resolver; this is the same resolver on the path that was missed. Locked by a wiring test: the seam is the session handing its handlers to the provider, so the test captures them there, moves the session, and asserts the frame acts on the new workspace and leaves the old file alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014oA3H7aHUL85ydp9PJ3ryF (cherry picked from commit 079c7ac61104d017eecbf781aa1c58eebd39b0b1)
843 lines
31 KiB
TypeScript
843 lines
31 KiB
TypeScript
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import type { StreamFn } from "@oh-my-pi/pi-agent-core";
|
|
import type { Model, ToolResultMessage } from "@oh-my-pi/pi-ai";
|
|
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
|
|
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
|
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import type { CursorExecHandlers } from "@oh-my-pi/pi-coding-agent/cursor";
|
|
import type { MCPManager } from "@oh-my-pi/pi-coding-agent/mcp/manager";
|
|
import {
|
|
type CreateAgentSessionOptions,
|
|
type CustomTool,
|
|
createAgentSession,
|
|
discoverAuthStorage,
|
|
type ExtensionFactory,
|
|
} from "@oh-my-pi/pi-coding-agent/sdk";
|
|
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
|
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
|
import { VIBE_TOOL_NAMES } from "@oh-my-pi/pi-coding-agent/tools/vibe";
|
|
import { logger, removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils";
|
|
import { type } from "arktype";
|
|
|
|
const toolActivationExtension: ExtensionFactory = pi => {
|
|
pi.registerTool({
|
|
name: "default_inactive_tool",
|
|
label: "Default Inactive Tool",
|
|
description: "Tool hidden from the initial active set unless explicitly requested.",
|
|
parameters: type({}),
|
|
defaultInactive: true,
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "inactive" }] };
|
|
},
|
|
});
|
|
pi.registerTool({
|
|
name: "default_active_tool",
|
|
label: "Default Active Tool",
|
|
description: "Tool included in the initial active set.",
|
|
parameters: type({}),
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "active" }] };
|
|
},
|
|
});
|
|
};
|
|
|
|
const sdkCustomTool = {
|
|
name: "sdk_custom_tool",
|
|
label: "SDK Custom Tool",
|
|
description: "SDK-provided custom tool used to verify activation boundaries.",
|
|
parameters: type({}),
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "sdk custom" }] };
|
|
},
|
|
} satisfies CustomTool;
|
|
|
|
describe("createAgentSession defaultInactive tool activation", () => {
|
|
const tempDirs: string[] = [];
|
|
|
|
// Built once and shared by every session. `ModelRegistry` eagerly loads all
|
|
// bundled + cached models and `discoverAuthStorage` opens the auth DB — the
|
|
// dominant (~50ms) slice of a cold boot, and identical for every test here.
|
|
// Injecting it drops each per-test boot to the ~4ms of activation-specific work
|
|
// these tests vary, and skips the background model refresh the SDK would
|
|
// otherwise start when it builds its own registry.
|
|
let modelRegistry!: ModelRegistry;
|
|
let registryAuthDir: string;
|
|
|
|
const makeTempDir = (): string => {
|
|
const tempDir = path.join(os.tmpdir(), `pi-sdk-tool-activation-${Snowflake.next()}`);
|
|
tempDirs.push(tempDir);
|
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
return tempDir;
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
registryAuthDir = path.join(os.tmpdir(), `pi-sdk-tool-activation-auth-${Snowflake.next()}`);
|
|
fs.mkdirSync(registryAuthDir, { recursive: true });
|
|
modelRegistry = new ModelRegistry(await discoverAuthStorage(registryAuthDir));
|
|
});
|
|
|
|
// Shared options for every session. `rules: []` and `workspaceTree` short-circuit
|
|
// the two slow startup scans (rule discovery + native workspace walk, ~100ms each)
|
|
// that are irrelevant to tool activation: these tests assert only which tools are
|
|
// registered/active and that tool names appear in the system prompt. The shared
|
|
// `modelRegistry` is injected here; each call still returns fresh
|
|
// `settings`/`sessionManager` instances to keep tests isolated.
|
|
const baseOptions = (tempDir: string): CreateAgentSessionOptions => ({
|
|
cwd: tempDir,
|
|
agentDir: tempDir,
|
|
modelRegistry,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings: Settings.isolated(),
|
|
model: getBundledModel("openai", "gpt-4o-mini"),
|
|
disableExtensionDiscovery: true,
|
|
skills: [],
|
|
contextFiles: [],
|
|
promptTemplates: [],
|
|
slashCommands: [],
|
|
enableMCP: false,
|
|
enableLsp: false,
|
|
rules: [],
|
|
workspaceTree: { rootPath: tempDir, rendered: "", truncated: false, totalLines: 0, agentsMdFiles: [] },
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const tempDir of tempDirs.splice(0)) {
|
|
removeSyncWithRetries(tempDir);
|
|
}
|
|
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
afterAll(() => {
|
|
removeSyncWithRetries(registryAuthDir);
|
|
});
|
|
|
|
it("excludes defaultInactive extension tools from the initial active set unless explicitly requested", async () => {
|
|
const tempDir = makeTempDir();
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [toolActivationExtension],
|
|
});
|
|
|
|
try {
|
|
expect(session.getAllToolNames()).toEqual(
|
|
expect.arrayContaining(["default_active_tool", "default_inactive_tool"]),
|
|
);
|
|
// Discoverable extension tools mount as xd:// devices, not top-level active tools.
|
|
const deviceNames = session.getXdevToolEntries().map(entry => entry.name);
|
|
expect(deviceNames).toContain("default_active_tool");
|
|
expect(session.getActiveToolNames()).not.toContain("default_active_tool");
|
|
expect(deviceNames).not.toContain("default_inactive_tool");
|
|
expect(session.getActiveToolNames()).not.toContain("default_inactive_tool");
|
|
expect(session.systemPrompt.join("\n")).toContain("default_active_tool");
|
|
expect(session.systemPrompt.join("\n")).not.toContain("default_inactive_tool");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("forwards built-in and external xd:// devices to Cursor provider contexts", async () => {
|
|
const tempDir = makeTempDir();
|
|
const cursorModel = getBundledModel("cursor", "composer-1.5");
|
|
if (!cursorModel) throw new Error("expected bundled Cursor model");
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
model: cursorModel,
|
|
});
|
|
const externalMcpTool: CustomTool = {
|
|
name: "mcp__fixture_report",
|
|
label: "fixture/report",
|
|
description: "Report a fixture result.",
|
|
parameters: type({}),
|
|
strict: true,
|
|
mcpServerName: "fixture",
|
|
mcpToolName: "report",
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "reported" }] };
|
|
},
|
|
};
|
|
|
|
try {
|
|
await session.refreshMCPTools([externalMcpTool]);
|
|
const deviceNames = session.getXdevToolEntries().map(entry => entry.name);
|
|
expect(deviceNames).toEqual(expect.arrayContaining(["ast_edit", "mcp__fixture_report"]));
|
|
expect(session.getActiveToolNames()).not.toContain("mcp__fixture_report");
|
|
|
|
const context = await session.agent.buildSideRequestContext([]);
|
|
const providerToolNames = context.tools?.map(tool => tool.name);
|
|
expect(providerToolNames).toEqual(expect.arrayContaining(["ast_edit", "mcp__fixture_report"]));
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("allows explicitly requested defaultInactive extension tools into the initial active set", async () => {
|
|
const tempDir = makeTempDir();
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [toolActivationExtension],
|
|
toolNames: ["read", "default_inactive_tool"],
|
|
});
|
|
|
|
try {
|
|
expect(session.getActiveToolNames()).toEqual(
|
|
expect.arrayContaining(["read", "default_inactive_tool", "write"]),
|
|
);
|
|
expect(session.getActiveToolNames()).not.toContain("default_active_tool");
|
|
expect(session.getXdevToolEntries().map(entry => entry.name)).toContain("default_active_tool");
|
|
expect(session.getXdevToolEntries().map(entry => entry.name)).not.toContain("default_inactive_tool");
|
|
expect(session.systemPrompt.join("\n")).toContain("default_inactive_tool");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("activates the yield tool when requireYieldTool is set and toolNames is explicit", async () => {
|
|
// Regression for #1408: plan-mode subagents pass an explicit `toolNames` list
|
|
// (e.g. `["read", "grep", "glob", "lsp", "web_search"]`). Without this
|
|
// invariant, `yield` ended up registered but not active, and the model
|
|
// could not satisfy the idle-reminder contract that demands a `yield` call.
|
|
const tempDir = makeTempDir();
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
requireYieldTool: true,
|
|
toolNames: ["read", "grep", "glob", "web_search"],
|
|
});
|
|
|
|
try {
|
|
expect(session.getActiveToolNames()).toContain("yield");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("normalizes legacy builtin toolNames before selecting the active SDK tools", async () => {
|
|
const tempDir = makeTempDir();
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
toolNames: ["read", "search", "find"],
|
|
});
|
|
|
|
try {
|
|
const activeToolNames = session.getActiveToolNames();
|
|
|
|
expect(activeToolNames).toContain("read");
|
|
expect(activeToolNames).toContain("grep");
|
|
expect(activeToolNames).toContain("glob");
|
|
expect(activeToolNames).not.toContain("search");
|
|
expect(activeToolNames).not.toContain("find");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("keeps the write tool registered for plan mode even when no deferrable tool is requested", async () => {
|
|
// Regression for #1428 (adapted to the xd://propose device): plan mode
|
|
// submits its finalized plan by writing the chosen slug/title to
|
|
// xd://propose, dispatched through the plan-proposal handler
|
|
// (interactive-mode.ts: `setPlanProposalHandler`). With an explicit
|
|
// read-only `toolNames` (e.g. `read`, `search`, `find`, `web_search`)
|
|
// the registry has no `write` and no `deferrable` tool; dropping it would
|
|
// silently activate plan mode with no way to submit the plan.
|
|
const tempDir = makeTempDir();
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
toolNames: ["read", "grep", "glob", "web_search"],
|
|
});
|
|
|
|
try {
|
|
expect(session.getToolByName("write")).toBeDefined();
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("does not force write into the registry when neither a deferrable tool nor plan mode needs it", async () => {
|
|
const tempDir = makeTempDir();
|
|
|
|
const settings = Settings.isolated();
|
|
settings.set("plan.enabled", false);
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
settings,
|
|
toolNames: ["read", "grep", "glob", "web_search"],
|
|
});
|
|
|
|
try {
|
|
expect(session.getToolByName("write")).toBeUndefined();
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("does not activate write merely because plan mode is available", async () => {
|
|
const tempDir = makeTempDir();
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
toolNames: ["read"],
|
|
});
|
|
|
|
try {
|
|
await session.setActiveToolsByName(["read"]);
|
|
expect(session.getActiveToolNames()).not.toContain("write");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("preserves write explicitly selected by a runtime caller", async () => {
|
|
const tempDir = makeTempDir();
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
toolNames: ["read"],
|
|
});
|
|
|
|
try {
|
|
await session.setActiveToolsByName(["read", "write"]);
|
|
await session.refreshMCPTools([]);
|
|
expect(session.getActiveToolNames()).toContain("write");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
it("registers vibe tools only during explicit vibe activation and exposes parent Todo bookkeeping", async () => {
|
|
const tempDir = makeTempDir();
|
|
const { session } = await createAgentSession(baseOptions(tempDir));
|
|
const previousActiveToolNames = session.getActiveToolNames();
|
|
|
|
try {
|
|
for (const name of VIBE_TOOL_NAMES) {
|
|
expect(session.getToolByName(name)).toBeUndefined();
|
|
}
|
|
|
|
await session.activateVibeTools(["read", "todo"]);
|
|
const todo = session.getToolByName("todo");
|
|
if (!todo) throw new Error("Expected real Todo tool");
|
|
expect(session.getActiveToolNames()).toContain("todo");
|
|
for (const name of VIBE_TOOL_NAMES) {
|
|
expect(session.getToolByName(name)).toBeDefined();
|
|
expect(session.getActiveToolNames()).toContain(name);
|
|
}
|
|
|
|
await todo.execute("vibe-todo-init", {
|
|
op: "init",
|
|
list: [{ phase: "Work", items: ["Worker change"] }],
|
|
});
|
|
await todo.execute("vibe-todo-done", { op: "done", task: "Worker change" });
|
|
expect(session.getTodoPhases()).toMatchObject([
|
|
{
|
|
name: "Work",
|
|
tasks: [{ content: "Worker change", status: "completed" }],
|
|
},
|
|
]);
|
|
|
|
await session.deactivateVibeTools(previousActiveToolNames);
|
|
for (const name of VIBE_TOOL_NAMES) {
|
|
expect(session.getToolByName(name)).toBeUndefined();
|
|
}
|
|
expect(session.getActiveToolNames()).toEqual(previousActiveToolNames);
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("rehydrates completed parent Todo work from persisted session history", async () => {
|
|
const tempDir = makeTempDir();
|
|
const sessionManager = SessionManager.create(tempDir, tempDir);
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
sessionManager,
|
|
});
|
|
|
|
try {
|
|
await session.activateVibeTools(["read", "todo"]);
|
|
const todo = session.getToolByName("todo");
|
|
if (!todo) throw new Error("Expected real Todo tool");
|
|
const init = await todo.execute("vibe-todo-init", {
|
|
op: "init",
|
|
list: [{ phase: "Worker flow", items: ["Reconcile worker result"] }],
|
|
});
|
|
const done = await todo.execute("vibe-todo-done", { op: "done", task: "Reconcile worker result" });
|
|
for (const [toolCallId, result] of [
|
|
["vibe-todo-init", init],
|
|
["vibe-todo-done", done],
|
|
] as const) {
|
|
sessionManager.appendMessage({
|
|
role: "toolResult",
|
|
toolCallId,
|
|
toolName: "todo",
|
|
content: result.content,
|
|
details: result.details,
|
|
isError: result.isError === true,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
await sessionManager.ensureOnDisk();
|
|
const sessionFile = session.sessionFile;
|
|
if (!sessionFile) throw new Error("Expected persisted session file");
|
|
|
|
session.setTodoPhases([]);
|
|
expect(session.getTodoPhases()).toEqual([]);
|
|
expect(await session.switchSession(sessionFile)).toBe(true);
|
|
expect(session.getTodoPhases()).toMatchObject([
|
|
{
|
|
name: "Worker flow",
|
|
tasks: [{ content: "Reconcile worker result", status: "completed" }],
|
|
},
|
|
]);
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("does not register the xAI TTS tool unless enabled", async () => {
|
|
const tempDir = makeTempDir();
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
});
|
|
|
|
try {
|
|
expect(session.getToolByName("tts")).toBeUndefined();
|
|
expect(session.getAllToolNames()).not.toContain("tts");
|
|
expect(session.getActiveToolNames()).not.toContain("tts");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("registers the xAI TTS tool when enabled", async () => {
|
|
const tempDir = makeTempDir();
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
settings: Settings.isolated({ "speechgen.enabled": true }),
|
|
});
|
|
|
|
try {
|
|
expect(session.getToolByName("tts")).toBeDefined();
|
|
// tts is a discoverable custom tool → mounted as an xd:// device, not top-level.
|
|
expect(session.getXdevToolEntries().map(entry => entry.name)).toContain("tts");
|
|
expect(session.getActiveToolNames()).not.toContain("tts");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("keeps the stable MCP tool-name collision winner during SDK startup and warns", async () => {
|
|
const tempDir = makeTempDir();
|
|
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
|
const createMcpTool = (serverName: string, label: string): CustomTool => ({
|
|
name: "mcp__foo_bar_lookup",
|
|
label,
|
|
description: `Lookup from ${serverName}`,
|
|
parameters: type({}),
|
|
mcpServerName: serverName,
|
|
mcpToolName: "lookup",
|
|
async execute() {
|
|
return { content: [{ type: "text", text: serverName }] };
|
|
},
|
|
});
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
customTools: [createMcpTool("foo.bar", "foo.bar/lookup"), createMcpTool("foo_bar", "foo_bar/lookup")],
|
|
});
|
|
|
|
try {
|
|
expect(session.getToolByName("mcp__foo_bar_lookup")?.label).toBe("foo.bar/lookup");
|
|
expect(warn).toHaveBeenCalledWith("MCP tool name collision; keeping stable winner", {
|
|
name: "mcp__foo_bar_lookup",
|
|
keptServer: "foo.bar",
|
|
keptTool: "lookup",
|
|
ignoredServer: "foo_bar",
|
|
ignoredTool: "lookup",
|
|
});
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("keeps restricted host tool lists isolated from configured custom capabilities", async () => {
|
|
const restrictedDir = makeTempDir();
|
|
const normalDir = makeTempDir();
|
|
const configuredSettings = () =>
|
|
Settings.isolated({
|
|
"providers.imageOrder": ["openai"],
|
|
"generate_image.enabled": true,
|
|
"speechgen.enabled": true,
|
|
"memory.backend": "hindsight",
|
|
"autolearn.enabled": true,
|
|
});
|
|
|
|
const inheritedManager = {
|
|
getServerInstructions: () => new Map([["private-server", "must not reach restricted child"]]),
|
|
} as unknown as MCPManager;
|
|
|
|
const { session: restricted } = await createAgentSession({
|
|
...baseOptions(restrictedDir),
|
|
settings: configuredSettings(),
|
|
extensions: [toolActivationExtension],
|
|
customTools: [sdkCustomTool],
|
|
toolNames: ["read", "lsp", "hub"],
|
|
requireYieldTool: true,
|
|
restrictToolNames: true,
|
|
enableMCP: true,
|
|
mcpManager: inheritedManager,
|
|
enableLsp: true,
|
|
enableIrc: true,
|
|
});
|
|
|
|
try {
|
|
expect(restricted.getAllToolNames()).toEqual(["read", "yield"]);
|
|
expect(restricted.getActiveToolNames()).toEqual(["read", "yield"]);
|
|
for (const name of [
|
|
"generate_image",
|
|
"tts",
|
|
"recall",
|
|
"retain",
|
|
"reflect",
|
|
"learn",
|
|
"manage_skill",
|
|
"default_active_tool",
|
|
"default_inactive_tool",
|
|
"sdk_custom_tool",
|
|
"lsp",
|
|
"hub",
|
|
]) {
|
|
expect(restricted.getToolByName(name)).toBeUndefined();
|
|
}
|
|
expect(restricted.getXdevToolEntries()).toEqual([]);
|
|
expect(restricted.systemPrompt.join("\n")).not.toContain("private-server");
|
|
expect(restricted.systemPrompt.join("\n")).not.toContain("MCP Server Instructions");
|
|
} finally {
|
|
await restricted.dispose();
|
|
}
|
|
|
|
const { session: normal } = await createAgentSession({
|
|
...baseOptions(normalDir),
|
|
settings: configuredSettings(),
|
|
extensions: [toolActivationExtension],
|
|
customTools: [sdkCustomTool],
|
|
toolNames: ["read", "generate_image"],
|
|
requireYieldTool: true,
|
|
restrictToolNames: false,
|
|
});
|
|
|
|
try {
|
|
const activeToolNames = normal.getActiveToolNames();
|
|
expect(activeToolNames).toEqual(
|
|
expect.arrayContaining(["read", "yield", "generate_image", "learn", "manage_skill", "write"]),
|
|
);
|
|
for (const name of ["tts", "default_active_tool", "sdk_custom_tool"]) {
|
|
expect(activeToolNames).not.toContain(name);
|
|
}
|
|
expect(normal.getXdevToolEntries().map(entry => entry.name)).toEqual(
|
|
expect.arrayContaining(["tts", "default_active_tool", "sdk_custom_tool"]),
|
|
);
|
|
expect(normal.getAllToolNames()).toEqual(
|
|
expect.arrayContaining([
|
|
"generate_image",
|
|
"read",
|
|
"yield",
|
|
"tts",
|
|
"default_active_tool",
|
|
"sdk_custom_tool",
|
|
"recall",
|
|
"retain",
|
|
"reflect",
|
|
]),
|
|
);
|
|
} finally {
|
|
await normal.dispose();
|
|
}
|
|
});
|
|
|
|
it("renders report-issue guidance only for unrestricted sessions", async () => {
|
|
const normalDir = makeTempDir();
|
|
const restrictedDir = makeTempDir();
|
|
const { session: normal } = await createAgentSession({
|
|
...baseOptions(normalDir),
|
|
settings: Settings.isolated({ "dev.autoqa": true }),
|
|
});
|
|
const { session: restricted } = await createAgentSession({
|
|
...baseOptions(restrictedDir),
|
|
settings: Settings.isolated({ "dev.autoqa": true }),
|
|
toolNames: ["read"],
|
|
restrictToolNames: true,
|
|
});
|
|
|
|
try {
|
|
expect(normal.systemPrompt.join("\n")).toContain("xd://report_issue");
|
|
expect(restricted.systemPrompt.join("\n")).not.toContain("xd://report_issue");
|
|
} finally {
|
|
await Promise.all([normal.dispose(), restricted.dispose()]);
|
|
}
|
|
});
|
|
|
|
it("ignores an inherited MCP manager when MCP is disabled", async () => {
|
|
const tempDir = makeTempDir();
|
|
const inheritedManager = {
|
|
getServerInstructions: () => new Map([["private-server", "must not reach restricted child"]]),
|
|
} as unknown as MCPManager;
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
enableMCP: false,
|
|
mcpManager: inheritedManager,
|
|
});
|
|
|
|
try {
|
|
expect(session.systemPrompt.join("\n")).not.toContain("private-server");
|
|
expect(session.systemPrompt.join("\n")).not.toContain("MCP Server Instructions");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
// A session created on another provider keeps its configured-mode `edit` in
|
|
// the registry (only a Cursor-created session moves it out) and the tool
|
|
// roster is built once, at creation — switching to Cursor later does not
|
|
// rebuild it. These two cover both directions of that wiring: the granted
|
|
// session must still reach a replace-mode instance for `pi_edit` (whose
|
|
// `old_text`/`new_text` args do not validate against the default `hashline`
|
|
// schema), and the restricted one must still be refused.
|
|
//
|
|
// The handlers are internal to the session; `streamFn` is where they are
|
|
// handed to the provider, which is the externally observable seam.
|
|
const captureCursorExecHandlers = async (session: AgentSession, cursorModel: Model): Promise<CursorExecHandlers> => {
|
|
let handlers: CursorExecHandlers | undefined;
|
|
const streamFn: StreamFn = (_model, _context, options) => {
|
|
// The session installs the concrete class; the provider option is
|
|
// typed as the wire-level interface, whose `piEdit` answers a proto
|
|
// result rather than the tool result the class returns.
|
|
handlers = options?.cursorExecHandlers as CursorExecHandlers | undefined;
|
|
throw new Error("captured");
|
|
};
|
|
vi.spyOn(session.agent, "streamFn").mockImplementation(streamFn);
|
|
|
|
await session.setModel(cursorModel);
|
|
// Not wrapped in a catch: `prompt` resolves even when the turn fails (the
|
|
// loop records the stream error), so a rejection here is a genuine setup
|
|
// failure and must surface rather than be mistaken for the capture.
|
|
await session.prompt("hi");
|
|
if (!handlers) throw new Error("no exec handlers reached the provider");
|
|
return handlers;
|
|
};
|
|
|
|
// `setModel` and `prompt` both refuse a provider with no configured auth.
|
|
// Granted on the suite's isolated storage rather than through the provider's
|
|
// env var — an env mutation would outlive this file — and removed after,
|
|
// since the storage is shared by every test here.
|
|
const withProviderAuth = async (providers: string[], run: () => Promise<void>): Promise<void> => {
|
|
for (const provider of providers) modelRegistry.authStorage.setRuntimeApiKey(provider, "test-key");
|
|
try {
|
|
await run();
|
|
} finally {
|
|
for (const provider of providers) modelRegistry.authStorage.removeRuntimeApiKey(provider);
|
|
}
|
|
};
|
|
|
|
it("answers a native pi_edit after a session switches onto Cursor", async () => {
|
|
const tempDir = makeTempDir();
|
|
const cursorModel = getBundledModel("cursor", "composer-1.5");
|
|
if (!cursorModel) throw new Error("expected bundled Cursor model");
|
|
const target = path.join(tempDir, "sample.txt");
|
|
fs.writeFileSync(target, "alpha\nbeta\n");
|
|
|
|
await withProviderAuth(["cursor"], async () => {
|
|
const { session } = await createAgentSession(baseOptions(tempDir));
|
|
try {
|
|
const handlers = await captureCursorExecHandlers(session, cursorModel);
|
|
const result = await handlers.piEdit({
|
|
toolCallId: "sdk-switch-1",
|
|
args: { path: target, edits: [{ oldText: "beta", newText: "gamma" }] },
|
|
} as never);
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
expect(fs.readFileSync(target, "utf8")).toBe("alpha\ngamma\n");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("refuses a native pi_edit after a read-only session switches onto Cursor", async () => {
|
|
// The bridge instance is constructed, not looked up, so building it for
|
|
// a roster that was never granted `edit` would hand a read-only session
|
|
// a mutating tool the native frames reach regardless of the advertised
|
|
// catalog (issue #5680). Making the construction provider-independent
|
|
// must not widen it.
|
|
const tempDir = makeTempDir();
|
|
const cursorModel = getBundledModel("cursor", "composer-1.5");
|
|
if (!cursorModel) throw new Error("expected bundled Cursor model");
|
|
const target = path.join(tempDir, "sample.txt");
|
|
fs.writeFileSync(target, "alpha\nbeta\n");
|
|
|
|
await withProviderAuth(["cursor"], async () => {
|
|
const { session } = await createAgentSession({ ...baseOptions(tempDir), toolNames: ["read"] });
|
|
try {
|
|
const handlers = await captureCursorExecHandlers(session, cursorModel);
|
|
const result = await handlers.piEdit({
|
|
toolCallId: "sdk-switch-2",
|
|
args: { path: target, edits: [{ oldText: "beta", newText: "gamma" }] },
|
|
} as never);
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(fs.readFileSync(target, "utf8")).toBe("alpha\nbeta\n");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("resolves bridge frame paths through the session's live cwd", async () => {
|
|
// The bridge is built once, at session creation, while the session's cwd
|
|
// moves under it (`/cd`, resume, branch restore). The path-confining
|
|
// frames — the native `delete`, and a `download_path` resource read —
|
|
// resolve a relative path against whichever cwd the bridge was handed, so
|
|
// a startup snapshot means acting on the workspace the session has left
|
|
// while reporting success for the path the server named.
|
|
const tempDir = makeTempDir();
|
|
const movedDir = makeTempDir();
|
|
const cursorModel = getBundledModel("cursor", "composer-1.5");
|
|
if (!cursorModel) throw new Error("expected bundled Cursor model");
|
|
const staleTarget = path.join(tempDir, "obsolete.txt");
|
|
const liveTarget = path.join(movedDir, "obsolete.txt");
|
|
fs.writeFileSync(staleTarget, "preserve me");
|
|
fs.writeFileSync(liveTarget, "remove me");
|
|
|
|
await withProviderAuth(["cursor"], async () => {
|
|
const sessionManager = SessionManager.inMemory();
|
|
const { session } = await createAgentSession({ ...baseOptions(tempDir), sessionManager });
|
|
try {
|
|
const handlers = await captureCursorExecHandlers(session, cursorModel);
|
|
await sessionManager.moveTo(movedDir);
|
|
|
|
const result = await handlers.delete({ toolCallId: "sdk-cwd-1", path: "obsolete.txt" } as never);
|
|
|
|
expect(result.isError).toBe(false);
|
|
expect(fs.existsSync(liveTarget)).toBe(false);
|
|
expect(fs.existsSync(staleTarget)).toBe(true);
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("does not execute an unadvertised edit call through the fallback resolver", async () => {
|
|
// One resolver serves two roles: the session's device resolver is passed
|
|
// to the bridge as `getTool` AND installed as the agent loop's
|
|
// `resolveFallbackTool`, which runs for ANY call the advertised set does
|
|
// not contain. It must stay device-only: routing `edit` through it would
|
|
// execute a replace-mode edit for a call the model was never offered —
|
|
// a hallucinated one, or a tool the session deselected after startup.
|
|
// `pi_edit` gets its instance from `getEditReplaceTool` instead.
|
|
const tempDir = makeTempDir();
|
|
const target = path.join(tempDir, "sample.txt");
|
|
fs.writeFileSync(target, "alpha\nbeta\n");
|
|
|
|
await withProviderAuth(["openai"], async () => {
|
|
// Granted at startup, so an `edit` instance exists to leak, then
|
|
// deselected — the exact state that makes the fallback dangerous.
|
|
const { session } = await createAgentSession(baseOptions(tempDir));
|
|
try {
|
|
await session.setActiveToolsByName(session.getActiveToolNames().filter(name => name !== "edit"));
|
|
expect(session.getActiveToolNames()).not.toContain("edit");
|
|
|
|
// A real mock provider, not a hand-rolled stream: the loop builds
|
|
// the assistant message from the full event sequence, and an
|
|
// incomplete one is dropped before tool dispatch ever runs.
|
|
const toolCallId = "unadvertised-edit-1";
|
|
const mock = createMockModel({
|
|
responses: [
|
|
{
|
|
content: [
|
|
{
|
|
type: "toolCall",
|
|
id: toolCallId,
|
|
name: "edit",
|
|
arguments: { path: target, edits: [{ old_text: "beta", new_text: "gamma" }] },
|
|
},
|
|
],
|
|
},
|
|
{ content: [{ type: "text", text: "done" }] },
|
|
],
|
|
});
|
|
vi.spyOn(session.agent, "streamFn").mockImplementation(mock.stream);
|
|
|
|
await session.prompt("hi");
|
|
|
|
// The surfaced result, not just the file: an unchanged file alone
|
|
// would also pass if the fallback HAD resolved the tool and the
|
|
// edit then failed validation or approval. Only "not found"
|
|
// proves the resolver refused to hand one over.
|
|
const result = session.messages.find(
|
|
(message): message is ToolResultMessage =>
|
|
message.role === "toolResult" && message.toolCallId === toolCallId,
|
|
);
|
|
expect(result?.isError).toBe(true);
|
|
expect(JSON.stringify(result?.content)).toContain("Tool edit not found");
|
|
expect(fs.readFileSync(target, "utf8")).toBe("alpha\nbeta\n");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("runs advisor tools through the approval gate", async () => {
|
|
// The advisor's tools are built straight from `BUILTIN_TOOLS`, outside
|
|
// the registry loop that wraps everything else. Its own loop and its
|
|
// Cursor exec bridge (`piWrite`/`piBash`) run those instances directly,
|
|
// so an unwrapped one executes whatever it is handed regardless of the
|
|
// user's `tools.approval.<tool>` policy — the gate lives in
|
|
// `ExtensionToolWrapper`, not in either caller.
|
|
const tempDir = makeTempDir();
|
|
const target = path.join(tempDir, "advisor-write.txt");
|
|
|
|
// An advisor only builds once a model resolves for it, and both the
|
|
// explicit override and the `advisor` role chain resolve against
|
|
// `modelRegistry.getAvailable()` — the models this machine holds auth
|
|
// for. Grant the suite's isolated storage a key and name the model
|
|
// outright, or the roster silently resolves to `no_model` wherever no
|
|
// provider is configured (CI) while passing on a developer box whose
|
|
// environment happens to carry provider keys.
|
|
await withProviderAuth(["openai"], async () => {
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
settings: Settings.isolated({ "advisor.enabled": true, "tools.approval": { write: "deny" } }),
|
|
});
|
|
try {
|
|
// The default advisor roster is read-only (read/grep/glob); the
|
|
// reviewed hole needs one actually granted a mutating tool.
|
|
session.applyAdvisorConfigs([{ name: "writer", tools: ["write"], model: "gpt-4o-mini" }], undefined);
|
|
const advisor = session.getAdvisorAgent();
|
|
if (!advisor) throw new Error("expected an advisor agent");
|
|
const writeTool = advisor.state.tools?.find(tool => tool.name === "write");
|
|
if (!writeTool) throw new Error("expected the advisor to hold a write tool");
|
|
|
|
// The gate rejects rather than returning an error result — that throw
|
|
// IS the refusal, and it only happens when the instance is wrapped.
|
|
await expect(
|
|
writeTool.execute("advisor-w1", { path: target, content: "written" }, undefined, undefined, {
|
|
settings: session.settings,
|
|
} as never),
|
|
).rejects.toThrow(/blocked by user policy/);
|
|
expect(fs.existsSync(target)).toBe(false);
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
});
|
|
});
|