mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Guard agent MCP Claude e2e from hangs
This commit is contained in:
@@ -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 {
|
||||
|
||||
3
plan.md
3
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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user