Guard agent MCP Claude e2e from hangs

This commit is contained in:
Mohamed Boudra
2025-12-24 21:18:25 +07:00
parent a4ae93cef0
commit b752c8a562
2 changed files with 155 additions and 51 deletions

View File

@@ -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<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -51,6 +55,47 @@ function getStructuredContent(result: McpToolResult): Record<string, unknown> |
return null;
}
async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label: string
): Promise<T> {
let timeoutId: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, 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<string, unknown> },
label: string,
timeoutMs = MCP_TOOL_TIMEOUT_MS
): Promise<unknown> {
return await withTimeout(client.callTool(input), timeoutMs, label);
}
async function closeWithTimeout(
label: string,
operation: Promise<void>
): Promise<void> {
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<string> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
@@ -68,29 +113,55 @@ async function waitForFile(filePath: string, timeoutMs = 30000): Promise<string>
async function waitForAgentCompletion(
client: McpClient,
agentId: string
agentId: string,
timeoutMs = AGENT_COMPLETION_TIMEOUT_MS
): Promise<void> {
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 {

View File

@@ -277,7 +277,8 @@ Build a new Codex MCP provider sidebyside 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.