From efc64a5443dfe166dfb0d80434ecb48516b8fbe2 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:08:31 +0000 Subject: [PATCH] Explain the mode required to create agents on another provider (#8483) * fix(server): say when create_agent needs an explicit mode * 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 * test(server): run mode discovery in the integration suite The unit command excludes E2E files, and CI integration uses an explicit list. Add the cross-provider MCP regression to that existing list so CI exercises its guidance, refusal, discovery, and retry assertions. --------- Co-authored-by: Noah Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/server/package.json | 2 +- .../server/agent/create-agent-mode.test.ts | 8 +-- .../src/server/agent/create-agent-mode.ts | 2 +- .../src/server/agent/tools/paseo-tools.ts | 2 +- .../daemon-e2e/create-agent-mode.e2e.test.ts | 67 +++++++++++++++++++ 5 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/create-agent-mode.e2e.test.ts diff --git a/packages/server/package.json b/packages/server/package.json index 191aee2945..261b20775f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/create-agent-mode.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", 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/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 0ca6c5a3e4..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. 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);