mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
test(server): verify cross-provider mode discovery through the daemon
Exercise the MCP contract, refusal without a child, provider inspection, and explicit-mode creation through the public daemon interfaces. Qualify unattended advice because targets without an unattended mode still require an explicit choice. Co-authored-by: Noah <noahguerin20@gmail.com>
This commit is contained in:
@@ -39,7 +39,7 @@ import type {
|
||||
AgentStreamEvent,
|
||||
ProviderSnapshotEntry,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
|
||||
import type { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
|
||||
import { createProviderSnapshotManagerStub } from "../test-utils/session-stubs.js";
|
||||
import {
|
||||
AgentListItemPayloadSchema,
|
||||
@@ -1526,64 +1526,6 @@ describe("terminal MCP tools", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const CLAUDE_CATALOG_MODES: AgentMode[] = [
|
||||
{ id: "default", label: "Default" },
|
||||
{ id: "bypassPermissions", label: "Bypass", isUnattended: true },
|
||||
];
|
||||
const CODEX_CATALOG_MODES: AgentMode[] = [
|
||||
{ id: "auto", label: "Auto" },
|
||||
{ id: "full-access", label: "Full Access", isUnattended: true },
|
||||
];
|
||||
|
||||
class ModeCatalogAgentClient implements AgentClient {
|
||||
readonly capabilities = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsSessionListing: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
} as const;
|
||||
|
||||
constructor(
|
||||
readonly provider: AgentProvider,
|
||||
private readonly modes: AgentMode[],
|
||||
) {}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async fetchCatalog() {
|
||||
return { models: [], modes: this.modes };
|
||||
}
|
||||
|
||||
async createSession(): Promise<AgentSession> {
|
||||
throw new Error("ModeCatalogAgentClient only serves catalogs");
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
throw new Error("ModeCatalogAgentClient only serves catalogs");
|
||||
}
|
||||
}
|
||||
|
||||
// A real snapshot manager, so create_agent runs the provider's shared mode resolver.
|
||||
function createModeCatalogManager(): ProviderSnapshotManager {
|
||||
return new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
providerOverrides: {
|
||||
copilot: { enabled: false },
|
||||
opencode: { enabled: false },
|
||||
pi: { enabled: false },
|
||||
},
|
||||
extraClients: {
|
||||
claude: new ModeCatalogAgentClient("claude", CLAUDE_CATALOG_MODES),
|
||||
codex: new ModeCatalogAgentClient("codex", CODEX_CATALOG_MODES),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("create_agent MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
const existingCwd = process.cwd();
|
||||
@@ -3490,113 +3432,6 @@ describe("create_agent MCP tool", () => {
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("advertises the cross-provider mode rule in the create_agent description", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
callerAgentId: "parent-agent",
|
||||
logger,
|
||||
});
|
||||
const client = await connectInMemoryMcpClient(server);
|
||||
|
||||
try {
|
||||
const { tools } = await client.listTools();
|
||||
const createAgent = tools.find((tool) => tool.name === "create_agent")!;
|
||||
|
||||
expect(createAgent.description).toContain(
|
||||
"A different provider does not inherit your mode: set settings.modeId from list_providers, or omit it if you run unattended so the child does too.",
|
||||
);
|
||||
expect(tools.map((tool) => tool.name)).toContain("list_providers");
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
function claudeCaller(currentModeId: string): ManagedAgent {
|
||||
return {
|
||||
id: "parent-agent",
|
||||
config: {},
|
||||
cwd: existingCwd,
|
||||
workspaceId: "wks_parent",
|
||||
provider: "claude",
|
||||
currentModeId,
|
||||
availableModes: CLAUDE_CATALOG_MODES,
|
||||
} as ManagedAgent;
|
||||
}
|
||||
|
||||
it("refuses to carry an attended caller's mode to a different provider", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue(claudeCaller("default"));
|
||||
const providerSnapshotManager = createModeCatalogManager();
|
||||
|
||||
try {
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager,
|
||||
callerAgentId: "parent-agent",
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
|
||||
await expect(
|
||||
tool.handler({
|
||||
...subagentCurrentWorkspace(),
|
||||
title: "Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
|
||||
);
|
||||
expect(spies.agentManager.createAgent).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
providerSnapshotManager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("gives an unattended caller's child the target provider's unattended mode", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue(claudeCaller("bypassPermissions"));
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "child-agent",
|
||||
cwd: existingCwd,
|
||||
lifecycle: "idle",
|
||||
currentModeId: "full-access",
|
||||
availableModes: CODEX_CATALOG_MODES,
|
||||
config: { title: "Child" },
|
||||
} as ManagedAgent);
|
||||
const providerSnapshotManager = createModeCatalogManager();
|
||||
|
||||
try {
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager,
|
||||
callerAgentId: "parent-agent",
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
...subagentCurrentWorkspace(),
|
||||
title: "Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
});
|
||||
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modeId: "full-access" }),
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
);
|
||||
} finally {
|
||||
providerSnapshotManager.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const HELD_TURN_CAPABILITIES = {
|
||||
|
||||
@@ -1590,7 +1590,7 @@ function createToolCatalog(
|
||||
{
|
||||
title: "Create agent",
|
||||
description:
|
||||
"Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. A different provider does not inherit your mode: set settings.modeId from list_providers, or omit it if you run unattended so the child does too. Do not guess; call list_providers and list_models first if uncertain.",
|
||||
"Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. For a different provider, set settings.modeId from inspect_provider; omission is accepted for targets with no modes and for unattended execution supported by the target. Do not guess; call list_providers and list_models first if uncertain.",
|
||||
inputSchema: createAgentInputSchema,
|
||||
outputSchema: {
|
||||
agentId: z.string(),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { expect, test } from "vitest";
|
||||
import { createDaemonTestContext } from "../test-utils/index.js";
|
||||
|
||||
test("an attended MCP caller can discover and supply a cross-provider child mode", async () => {
|
||||
const artifacts = path.join(process.cwd(), ".artifacts");
|
||||
await mkdir(artifacts, { recursive: true });
|
||||
const root = await mkdtemp(path.join(artifacts, "create-agent-mode-"));
|
||||
const ctx = await createDaemonTestContext({ paseoHomeRoot: root, mcpEnabled: true });
|
||||
const mcp = new Client({ name: "mode-contract-test", version: "1" });
|
||||
try {
|
||||
const parent = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: root,
|
||||
modeId: "default",
|
||||
title: "Attended parent",
|
||||
});
|
||||
await mcp.connect(
|
||||
new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${ctx.daemon.port}/mcp/agents?callerAgentId=${parent.id}`),
|
||||
),
|
||||
);
|
||||
const { tools } = await mcp.listTools();
|
||||
const create = tools.find((tool) => tool.name === "create_agent");
|
||||
expect.soft(create?.description).toContain("settings.modeId");
|
||||
|
||||
const args = {
|
||||
provider: "codex/gpt-5.4-mini",
|
||||
title: "Cross-provider child",
|
||||
initialPrompt: "Reply with OK",
|
||||
};
|
||||
const refused = await mcp.callTool({ name: "create_agent", arguments: args });
|
||||
expect(refused.isError).toBe(true);
|
||||
expect.soft(refused.content).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Pass an explicit mode in settings.modeId"),
|
||||
}),
|
||||
]);
|
||||
expect((await ctx.client.fetchAgents({ subscribe: {} })).entries).toHaveLength(1);
|
||||
|
||||
const inspected = await mcp.callTool({
|
||||
name: "inspect_provider",
|
||||
arguments: { provider: "codex" },
|
||||
});
|
||||
expect(inspected.isError).not.toBe(true);
|
||||
expect(inspected.structuredContent).toMatchObject({
|
||||
modes: expect.arrayContaining([expect.objectContaining({ id: "auto" })]),
|
||||
});
|
||||
const created = await mcp.callTool({
|
||||
name: "create_agent",
|
||||
arguments: { ...args, settings: { modeId: "auto" } },
|
||||
});
|
||||
expect(created.isError).not.toBe(true);
|
||||
expect(created.structuredContent).toMatchObject({ type: "codex", currentModeId: "auto" });
|
||||
const childId = (created.structuredContent as { agentId: string }).agentId;
|
||||
expect((await ctx.client.fetchAgent(childId))?.agent.currentModeId).toBe("auto");
|
||||
expect((await ctx.client.waitForFinish(childId, 5000)).status).toBe("idle");
|
||||
} finally {
|
||||
await mcp.close();
|
||||
await ctx.cleanup();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
Reference in New Issue
Block a user