diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index 440e492c4..3ef9ebdce 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -22,6 +22,10 @@ type PermissionPayload = { id: string; }; +const MCP_TOOL_TIMEOUT_MS = 60_000; +const MCP_CLOSE_TIMEOUT_MS = 10_000; +const AGENT_COMPLETION_TIMEOUT_MS = 120_000; + async function getAvailablePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); @@ -51,6 +55,47 @@ function getStructuredContent(result: McpToolResult): Record | return null; } +async function withTimeout( + promise: Promise, + timeoutMs: number, + label: string +): Promise { + let timeoutId: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`Timed out after ${timeoutMs}ms (${label})`)); + }, timeoutMs); + }); + + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +} + +async function callToolWithTimeout( + client: McpClient, + input: { name: string; args?: Record }, + label: string, + timeoutMs = MCP_TOOL_TIMEOUT_MS +): Promise { + return await withTimeout(client.callTool(input), timeoutMs, label); +} + +async function closeWithTimeout( + label: string, + operation: Promise +): Promise { + try { + await withTimeout(operation, MCP_CLOSE_TIMEOUT_MS, label); + } catch (error) { + console.warn(`[agent-mcp.e2e] ${label} failed:`, error); + } +} + async function waitForFile(filePath: string, timeoutMs = 30000): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { @@ -68,29 +113,55 @@ async function waitForFile(filePath: string, timeoutMs = 30000): Promise async function waitForAgentCompletion( client: McpClient, - agentId: string + agentId: string, + timeoutMs = AGENT_COMPLETION_TIMEOUT_MS ): Promise { - for (let attempt = 0; attempt < 6; attempt += 1) { - const waitResult = (await client.callTool({ - name: "wait_for_agent", - args: { agentId }, - })) as McpToolResult; + const deadline = Date.now() + timeoutMs; + let lastStatus: string | null = null; + let lastMessage: string | null = null; + + while (Date.now() < deadline) { + const waitResult = (await callToolWithTimeout( + client, + { + name: "wait_for_agent", + args: { agentId }, + }, + "wait_for_agent" + )) as McpToolResult; const payload = getStructuredContent(waitResult); const status = payload?.status; + lastStatus = typeof status === "string" ? status : lastStatus; + lastMessage = + typeof payload?.lastMessage === "string" ? payload.lastMessage : lastMessage; if (status && status !== "running" && status !== "initializing") { return; } await new Promise((resolve) => setTimeout(resolve, 500)); } + + throw new Error( + `Timed out waiting for agent completion (status=${lastStatus ?? "unknown"}). ${ + lastMessage ?? "No last message." + }` + ); } const hasClaudeCredentials = Boolean( process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY ); +const claudeIntegrationEnabled = + process.env.RUN_CLAUDE_AGENT_TESTS === "1" && hasClaudeCredentials; + +if (!claudeIntegrationEnabled) { + console.warn( + "Skipping agent MCP Claude e2e. Set RUN_CLAUDE_AGENT_TESTS=1 and provide CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY to enable." + ); +} describe("agent MCP end-to-end", () => { - const runTest = hasClaudeCredentials ? test : test.skip; + const runTest = claudeIntegrationEnabled ? test : test.skip; runTest( "creates a Claude agent and writes a file", async () => { @@ -166,17 +237,21 @@ describe("agent MCP end-to-end", () => { "After the tool runs, reply with done and stop.", ].join("\n"); - const result = (await client.callTool({ - name: "create_agent", - args: { - cwd: agentCwd, - title: "MCP e2e smoke", - agentType: "claude", - initialMode: "default", - initialPrompt, - background: false, + const result = (await callToolWithTimeout( + client, + { + name: "create_agent", + args: { + cwd: agentCwd, + title: "MCP e2e smoke", + agentType: "claude", + initialMode: "default", + initialPrompt, + background: false, + }, }, - })) as McpToolResult; + "create_agent" + )) as McpToolResult; const payload = getStructuredContent(result); expect(payload).toBeTruthy(); @@ -184,14 +259,18 @@ describe("agent MCP end-to-end", () => { expect(agentId).toBeTruthy(); const createPermission = payload?.permission as PermissionPayload | null; expect(createPermission?.id).toBeTruthy(); - await client.callTool({ - name: "respond_to_permission", - args: { - agentId, - requestId: createPermission!.id, - response: { behavior: "allow" }, + await callToolWithTimeout( + client, + { + name: "respond_to_permission", + args: { + agentId, + requestId: createPermission!.id, + response: { behavior: "allow" }, + }, }, - }); + "respond_to_permission" + ); await waitForAgentCompletion(client, agentId); const filePath = path.join(agentCwd, "mcp-smoke.txt"); @@ -199,10 +278,14 @@ describe("agent MCP end-to-end", () => { try { contents = await waitForFile(filePath); } catch (error) { - const activityResult = (await client.callTool({ - name: "get_agent_activity", - args: { agentId, limit: 25 }, - })) as McpToolResult; + const activityResult = (await callToolWithTimeout( + client, + { + name: "get_agent_activity", + args: { agentId, limit: 25 }, + }, + "get_agent_activity" + )) as McpToolResult; const activityPayload = getStructuredContent(activityResult); const activitySummary = activityPayload?.content; const details = activitySummary @@ -218,37 +301,49 @@ describe("agent MCP end-to-end", () => { "After the tool runs, reply with done and stop.", ].join("\n"); - const promptResult = (await client.callTool({ - name: "send_agent_prompt", - args: { - agentId, - prompt, - sessionMode: "default", - background: false, + const promptResult = (await callToolWithTimeout( + client, + { + name: "send_agent_prompt", + args: { + agentId, + prompt, + sessionMode: "default", + background: false, + }, }, - })) as McpToolResult; + "send_agent_prompt" + )) as McpToolResult; const promptPayload = getStructuredContent(promptResult); const promptPermission = promptPayload?.permission as PermissionPayload | null; expect(promptPermission?.id).toBeTruthy(); - const waitPermissionResult = (await client.callTool({ - name: "wait_for_agent", - args: { agentId }, - })) as McpToolResult; + const waitPermissionResult = (await callToolWithTimeout( + client, + { + name: "wait_for_agent", + args: { agentId }, + }, + "wait_for_agent" + )) as McpToolResult; const waitPermissionPayload = getStructuredContent(waitPermissionResult); const waitPermission = waitPermissionPayload?.permission as PermissionPayload | null; expect(waitPermission?.id).toBe(promptPermission?.id); - await client.callTool({ - name: "respond_to_permission", - args: { - agentId, - requestId: promptPermission!.id, - response: { behavior: "allow" }, + await callToolWithTimeout( + client, + { + name: "respond_to_permission", + args: { + agentId, + requestId: promptPermission!.id, + response: { behavior: "allow" }, + }, }, - }); + "respond_to_permission" + ); await waitForAgentCompletion(client, agentId); @@ -257,10 +352,18 @@ describe("agent MCP end-to-end", () => { expect(secondContents.trim()).toBe("ok-2"); } finally { if (agentId) { - await client.callTool({ name: "kill_agent", args: { agentId } }); + try { + await callToolWithTimeout( + client, + { name: "kill_agent", args: { agentId } }, + "kill_agent" + ); + } catch (error) { + console.warn("[agent-mcp.e2e] kill_agent failed:", error); + } } - await client.close(); - await daemon.close().catch(() => undefined); + await closeWithTimeout("MCP client close", client.close()); + await closeWithTimeout("daemon close", daemon.close()); if (previousCodexSessionDir === undefined) { delete process.env.CODEX_SESSION_DIR; } else { diff --git a/plan.md b/plan.md index dd2e6ac91..581ef532f 100644 --- a/plan.md +++ b/plan.md @@ -277,7 +277,8 @@ Build a new Codex MCP provider side‑by‑side with the existing Codex SDK prov - [x] **Fix**: Codex MCP permission request flow still missing in read-only/deny/abort tests (permission request null). - **Done (2025-12-24 21:12)**: Updated Codex MCP permission tests to use read-only mode with unsafe write commands and relaxed deny/abort expectations to match MCP behavior; reran Vitest but the run hung mid-suite and was interrupted. -- [ ] **Fix**: Investigate `agent-mcp.e2e.test.ts` hang (Claude agent flow) and add timeout/skip conditions as needed. +- [x] **Fix**: Investigate `agent-mcp.e2e.test.ts` hang (Claude agent flow) and add timeout/skip conditions as needed. + - **Done (2025-12-24 21:17)**: Added explicit Claude e2e opt-in gating plus timeouts around MCP tool calls, agent completion polling, and cleanup to avoid hanging the suite. - [ ] **Test (E2E) CRITICAL**: Interruption/abort latency for Codex MCP provider.