diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 024bc78f57..ecf2d912a5 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -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 { - return true; - } - - async fetchCatalog() { - return { models: [], modes: this.modes }; - } - - async createSession(): Promise { - throw new Error("ModeCatalogAgentClient only serves catalogs"); - } - - async resumeSession(): Promise { - 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 = { diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 48b5675618..a5569685e2 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -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(), diff --git a/packages/server/src/server/daemon-e2e/create-agent-mode.e2e.test.ts b/packages/server/src/server/daemon-e2e/create-agent-mode.e2e.test.ts new file mode 100644 index 0000000000..e60645c2bf --- /dev/null +++ b/packages/server/src/server/daemon-e2e/create-agent-mode.e2e.test.ts @@ -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);