Guard agent MCP Claude e2e from hangs
This commit is contained in:
@@ -22,6 +22,10 @@ type PermissionPayload = {
|
|||||||
id: string;
|
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> {
|
async function getAvailablePort(): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
@@ -51,6 +55,47 @@ function getStructuredContent(result: McpToolResult): Record<string, unknown> |
|
|||||||
return null;
|
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> {
|
async function waitForFile(filePath: string, timeoutMs = 30000): Promise<string> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
while (Date.now() - startedAt < timeoutMs) {
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
@@ -68,29 +113,55 @@ async function waitForFile(filePath: string, timeoutMs = 30000): Promise<string>
|
|||||||
|
|
||||||
async function waitForAgentCompletion(
|
async function waitForAgentCompletion(
|
||||||
client: McpClient,
|
client: McpClient,
|
||||||
agentId: string
|
agentId: string,
|
||||||
|
timeoutMs = AGENT_COMPLETION_TIMEOUT_MS
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
const deadline = Date.now() + timeoutMs;
|
||||||
const waitResult = (await client.callTool({
|
let lastStatus: string | null = null;
|
||||||
name: "wait_for_agent",
|
let lastMessage: string | null = null;
|
||||||
args: { agentId },
|
|
||||||
})) as McpToolResult;
|
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 payload = getStructuredContent(waitResult);
|
||||||
const status = payload?.status;
|
const status = payload?.status;
|
||||||
|
lastStatus = typeof status === "string" ? status : lastStatus;
|
||||||
|
lastMessage =
|
||||||
|
typeof payload?.lastMessage === "string" ? payload.lastMessage : lastMessage;
|
||||||
if (status && status !== "running" && status !== "initializing") {
|
if (status && status !== "running" && status !== "initializing") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
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(
|
const hasClaudeCredentials = Boolean(
|
||||||
process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY
|
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", () => {
|
describe("agent MCP end-to-end", () => {
|
||||||
const runTest = hasClaudeCredentials ? test : test.skip;
|
const runTest = claudeIntegrationEnabled ? test : test.skip;
|
||||||
runTest(
|
runTest(
|
||||||
"creates a Claude agent and writes a file",
|
"creates a Claude agent and writes a file",
|
||||||
async () => {
|
async () => {
|
||||||
@@ -166,17 +237,21 @@ describe("agent MCP end-to-end", () => {
|
|||||||
"After the tool runs, reply with done and stop.",
|
"After the tool runs, reply with done and stop.",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
const result = (await client.callTool({
|
const result = (await callToolWithTimeout(
|
||||||
name: "create_agent",
|
client,
|
||||||
args: {
|
{
|
||||||
cwd: agentCwd,
|
name: "create_agent",
|
||||||
title: "MCP e2e smoke",
|
args: {
|
||||||
agentType: "claude",
|
cwd: agentCwd,
|
||||||
initialMode: "default",
|
title: "MCP e2e smoke",
|
||||||
initialPrompt,
|
agentType: "claude",
|
||||||
background: false,
|
initialMode: "default",
|
||||||
|
initialPrompt,
|
||||||
|
background: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})) as McpToolResult;
|
"create_agent"
|
||||||
|
)) as McpToolResult;
|
||||||
|
|
||||||
const payload = getStructuredContent(result);
|
const payload = getStructuredContent(result);
|
||||||
expect(payload).toBeTruthy();
|
expect(payload).toBeTruthy();
|
||||||
@@ -184,14 +259,18 @@ describe("agent MCP end-to-end", () => {
|
|||||||
expect(agentId).toBeTruthy();
|
expect(agentId).toBeTruthy();
|
||||||
const createPermission = payload?.permission as PermissionPayload | null;
|
const createPermission = payload?.permission as PermissionPayload | null;
|
||||||
expect(createPermission?.id).toBeTruthy();
|
expect(createPermission?.id).toBeTruthy();
|
||||||
await client.callTool({
|
await callToolWithTimeout(
|
||||||
name: "respond_to_permission",
|
client,
|
||||||
args: {
|
{
|
||||||
agentId,
|
name: "respond_to_permission",
|
||||||
requestId: createPermission!.id,
|
args: {
|
||||||
response: { behavior: "allow" },
|
agentId,
|
||||||
|
requestId: createPermission!.id,
|
||||||
|
response: { behavior: "allow" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
"respond_to_permission"
|
||||||
|
);
|
||||||
await waitForAgentCompletion(client, agentId);
|
await waitForAgentCompletion(client, agentId);
|
||||||
|
|
||||||
const filePath = path.join(agentCwd, "mcp-smoke.txt");
|
const filePath = path.join(agentCwd, "mcp-smoke.txt");
|
||||||
@@ -199,10 +278,14 @@ describe("agent MCP end-to-end", () => {
|
|||||||
try {
|
try {
|
||||||
contents = await waitForFile(filePath);
|
contents = await waitForFile(filePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const activityResult = (await client.callTool({
|
const activityResult = (await callToolWithTimeout(
|
||||||
name: "get_agent_activity",
|
client,
|
||||||
args: { agentId, limit: 25 },
|
{
|
||||||
})) as McpToolResult;
|
name: "get_agent_activity",
|
||||||
|
args: { agentId, limit: 25 },
|
||||||
|
},
|
||||||
|
"get_agent_activity"
|
||||||
|
)) as McpToolResult;
|
||||||
const activityPayload = getStructuredContent(activityResult);
|
const activityPayload = getStructuredContent(activityResult);
|
||||||
const activitySummary = activityPayload?.content;
|
const activitySummary = activityPayload?.content;
|
||||||
const details = activitySummary
|
const details = activitySummary
|
||||||
@@ -218,37 +301,49 @@ describe("agent MCP end-to-end", () => {
|
|||||||
"After the tool runs, reply with done and stop.",
|
"After the tool runs, reply with done and stop.",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
const promptResult = (await client.callTool({
|
const promptResult = (await callToolWithTimeout(
|
||||||
name: "send_agent_prompt",
|
client,
|
||||||
args: {
|
{
|
||||||
agentId,
|
name: "send_agent_prompt",
|
||||||
prompt,
|
args: {
|
||||||
sessionMode: "default",
|
agentId,
|
||||||
background: false,
|
prompt,
|
||||||
|
sessionMode: "default",
|
||||||
|
background: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})) as McpToolResult;
|
"send_agent_prompt"
|
||||||
|
)) as McpToolResult;
|
||||||
|
|
||||||
const promptPayload = getStructuredContent(promptResult);
|
const promptPayload = getStructuredContent(promptResult);
|
||||||
const promptPermission = promptPayload?.permission as PermissionPayload | null;
|
const promptPermission = promptPayload?.permission as PermissionPayload | null;
|
||||||
expect(promptPermission?.id).toBeTruthy();
|
expect(promptPermission?.id).toBeTruthy();
|
||||||
|
|
||||||
const waitPermissionResult = (await client.callTool({
|
const waitPermissionResult = (await callToolWithTimeout(
|
||||||
name: "wait_for_agent",
|
client,
|
||||||
args: { agentId },
|
{
|
||||||
})) as McpToolResult;
|
name: "wait_for_agent",
|
||||||
|
args: { agentId },
|
||||||
|
},
|
||||||
|
"wait_for_agent"
|
||||||
|
)) as McpToolResult;
|
||||||
const waitPermissionPayload = getStructuredContent(waitPermissionResult);
|
const waitPermissionPayload = getStructuredContent(waitPermissionResult);
|
||||||
const waitPermission =
|
const waitPermission =
|
||||||
waitPermissionPayload?.permission as PermissionPayload | null;
|
waitPermissionPayload?.permission as PermissionPayload | null;
|
||||||
expect(waitPermission?.id).toBe(promptPermission?.id);
|
expect(waitPermission?.id).toBe(promptPermission?.id);
|
||||||
|
|
||||||
await client.callTool({
|
await callToolWithTimeout(
|
||||||
name: "respond_to_permission",
|
client,
|
||||||
args: {
|
{
|
||||||
agentId,
|
name: "respond_to_permission",
|
||||||
requestId: promptPermission!.id,
|
args: {
|
||||||
response: { behavior: "allow" },
|
agentId,
|
||||||
|
requestId: promptPermission!.id,
|
||||||
|
response: { behavior: "allow" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
"respond_to_permission"
|
||||||
|
);
|
||||||
|
|
||||||
await waitForAgentCompletion(client, agentId);
|
await waitForAgentCompletion(client, agentId);
|
||||||
|
|
||||||
@@ -257,10 +352,18 @@ describe("agent MCP end-to-end", () => {
|
|||||||
expect(secondContents.trim()).toBe("ok-2");
|
expect(secondContents.trim()).toBe("ok-2");
|
||||||
} finally {
|
} finally {
|
||||||
if (agentId) {
|
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 closeWithTimeout("MCP client close", client.close());
|
||||||
await daemon.close().catch(() => undefined);
|
await closeWithTimeout("daemon close", daemon.close());
|
||||||
if (previousCodexSessionDir === undefined) {
|
if (previousCodexSessionDir === undefined) {
|
||||||
delete process.env.CODEX_SESSION_DIR;
|
delete process.env.CODEX_SESSION_DIR;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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).
|
- [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.
|
- **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.
|
- [ ] **Test (E2E) CRITICAL**: Interruption/abort latency for Codex MCP provider.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user