Merge origin/main
This commit is contained in:
@@ -60,6 +60,7 @@ export type AgentManagerOptions = {
|
|||||||
|
|
||||||
export type WaitForAgentOptions = {
|
export type WaitForAgentOptions = {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
waitForActive?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WaitForAgentResult = {
|
export type WaitForAgentResult = {
|
||||||
@@ -611,9 +612,12 @@ export class AgentManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAgentBusy(snapshot.lifecycle)) {
|
const initialStatus = snapshot.lifecycle;
|
||||||
|
const initialBusy = isAgentBusy(initialStatus);
|
||||||
|
const waitForActive = options?.waitForActive ?? false;
|
||||||
|
if (!waitForActive && !initialBusy) {
|
||||||
return {
|
return {
|
||||||
status: snapshot.lifecycle,
|
status: initialStatus,
|
||||||
permission: null,
|
permission: null,
|
||||||
lastMessage: this.getLastAssistantMessage(agentId)
|
lastMessage: this.getLastAssistantMessage(agentId)
|
||||||
};
|
};
|
||||||
@@ -632,7 +636,8 @@ export class AgentManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentStatus: AgentLifecycleStatus = snapshot.lifecycle;
|
let currentStatus: AgentLifecycleStatus = initialStatus;
|
||||||
|
let hasStarted = initialBusy;
|
||||||
|
|
||||||
// Bug #3 Fix: Declare unsubscribe and abortHandler upfront so cleanup can reference them
|
// Bug #3 Fix: Declare unsubscribe and abortHandler upfront so cleanup can reference them
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
@@ -691,7 +696,11 @@ export class AgentManager {
|
|||||||
finish(pending);
|
finish(pending);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isAgentBusy(event.agent.lifecycle)) {
|
if (isAgentBusy(event.agent.lifecycle)) {
|
||||||
|
hasStarted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!waitForActive || hasStarted) {
|
||||||
finish(null);
|
finish(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import net from "node:net";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
@@ -5,20 +6,15 @@ import { describe, expect, test } from "vitest";
|
|||||||
import { experimental_createMCPClient } from "ai";
|
import { experimental_createMCPClient } from "ai";
|
||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||||
|
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js";
|
||||||
|
|
||||||
type McpToolResult = {
|
type McpToolResult = {
|
||||||
structuredContent?: Record<string, unknown>;
|
structuredContent?: Record<string, unknown>;
|
||||||
content?: Array<
|
content?: Array<{ structuredContent?: Record<string, unknown> } | Record<string, unknown>>;
|
||||||
{ structuredContent?: Record<string, unknown> } | Record<string, unknown>
|
|
||||||
>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type McpClient = {
|
type McpClient = {
|
||||||
callTool: (input: {
|
callTool: (input: { name: string; args?: Record<string, unknown> }) => Promise<unknown>;
|
||||||
name: string;
|
|
||||||
args?: Record<string, unknown>;
|
|
||||||
}) => Promise<unknown>;
|
|
||||||
close: () => Promise<void>;
|
close: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,13 +22,23 @@ type PermissionPayload = {
|
|||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getStructuredContent(
|
async function getAvailablePort(): Promise<number> {
|
||||||
result: McpToolResult
|
return new Promise((resolve, reject) => {
|
||||||
): Record<string, unknown> | null {
|
const server = net.createServer();
|
||||||
if (
|
server.once("error", reject);
|
||||||
result.structuredContent &&
|
server.listen(0, () => {
|
||||||
typeof result.structuredContent === "object"
|
const address = server.address();
|
||||||
) {
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Failed to acquire port")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
server.close(() => resolve(address.port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStructuredContent(result: McpToolResult): Record<string, unknown> | null {
|
||||||
|
if (result.structuredContent && typeof result.structuredContent === "object") {
|
||||||
return result.structuredContent;
|
return result.structuredContent;
|
||||||
}
|
}
|
||||||
const content = result.content?.[0];
|
const content = result.content?.[0];
|
||||||
@@ -45,10 +51,7 @@ function getStructuredContent(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForFile(
|
async function waitForFile(filePath: string, timeoutMs = 30000): Promise<string> {
|
||||||
filePath: string,
|
|
||||||
timeoutMs = 30000
|
|
||||||
): Promise<string> {
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
while (Date.now() - startedAt < timeoutMs) {
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
try {
|
try {
|
||||||
@@ -82,109 +85,199 @@ async function waitForAgentCompletion(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasClaudeCredentials = Boolean(
|
||||||
|
process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY
|
||||||
|
);
|
||||||
|
|
||||||
describe("agent MCP end-to-end", () => {
|
describe("agent MCP end-to-end", () => {
|
||||||
test("creates a Claude agent and writes a file", async () => {
|
const runTest = hasClaudeCredentials ? test : test.skip;
|
||||||
const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-"));
|
runTest(
|
||||||
const daemonHandle = await createTestPaseoDaemon();
|
"creates a Claude agent and writes a file",
|
||||||
|
async () => {
|
||||||
|
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
|
||||||
|
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||||
|
const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-"));
|
||||||
|
const port = await getAvailablePort();
|
||||||
|
const basicUsers = { test: "pass" };
|
||||||
|
const [agentMcpUser, agentMcpPassword] =
|
||||||
|
Object.entries(basicUsers)[0] ?? [];
|
||||||
|
const agentMcpAuthHeader =
|
||||||
|
agentMcpUser && agentMcpPassword
|
||||||
|
? `Basic ${Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")}`
|
||||||
|
: undefined;
|
||||||
|
const agentMcpBearerToken =
|
||||||
|
agentMcpUser && agentMcpPassword
|
||||||
|
? Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const transport = new StreamableHTTPClientTransport(
|
const daemonConfig: PaseoDaemonConfig = {
|
||||||
new URL(`http://127.0.0.1:${daemonHandle.port}/mcp/agents`),
|
port,
|
||||||
daemonHandle.agentMcpAuthHeader
|
paseoHome,
|
||||||
? {
|
agentMcpRoute: "/mcp/agents",
|
||||||
requestInit: {
|
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`],
|
||||||
headers: { Authorization: daemonHandle.agentMcpAuthHeader },
|
auth: {
|
||||||
},
|
basicUsers,
|
||||||
}
|
agentMcpAuthHeader,
|
||||||
: undefined
|
agentMcpBearerToken,
|
||||||
);
|
realm: "Voice Assistant",
|
||||||
const client = (await experimental_createMCPClient({
|
|
||||||
transport,
|
|
||||||
})) as McpClient;
|
|
||||||
|
|
||||||
let agentId: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = (await client.callTool({
|
|
||||||
name: "create_agent",
|
|
||||||
args: {
|
|
||||||
cwd: agentCwd,
|
|
||||||
title: "MCP e2e codex smoke",
|
|
||||||
agentType: "codex",
|
|
||||||
initialMode: "read-only",
|
|
||||||
background: false,
|
|
||||||
},
|
},
|
||||||
})) as McpToolResult;
|
staticDir,
|
||||||
|
mcpDebug: false,
|
||||||
const payload = getStructuredContent(result);
|
agentClients: {},
|
||||||
expect(payload).toBeTruthy();
|
agentRegistryPath: path.join(paseoHome, "agents.json"),
|
||||||
agentId = payload?.agentId as string | null;
|
agentControlMcp: {
|
||||||
expect(agentId).toBeTruthy();
|
url: `http://127.0.0.1:${port}/mcp/agents`,
|
||||||
|
...(agentMcpAuthHeader
|
||||||
const prompt = [
|
? { headers: { Authorization: agentMcpAuthHeader } }
|
||||||
'Run this command exactly: ["bash", "-lc", "echo ok > mcp-smoke.txt"].',
|
: {}),
|
||||||
"After the command runs, reply with done and stop.",
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const promptResult = (await client.callTool({
|
|
||||||
name: "send_agent_prompt",
|
|
||||||
args: {
|
|
||||||
agentId,
|
|
||||||
prompt,
|
|
||||||
sessionMode: "read-only",
|
|
||||||
background: false,
|
|
||||||
},
|
|
||||||
})) as McpToolResult;
|
|
||||||
const promptPayload = getStructuredContent(promptResult);
|
|
||||||
const permission = promptPayload?.permission as PermissionPayload | null;
|
|
||||||
if (!permission?.id) {
|
|
||||||
const activityResult = (await client.callTool({
|
|
||||||
name: "get_agent_activity",
|
|
||||||
args: { agentId, limit: 10 },
|
|
||||||
})) as McpToolResult;
|
|
||||||
console.log(
|
|
||||||
"[agent-mcp.e2e] send_agent_prompt payload:",
|
|
||||||
promptPayload
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
"[agent-mcp.e2e] get_agent_activity payload:",
|
|
||||||
getStructuredContent(activityResult)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
expect(permission?.id).toBeTruthy();
|
|
||||||
await client.callTool({
|
|
||||||
name: "respond_to_permission",
|
|
||||||
args: {
|
|
||||||
agentId,
|
|
||||||
requestId: permission!.id,
|
|
||||||
response: { behavior: "allow" },
|
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const previousCodexSessionDir = process.env.CODEX_SESSION_DIR;
|
||||||
|
const previousCodexHome = process.env.CODEX_HOME;
|
||||||
|
const codexSessionDir = await mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "codex-session-")
|
||||||
|
);
|
||||||
|
const codexHome = await mkdtemp(path.join(os.tmpdir(), "codex-home-"));
|
||||||
|
process.env.CODEX_SESSION_DIR = codexSessionDir;
|
||||||
|
process.env.CODEX_HOME = codexHome;
|
||||||
|
|
||||||
|
const daemon = await createPaseoDaemon(daemonConfig);
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
daemon.httpServer.listen(port, () => resolve());
|
||||||
});
|
});
|
||||||
await waitForAgentCompletion(client, agentId);
|
|
||||||
|
|
||||||
const filePath = path.join(agentCwd, "mcp-smoke.txt");
|
const transport = new StreamableHTTPClientTransport(
|
||||||
let contents: string;
|
new URL(`http://127.0.0.1:${port}/mcp/agents`),
|
||||||
|
agentMcpAuthHeader
|
||||||
|
? { requestInit: { headers: { Authorization: agentMcpAuthHeader } } }
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
const client = (await experimental_createMCPClient({
|
||||||
|
transport,
|
||||||
|
})) as McpClient;
|
||||||
|
|
||||||
|
let agentId: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
contents = await waitForFile(filePath);
|
const initialPrompt = [
|
||||||
} catch (error) {
|
"You must call the tool named shell.",
|
||||||
const activityResult = (await client.callTool({
|
"Run this command exactly: [\"bash\", \"-lc\", \"echo ok > mcp-smoke.txt\"].",
|
||||||
name: "get_agent_activity",
|
"After the tool runs, reply with done and stop.",
|
||||||
args: { agentId, limit: 25 },
|
].join("\n");
|
||||||
|
|
||||||
|
const result = (await client.callTool({
|
||||||
|
name: "create_agent",
|
||||||
|
args: {
|
||||||
|
cwd: agentCwd,
|
||||||
|
title: "MCP e2e smoke",
|
||||||
|
agentType: "claude",
|
||||||
|
initialMode: "default",
|
||||||
|
initialPrompt,
|
||||||
|
background: false,
|
||||||
|
},
|
||||||
})) as McpToolResult;
|
})) as McpToolResult;
|
||||||
const activityPayload = getStructuredContent(activityResult);
|
|
||||||
const activitySummary = activityPayload?.content;
|
const payload = getStructuredContent(result);
|
||||||
const details = activitySummary
|
expect(payload).toBeTruthy();
|
||||||
? `Agent activity:\n${activitySummary}`
|
agentId = payload?.agentId as string | null;
|
||||||
: "Agent activity unavailable";
|
expect(agentId).toBeTruthy();
|
||||||
throw new Error(`${(error as Error).message}\n${details}`);
|
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 waitForAgentCompletion(client, agentId);
|
||||||
|
|
||||||
|
const filePath = path.join(agentCwd, "mcp-smoke.txt");
|
||||||
|
let contents: string;
|
||||||
|
try {
|
||||||
|
contents = await waitForFile(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
const activityResult = (await client.callTool({
|
||||||
|
name: "get_agent_activity",
|
||||||
|
args: { agentId, limit: 25 },
|
||||||
|
})) as McpToolResult;
|
||||||
|
const activityPayload = getStructuredContent(activityResult);
|
||||||
|
const activitySummary = activityPayload?.content;
|
||||||
|
const details = activitySummary
|
||||||
|
? `Agent activity:\n${activitySummary}`
|
||||||
|
: "Agent activity unavailable";
|
||||||
|
throw new Error(`${(error as Error).message}\n${details}`);
|
||||||
|
}
|
||||||
|
expect(contents.trim()).toBe("ok");
|
||||||
|
|
||||||
|
const prompt = [
|
||||||
|
"You must call the tool named shell.",
|
||||||
|
"Run this command exactly: [\"bash\", \"-lc\", \"echo ok-2 > mcp-smoke-2.txt\"].",
|
||||||
|
"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,
|
||||||
|
},
|
||||||
|
})) 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 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 waitForAgentCompletion(client, agentId);
|
||||||
|
|
||||||
|
const secondFilePath = path.join(agentCwd, "mcp-smoke-2.txt");
|
||||||
|
const secondContents = await waitForFile(secondFilePath);
|
||||||
|
expect(secondContents.trim()).toBe("ok-2");
|
||||||
|
} finally {
|
||||||
|
if (agentId) {
|
||||||
|
await client.callTool({ name: "kill_agent", args: { agentId } });
|
||||||
|
}
|
||||||
|
await client.close();
|
||||||
|
await daemon.close().catch(() => undefined);
|
||||||
|
if (previousCodexSessionDir === undefined) {
|
||||||
|
delete process.env.CODEX_SESSION_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.CODEX_SESSION_DIR = previousCodexSessionDir;
|
||||||
|
}
|
||||||
|
if (previousCodexHome === undefined) {
|
||||||
|
delete process.env.CODEX_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.CODEX_HOME = previousCodexHome;
|
||||||
|
}
|
||||||
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
|
await rm(staticDir, { recursive: true, force: true });
|
||||||
|
await rm(agentCwd, { recursive: true, force: true });
|
||||||
|
await rm(codexSessionDir, { recursive: true, force: true });
|
||||||
|
await rm(codexHome, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
expect(contents.trim()).toBe("ok");
|
},
|
||||||
} finally {
|
180_000
|
||||||
if (agentId) {
|
);
|
||||||
await client.callTool({ name: "kill_agent", args: { agentId } });
|
|
||||||
}
|
|
||||||
await client.close();
|
|
||||||
await daemonHandle.close();
|
|
||||||
await rm(agentCwd, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}, 180_000);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ function expandPath(path: string): string {
|
|||||||
async function waitForAgentWithTimeout(
|
async function waitForAgentWithTimeout(
|
||||||
agentManager: AgentManager,
|
agentManager: AgentManager,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
existingSignal?: AbortSignal
|
existingSignal?: AbortSignal,
|
||||||
|
options?: { waitForActive?: boolean }
|
||||||
): Promise<WaitForAgentResult> {
|
): Promise<WaitForAgentResult> {
|
||||||
const timeoutSignal = AbortSignal.timeout(AGENT_WAIT_TIMEOUT_MS);
|
const timeoutSignal = AbortSignal.timeout(AGENT_WAIT_TIMEOUT_MS);
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
@@ -99,6 +100,7 @@ async function waitForAgentWithTimeout(
|
|||||||
try {
|
try {
|
||||||
const result = await agentManager.waitForAgentEvent(agentId, {
|
const result = await agentManager.waitForAgentEvent(agentId, {
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
|
waitForActive: options?.waitForActive,
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -297,7 +299,9 @@ export async function createAgentMcpServer(
|
|||||||
|
|
||||||
// If not running in background, wait for completion
|
// If not running in background, wait for completion
|
||||||
if (!background) {
|
if (!background) {
|
||||||
const result = await waitForAgentWithTimeout(agentManager, snapshot.id);
|
const result = await waitForAgentWithTimeout(agentManager, snapshot.id, undefined, {
|
||||||
|
waitForActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
const responseData = {
|
const responseData = {
|
||||||
agentId: snapshot.id,
|
agentId: snapshot.id,
|
||||||
@@ -509,7 +513,9 @@ export async function createAgentMcpServer(
|
|||||||
|
|
||||||
// If not running in background, wait for completion
|
// If not running in background, wait for completion
|
||||||
if (!background) {
|
if (!background) {
|
||||||
const result = await waitForAgentWithTimeout(agentManager, agentId);
|
const result = await waitForAgentWithTimeout(agentManager, agentId, undefined, {
|
||||||
|
waitForActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
const responseData = {
|
const responseData = {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user