6b4823181b
- Remove redundant definedness, null, and type checks across test suites in multiple packages. - Clean up unused assertions, metadata tests, and obsolete test cases. - Add good versus bad test filter guidelines and requirements to project documentation.
2215 lines
77 KiB
TypeScript
2215 lines
77 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 } from "@oh-my-pi/omptype";
|
|
import type { AgentTool, 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 {
|
|
EXTENSION_HANDLER_TIMEOUT_MS,
|
|
testSetExtensionHandlerTimeoutMs,
|
|
} from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner";
|
|
import type { MCPManager } from "@oh-my-pi/pi-coding-agent/mcp/manager";
|
|
import { initializeExtensions } from "@oh-my-pi/pi-coding-agent/modes/runtime-init";
|
|
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, untilAborted } from "@oh-my-pi/pi-utils";
|
|
|
|
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: [] },
|
|
});
|
|
|
|
const requireBundledModel = (provider: "anthropic" | "google" | "openai" | "xai", id: string): Model => {
|
|
const bundled = getBundledModel(provider, id);
|
|
if (!bundled) throw new Error(`Expected ${provider}/${id} model to exist`);
|
|
return bundled;
|
|
};
|
|
|
|
afterEach(() => {
|
|
for (const tempDir of tempDirs.splice(0)) {
|
|
removeSyncWithRetries(tempDir);
|
|
}
|
|
|
|
vi.restoreAllMocks();
|
|
testSetExtensionHandlerTimeoutMs(EXTENSION_HANDLER_TIMEOUT_MS);
|
|
});
|
|
|
|
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("activates the private think tool when external thinking is enabled at runtime", async () => {
|
|
const tempDir = makeTempDir();
|
|
const settings = Settings.isolated();
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
model: requireBundledModel("openai", "gpt-5"),
|
|
settings,
|
|
});
|
|
|
|
try {
|
|
expect(session.getToolByName("think")).toBeUndefined();
|
|
expect(session.getActiveToolNames()).not.toContain("think");
|
|
|
|
settings.set("externalThinking", true);
|
|
await session.setThinkToolEnabled(true);
|
|
|
|
expect(session.getToolByName("think")).toBeDefined();
|
|
expect(session.getActiveToolNames()).toContain("think");
|
|
expect(session.getXdevToolEntries().map(entry => entry.name)).not.toContain("think");
|
|
|
|
settings.set("externalThinking", false);
|
|
await session.setThinkToolEnabled(false);
|
|
expect(session.getActiveToolNames()).not.toContain("think");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("exposes the private think tool only on transports that can disable native reasoning", async () => {
|
|
const tempDir = makeTempDir();
|
|
const settings = Settings.isolated({ externalThinking: true });
|
|
const unsupported = requireBundledModel("xai", "grok-4");
|
|
const fable = requireBundledModel("anthropic", "claude-fable-5");
|
|
const responses = requireBundledModel("openai", "gpt-5");
|
|
const gemini = requireBundledModel("google", "gemini-2.5-flash");
|
|
const mandatoryGemini = requireBundledModel("google", "gemini-2.5-pro");
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
settings,
|
|
model: unsupported,
|
|
});
|
|
const authStorage = session.modelRegistry.authStorage;
|
|
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
|
authStorage.setRuntimeApiKey("openai", "test-key");
|
|
authStorage.setRuntimeApiKey("google", "test-key");
|
|
authStorage.setRuntimeApiKey("xai", "test-key");
|
|
|
|
try {
|
|
expect(session.getActiveToolNames()).not.toContain("think");
|
|
|
|
await session.setModel(fable);
|
|
expect(session.getToolByName("think")).toBeDefined();
|
|
expect(session.getActiveToolNames()).toContain("think");
|
|
expect(session.systemPrompt.join("\n")).toContain("private scratchpad; not shown to user");
|
|
|
|
await session.setModel(responses);
|
|
expect(session.getActiveToolNames()).toContain("think");
|
|
await session.setModel(gemini);
|
|
expect(session.getActiveToolNames()).toContain("think");
|
|
await session.setModel(mandatoryGemini);
|
|
expect(session.getActiveToolNames()).not.toContain("think");
|
|
|
|
await session.setModel(unsupported);
|
|
expect(session.getActiveToolNames()).not.toContain("think");
|
|
expect(session.systemPrompt.join("\n")).not.toContain("private scratchpad; not shown to user");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("forces think and sends reasoning effort off for a Responses turn", async () => {
|
|
const tempDir = makeTempDir();
|
|
const settings = Settings.isolated({ externalThinking: true });
|
|
const requestTexts: string[] = [];
|
|
const sse = (events: unknown[]): Response =>
|
|
new Response(events.map(event => `data: ${JSON.stringify(event)}\n\n`).join(""), {
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
const completed = (id: string) => ({
|
|
type: "response.completed",
|
|
response: {
|
|
id,
|
|
status: "completed",
|
|
usage: {
|
|
input_tokens: 1,
|
|
output_tokens: 1,
|
|
total_tokens: 2,
|
|
input_tokens_details: { cached_tokens: 0 },
|
|
},
|
|
},
|
|
});
|
|
const server = Bun.serve({
|
|
port: 0,
|
|
fetch: async request => {
|
|
requestTexts.push(await request.text());
|
|
if (requestTexts.length === 1) {
|
|
const argumentsJson = JSON.stringify({ thoughts: "Checked the request before answering." });
|
|
return sse([
|
|
{
|
|
type: "response.output_item.added",
|
|
output_index: 0,
|
|
item: {
|
|
type: "function_call",
|
|
id: "fc_think",
|
|
call_id: "call_think",
|
|
name: "think",
|
|
arguments: "",
|
|
},
|
|
},
|
|
{
|
|
type: "response.function_call_arguments.done",
|
|
output_index: 0,
|
|
item_id: "fc_think",
|
|
arguments: argumentsJson,
|
|
},
|
|
{
|
|
type: "response.output_item.done",
|
|
output_index: 0,
|
|
item: {
|
|
type: "function_call",
|
|
id: "fc_think",
|
|
call_id: "call_think",
|
|
name: "think",
|
|
arguments: argumentsJson,
|
|
},
|
|
},
|
|
completed("resp_think"),
|
|
]);
|
|
}
|
|
return sse([
|
|
{ type: "response.output_text.delta", output_index: 0, delta: "Done." },
|
|
{
|
|
type: "response.output_item.done",
|
|
output_index: 0,
|
|
item: {
|
|
type: "message",
|
|
id: "msg_done",
|
|
role: "assistant",
|
|
status: "completed",
|
|
content: [{ type: "output_text", text: "Done." }],
|
|
},
|
|
},
|
|
completed("resp_done"),
|
|
]);
|
|
},
|
|
});
|
|
const model = requireBundledModel("openai", "gpt-5");
|
|
// The prompt preflight validates the key through the registry (not the
|
|
// per-request `getApiKey` override), so seed it for keyless CI runners.
|
|
modelRegistry.authStorage.setRuntimeApiKey("openai", "test-key");
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
settings,
|
|
model: { ...model, baseUrl: `${server.url}v1` },
|
|
getApiKey: () => "test-key",
|
|
});
|
|
expect(session.getActiveToolNames()).toContain("think");
|
|
|
|
try {
|
|
await session.prompt("Use the scratchpad before answering.");
|
|
const firstRequest = requestTexts.at(0);
|
|
if (!firstRequest) throw new Error("Expected the initial provider request.");
|
|
expect(requestTexts).toHaveLength(2);
|
|
expect(JSON.parse(firstRequest)).toEqual(
|
|
expect.objectContaining({
|
|
// "none" is the only disable level the Responses wire accepts ("off" 400s).
|
|
reasoning: { effort: "none" },
|
|
tool_choice: expect.objectContaining({ name: "think" }),
|
|
}),
|
|
);
|
|
} finally {
|
|
await session.dispose();
|
|
server.stop(true);
|
|
}
|
|
});
|
|
|
|
it("publishes tools from lazy session startup before the input lifecycle completes", async () => {
|
|
const tempDir = makeTempDir();
|
|
const startupGate = Promise.withResolvers<void>();
|
|
const lateRegistrationExtension: ExtensionFactory = pi => {
|
|
let startupPromise: Promise<void> | undefined;
|
|
pi.on("session_start", () => {
|
|
startupPromise = (async () => {
|
|
await startupGate.promise;
|
|
pi.registerTool({
|
|
name: "late_active_tool",
|
|
label: "Late Active Tool",
|
|
description: "Registered after asynchronous session initialization.",
|
|
parameters: type({}),
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "late active" }] };
|
|
},
|
|
});
|
|
pi.registerTool({
|
|
name: "late_inactive_tool",
|
|
label: "Late Inactive Tool",
|
|
description: "Registered late but left disabled by default.",
|
|
parameters: type({}),
|
|
defaultInactive: true,
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "late inactive" }] };
|
|
},
|
|
});
|
|
})();
|
|
});
|
|
pi.on("input", async () => {
|
|
await startupPromise;
|
|
await pi.setActiveTools([...pi.getActiveTools(), "late_active_tool"]);
|
|
});
|
|
};
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [lateRegistrationExtension],
|
|
});
|
|
|
|
try {
|
|
expect(session.getAllToolNames()).not.toContain("late_active_tool");
|
|
const runner = session.extensionRunner;
|
|
if (!runner) throw new Error("expected extension runner");
|
|
const errors: string[] = [];
|
|
const unsubscribe = runner.onError(error => {
|
|
errors.push(error.error);
|
|
});
|
|
await initializeExtensions(session, {
|
|
reportSendError: vi.fn(),
|
|
reportRuntimeError: vi.fn(),
|
|
});
|
|
expect(session.getAllToolNames()).not.toContain("late_active_tool");
|
|
startupGate.resolve();
|
|
await runner.emitInput("probe", undefined, "interactive");
|
|
unsubscribe();
|
|
expect(errors).toEqual([]);
|
|
|
|
expect(session.getAllToolNames()).toEqual(expect.arrayContaining(["late_active_tool", "late_inactive_tool"]));
|
|
expect(session.getEnabledToolNames()).toContain("late_active_tool");
|
|
expect(session.getEnabledToolNames()).not.toContain("late_inactive_tool");
|
|
expect(session.getXdevToolEntries().map(entry => entry.name)).toContain("late_active_tool");
|
|
expect(session.getActiveToolNames()).not.toContain("late_active_tool");
|
|
expect(session.systemPrompt.join("\n")).toContain("late_active_tool");
|
|
expect(session.systemPrompt.join("\n")).not.toContain("late_inactive_tool");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("activates explicitly requested defaultInactive tools registered during session startup", async () => {
|
|
const tempDir = makeTempDir();
|
|
const lateRequestedExtension: ExtensionFactory = pi => {
|
|
pi.on("session_start", async () => {
|
|
await Promise.resolve();
|
|
pi.registerTool({
|
|
name: "late_requested_tool",
|
|
label: "Late Requested Tool",
|
|
description: "Registered asynchronously after being explicitly requested.",
|
|
parameters: type({}),
|
|
defaultInactive: true,
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "late requested" }] };
|
|
},
|
|
});
|
|
});
|
|
};
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [lateRequestedExtension],
|
|
toolNames: ["read", "write", "late_requested_tool"],
|
|
});
|
|
|
|
try {
|
|
const runner = session.extensionRunner;
|
|
if (!runner) throw new Error("expected extension runner");
|
|
await runner.emit({ type: "session_start" });
|
|
|
|
expect(session.getAllToolNames()).toContain("late_requested_tool");
|
|
expect(session.getEnabledToolNames()).toContain("late_requested_tool");
|
|
expect(session.getActiveToolNames()).toContain("late_requested_tool");
|
|
expect(session.getXdevToolEntries().map(entry => entry.name)).not.toContain("late_requested_tool");
|
|
expect(session.systemPrompt.join("\n")).toContain("late_requested_tool");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("deactivates an enabled tool when a late replacement is default-inactive", async () => {
|
|
const tempDir = makeTempDir();
|
|
const lateInactiveReplacement: ExtensionFactory = pi => {
|
|
pi.on("session_start", async () => {
|
|
await Promise.resolve();
|
|
pi.registerTool({
|
|
name: "bash",
|
|
label: "Late Inactive Bash",
|
|
description: "A late replacement that must remain disabled by default.",
|
|
parameters: type({}),
|
|
defaultInactive: true,
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "late inactive bash" }] };
|
|
},
|
|
});
|
|
});
|
|
};
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [lateInactiveReplacement],
|
|
});
|
|
|
|
try {
|
|
expect(session.getEnabledToolNames()).toContain("bash");
|
|
const runner = session.extensionRunner;
|
|
if (!runner) throw new Error("expected extension runner");
|
|
await runner.emit({ type: "session_start" });
|
|
|
|
expect(session.getToolByName("bash")?.label).toBe("Late Inactive Bash");
|
|
expect(session.hasBuiltInTool("bash")).toBe(false);
|
|
expect(session.getEnabledToolNames()).not.toContain("bash");
|
|
expect(session.getActiveToolNames()).not.toContain("bash");
|
|
expect(session.getMountedXdevToolNames()).not.toContain("bash");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("publishes late tools before returning from a failing lifecycle handler", async () => {
|
|
const tempDir = makeTempDir();
|
|
const activationEntered = Promise.withResolvers<void>();
|
|
const releaseActivation = Promise.withResolvers<void>();
|
|
const failingRegistrationExtension: ExtensionFactory = pi => {
|
|
pi.on("session_start", async () => {
|
|
await Promise.resolve();
|
|
pi.registerTool({
|
|
name: "late_tool_before_failure",
|
|
label: "Late Tool Before Failure",
|
|
description: "Registered before its lifecycle handler fails.",
|
|
parameters: type({}),
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "late tool before failure" }] };
|
|
},
|
|
});
|
|
throw new Error("expected lifecycle failure");
|
|
});
|
|
};
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [failingRegistrationExtension],
|
|
});
|
|
const originalSetPresentation = session.setActiveToolPresentation.bind(session);
|
|
vi.spyOn(session, "setActiveToolPresentation").mockImplementation(async (toolNames, mountedToolNames) => {
|
|
activationEntered.resolve();
|
|
await releaseActivation.promise;
|
|
await originalSetPresentation(toolNames, mountedToolNames);
|
|
});
|
|
const runner = session.extensionRunner;
|
|
if (!runner) throw new Error("expected extension runner");
|
|
let emissionCompleted = false;
|
|
const emission = runner.emit({ type: "session_start" }).finally(() => {
|
|
emissionCompleted = true;
|
|
});
|
|
|
|
try {
|
|
await activationEntered.promise;
|
|
// Drain the handler rejection and outer emit continuations without releasing the registration apply.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(emissionCompleted).toBe(false);
|
|
|
|
releaseActivation.resolve();
|
|
await emission;
|
|
expect(session.getAllToolNames()).toContain("late_tool_before_failure");
|
|
expect(session.getEnabledToolNames()).toContain("late_tool_before_failure");
|
|
expect(session.systemPrompt.join("\n")).toContain("late_tool_before_failure");
|
|
} finally {
|
|
releaseActivation.resolve();
|
|
await emission;
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("keeps the stable MCP tool-name collision winner during late registration", async () => {
|
|
const tempDir = makeTempDir();
|
|
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
|
const lateMcpCollisionExtension: ExtensionFactory = pi => {
|
|
pi.on("session_start", async () => {
|
|
await Promise.resolve();
|
|
for (const [serverName, label] of [
|
|
["foo.bar", "foo.bar/lookup"],
|
|
["foo_bar", "foo_bar/lookup"],
|
|
] as const) {
|
|
pi.registerTool({
|
|
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),
|
|
extensions: [lateMcpCollisionExtension],
|
|
});
|
|
|
|
try {
|
|
const runner = session.extensionRunner;
|
|
if (!runner) throw new Error("expected extension runner");
|
|
await runner.emit({ type: "session_start" });
|
|
|
|
expect(session.getToolByName("mcp__foo_bar_lookup")?.label).toBe("foo.bar/lookup");
|
|
await session.refreshMCPTools([
|
|
{
|
|
name: "mcp__foo_bar_lookup",
|
|
label: "foo_bar/lookup manager",
|
|
description: "Colliding manager tool with the losing stable origin.",
|
|
parameters: type({}),
|
|
mcpServerName: "foo_bar",
|
|
mcpToolName: "lookup",
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "manager" }] };
|
|
},
|
|
} satisfies CustomTool,
|
|
]);
|
|
expect(session.getToolByName("mcp__foo_bar_lookup")?.label).toBe("foo.bar/lookup");
|
|
expect(session.getEnabledToolNames()).toContain("mcp__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 an inactive extension MCP winner disabled when a manager collision loses", async () => {
|
|
const tempDir = makeTempDir();
|
|
const inactiveMcpExtension: ExtensionFactory = pi => {
|
|
pi.registerTool({
|
|
name: "mcp__foo_bar_inactive",
|
|
label: "Inactive extension winner",
|
|
description: "Stable extension winner that starts disabled.",
|
|
parameters: type({}),
|
|
mcpServerName: "foo.bar",
|
|
mcpToolName: "inactive",
|
|
defaultInactive: true,
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "extension" }] };
|
|
},
|
|
});
|
|
};
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [inactiveMcpExtension],
|
|
});
|
|
|
|
try {
|
|
expect(session.getEnabledToolNames()).not.toContain("mcp__foo_bar_inactive");
|
|
await session.refreshMCPTools([
|
|
{
|
|
name: "mcp__foo_bar_inactive",
|
|
label: "Losing manager collision",
|
|
description: "Manager origin loses stable deduplication.",
|
|
parameters: type({}),
|
|
mcpServerName: "foo_bar",
|
|
mcpToolName: "inactive",
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "manager" }] };
|
|
},
|
|
} satisfies CustomTool,
|
|
]);
|
|
expect(session.getToolByName("mcp__foo_bar_inactive")?.label).toBe("Inactive extension winner");
|
|
expect(session.getEnabledToolNames()).not.toContain("mcp__foo_bar_inactive");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("refreshes an earlier extension's stable MCP winner instead of the later colliding registrant", async () => {
|
|
const tempDir = makeTempDir();
|
|
const stableWinnerExtension: ExtensionFactory = pi => {
|
|
pi.registerTool({
|
|
name: "mcp__foo_bar_refresh",
|
|
label: "foo.bar/refresh connected",
|
|
description: "Initial stable MCP winner.",
|
|
parameters: type({}),
|
|
mcpServerName: "foo.bar",
|
|
mcpToolName: "refresh",
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "connected" }] };
|
|
},
|
|
});
|
|
pi.on("session_start", async () => {
|
|
await Promise.resolve();
|
|
pi.registerTool({
|
|
name: "mcp__foo_bar_refresh",
|
|
label: "foo.bar/refresh reconnected",
|
|
description: "Reconnected stable MCP winner.",
|
|
parameters: type({}),
|
|
mcpServerName: "foo.bar",
|
|
mcpToolName: "refresh",
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "reconnected" }] };
|
|
},
|
|
});
|
|
});
|
|
};
|
|
const collidingLoserExtension: ExtensionFactory = pi => {
|
|
pi.registerTool({
|
|
name: "mcp__foo_bar_refresh",
|
|
label: "foo_bar/refresh",
|
|
description: "Later extension with the losing MCP origin.",
|
|
parameters: type({}),
|
|
mcpServerName: "foo_bar",
|
|
mcpToolName: "refresh",
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "loser" }] };
|
|
},
|
|
});
|
|
};
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [stableWinnerExtension, collidingLoserExtension],
|
|
});
|
|
|
|
try {
|
|
expect(session.getToolByName("mcp__foo_bar_refresh")?.label).toBe("foo.bar/refresh connected");
|
|
const runner = session.extensionRunner;
|
|
if (!runner) throw new Error("expected extension runner");
|
|
await runner.emit({ type: "session_start" });
|
|
expect(session.getToolByName("mcp__foo_bar_refresh")?.label).toBe("foo.bar/refresh reconnected");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("retains later-extension precedence when an earlier non-MCP registrant updates", async () => {
|
|
const tempDir = makeTempDir();
|
|
const earlierExtension: ExtensionFactory = pi => {
|
|
pi.registerTool({
|
|
name: "shared_lifecycle_tool",
|
|
label: "Earlier Tool",
|
|
description: "Earlier extension tool.",
|
|
parameters: type({}),
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "earlier" }] };
|
|
},
|
|
});
|
|
pi.on("session_start", async () => {
|
|
await Promise.resolve();
|
|
pi.registerTool({
|
|
name: "shared_lifecycle_tool",
|
|
label: "Updated Earlier Tool",
|
|
description: "Updated earlier extension tool.",
|
|
parameters: type({}),
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "updated earlier" }] };
|
|
},
|
|
});
|
|
});
|
|
};
|
|
const laterExtension: ExtensionFactory = pi => {
|
|
pi.registerTool({
|
|
name: "shared_lifecycle_tool",
|
|
label: "Later Tool",
|
|
description: "Later extension winner.",
|
|
parameters: type({}),
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "later" }] };
|
|
},
|
|
});
|
|
};
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [earlierExtension, laterExtension],
|
|
});
|
|
|
|
try {
|
|
expect(session.getToolByName("shared_lifecycle_tool")?.label).toBe("Later Tool");
|
|
const runner = session.extensionRunner;
|
|
if (!runner) throw new Error("expected extension runner");
|
|
await runner.emit({ type: "session_start" });
|
|
expect(session.getToolByName("shared_lifecycle_tool")?.label).toBe("Later Tool");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("preserves SDK custom-tool precedence when an extension registers the same name later", async () => {
|
|
const tempDir = makeTempDir();
|
|
const lateCollisionExtension: ExtensionFactory = pi => {
|
|
pi.on("session_start", async () => {
|
|
await Promise.resolve();
|
|
pi.registerTool({
|
|
name: sdkCustomTool.name,
|
|
label: "Late Extension Collision",
|
|
description: "Extension tool that must not replace the SDK custom tool.",
|
|
parameters: type({}),
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "late extension" }] };
|
|
},
|
|
});
|
|
});
|
|
};
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [lateCollisionExtension],
|
|
customTools: [sdkCustomTool],
|
|
});
|
|
|
|
try {
|
|
expect(session.getToolByName(sdkCustomTool.name)?.label).toBe(sdkCustomTool.label);
|
|
const runner = session.extensionRunner;
|
|
if (!runner) throw new Error("expected extension runner");
|
|
await runner.emit({ type: "session_start" });
|
|
expect(session.getToolByName(sdkCustomTool.name)?.label).toBe(sdkCustomTool.label);
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("preserves RPC host-tool precedence when an extension registers the same name later", async () => {
|
|
const tempDir = makeTempDir();
|
|
const rpcHostTool = {
|
|
name: "rpc_host_collision",
|
|
label: "RPC Host Tool",
|
|
description: "Host-owned RPC tool.",
|
|
parameters: type({}),
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "rpc host" }] };
|
|
},
|
|
} satisfies AgentTool;
|
|
const lateCollisionExtension: ExtensionFactory = pi => {
|
|
pi.on("session_start", async () => {
|
|
await Promise.resolve();
|
|
pi.registerTool({
|
|
name: rpcHostTool.name,
|
|
label: "Late Extension Collision",
|
|
description: "Extension tool that must not replace the RPC host tool.",
|
|
parameters: type({}),
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "late extension" }] };
|
|
},
|
|
});
|
|
});
|
|
};
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [lateCollisionExtension],
|
|
});
|
|
|
|
try {
|
|
await session.refreshRpcHostTools([rpcHostTool]);
|
|
const runner = session.extensionRunner;
|
|
if (!runner) throw new Error("expected extension runner");
|
|
await runner.emit({ type: "session_start" });
|
|
expect(session.getToolByName(rpcHostTool.name)?.label).toBe(rpcHostTool.label);
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("serializes late extension activation with MCP refreshes", async () => {
|
|
const tempDir = makeTempDir();
|
|
const activationEntered = Promise.withResolvers<void>();
|
|
const releaseActivation = Promise.withResolvers<void>();
|
|
const lateRegistrationExtension: ExtensionFactory = pi => {
|
|
pi.on("session_start", async () => {
|
|
await Promise.resolve();
|
|
pi.registerTool({
|
|
name: "serialized_lifecycle_tool",
|
|
label: "Serialized Lifecycle Tool",
|
|
description: "Lifecycle tool activated before an MCP refresh.",
|
|
parameters: type({}),
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "lifecycle" }] };
|
|
},
|
|
});
|
|
});
|
|
};
|
|
const mcpTool = {
|
|
name: "mcp__serialized_refresh_lookup",
|
|
label: "serialized/refresh lookup",
|
|
description: "MCP tool refreshed during lifecycle activation.",
|
|
parameters: type({}),
|
|
mcpServerName: "serialized",
|
|
mcpToolName: "refresh_lookup",
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "mcp" }] };
|
|
},
|
|
} satisfies CustomTool;
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [lateRegistrationExtension],
|
|
});
|
|
const originalSetActiveToolPresentation = session.setActiveToolPresentation.bind(session);
|
|
vi.spyOn(session, "setActiveToolPresentation").mockImplementation(async (...args) => {
|
|
activationEntered.resolve();
|
|
await releaseActivation.promise;
|
|
return originalSetActiveToolPresentation(...args);
|
|
});
|
|
|
|
try {
|
|
const runner = session.extensionRunner;
|
|
if (!runner) throw new Error("expected extension runner");
|
|
const emission = runner.emit({ type: "session_start" });
|
|
await activationEntered.promise;
|
|
const mcpRefresh = session.refreshMCPTools([mcpTool]);
|
|
await Promise.resolve();
|
|
expect(session.getToolByName(mcpTool.name)).toBeUndefined();
|
|
|
|
releaseActivation.resolve();
|
|
await Promise.all([emission, mcpRefresh]);
|
|
expect(session.getEnabledToolNames()).toEqual(
|
|
expect.arrayContaining(["serialized_lifecycle_tool", mcpTool.name]),
|
|
);
|
|
} finally {
|
|
releaseActivation.resolve();
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("serializes complete memory-tool replacement with late extension activation", async () => {
|
|
const tempDir = makeTempDir();
|
|
const activationEntered = Promise.withResolvers<void>();
|
|
const releaseActivation = Promise.withResolvers<void>();
|
|
const lateRegistrationExtension: ExtensionFactory = pi => {
|
|
pi.on("session_start", async () => {
|
|
await Promise.resolve();
|
|
pi.registerTool({
|
|
name: "memory_race_lifecycle_tool",
|
|
label: "Memory Race Lifecycle Tool",
|
|
description: "Lifecycle tool activated before a memory-tool replacement.",
|
|
parameters: type({}),
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "lifecycle" }] };
|
|
},
|
|
});
|
|
});
|
|
};
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [lateRegistrationExtension],
|
|
});
|
|
const originalSetActiveToolPresentation = session.setActiveToolPresentation.bind(session);
|
|
vi.spyOn(session, "setActiveToolPresentation").mockImplementation(async (...args) => {
|
|
activationEntered.resolve();
|
|
await releaseActivation.promise;
|
|
return originalSetActiveToolPresentation(...args);
|
|
});
|
|
|
|
try {
|
|
const runner = session.extensionRunner;
|
|
if (!runner) throw new Error("expected extension runner");
|
|
const emission = runner.emit({ type: "session_start" });
|
|
await activationEntered.promise;
|
|
const memoryRefresh = session.applyMemoryBackend();
|
|
|
|
releaseActivation.resolve();
|
|
await Promise.all([emission, memoryRefresh]);
|
|
expect(session.getEnabledToolNames()).toContain("memory_race_lifecycle_tool");
|
|
} finally {
|
|
releaseActivation.resolve();
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("keeps an explicitly disabled tool disabled when its extension re-registers it", async () => {
|
|
const tempDir = makeTempDir();
|
|
const disabledReplacementExtension: ExtensionFactory = pi => {
|
|
pi.registerTool({
|
|
name: "disabled_replacement_tool",
|
|
label: "Initial Enabled Tool",
|
|
description: "Initially enabled extension tool.",
|
|
parameters: type({}),
|
|
loadMode: "essential",
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "initial" }] };
|
|
},
|
|
});
|
|
pi.on("session_start", async () => {
|
|
await pi.setActiveTools(["read"]);
|
|
pi.registerTool({
|
|
name: "disabled_replacement_tool",
|
|
label: "Disabled Replacement Tool",
|
|
description: "Replacement that must retain the disabled state.",
|
|
parameters: type({}),
|
|
loadMode: "essential",
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "replacement" }] };
|
|
},
|
|
});
|
|
});
|
|
};
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [disabledReplacementExtension],
|
|
});
|
|
|
|
try {
|
|
expect(session.getEnabledToolNames()).toContain("disabled_replacement_tool");
|
|
const runner = session.extensionRunner;
|
|
if (!runner) throw new Error("expected extension runner");
|
|
const errors: string[] = [];
|
|
const unsubscribe = runner.onError(error => {
|
|
errors.push(error.error);
|
|
});
|
|
await initializeExtensions(session, {
|
|
reportSendError: vi.fn(),
|
|
reportRuntimeError: vi.fn(),
|
|
});
|
|
unsubscribe();
|
|
expect(errors).toEqual([]);
|
|
|
|
expect(session.getToolByName("disabled_replacement_tool")?.label).toBe("Disabled Replacement Tool");
|
|
expect(session.getEnabledToolNames()).not.toContain("disabled_replacement_tool");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("reclassifies late replacements when their load modes change", async () => {
|
|
const tempDir = makeTempDir();
|
|
const loadModeReplacementExtension: ExtensionFactory = pi => {
|
|
const registerTransitionTool = (name: string, label: string, loadMode: "essential" | "discoverable"): void => {
|
|
pi.registerTool({
|
|
name,
|
|
label,
|
|
description: `${label} extension tool.`,
|
|
parameters: type({}),
|
|
loadMode,
|
|
async execute() {
|
|
return { content: [{ type: "text", text: label }] };
|
|
},
|
|
});
|
|
};
|
|
registerTransitionTool("late_becomes_discoverable", "Initially Essential", "essential");
|
|
registerTransitionTool("late_becomes_essential", "Initially Discoverable", "discoverable");
|
|
pi.on("session_start", async () => {
|
|
await Promise.resolve();
|
|
registerTransitionTool("late_becomes_discoverable", "Now Discoverable", "discoverable");
|
|
registerTransitionTool("late_becomes_essential", "Now Essential", "essential");
|
|
});
|
|
};
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [loadModeReplacementExtension],
|
|
});
|
|
|
|
try {
|
|
expect(session.getActiveToolNames()).toContain("late_becomes_discoverable");
|
|
expect(session.getMountedXdevToolNames()).toContain("late_becomes_essential");
|
|
const runner = session.extensionRunner;
|
|
if (!runner) throw new Error("expected extension runner");
|
|
await runner.emit({ type: "session_start" });
|
|
|
|
expect(session.getActiveToolNames()).not.toContain("late_becomes_discoverable");
|
|
expect(session.getMountedXdevToolNames()).toContain("late_becomes_discoverable");
|
|
expect(session.getActiveToolNames()).toContain("late_becomes_essential");
|
|
expect(session.getMountedXdevToolNames()).not.toContain("late_becomes_essential");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("refreshes prompt-visible metadata when a lifecycle registration replaces an enabled tool", async () => {
|
|
const tempDir = makeTempDir();
|
|
const replacementExtension: ExtensionFactory = pi => {
|
|
const register = (label: string, description: string): void => {
|
|
pi.registerTool({
|
|
name: "prompt_refresh_tool",
|
|
label,
|
|
description,
|
|
parameters: type({}),
|
|
async execute() {
|
|
return { content: [{ type: "text", text: label }] };
|
|
},
|
|
});
|
|
};
|
|
register("Original Prompt Tool", "Original prompt-visible lifecycle description.");
|
|
pi.on("session_start", async () => {
|
|
await Promise.resolve();
|
|
register("Replacement Prompt Tool", "Replacement prompt-visible lifecycle description.");
|
|
});
|
|
};
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [replacementExtension],
|
|
});
|
|
|
|
try {
|
|
expect(session.systemPrompt.join("\n")).toContain("Original prompt-visible lifecycle description.");
|
|
const runner = session.extensionRunner;
|
|
if (!runner) throw new Error("expected extension runner");
|
|
await runner.emit({ type: "session_start" });
|
|
|
|
const prompt = session.systemPrompt.join("\n");
|
|
expect(session.getToolByName("prompt_refresh_tool")?.label).toBe("Replacement Prompt Tool");
|
|
expect(prompt).toContain("Replacement prompt-visible lifecycle description.");
|
|
expect(prompt).not.toContain("Original prompt-visible lifecycle description.");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("restores a built-in tool and its provenance when a replacement prompt rebuild fails", async () => {
|
|
let rejectReplacementPrompt = false;
|
|
const releaseHandler = Promise.withResolvers<void>();
|
|
const replacementRefreshAttempted = Promise.withResolvers<void>();
|
|
const tempDir = makeTempDir();
|
|
const replacementExtension: ExtensionFactory = pi => {
|
|
pi.on("session_start", async () => {
|
|
await Promise.resolve();
|
|
pi.registerTool({
|
|
name: "bash",
|
|
label: "Rejected Rollback Bash",
|
|
description: "Rejected rollback lifecycle description.",
|
|
parameters: type({ changed: type.string }),
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "rejected" }] };
|
|
},
|
|
});
|
|
await releaseHandler.promise;
|
|
});
|
|
};
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [replacementExtension],
|
|
systemPrompt: defaultPrompt => {
|
|
if (rejectReplacementPrompt) {
|
|
replacementRefreshAttempted.resolve();
|
|
throw new Error("expected replacement prompt failure");
|
|
}
|
|
return defaultPrompt;
|
|
},
|
|
});
|
|
let emission: Promise<unknown> | undefined;
|
|
|
|
try {
|
|
const enabledBefore = session.getEnabledToolNames();
|
|
const mountedBefore = session.getMountedXdevToolNames();
|
|
const promptBefore = session.systemPrompt;
|
|
const originalTool = session.getToolByName("bash");
|
|
expect(session.hasBuiltInTool("bash")).toBe(true);
|
|
const runner = session.extensionRunner;
|
|
if (!runner) throw new Error("expected extension runner");
|
|
const errors: string[] = [];
|
|
const unsubscribe = runner.onError(error => {
|
|
errors.push(error.error);
|
|
});
|
|
rejectReplacementPrompt = true;
|
|
emission = runner.emit({ type: "session_start" });
|
|
await replacementRefreshAttempted.promise;
|
|
expect(errors).not.toContain("expected replacement prompt failure");
|
|
releaseHandler.resolve();
|
|
await emission;
|
|
unsubscribe();
|
|
|
|
expect(errors).toContain("expected replacement prompt failure");
|
|
expect(session.getToolByName("bash")).toBe(originalTool);
|
|
expect(session.hasBuiltInTool("bash")).toBe(true);
|
|
expect(session.getEnabledToolNames()).toEqual(enabledBefore);
|
|
expect(session.getMountedXdevToolNames()).toEqual(mountedBefore);
|
|
expect(session.systemPrompt).toEqual(promptBefore);
|
|
} finally {
|
|
releaseHandler.resolve();
|
|
await emission;
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("waits for later registrations after an earlier activation fails", async () => {
|
|
const tempDir = makeTempDir();
|
|
const releaseLaterActivation = Promise.withResolvers<void>();
|
|
const laterActivationEntered = Promise.withResolvers<void>();
|
|
const registrationExtension: ExtensionFactory = pi => {
|
|
pi.on("session_start", async () => {
|
|
await Promise.resolve();
|
|
for (const name of ["failed_registration_tool", "drained_registration_tool"]) {
|
|
pi.registerTool({
|
|
name,
|
|
label: name,
|
|
description: `${name} lifecycle description.`,
|
|
parameters: type({}),
|
|
async execute() {
|
|
return { content: [{ type: "text", text: name }] };
|
|
},
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [registrationExtension],
|
|
});
|
|
const originalSetPresentation = session.setActiveToolPresentation.bind(session);
|
|
vi.spyOn(session, "setActiveToolPresentation").mockImplementation(
|
|
async (toolNames, mountedToolNames, forcePromptRefresh) => {
|
|
if (toolNames.includes("failed_registration_tool")) throw new Error("expected activation failure");
|
|
if (toolNames.includes("drained_registration_tool")) {
|
|
laterActivationEntered.resolve();
|
|
await releaseLaterActivation.promise;
|
|
}
|
|
await originalSetPresentation(toolNames, mountedToolNames, forcePromptRefresh);
|
|
},
|
|
);
|
|
const runner = session.extensionRunner;
|
|
if (!runner) throw new Error("expected extension runner");
|
|
const errors: string[] = [];
|
|
runner.onError(error => {
|
|
errors.push(error.error);
|
|
});
|
|
let emissionCompleted = false;
|
|
const emission = runner.emit({ type: "session_start" }).finally(() => {
|
|
emissionCompleted = true;
|
|
});
|
|
|
|
try {
|
|
await laterActivationEntered.promise;
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(emissionCompleted).toBe(false);
|
|
expect(errors).toEqual([]);
|
|
|
|
releaseLaterActivation.resolve();
|
|
await emission;
|
|
expect(errors).toContain("expected activation failure");
|
|
expect(session.getToolByName("failed_registration_tool")).toBeUndefined();
|
|
expect(session.getToolByName("drained_registration_tool")).toBeDefined();
|
|
expect(session.systemPrompt.join("\n")).toContain("drained_registration_tool");
|
|
} finally {
|
|
releaseLaterActivation.resolve();
|
|
await emission;
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("releases a timed-out activation so later lifecycle registrations can proceed", async () => {
|
|
const tempDir = makeTempDir();
|
|
const registrationExtension: ExtensionFactory = pi => {
|
|
for (const name of ["stalled_registration_tool", "recovered_registration_tool"]) {
|
|
pi.on("session_start", async () => {
|
|
await Promise.resolve();
|
|
pi.registerTool({
|
|
name,
|
|
label: name,
|
|
description: `${name} lifecycle tool.`,
|
|
parameters: type({}),
|
|
loadMode: "essential",
|
|
async execute() {
|
|
return { content: [{ type: "text", text: name }] };
|
|
},
|
|
});
|
|
});
|
|
}
|
|
};
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [registrationExtension],
|
|
});
|
|
|
|
try {
|
|
const originalSetPresentation = session.setActiveToolPresentation.bind(session);
|
|
vi.spyOn(session, "setActiveToolPresentation")
|
|
.mockImplementationOnce((_toolNames, _mountedToolNames, _forcePromptRefresh, signal) =>
|
|
untilAborted(signal, Promise.withResolvers<void>().promise),
|
|
)
|
|
.mockImplementation(originalSetPresentation);
|
|
const runner = session.extensionRunner;
|
|
if (!runner) throw new Error("expected extension runner");
|
|
const errors: string[] = [];
|
|
const unsubscribe = runner.onError(error => {
|
|
errors.push(error.error);
|
|
});
|
|
testSetExtensionHandlerTimeoutMs(250);
|
|
|
|
await runner.emit({ type: "session_start" });
|
|
unsubscribe();
|
|
|
|
expect(errors).toContain("handler timed out after 250ms");
|
|
expect(session.getToolByName("stalled_registration_tool")).toBeUndefined();
|
|
expect(session.getToolByName("recovered_registration_tool")?.label).toBe("recovered_registration_tool");
|
|
expect(session.getEnabledToolNames()).toContain("recovered_registration_tool");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("applies explicit tool selection after preceding lifecycle registrations", async () => {
|
|
const tempDir = makeTempDir();
|
|
const registrationExtension: ExtensionFactory = pi => {
|
|
pi.on("session_start", async () => {
|
|
pi.registerTool({
|
|
name: "register_then_select_tool",
|
|
label: "Register Then Select Tool",
|
|
description: "Must not overwrite the explicit selection that follows registration.",
|
|
parameters: type({}),
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "registered" }] };
|
|
},
|
|
});
|
|
await pi.setActiveTools(["read"]);
|
|
});
|
|
};
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [registrationExtension],
|
|
});
|
|
|
|
try {
|
|
await initializeExtensions(session, {
|
|
reportSendError: vi.fn(),
|
|
reportRuntimeError: vi.fn(),
|
|
});
|
|
|
|
expect(session.getAllToolNames()).toContain("register_then_select_tool");
|
|
expect(session.getEnabledToolNames()).toContain("read");
|
|
expect(session.getEnabledToolNames()).not.toContain("register_then_select_tool");
|
|
expect(session.getMountedXdevToolNames()).not.toContain("register_then_select_tool");
|
|
} finally {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("attributes detached registration failures without waiting for another lifecycle handler", async () => {
|
|
const tempDir = makeTempDir();
|
|
const releaseDetachedRegistration = Promise.withResolvers<void>();
|
|
const registrationFailure = Promise.withResolvers<{ event: string; error: string }>();
|
|
let rejectDetachedPrompt = false;
|
|
const detachedRegistrationExtension: ExtensionFactory = pi => {
|
|
pi.on("session_start", () => {
|
|
void releaseDetachedRegistration.promise.then(() => {
|
|
pi.registerTool({
|
|
name: "detached_registration_tool",
|
|
label: "Detached Registration Tool",
|
|
description: "Detached tool whose activation intentionally fails.",
|
|
parameters: type({}),
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "detached" }] };
|
|
},
|
|
});
|
|
});
|
|
});
|
|
};
|
|
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
settings: Settings.isolated({
|
|
"bashInterceptor.enabled": true,
|
|
"bashInterceptor.patterns": [
|
|
{
|
|
pattern: "^\\s*printf\\s+",
|
|
tool: "detached_registration_tool",
|
|
message: "Use the detached registration tool.",
|
|
},
|
|
],
|
|
}),
|
|
autoApprove: true,
|
|
extensions: [detachedRegistrationExtension],
|
|
systemPrompt: defaultPrompt => {
|
|
if (rejectDetachedPrompt) throw new Error("expected detached registration failure");
|
|
return defaultPrompt;
|
|
},
|
|
});
|
|
|
|
try {
|
|
await initializeExtensions(session, {
|
|
reportSendError: vi.fn(),
|
|
reportRuntimeError: error => {
|
|
if (error.error === "expected detached registration failure") {
|
|
registrationFailure.resolve({ event: error.event, error: error.error });
|
|
}
|
|
},
|
|
});
|
|
rejectDetachedPrompt = true;
|
|
releaseDetachedRegistration.resolve();
|
|
|
|
expect(await registrationFailure.promise).toEqual({
|
|
event: "tool_registration",
|
|
error: "expected detached registration failure",
|
|
});
|
|
expect(session.getToolByName("detached_registration_tool")).toBeUndefined();
|
|
rejectDetachedPrompt = false;
|
|
const toolCallId = "detached-rollback-bash";
|
|
const mock = createMockModel({
|
|
responses: [
|
|
{
|
|
content: [
|
|
{
|
|
type: "toolCall",
|
|
id: toolCallId,
|
|
name: "bash",
|
|
arguments: { command: "printf rollback-ok" },
|
|
},
|
|
],
|
|
},
|
|
{ content: [{ type: "text", text: "done" }] },
|
|
],
|
|
});
|
|
vi.spyOn(session.agent, "streamFn").mockImplementation(mock.stream);
|
|
await withProviderAuth(["openai"], async () => {
|
|
await session.prompt("verify rollback context");
|
|
const bashResult = session.messages.find(
|
|
(message): message is ToolResultMessage =>
|
|
message.role === "toolResult" && message.toolCallId === toolCallId,
|
|
);
|
|
expect(bashResult?.isError).toBe(false);
|
|
expect(JSON.stringify(bashResult?.content)).toContain("rollback-ok");
|
|
});
|
|
} finally {
|
|
releaseDetachedRegistration.resolve();
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
it("times out detached activations without blocking later registrations", async () => {
|
|
const tempDir = makeTempDir();
|
|
const releaseStalledRegistration = Promise.withResolvers<void>();
|
|
const releaseRecoveredRegistration = Promise.withResolvers<void>();
|
|
const detachedRegistrationExtension: ExtensionFactory = pi => {
|
|
pi.on("session_start", () => {
|
|
void releaseStalledRegistration.promise.then(() => {
|
|
pi.registerTool({
|
|
name: "stalled_detached_tool",
|
|
label: "Stalled Detached Tool",
|
|
description: "Detached registration whose activation stalls.",
|
|
parameters: type({}),
|
|
loadMode: "essential",
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "stalled" }] };
|
|
},
|
|
});
|
|
});
|
|
void releaseRecoveredRegistration.promise.then(() => {
|
|
pi.registerTool({
|
|
name: "recovered_detached_tool",
|
|
label: "Recovered Detached Tool",
|
|
description: "Detached registration that follows the timeout.",
|
|
parameters: type({}),
|
|
loadMode: "essential",
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "recovered" }] };
|
|
},
|
|
});
|
|
});
|
|
});
|
|
};
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
extensions: [detachedRegistrationExtension],
|
|
});
|
|
|
|
try {
|
|
await initializeExtensions(session, {
|
|
reportSendError: vi.fn(),
|
|
reportRuntimeError: vi.fn(),
|
|
});
|
|
const runner = session.extensionRunner;
|
|
if (!runner) throw new Error("expected extension runner");
|
|
const detachedFailure = Promise.withResolvers<{ event: string; error: string }>();
|
|
runner.onError(error => {
|
|
if (error.event === "tool_registration") {
|
|
detachedFailure.resolve({ event: error.event, error: error.error });
|
|
}
|
|
});
|
|
const recoveredActivation = Promise.withResolvers<void>();
|
|
const originalSetPresentation = session.setActiveToolPresentation.bind(session);
|
|
vi.spyOn(session, "setActiveToolPresentation")
|
|
.mockImplementationOnce((_toolNames, _mountedToolNames, _forcePromptRefresh, signal) =>
|
|
untilAborted(signal, Promise.withResolvers<void>().promise),
|
|
)
|
|
.mockImplementation(async (toolNames, mountedToolNames, forcePromptRefresh, signal) => {
|
|
await originalSetPresentation(toolNames, mountedToolNames, forcePromptRefresh, signal);
|
|
if (toolNames.includes("recovered_detached_tool")) recoveredActivation.resolve();
|
|
});
|
|
testSetExtensionHandlerTimeoutMs(250);
|
|
|
|
releaseStalledRegistration.resolve();
|
|
const failure = await detachedFailure.promise;
|
|
releaseRecoveredRegistration.resolve();
|
|
await recoveredActivation.promise;
|
|
|
|
expect(failure.event).toBe("tool_registration");
|
|
expect(failure.error).toContain("timed out");
|
|
expect(session.getToolByName("stalled_detached_tool")).toBeUndefined();
|
|
expect(session.getToolByName("recovered_detached_tool")?.label).toBe("Recovered Detached Tool");
|
|
} finally {
|
|
releaseStalledRegistration.resolve();
|
|
releaseRecoveredRegistration.resolve();
|
|
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", "default_active_tool"]),
|
|
);
|
|
// No granted write tool → no xd:// transport: extension tools surface
|
|
// top-level instead of mounting with an auto-granted write.
|
|
expect(session.getActiveToolNames()).not.toContain("write");
|
|
expect(session.getXdevToolEntries()).toEqual([]);
|
|
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 restrictedLateExtension: ExtensionFactory = pi => {
|
|
pi.on("session_start", async () => {
|
|
await Promise.resolve();
|
|
pi.registerTool({
|
|
name: "restricted_late_extension_tool",
|
|
label: "Restricted Late Extension Tool",
|
|
description: "Must not enter a caller-restricted session.",
|
|
parameters: type({}),
|
|
async execute() {
|
|
return { content: [{ type: "text", text: "restricted late" }] };
|
|
},
|
|
});
|
|
});
|
|
};
|
|
|
|
const { session: restricted } = await createAgentSession({
|
|
...baseOptions(restrictedDir),
|
|
settings: configuredSettings(),
|
|
extensions: [toolActivationExtension, restrictedLateExtension],
|
|
customTools: [sdkCustomTool],
|
|
toolNames: ["read", "lsp", "hub"],
|
|
requireYieldTool: true,
|
|
restrictToolNames: true,
|
|
enableMCP: true,
|
|
mcpManager: inheritedManager,
|
|
enableLsp: true,
|
|
enableIrc: true,
|
|
});
|
|
|
|
try {
|
|
await initializeExtensions(restricted, {
|
|
reportSendError: vi.fn(),
|
|
reportRuntimeError: vi.fn(),
|
|
});
|
|
expect(restricted.getAllToolNames()).toEqual(["read", "lsp", "yield"]);
|
|
expect(restricted.getActiveToolNames()).toEqual(["read", "lsp", "yield"]);
|
|
for (const name of [
|
|
"generate_image",
|
|
"tts",
|
|
"recall",
|
|
"retain",
|
|
"reflect",
|
|
"learn",
|
|
"manage_skill",
|
|
"default_active_tool",
|
|
"default_inactive_tool",
|
|
"sdk_custom_tool",
|
|
"restricted_late_extension_tool",
|
|
"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",
|
|
"tts",
|
|
"default_active_tool",
|
|
"sdk_custom_tool",
|
|
]),
|
|
);
|
|
// Without a granted write tool the session allocates no xd:// state;
|
|
// SDK custom and extension capabilities surface top-level instead.
|
|
expect(activeToolNames).not.toContain("write");
|
|
expect(normal.getXdevToolEntries()).toEqual([]);
|
|
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("permits only explicitly named SDK custom tools when a restricted caller opts in", async () => {
|
|
const tempDir = makeTempDir();
|
|
const { session } = await createAgentSession({
|
|
...baseOptions(tempDir),
|
|
customTools: [sdkCustomTool],
|
|
toolNames: ["read", "sdk_custom_tool"],
|
|
restrictToolNames: true,
|
|
allowRestrictedCustomTools: true,
|
|
});
|
|
|
|
try {
|
|
expect(session.getAllToolNames()).toEqual(["read", "sdk_custom_tool"]);
|
|
expect(session.getActiveToolNames()).toEqual(["read", "sdk_custom_tool"]);
|
|
} finally {
|
|
await session.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_string`/`new_string` 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, old_string: "beta", new_string: "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();
|
|
}
|
|
});
|
|
});
|
|
});
|