From 711bf919040ef786650de1fd402591cd8b244e79 Mon Sep 17 00:00:00 2001 From: Noah Date: Fri, 23 Oct 2026 17:51:21 +0200 Subject: [PATCH] fix(server): say when create_agent needs an explicit mode --- .../server/agent/create-agent-mode.test.ts | 8 +- .../src/server/agent/create-agent-mode.ts | 2 +- .../src/server/agent/mcp-server.test.ts | 167 +++++++++++++++++- .../src/server/agent/tools/paseo-tools.ts | 2 +- 4 files changed, 172 insertions(+), 7 deletions(-) diff --git a/packages/server/src/server/agent/create-agent-mode.test.ts b/packages/server/src/server/agent/create-agent-mode.test.ts index dd7d83d8a4..1662ec4c25 100644 --- a/packages/server/src/server/agent/create-agent-mode.test.ts +++ b/packages/server/src/server/agent/create-agent-mode.test.ts @@ -78,7 +78,7 @@ describe("resolveAndValidateCreateAgentMode", () => { availableModes: OPENCODE_MODES, }), ).toThrow( - "cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, plan", + "cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode in settings.modeId. Available modes for 'opencode': build, plan", ); }); @@ -92,7 +92,7 @@ describe("resolveAndValidateCreateAgentMode", () => { availableModes: CODEX_MODES, }), ).toThrow( - "cannot inherit mode '' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access", + "cannot inherit mode '' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access", ); }); @@ -180,7 +180,7 @@ describe("resolveAndValidateCreateAgentMode", () => { targetUnattendedMode: "full-access", }), ).toThrow( - "cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access", + "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", ); }); @@ -195,7 +195,7 @@ describe("resolveAndValidateCreateAgentMode", () => { targetUnattendedMode: undefined, }), ).toThrow( - "cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode. Available modes for 'zai-custom': unknown", + "cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode in settings.modeId. Available modes for 'zai-custom': unknown", ); }); diff --git a/packages/server/src/server/agent/create-agent-mode.ts b/packages/server/src/server/agent/create-agent-mode.ts index 973642e475..048da2814d 100644 --- a/packages/server/src/server/agent/create-agent-mode.ts +++ b/packages/server/src/server/agent/create-agent-mode.ts @@ -77,7 +77,7 @@ export function resolveAndValidateCreateAgentMode( } throw new Error( - `cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`, + `cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode in settings.modeId. Available modes for '${targetProvider}': ${listModes(availableModes)}`, ); } diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index ecf2d912a5..024bc78f57 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 type { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; +import { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; import { createProviderSnapshotManagerStub } from "../test-utils/session-stubs.js"; import { AgentListItemPayloadSchema, @@ -1526,6 +1526,64 @@ 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(); @@ -3432,6 +3490,113 @@ 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 0ca6c5a3e4..48b5675618 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. 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. 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.", inputSchema: createAgentInputSchema, outputSchema: { agentId: z.string(),