mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Consolidate MCP server: remove voice bridge, add schedule/terminal/worktree tools
Merge agent-management and agent MCP into a unified server with shared utilities. Remove the voice-mcp-bridge in favor of direct MCP tool registration. Add schedule, terminal, and worktree management tools to the MCP server for CLI parity. Include e2e parity tests.
This commit is contained in:
@@ -34,6 +34,7 @@ export function createDaemonCommand(): Command {
|
||||
.option("--port <port>", "Port for restarted daemon listen target")
|
||||
.option("--no-relay", "Disable relay on restarted daemon")
|
||||
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
|
||||
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
|
||||
.option(
|
||||
"--allowed-hosts <hosts>",
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface DaemonStartOptions {
|
||||
foreground?: boolean;
|
||||
relay?: boolean;
|
||||
mcp?: boolean;
|
||||
injectMcp?: boolean;
|
||||
allowedHosts?: string;
|
||||
}
|
||||
|
||||
@@ -95,6 +96,9 @@ function buildRunnerArgs(options: DaemonStartOptions): string[] {
|
||||
if (options.mcp === false) {
|
||||
args.push("--no-mcp");
|
||||
}
|
||||
if (options.injectMcp === false) {
|
||||
args.push("--no-inject-mcp");
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ function toStartOptions(options: CommandOptions): DaemonStartOptions {
|
||||
port: typeof options.port === "string" ? options.port : undefined,
|
||||
relay: typeof options.relay === "boolean" ? options.relay : undefined,
|
||||
mcp: typeof options.mcp === "boolean" ? options.mcp : undefined,
|
||||
injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : undefined,
|
||||
allowedHosts: typeof options.allowedHosts === "string" ? options.allowedHosts : undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export function startCommand(): Command {
|
||||
.option("--foreground", "Run in foreground (don't daemonize)")
|
||||
.option("--no-relay", "Disable relay connection")
|
||||
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
|
||||
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
|
||||
.option(
|
||||
"--allowed-hosts <hosts>",
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
|
||||
|
||||
197
packages/server/scripts/test-mcp-inject.ts
Normal file
197
packages/server/scripts/test-mcp-inject.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import pino from "pino";
|
||||
|
||||
import { ClaudeAgentClient } from "../src/server/agent/providers/claude-agent.js";
|
||||
import { CodexAppServerAgentClient } from "../src/server/agent/providers/codex-app-server-agent.js";
|
||||
import { getFullAccessConfig, isProviderAvailable } from "../src/server/daemon-e2e/agent-configs.js";
|
||||
import { DaemonClient } from "../src/server/test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../src/server/test-utils/paseo-daemon.js";
|
||||
|
||||
function collectAssistantText(
|
||||
entries: Array<{ item: { type: string; text?: string } }>,
|
||||
): string {
|
||||
return entries
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is { item: { type: "assistant_message"; text: string } } =>
|
||||
entry.item.type === "assistant_message" && typeof entry.item.text === "string",
|
||||
)
|
||||
.map((entry) => entry.item.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
type ToolCallRecord = {
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type ProviderRunResult = {
|
||||
provider: "claude" | "codex";
|
||||
agentId: string;
|
||||
assistantText: string;
|
||||
toolCalls: ToolCallRecord[];
|
||||
};
|
||||
|
||||
async function verifyInjectedMcpForProvider(
|
||||
client: DaemonClient,
|
||||
provider: "claude" | "codex",
|
||||
cwd: string,
|
||||
): Promise<ProviderRunResult> {
|
||||
const created = await client.createAgent({
|
||||
cwd,
|
||||
title: `mcp-inject-real-${provider}`,
|
||||
...getFullAccessConfig(provider),
|
||||
});
|
||||
const agentId = created.id;
|
||||
|
||||
try {
|
||||
const prompt = [
|
||||
"List all your available MCP tools.",
|
||||
"If you have a tool called list_agents or create_agent from a paseo MCP server, call list_agents once.",
|
||||
"After checking, reply with exactly PASEO_MCP_FOUND.",
|
||||
"If you do not have those tools, reply with exactly PASEO_MCP_NOT_FOUND.",
|
||||
"Do not say anything else.",
|
||||
].join(" ");
|
||||
|
||||
await client.sendMessage(agentId, prompt);
|
||||
|
||||
const finished = await client.waitForFinish(agentId, 240_000);
|
||||
if (finished.status !== "idle") {
|
||||
throw new Error(`Agent did not finish successfully (status=${finished.status})`);
|
||||
}
|
||||
|
||||
const timeline = await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
const assistantText = collectAssistantText(timeline.entries);
|
||||
const toolCalls = timeline.entries
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is typeof entry & {
|
||||
item: { type: "tool_call"; name: string; status: string };
|
||||
} => entry.item.type === "tool_call" && typeof entry.item.name === "string",
|
||||
)
|
||||
.map((entry) => ({
|
||||
name: entry.item.name,
|
||||
status: entry.item.status,
|
||||
}));
|
||||
|
||||
if (!assistantText.includes("PASEO_MCP_FOUND")) {
|
||||
throw new Error(
|
||||
`Expected assistant to confirm Paseo MCP availability. Assistant text:\n${assistantText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const listAgentsCalls = toolCalls.filter(
|
||||
(call) =>
|
||||
call.name === "list_agents" ||
|
||||
call.name === "paseo.list_agents" ||
|
||||
call.name.endsWith("__list_agents"),
|
||||
);
|
||||
if (listAgentsCalls.length === 0) {
|
||||
throw new Error(
|
||||
`Expected agent to call list_agents. Tool calls:\n${JSON.stringify(toolCalls, null, 2)}`,
|
||||
);
|
||||
}
|
||||
if (!listAgentsCalls.some((call) => call.status === "completed")) {
|
||||
throw new Error(
|
||||
`Expected list_agents to complete successfully. Tool calls:\n${JSON.stringify(toolCalls, null, 2)}`,
|
||||
);
|
||||
}
|
||||
if (listAgentsCalls.some((call) => call.status === "failed")) {
|
||||
throw new Error(
|
||||
`Expected list_agents to succeed. Tool calls:\n${JSON.stringify(toolCalls, null, 2)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
agentId,
|
||||
assistantText,
|
||||
toolCalls,
|
||||
};
|
||||
} catch (error) {
|
||||
await client.archiveAgent(agentId).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!isProviderAvailable("claude")) {
|
||||
throw new Error(
|
||||
"Claude is not available in this environment. Ensure the `claude` binary and credentials are configured.",
|
||||
);
|
||||
}
|
||||
|
||||
const logger = pino({ level: "silent" });
|
||||
const rootCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-mcp-inject-real-"));
|
||||
const claudeCwd = path.join(rootCwd, "claude");
|
||||
const codexCwd = path.join(rootCwd, "codex");
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: {
|
||||
claude: new ClaudeAgentClient({ logger }),
|
||||
...(isProviderAvailable("codex")
|
||||
? { codex: new CodexAppServerAgentClient(logger) }
|
||||
: {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const createdAgentIds: string[] = [];
|
||||
|
||||
try {
|
||||
await mkdir(claudeCwd, { recursive: true });
|
||||
await mkdir(codexCwd, { recursive: true });
|
||||
|
||||
await client.connect();
|
||||
await client.fetchAgents({
|
||||
subscribe: { subscriptionId: "mcp-inject-real-claude" },
|
||||
});
|
||||
|
||||
const results: ProviderRunResult[] = [];
|
||||
|
||||
const claudeResult = await verifyInjectedMcpForProvider(client, "claude", claudeCwd);
|
||||
createdAgentIds.push(claudeResult.agentId);
|
||||
results.push(claudeResult);
|
||||
console.log(`[PASS] Claude MCP injection verified for agent ${claudeResult.agentId}`);
|
||||
|
||||
if (isProviderAvailable("codex")) {
|
||||
const codexResult = await verifyInjectedMcpForProvider(client, "codex", codexCwd);
|
||||
createdAgentIds.push(codexResult.agentId);
|
||||
results.push(codexResult);
|
||||
console.log(`[PASS] Codex MCP injection verified for agent ${codexResult.agentId}`);
|
||||
} else {
|
||||
console.log("[SKIP] Codex is not available in this environment");
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
results,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
for (const agentId of createdAgentIds) {
|
||||
await client.archiveAgent(agentId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close().catch(() => undefined);
|
||||
await rm(rootCwd, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -26,17 +26,15 @@ import { z } from "zod";
|
||||
import { ensureValidJson } from "../json-utils.js";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { AgentPromptInput, AgentProvider, AgentPermissionRequest } from "./agent-sdk-types.js";
|
||||
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
|
||||
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
import type { AgentManager, WaitForAgentResult } from "./agent-manager.js";
|
||||
import {
|
||||
AgentPermissionRequestPayloadSchema,
|
||||
AgentPermissionResponseSchema,
|
||||
AgentSnapshotPayloadSchema,
|
||||
serializeAgentSnapshot,
|
||||
} from "../messages.js";
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
@@ -48,170 +46,54 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||
import { expandUserPath } from "../path-utils.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
|
||||
import type { ScheduleService } from "../schedule/service.js";
|
||||
import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type ProviderDefinition,
|
||||
} from "./provider-registry.js";
|
||||
import {
|
||||
AgentModelSchema,
|
||||
AgentProviderEnum,
|
||||
AgentStatusEnum,
|
||||
ProviderSummarySchema,
|
||||
parseDurationString,
|
||||
sanitizePermissionRequest,
|
||||
serializeSnapshotWithMetadata,
|
||||
startAgentRun,
|
||||
toScheduleSummary,
|
||||
waitForAgentWithTimeout,
|
||||
} from "./mcp-shared.js";
|
||||
|
||||
export interface AgentManagementMcpOptions {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
terminalManager?: TerminalManager | null;
|
||||
scheduleService?: ScheduleService | null;
|
||||
providerRegistry?: Record<AgentProvider, ProviderDefinition> | null;
|
||||
paseoHome?: string;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
const AgentProviderEnum = z.enum(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [
|
||||
AgentProvider,
|
||||
...AgentProvider[],
|
||||
],
|
||||
);
|
||||
|
||||
const AgentStatusEnum = z.enum(["initializing", "idle", "running", "error", "closed"]);
|
||||
|
||||
// 50 seconds - surface friendly message before SDK tool timeout (~60s)
|
||||
const AGENT_WAIT_TIMEOUT_MS = 50000;
|
||||
|
||||
async function waitForAgentWithTimeout(
|
||||
agentManager: AgentManager,
|
||||
agentId: string,
|
||||
options?: {
|
||||
signal?: AbortSignal;
|
||||
waitForActive?: boolean;
|
||||
},
|
||||
): Promise<WaitForAgentResult> {
|
||||
const timeoutController = new AbortController();
|
||||
const combinedController = new AbortController();
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
timeoutController.abort(new Error("wait timeout"));
|
||||
}, AGENT_WAIT_TIMEOUT_MS);
|
||||
|
||||
const forwardAbort = (reason: unknown) => {
|
||||
if (!combinedController.signal.aborted) {
|
||||
combinedController.abort(reason);
|
||||
}
|
||||
};
|
||||
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
forwardAbort(options.signal.reason);
|
||||
} else {
|
||||
options.signal.addEventListener("abort", () => forwardAbort(options.signal!.reason), {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
timeoutController.signal.addEventListener(
|
||||
"abort",
|
||||
() => forwardAbort(timeoutController.signal.reason),
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await agentManager.waitForAgentEvent(agentId, {
|
||||
signal: combinedController.signal,
|
||||
waitForActive: options?.waitForActive,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "wait timeout") {
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
const timeline = agentManager.getTimeline(agentId);
|
||||
const recentActivity = curateAgentActivity(timeline.slice(-5));
|
||||
const message = `Awaiting the agent timed out. This does not mean the agent failed - call wait_for_agent again to continue waiting.\n\nRecent activity:\n${recentActivity}`;
|
||||
return {
|
||||
status: snapshot?.lifecycle ?? "idle",
|
||||
permission: null,
|
||||
lastMessage: message,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
function startAgentRun(
|
||||
agentManager: AgentManager,
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
logger: Logger,
|
||||
options?: { replaceRunning?: boolean },
|
||||
): void {
|
||||
const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
|
||||
const iterator = shouldReplace
|
||||
? agentManager.replaceAgentRun(agentId, prompt)
|
||||
: agentManager.streamAgent(agentId, prompt);
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const _ of iterator) {
|
||||
// Events are broadcast via AgentManager subscribers.
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error, agentId }, "Agent stream failed");
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function sanitizePermissionRequest(
|
||||
permission: AgentPermissionRequest | null | undefined,
|
||||
): AgentPermissionRequest | null {
|
||||
if (!permission) {
|
||||
return null;
|
||||
}
|
||||
const sanitized: AgentPermissionRequest = { ...permission };
|
||||
if (sanitized.title === undefined) {
|
||||
delete sanitized.title;
|
||||
}
|
||||
if (sanitized.description === undefined) {
|
||||
delete sanitized.description;
|
||||
}
|
||||
if (sanitized.input === undefined) {
|
||||
delete sanitized.input;
|
||||
}
|
||||
if (sanitized.suggestions === undefined) {
|
||||
delete sanitized.suggestions;
|
||||
}
|
||||
if (sanitized.actions === undefined) {
|
||||
delete sanitized.actions;
|
||||
}
|
||||
if (sanitized.metadata === undefined) {
|
||||
delete sanitized.metadata;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
async function resolveAgentTitle(
|
||||
agentStorage: AgentStorage,
|
||||
agentId: string,
|
||||
logger: Logger,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const record = await agentStorage.get(agentId);
|
||||
return record?.title ?? null;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, agentId }, "Failed to load agent title");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function serializeSnapshotWithMetadata(
|
||||
agentStorage: AgentStorage,
|
||||
snapshot: ManagedAgent,
|
||||
logger: Logger,
|
||||
) {
|
||||
const title = await resolveAgentTitle(agentStorage, snapshot.id, logger);
|
||||
return serializeAgentSnapshot(snapshot, { title });
|
||||
}
|
||||
|
||||
export async function createAgentManagementMcpServer(
|
||||
options: AgentManagementMcpOptions,
|
||||
): Promise<McpServer> {
|
||||
const { agentManager, agentStorage, logger } = options;
|
||||
const { agentManager, agentStorage, scheduleService, providerRegistry, logger } = options;
|
||||
const childLogger = logger.child({
|
||||
module: "agent",
|
||||
component: "agent-management-mcp",
|
||||
});
|
||||
const waitTracker = new WaitForAgentTracker(logger);
|
||||
const resolveNewAgentScheduleTarget = (params?: {
|
||||
provider?: AgentProvider;
|
||||
cwd?: string;
|
||||
}) => ({
|
||||
type: "new-agent" as const,
|
||||
config: {
|
||||
provider: params?.provider ?? ("claude" as AgentProvider),
|
||||
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
|
||||
},
|
||||
});
|
||||
|
||||
const server = new McpServer({
|
||||
name: "paseo-agent-management",
|
||||
@@ -231,6 +113,15 @@ export async function createAgentManagementMcpServer(
|
||||
agentType: AgentProviderEnum.optional().describe(
|
||||
"Optional agent implementation to spawn. Defaults to 'claude'.",
|
||||
),
|
||||
model: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Model to use (e.g. claude-sonnet-4-20250514)"),
|
||||
thinking: z.string().optional().describe("Thinking option ID"),
|
||||
labels: z
|
||||
.record(z.string(), z.string())
|
||||
.optional()
|
||||
.describe("Labels to set on the agent"),
|
||||
initialPrompt: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -287,6 +178,9 @@ export async function createAgentManagementMcpServer(
|
||||
baseBranch,
|
||||
background = false,
|
||||
title,
|
||||
model,
|
||||
thinking,
|
||||
labels,
|
||||
} = args as {
|
||||
cwd: string;
|
||||
agentType?: AgentProvider;
|
||||
@@ -296,6 +190,9 @@ export async function createAgentManagementMcpServer(
|
||||
baseBranch?: string;
|
||||
background?: boolean;
|
||||
title: string;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
labels?: Record<string, string>;
|
||||
};
|
||||
|
||||
let resolvedCwd = expandUserPath(cwd);
|
||||
@@ -318,12 +215,18 @@ export async function createAgentManagementMcpServer(
|
||||
|
||||
const provider: AgentProvider = agentType ?? "claude";
|
||||
const normalizedTitle = title?.trim() ?? null;
|
||||
const snapshot = await agentManager.createAgent({
|
||||
provider,
|
||||
cwd: resolvedCwd,
|
||||
modeId: initialMode,
|
||||
title: normalizedTitle ?? undefined,
|
||||
});
|
||||
const snapshot = await agentManager.createAgent(
|
||||
{
|
||||
provider,
|
||||
cwd: resolvedCwd,
|
||||
modeId: initialMode,
|
||||
title: normalizedTitle ?? undefined,
|
||||
model,
|
||||
thinkingOptionId: thinking,
|
||||
},
|
||||
undefined,
|
||||
labels ? { labels } : undefined,
|
||||
);
|
||||
|
||||
if (worktreeConfig) {
|
||||
void runAsyncWorktreeBootstrap({
|
||||
@@ -666,6 +569,29 @@ export async function createAgentManagementMcpServer(
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"archive_agent",
|
||||
{
|
||||
title: "Archive Agent",
|
||||
description:
|
||||
"Archive an agent (soft-delete). The agent is interrupted if running and removed from the active list.",
|
||||
inputSchema: {
|
||||
agentId: z.string(),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
},
|
||||
},
|
||||
async ({ agentId }) => {
|
||||
await agentManager.archiveAgent(agentId);
|
||||
waitTracker.cancel(agentId, "Agent archived");
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success: true }),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"kill_agent",
|
||||
{
|
||||
@@ -688,6 +614,281 @@ export async function createAgentManagementMcpServer(
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"update_agent",
|
||||
{
|
||||
title: "Update Agent",
|
||||
description: "Update an agent name and/or labels.",
|
||||
inputSchema: {
|
||||
agentId: z.string(),
|
||||
name: z.string().optional(),
|
||||
labels: z
|
||||
.record(z.string(), z.string())
|
||||
.optional()
|
||||
.describe("Labels to set on the agent"),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
},
|
||||
},
|
||||
async ({ agentId, name, labels }) => {
|
||||
const trimmedName = name?.trim();
|
||||
if (trimmedName) {
|
||||
const record = await agentStorage.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
await agentStorage.upsert({
|
||||
...record,
|
||||
title: trimmedName,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
agentManager.notifyAgentState(agentId);
|
||||
}
|
||||
|
||||
if (labels) {
|
||||
await agentManager.setLabels(agentId, labels);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success: true }),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"create_schedule",
|
||||
{
|
||||
title: "Create Schedule",
|
||||
description: "Create a recurring schedule that runs on an agent or a new agent.",
|
||||
inputSchema: {
|
||||
prompt: z.string().trim().min(1, "prompt is required"),
|
||||
every: z.string().optional(),
|
||||
cron: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
target: z.enum(["self", "new-agent"]).optional(),
|
||||
provider: AgentProviderEnum.optional(),
|
||||
cwd: z.string().optional(),
|
||||
maxRuns: z.number().int().positive().optional(),
|
||||
expiresIn: z.string().optional(),
|
||||
},
|
||||
outputSchema: ScheduleSummarySchema.shape,
|
||||
},
|
||||
async ({ prompt, every, cron, name, target, provider, cwd, maxRuns, expiresIn }) => {
|
||||
if (!scheduleService) {
|
||||
throw new Error("Schedule service is not configured");
|
||||
}
|
||||
|
||||
const cadenceCount = Number(every !== undefined) + Number(cron !== undefined);
|
||||
if (cadenceCount !== 1) {
|
||||
throw new Error("Specify exactly one of every or cron");
|
||||
}
|
||||
if (target === "self") {
|
||||
throw new Error("target=self requires a caller agent");
|
||||
}
|
||||
|
||||
const schedule = await scheduleService.create({
|
||||
prompt: prompt.trim(),
|
||||
cadence: every
|
||||
? { type: "every" as const, everyMs: parseDurationString(every) }
|
||||
: { type: "cron" as const, expression: cron!.trim() },
|
||||
target: resolveNewAgentScheduleTarget({ provider, cwd }),
|
||||
...(name?.trim() ? { name: name.trim() } : {}),
|
||||
...(maxRuns === undefined ? {} : { maxRuns }),
|
||||
...(expiresIn === undefined
|
||||
? {}
|
||||
: { expiresAt: new Date(Date.now() + parseDurationString(expiresIn)).toISOString() }),
|
||||
});
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson(toScheduleSummary(schedule)),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"list_schedules",
|
||||
{
|
||||
title: "List Schedules",
|
||||
description: "List all schedules managed by the daemon.",
|
||||
inputSchema: {},
|
||||
outputSchema: {
|
||||
schedules: z.array(ScheduleSummarySchema),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
if (!scheduleService) {
|
||||
throw new Error("Schedule service is not configured");
|
||||
}
|
||||
|
||||
const schedules = (await scheduleService.list()).map((schedule) => toScheduleSummary(schedule));
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ schedules }),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"inspect_schedule",
|
||||
{
|
||||
title: "Inspect Schedule",
|
||||
description: "Inspect a schedule and its run history.",
|
||||
inputSchema: {
|
||||
id: z.string(),
|
||||
},
|
||||
outputSchema: StoredScheduleSchema.shape,
|
||||
},
|
||||
async ({ id }) => {
|
||||
if (!scheduleService) {
|
||||
throw new Error("Schedule service is not configured");
|
||||
}
|
||||
|
||||
const schedule = await scheduleService.inspect(id);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson(schedule),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"pause_schedule",
|
||||
{
|
||||
title: "Pause Schedule",
|
||||
description: "Pause an active schedule.",
|
||||
inputSchema: {
|
||||
id: z.string(),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
if (!scheduleService) {
|
||||
throw new Error("Schedule service is not configured");
|
||||
}
|
||||
|
||||
await scheduleService.pause(id);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success: true }),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"resume_schedule",
|
||||
{
|
||||
title: "Resume Schedule",
|
||||
description: "Resume a paused schedule.",
|
||||
inputSchema: {
|
||||
id: z.string(),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
if (!scheduleService) {
|
||||
throw new Error("Schedule service is not configured");
|
||||
}
|
||||
|
||||
await scheduleService.resume(id);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success: true }),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"delete_schedule",
|
||||
{
|
||||
title: "Delete Schedule",
|
||||
description: "Delete a schedule permanently.",
|
||||
inputSchema: {
|
||||
id: z.string(),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
if (!scheduleService) {
|
||||
throw new Error("Schedule service is not configured");
|
||||
}
|
||||
|
||||
await scheduleService.delete(id);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success: true }),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"list_providers",
|
||||
{
|
||||
title: "List Providers",
|
||||
description: "List available agent providers and their modes.",
|
||||
inputSchema: {},
|
||||
outputSchema: {
|
||||
providers: z.array(ProviderSummarySchema),
|
||||
},
|
||||
},
|
||||
async () => ({
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
providers: AGENT_PROVIDER_DEFINITIONS.map((provider) => ({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
modes: provider.modes.map((mode) => ({
|
||||
id: mode.id,
|
||||
label: mode.label,
|
||||
...(mode.description ? { description: mode.description } : {}),
|
||||
})),
|
||||
})),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"list_models",
|
||||
{
|
||||
title: "List Models",
|
||||
description: "List models for an agent provider.",
|
||||
inputSchema: {
|
||||
provider: AgentProviderEnum,
|
||||
},
|
||||
outputSchema: {
|
||||
provider: z.string(),
|
||||
models: z.array(AgentModelSchema),
|
||||
},
|
||||
},
|
||||
async ({ provider }) => {
|
||||
if (!providerRegistry) {
|
||||
throw new Error("Provider registry is not configured");
|
||||
}
|
||||
|
||||
const definition = providerRegistry[provider];
|
||||
if (!definition) {
|
||||
throw new Error(`Provider ${provider} is not configured`);
|
||||
}
|
||||
|
||||
const models = await definition.fetchModels();
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
provider,
|
||||
models,
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"get_agent_activity",
|
||||
{
|
||||
|
||||
@@ -300,6 +300,100 @@ describe("AgentManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("createAgent injects paseo MCP server when manager has an MCP base URL", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class CaptureClient extends TestAgentClient {
|
||||
lastConfig: AgentSessionConfig | null = null;
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.lastConfig = config;
|
||||
return new TestAgentSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new CaptureClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000103",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
mcpServers: {
|
||||
custom: {
|
||||
type: "stdio",
|
||||
command: "custom-mcp",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.config.mcpServers).toEqual({
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`,
|
||||
},
|
||||
custom: {
|
||||
type: "stdio",
|
||||
command: "custom-mcp",
|
||||
},
|
||||
});
|
||||
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
|
||||
});
|
||||
|
||||
test("createAgent preserves a user-provided paseo MCP config", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class CaptureClient extends TestAgentClient {
|
||||
lastConfig: AgentSessionConfig | null = null;
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.lastConfig = config;
|
||||
return new TestAgentSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new CaptureClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000104",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
mcpServers: {
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: "https://example.com/custom-paseo",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.config.mcpServers).toEqual({
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: "https://example.com/custom-paseo",
|
||||
},
|
||||
});
|
||||
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
|
||||
});
|
||||
|
||||
test("createAgent fails when cwd does not exist", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -76,6 +76,7 @@ export type AgentManagerOptions = {
|
||||
idFactory?: () => string;
|
||||
registry?: AgentStorage;
|
||||
onAgentAttention?: AgentAttentionCallback;
|
||||
mcpBaseUrl?: string;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
@@ -331,6 +332,7 @@ export class AgentManager {
|
||||
private readonly registry?: AgentStorage;
|
||||
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
|
||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||
private mcpBaseUrl: string | null;
|
||||
private onAgentAttention?: AgentAttentionCallback;
|
||||
private logger: Logger;
|
||||
|
||||
@@ -345,6 +347,7 @@ export class AgentManager {
|
||||
this.idFactory = options?.idFactory ?? (() => randomUUID());
|
||||
this.registry = options?.registry;
|
||||
this.onAgentAttention = options?.onAgentAttention;
|
||||
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
|
||||
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
|
||||
if (options?.clients) {
|
||||
for (const [provider, client] of Object.entries(options.clients)) {
|
||||
@@ -363,6 +366,10 @@ export class AgentManager {
|
||||
this.onAgentAttention = callback;
|
||||
}
|
||||
|
||||
setMcpBaseUrl(url: string | null): void {
|
||||
this.mcpBaseUrl = url;
|
||||
}
|
||||
|
||||
public getMetricsSnapshot(): AgentMetricsSnapshot {
|
||||
const byLifecycle: Record<string, number> = {};
|
||||
let withActiveForegroundTurn = 0;
|
||||
@@ -731,9 +738,21 @@ export class AgentManager {
|
||||
agentId?: string,
|
||||
options?: { labels?: Record<string, string> },
|
||||
): Promise<ManagedAgent> {
|
||||
// Generate agent ID early so we can use it in MCP config
|
||||
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||
const normalizedConfig = await this.normalizeConfig(config);
|
||||
const injectedConfig =
|
||||
this.mcpBaseUrl == null
|
||||
? config
|
||||
: {
|
||||
...config,
|
||||
mcpServers: {
|
||||
paseo: {
|
||||
type: "http" as const,
|
||||
url: `${this.mcpBaseUrl}?callerAgentId=${resolvedAgentId}`,
|
||||
},
|
||||
...(config.mcpServers ?? {}),
|
||||
},
|
||||
};
|
||||
const normalizedConfig = await this.normalizeConfig(injectedConfig);
|
||||
const launchContext = this.buildLaunchContext(resolvedAgentId);
|
||||
const client = this.requireClient(normalizedConfig.provider);
|
||||
const available = await client.isAvailable();
|
||||
|
||||
@@ -182,6 +182,124 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("create_agent auto-injects paseo MCP by default and can be disabled", 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 daemonConfig: PaseoDaemonConfig = {
|
||||
listen: `127.0.0.1:${port}`,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
allowedHosts: true,
|
||||
mcpEnabled: true,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: createTestAgentClients(),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
};
|
||||
|
||||
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
|
||||
await daemon.start();
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${port}/mcp/agents`),
|
||||
);
|
||||
const client = (await experimental_createMCPClient({ transport })) as McpClient;
|
||||
|
||||
const disabledPaseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-disabled-"));
|
||||
const disabledStaticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-disabled-"));
|
||||
const disabledAgentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-disabled-"));
|
||||
const disabledPort = await getAvailablePort();
|
||||
const disabledDaemonConfig: PaseoDaemonConfig = {
|
||||
listen: `127.0.0.1:${disabledPort}`,
|
||||
paseoHome: disabledPaseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
allowedHosts: true,
|
||||
mcpEnabled: true,
|
||||
mcpInjectIntoAgents: false,
|
||||
staticDir: disabledStaticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: createTestAgentClients(),
|
||||
agentStoragePath: path.join(disabledPaseoHome, "agents"),
|
||||
};
|
||||
const disabledDaemon = await createPaseoDaemon(
|
||||
disabledDaemonConfig,
|
||||
pino({ level: "silent" }),
|
||||
);
|
||||
await disabledDaemon.start();
|
||||
|
||||
const disabledTransport = new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${disabledPort}/mcp/agents`),
|
||||
);
|
||||
const disabledClient = (await experimental_createMCPClient({
|
||||
transport: disabledTransport,
|
||||
})) as McpClient;
|
||||
|
||||
let agentId: string | null = null;
|
||||
let disabledAgentId: string | null = null;
|
||||
try {
|
||||
const result = (await client.callTool({
|
||||
name: "create_agent",
|
||||
args: {
|
||||
cwd: agentCwd,
|
||||
title: "Injected MCP",
|
||||
agentType: "claude",
|
||||
initialMode: "bypassPermissions",
|
||||
initialPrompt: "reply with done and stop",
|
||||
background: true,
|
||||
},
|
||||
})) as McpToolResult;
|
||||
const payload = getStructuredContent(result);
|
||||
agentId = (payload?.agentId as string | undefined) ?? null;
|
||||
expect(agentId).toBeTruthy();
|
||||
|
||||
const injectedAgent = daemon.agentManager.getAgent(agentId!);
|
||||
expect(injectedAgent?.config.mcpServers).toMatchObject({
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: `http://127.0.0.1:${port}/mcp/agents?callerAgentId=${agentId!}`,
|
||||
},
|
||||
});
|
||||
|
||||
const disabledResult = (await disabledClient.callTool({
|
||||
name: "create_agent",
|
||||
args: {
|
||||
cwd: disabledAgentCwd,
|
||||
title: "No injected MCP",
|
||||
agentType: "claude",
|
||||
initialMode: "bypassPermissions",
|
||||
initialPrompt: "reply with done and stop",
|
||||
background: true,
|
||||
},
|
||||
})) as McpToolResult;
|
||||
const disabledPayload = getStructuredContent(disabledResult);
|
||||
disabledAgentId = (disabledPayload?.agentId as string | undefined) ?? null;
|
||||
expect(disabledAgentId).toBeTruthy();
|
||||
|
||||
const disabledAgent = disabledDaemon.agentManager.getAgent(disabledAgentId!);
|
||||
expect(disabledAgent?.config.mcpServers?.paseo).toBeUndefined();
|
||||
} finally {
|
||||
if (agentId) {
|
||||
await client.callTool({ name: "kill_agent", args: { agentId } });
|
||||
}
|
||||
if (disabledAgentId) {
|
||||
await disabledClient.callTool({ name: "kill_agent", args: { agentId: disabledAgentId } });
|
||||
}
|
||||
await disabledClient.close();
|
||||
await disabledDaemon.stop();
|
||||
await rm(disabledPaseoHome, { recursive: true, force: true });
|
||||
await rm(disabledStaticDir, { recursive: true, force: true });
|
||||
await rm(disabledAgentCwd, { recursive: true, force: true });
|
||||
await client.close();
|
||||
await daemon.stop();
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
await rm(agentCwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("create_agent with worktree is async and boots terminals only after setup success", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AgentSnapshotPayload } from "../messages.js";
|
||||
import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentFeature,
|
||||
AgentMetadata,
|
||||
AgentMode,
|
||||
AgentPermissionRequest,
|
||||
@@ -61,7 +62,7 @@ export function toStoredAgentRecord(
|
||||
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
||||
config: config ?? null,
|
||||
runtimeInfo,
|
||||
features: agent.features,
|
||||
features: normalizeFeatures(agent.features),
|
||||
persistence,
|
||||
requiresAttention: agent.attention.requiresAttention,
|
||||
attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null,
|
||||
@@ -98,7 +99,7 @@ export function toAgentPayload(
|
||||
capabilities: cloneCapabilities(agent.capabilities),
|
||||
currentModeId: agent.currentModeId,
|
||||
availableModes: cloneAvailableModes(agent.availableModes),
|
||||
features: agent.features,
|
||||
features: normalizeFeatures(agent.features),
|
||||
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
|
||||
persistence: sanitizePersistenceHandle(agent.persistence),
|
||||
title: options?.title ?? null,
|
||||
@@ -200,6 +201,10 @@ function cloneAvailableModes(modes: AgentMode[]): AgentMode[] {
|
||||
return modes.map((mode) => ({ ...mode }));
|
||||
}
|
||||
|
||||
function normalizeFeatures(features: AgentFeature[] | null | undefined): AgentFeature[] {
|
||||
return Array.isArray(features) ? features.map((feature) => ({ ...feature })) : [];
|
||||
}
|
||||
|
||||
function sanitizeOptionalJson(value: unknown): JsonValue | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
|
||||
665
packages/server/src/server/agent/mcp-parity.e2e.test.ts
Normal file
665
packages/server/src/server/agent/mcp-parity.e2e.test.ts
Normal file
@@ -0,0 +1,665 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { experimental_createMCPClient } from "ai";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
|
||||
import { AGENT_WAIT_TIMEOUT_MS } from "./mcp-shared.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
|
||||
type StructuredContent = { [key: string]: unknown };
|
||||
|
||||
type McpToolResult = {
|
||||
structuredContent?: StructuredContent;
|
||||
content?: Array<{ structuredContent?: StructuredContent } | StructuredContent>;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
type McpClient = {
|
||||
callTool: (input: { name: string; args?: StructuredContent }) => Promise<unknown>;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
function formatHostForHttpUrl(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
function buildExpectedAgentMcpUrl(params: { host: string; port: number; agentId: string }): string {
|
||||
const baseUrl = new URL("/mcp/agents", `http://${formatHostForHttpUrl(params.host)}:${params.port}`);
|
||||
baseUrl.searchParams.set("callerAgentId", params.agentId);
|
||||
return baseUrl.toString();
|
||||
}
|
||||
|
||||
function getStructuredContent(result: McpToolResult): StructuredContent | null {
|
||||
if (result.structuredContent && typeof result.structuredContent === "object") {
|
||||
return result.structuredContent;
|
||||
}
|
||||
const content = result.content?.[0];
|
||||
if (content && typeof content === "object" && "structuredContent" in content) {
|
||||
const structured = (content as { structuredContent?: StructuredContent }).structuredContent;
|
||||
if (structured) {
|
||||
return structured;
|
||||
}
|
||||
}
|
||||
if (content && typeof content === "object") {
|
||||
return content as StructuredContent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function createMcpClient(url: string): Promise<McpClient> {
|
||||
const transport = new StreamableHTTPClientTransport(new URL(url));
|
||||
return (await experimental_createMCPClient({ transport })) as McpClient;
|
||||
}
|
||||
|
||||
async function callToolStructured(
|
||||
client: McpClient,
|
||||
name: string,
|
||||
args?: StructuredContent,
|
||||
): Promise<StructuredContent> {
|
||||
const result = (await client.callTool({ name, args: args ?? {} })) as McpToolResult;
|
||||
const payload = getStructuredContent(result);
|
||||
if (!payload) {
|
||||
throw new Error(`${name} returned no structured payload`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function expectToolError(
|
||||
client: McpClient,
|
||||
name: string,
|
||||
args: StructuredContent,
|
||||
pattern: RegExp,
|
||||
): Promise<void> {
|
||||
const result = (await client.callTool({ name, args })) as McpToolResult;
|
||||
expect(result.isError).toBe(true);
|
||||
const content = result.content?.[0] as { text?: string } | undefined;
|
||||
expect(content?.text ?? "").toMatch(pattern);
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitFor<T>(options: {
|
||||
timeoutMs: number;
|
||||
intervalMs?: number;
|
||||
check: () => Promise<T | null> | T | null;
|
||||
label: string;
|
||||
}): Promise<T> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < options.timeoutMs) {
|
||||
const result = await options.check();
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
await sleep(options.intervalMs ?? 50);
|
||||
}
|
||||
throw new Error(`Timed out after ${options.timeoutMs}ms waiting for ${options.label}`);
|
||||
}
|
||||
|
||||
describe("MCP parity end-to-end", () => {
|
||||
let tempRoot: string;
|
||||
let daemonHandle: TestPaseoDaemon;
|
||||
let topLevelClient: McpClient;
|
||||
let agentScopedClient: McpClient;
|
||||
let parentAgentId: string;
|
||||
let parentAgentCwd: string;
|
||||
let worktreeRepoCwd: string;
|
||||
|
||||
async function makeCwd(prefix: string): Promise<string> {
|
||||
return await mkdtemp(path.join(tempRoot, `${prefix}-`));
|
||||
}
|
||||
|
||||
async function createTopLevelAgent(args?: Partial<StructuredContent>): Promise<string> {
|
||||
const cwd = (args?.cwd as string | undefined) ?? (await makeCwd("agent-cwd"));
|
||||
const payload = await callToolStructured(topLevelClient, "create_agent", {
|
||||
cwd,
|
||||
title: "Parity agent",
|
||||
agentType: "claude",
|
||||
initialPrompt: "say done and stop",
|
||||
initialMode: "bypassPermissions",
|
||||
background: true,
|
||||
...args,
|
||||
});
|
||||
return payload.agentId as string;
|
||||
}
|
||||
|
||||
async function createChildAgent(args?: Partial<StructuredContent>): Promise<string> {
|
||||
const payload = await callToolStructured(agentScopedClient, "create_agent", {
|
||||
title: "Parity child",
|
||||
agentType: "claude",
|
||||
initialPrompt: "say done and stop",
|
||||
background: true,
|
||||
...args,
|
||||
});
|
||||
return payload.agentId as string;
|
||||
}
|
||||
|
||||
async function archiveAgentIfPresent(agentId: string | null | undefined): Promise<void> {
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await topLevelClient.callTool({ name: "archive_agent", args: { agentId } });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteScheduleIfPresent(id: string | null | undefined): Promise<void> {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await topLevelClient.callTool({ name: "delete_schedule", args: { id } });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
async function killTerminalIfPresent(terminalId: string | null | undefined): Promise<void> {
|
||||
if (!terminalId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await agentScopedClient.callTool({ name: "kill_terminal", args: { terminalId } });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveWorktreeIfPresent(params: {
|
||||
cwd: string;
|
||||
worktreePath?: string | null;
|
||||
worktreeSlug?: string | null;
|
||||
}): Promise<void> {
|
||||
if (!params.worktreePath && !params.worktreeSlug) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await topLevelClient.callTool({
|
||||
name: "archive_worktree",
|
||||
args: {
|
||||
cwd: params.cwd,
|
||||
...(params.worktreePath ? { worktreePath: params.worktreePath } : {}),
|
||||
...(params.worktreeSlug ? { worktreeSlug: params.worktreeSlug } : {}),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tempRoot = await mkdtemp(path.join(os.tmpdir(), "mcp-parity-e2e-"));
|
||||
parentAgentCwd = await makeCwd("parent-agent-cwd");
|
||||
worktreeRepoCwd = await makeCwd("worktree-repo");
|
||||
|
||||
daemonHandle = await createTestPaseoDaemon();
|
||||
topLevelClient = await createMcpClient(`http://127.0.0.1:${daemonHandle.port}/mcp/agents`);
|
||||
|
||||
const parentPayload = await callToolStructured(topLevelClient, "create_agent", {
|
||||
cwd: parentAgentCwd,
|
||||
title: "MCP parity parent",
|
||||
agentType: "claude",
|
||||
initialPrompt: "say done and stop",
|
||||
initialMode: "bypassPermissions",
|
||||
background: true,
|
||||
});
|
||||
parentAgentId = parentPayload.agentId as string;
|
||||
|
||||
agentScopedClient = await createMcpClient(
|
||||
`http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${parentAgentId}`,
|
||||
);
|
||||
|
||||
execSync("git init -b main", { cwd: worktreeRepoCwd, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@example.com'", { cwd: worktreeRepoCwd, stdio: "pipe" });
|
||||
execSync("git config user.name 'Test User'", { cwd: worktreeRepoCwd, stdio: "pipe" });
|
||||
await writeFile(path.join(worktreeRepoCwd, "README.md"), "# repo\n", "utf8");
|
||||
execSync("git add README.md", { cwd: worktreeRepoCwd, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'init'", {
|
||||
cwd: worktreeRepoCwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await archiveAgentIfPresent(parentAgentId);
|
||||
await agentScopedClient?.close();
|
||||
await topLevelClient?.close();
|
||||
await daemonHandle?.close();
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("Suite A: Core Fixes", () => {
|
||||
test("AGENT_WAIT_TIMEOUT_MS is 30000", () => {
|
||||
expect(AGENT_WAIT_TIMEOUT_MS).toBe(30_000);
|
||||
});
|
||||
|
||||
test("create_agent with callerAgentId sets paseo.parent-agent-id label", async () => {
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
agentId = await createChildAgent();
|
||||
const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
||||
expect(snapshot?.labels).toMatchObject({
|
||||
"paseo.parent-agent-id": parentAgentId,
|
||||
});
|
||||
} finally {
|
||||
await archiveAgentIfPresent(agentId);
|
||||
}
|
||||
});
|
||||
|
||||
test("agentManager.createAgent injects paseo MCP using the daemon listen target", async () => {
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const listenTarget = daemonHandle.daemon.getListenTarget();
|
||||
expect(listenTarget?.type).toBe("tcp");
|
||||
|
||||
const snapshot = await daemonHandle.daemon.agentManager.createAgent({
|
||||
provider: "claude",
|
||||
cwd: await makeCwd("manager-direct-agent-cwd"),
|
||||
title: "Manager direct parity agent",
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
agentId = snapshot.id;
|
||||
|
||||
const expectedUrl = buildExpectedAgentMcpUrl({
|
||||
host: listenTarget!.host,
|
||||
port: listenTarget!.port,
|
||||
agentId,
|
||||
});
|
||||
|
||||
expect(snapshot.config.mcpServers).toMatchObject({
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: expectedUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const liveAgent = daemonHandle.daemon.agentManager.getAgent(agentId);
|
||||
expect(liveAgent?.config.mcpServers).toMatchObject({
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: expectedUrl,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await archiveAgentIfPresent(agentId);
|
||||
}
|
||||
});
|
||||
|
||||
test("create_agent accepts model param", async () => {
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
agentId = await createTopLevelAgent({ model: "claude-test-model" });
|
||||
const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
||||
expect(snapshot?.config.model).toBe("claude-test-model");
|
||||
} finally {
|
||||
await archiveAgentIfPresent(agentId);
|
||||
}
|
||||
});
|
||||
|
||||
test("create_agent accepts labels param", async () => {
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
agentId = await createTopLevelAgent({ labels: { team: "infra" } });
|
||||
const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
||||
expect(snapshot?.labels).toMatchObject({ team: "infra" });
|
||||
} finally {
|
||||
await archiveAgentIfPresent(agentId);
|
||||
}
|
||||
});
|
||||
|
||||
test("archive_agent archives an agent", async () => {
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
agentId = await createTopLevelAgent();
|
||||
const archivedAgentId = agentId;
|
||||
await callToolStructured(topLevelClient, "archive_agent", { agentId });
|
||||
agentId = null;
|
||||
|
||||
const agents = daemonHandle.daemon.agentManager.listAgents();
|
||||
expect(agents.some((agent) => agent.id === archivedAgentId)).toBe(false);
|
||||
} finally {
|
||||
await archiveAgentIfPresent(agentId);
|
||||
}
|
||||
});
|
||||
|
||||
test("update_agent updates name and labels", async () => {
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
agentId = await createTopLevelAgent();
|
||||
await callToolStructured(topLevelClient, "update_agent", {
|
||||
agentId,
|
||||
name: "Renamed parity agent",
|
||||
labels: { team: "infra", surface: "mcp" },
|
||||
});
|
||||
|
||||
const stored = await daemonHandle.daemon.agentStorage.get(agentId);
|
||||
const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
||||
expect(stored?.title).toBe("Renamed parity agent");
|
||||
expect(snapshot?.labels).toMatchObject({
|
||||
team: "infra",
|
||||
surface: "mcp",
|
||||
});
|
||||
} finally {
|
||||
await archiveAgentIfPresent(agentId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite B: Terminal Tools", () => {
|
||||
test("create_terminal and list_terminals", async () => {
|
||||
let terminalId: string | null = null;
|
||||
try {
|
||||
const created = await callToolStructured(agentScopedClient, "create_terminal", {
|
||||
name: "Parity terminal",
|
||||
});
|
||||
terminalId = created.id as string;
|
||||
|
||||
const listed = await callToolStructured(agentScopedClient, "list_terminals");
|
||||
const terminals = listed.terminals as Array<StructuredContent>;
|
||||
expect(terminals).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: terminalId,
|
||||
name: "Parity terminal",
|
||||
cwd: parentAgentCwd,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
await killTerminalIfPresent(terminalId);
|
||||
}
|
||||
});
|
||||
|
||||
test("send_terminal_keys and capture_terminal", async () => {
|
||||
let terminalId: string | null = null;
|
||||
try {
|
||||
const created = await callToolStructured(agentScopedClient, "create_terminal", {
|
||||
name: "Parity capture terminal",
|
||||
});
|
||||
terminalId = created.id as string;
|
||||
|
||||
await callToolStructured(agentScopedClient, "send_terminal_keys", {
|
||||
terminalId,
|
||||
keys: "echo hello\r",
|
||||
literal: true,
|
||||
});
|
||||
await sleep(500);
|
||||
|
||||
const captured = await waitFor({
|
||||
timeoutMs: 10_000,
|
||||
intervalMs: 100,
|
||||
label: "terminal output to contain hello",
|
||||
check: async () => {
|
||||
const payload = await callToolStructured(agentScopedClient, "capture_terminal", {
|
||||
terminalId,
|
||||
scrollback: true,
|
||||
});
|
||||
const lines = (payload.lines as string[] | undefined) ?? [];
|
||||
return lines.some((line) => line.includes("hello")) ? payload : null;
|
||||
},
|
||||
});
|
||||
|
||||
expect(captured.lines).toEqual(expect.arrayContaining([expect.stringContaining("hello")]));
|
||||
} finally {
|
||||
await killTerminalIfPresent(terminalId);
|
||||
}
|
||||
});
|
||||
|
||||
test("kill_terminal removes terminal", async () => {
|
||||
let terminalId: string | null = null;
|
||||
try {
|
||||
const created = await callToolStructured(agentScopedClient, "create_terminal", {
|
||||
name: "Parity kill terminal",
|
||||
});
|
||||
terminalId = created.id as string;
|
||||
|
||||
await callToolStructured(agentScopedClient, "kill_terminal", { terminalId });
|
||||
terminalId = null;
|
||||
|
||||
const listed = await waitFor({
|
||||
timeoutMs: 5_000,
|
||||
intervalMs: 100,
|
||||
label: "terminal removal",
|
||||
check: async () => {
|
||||
const payload = await callToolStructured(agentScopedClient, "list_terminals");
|
||||
const terminals = payload.terminals as Array<StructuredContent>;
|
||||
return terminals.some((terminal) => terminal.id === created.id) ? null : payload;
|
||||
},
|
||||
});
|
||||
const terminals = listed.terminals as Array<StructuredContent>;
|
||||
expect(terminals.some((terminal) => terminal.id === created.id)).toBe(false);
|
||||
} finally {
|
||||
await killTerminalIfPresent(terminalId);
|
||||
}
|
||||
});
|
||||
|
||||
test("kill_terminal with invalid id throws", async () => {
|
||||
await expectToolError(
|
||||
agentScopedClient,
|
||||
"kill_terminal",
|
||||
{ terminalId: "missing-terminal-id" },
|
||||
/not found/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite C: Schedule Tools", () => {
|
||||
test("create_schedule and list_schedules", async () => {
|
||||
let scheduleId: string | null = null;
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
name: "Parity schedule list",
|
||||
});
|
||||
scheduleId = created.id as string;
|
||||
|
||||
const listed = await callToolStructured(topLevelClient, "list_schedules");
|
||||
const schedules = listed.schedules as Array<StructuredContent>;
|
||||
expect(schedules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: scheduleId,
|
||||
name: "Parity schedule list",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
await deleteScheduleIfPresent(scheduleId);
|
||||
}
|
||||
});
|
||||
|
||||
test("inspect_schedule returns details", async () => {
|
||||
let scheduleId: string | null = null;
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
name: "Parity inspect schedule",
|
||||
});
|
||||
scheduleId = created.id as string;
|
||||
|
||||
const inspected = await callToolStructured(topLevelClient, "inspect_schedule", {
|
||||
id: scheduleId,
|
||||
});
|
||||
expect(inspected).toMatchObject({
|
||||
id: scheduleId,
|
||||
name: "Parity inspect schedule",
|
||||
prompt: "say hello",
|
||||
status: "active",
|
||||
});
|
||||
} finally {
|
||||
await deleteScheduleIfPresent(scheduleId);
|
||||
}
|
||||
});
|
||||
|
||||
test("pause and resume schedule", async () => {
|
||||
let scheduleId: string | null = null;
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
name: "Parity pause schedule",
|
||||
});
|
||||
scheduleId = created.id as string;
|
||||
|
||||
await callToolStructured(topLevelClient, "pause_schedule", { id: scheduleId });
|
||||
const paused = await callToolStructured(topLevelClient, "inspect_schedule", { id: scheduleId });
|
||||
expect(paused.status).toBe("paused");
|
||||
|
||||
await callToolStructured(topLevelClient, "resume_schedule", { id: scheduleId });
|
||||
const resumed = await callToolStructured(topLevelClient, "inspect_schedule", { id: scheduleId });
|
||||
expect(resumed.status).toBe("active");
|
||||
} finally {
|
||||
await deleteScheduleIfPresent(scheduleId);
|
||||
}
|
||||
});
|
||||
|
||||
test("delete_schedule removes schedule", async () => {
|
||||
let scheduleId: string | null = null;
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
name: "Parity delete schedule",
|
||||
});
|
||||
scheduleId = created.id as string;
|
||||
|
||||
await callToolStructured(topLevelClient, "delete_schedule", { id: scheduleId });
|
||||
scheduleId = null;
|
||||
|
||||
const listed = await callToolStructured(topLevelClient, "list_schedules");
|
||||
const schedules = listed.schedules as Array<StructuredContent>;
|
||||
expect(schedules.some((schedule) => schedule.id === created.id)).toBe(false);
|
||||
} finally {
|
||||
await deleteScheduleIfPresent(scheduleId);
|
||||
}
|
||||
});
|
||||
|
||||
test("create_schedule target self with callerAgentId", async () => {
|
||||
let scheduleId: string | null = null;
|
||||
try {
|
||||
const created = await callToolStructured(agentScopedClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
name: "Parity self schedule",
|
||||
target: "self",
|
||||
});
|
||||
scheduleId = created.id as string;
|
||||
expect(created.target).toMatchObject({
|
||||
type: "agent",
|
||||
agentId: parentAgentId,
|
||||
});
|
||||
} finally {
|
||||
await deleteScheduleIfPresent(scheduleId);
|
||||
}
|
||||
});
|
||||
|
||||
test("create_schedule target self without callerAgentId throws", async () => {
|
||||
await expectToolError(
|
||||
topLevelClient,
|
||||
"create_schedule",
|
||||
{
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
target: "self",
|
||||
},
|
||||
/requires a caller agent/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite D: Provider Tools", () => {
|
||||
test("list_providers returns providers", async () => {
|
||||
const payload = await callToolStructured(topLevelClient, "list_providers");
|
||||
const providers = payload.providers as Array<StructuredContent>;
|
||||
expect(Array.isArray(providers)).toBe(true);
|
||||
expect(providers.length).toBeGreaterThan(0);
|
||||
expect(providers[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
label: expect.any(String),
|
||||
modes: expect.any(Array),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("list_models returns models for provider", async () => {
|
||||
const payload = await callToolStructured(topLevelClient, "list_models", {
|
||||
provider: "claude",
|
||||
});
|
||||
expect(payload.provider).toBe("claude");
|
||||
expect(Array.isArray(payload.models)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite E: Worktree Tools", () => {
|
||||
test("list_worktrees on empty repo", async () => {
|
||||
const payload = await callToolStructured(topLevelClient, "list_worktrees", {
|
||||
cwd: worktreeRepoCwd,
|
||||
});
|
||||
expect(payload.worktrees).toEqual([]);
|
||||
});
|
||||
|
||||
test("create_worktree and list_worktrees", async () => {
|
||||
let worktreePath: string | null = null;
|
||||
const branchName = `parity-create-${Date.now()}`;
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_worktree", {
|
||||
cwd: worktreeRepoCwd,
|
||||
branchName,
|
||||
baseBranch: "main",
|
||||
});
|
||||
worktreePath = created.worktreePath as string;
|
||||
|
||||
const listed = await callToolStructured(topLevelClient, "list_worktrees", {
|
||||
cwd: worktreeRepoCwd,
|
||||
});
|
||||
const worktrees = listed.worktrees as Array<StructuredContent>;
|
||||
expect(worktrees).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: worktreePath,
|
||||
branchName,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath });
|
||||
}
|
||||
});
|
||||
|
||||
test("archive_worktree removes worktree", async () => {
|
||||
let worktreePath: string | null = null;
|
||||
const branchName = `parity-archive-${Date.now()}`;
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_worktree", {
|
||||
cwd: worktreeRepoCwd,
|
||||
branchName,
|
||||
baseBranch: "main",
|
||||
});
|
||||
worktreePath = created.worktreePath as string;
|
||||
|
||||
await callToolStructured(topLevelClient, "archive_worktree", {
|
||||
cwd: worktreeRepoCwd,
|
||||
worktreePath,
|
||||
});
|
||||
worktreePath = null;
|
||||
|
||||
const listed = await callToolStructured(topLevelClient, "list_worktrees", {
|
||||
cwd: worktreeRepoCwd,
|
||||
});
|
||||
const worktrees = listed.worktrees as Array<StructuredContent>;
|
||||
expect(worktrees.some((worktree) => worktree.path === created.worktreePath)).toBe(false);
|
||||
} finally {
|
||||
await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,10 @@ function createTestDeps(): TestDeps {
|
||||
waitForAgentEvent: vi.fn(),
|
||||
recordUserMessage: vi.fn(),
|
||||
setAgentMode: vi.fn(),
|
||||
setLabels: vi.fn().mockResolvedValue(undefined),
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
archiveAgent: vi.fn().mockResolvedValue({ archivedAt: new Date().toISOString() }),
|
||||
notifyAgentState: vi.fn(),
|
||||
getAgent: vi.fn(),
|
||||
streamAgent: vi.fn(() => (async function* noop() {})()),
|
||||
respondToPermission: vi.fn(),
|
||||
@@ -34,6 +37,7 @@ function createTestDeps(): TestDeps {
|
||||
const agentStorageSpies = {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
upsert: vi.fn().mockResolvedValue(undefined),
|
||||
applySnapshot: vi.fn(),
|
||||
list: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
@@ -174,6 +178,41 @@ describe("create_agent MCP tool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes optional model, thinking, and labels through createAgent", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "agent-789",
|
||||
cwd: "/tmp/repo",
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Config test", model: "claude-sonnet-4-20250514" },
|
||||
} as ManagedAgent);
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
await tool.callback({
|
||||
cwd: existingCwd,
|
||||
title: "Config test",
|
||||
initialMode: "default",
|
||||
initialPrompt: "Do work",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
thinking: "think-hard",
|
||||
labels: { source: "mcp" },
|
||||
});
|
||||
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: existingCwd,
|
||||
title: "Config test",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
thinkingOptionId: "think-hard",
|
||||
}),
|
||||
undefined,
|
||||
{ labels: { source: "mcp" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("allows caller agents to override cwd and applies caller context labels", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "paseo-mcp-test-"));
|
||||
@@ -218,10 +257,49 @@ describe("create_agent MCP tool", () => {
|
||||
cwd: subdir,
|
||||
}),
|
||||
undefined,
|
||||
{ labels: { source: "voice" } },
|
||||
{
|
||||
labels: {
|
||||
"paseo.parent-agent-id": "voice-agent",
|
||||
source: "voice",
|
||||
},
|
||||
},
|
||||
);
|
||||
await rm(baseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("delegates MCP injection to AgentManager and passes through an undefined agent ID", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "agent-injected-123",
|
||||
cwd: "/tmp/repo",
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Injected config test" },
|
||||
} as ManagedAgent);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
await tool.callback({
|
||||
cwd: existingCwd,
|
||||
title: "Injected config test",
|
||||
initialMode: "default",
|
||||
initialPrompt: "Do work",
|
||||
});
|
||||
|
||||
const [configArg, agentIdArg, optionsArg] = spies.agentManager.createAgent.mock.calls[0];
|
||||
expect(configArg).toMatchObject({
|
||||
cwd: existingCwd,
|
||||
title: "Injected config test",
|
||||
});
|
||||
expect(configArg.mcpServers).toBeUndefined();
|
||||
expect(agentIdArg).toBeUndefined();
|
||||
expect(optionsArg).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("speak MCP tool", () => {
|
||||
@@ -278,3 +356,54 @@ describe("speak MCP tool", () => {
|
||||
expect(tool).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent snapshot MCP serialization", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
it("normalizes null features to an empty array for list_agents", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.listAgents = vi.fn().mockReturnValue([
|
||||
{
|
||||
id: "agent-null-features",
|
||||
provider: "claude",
|
||||
cwd: "/tmp/repo",
|
||||
config: {},
|
||||
runtimeInfo: undefined,
|
||||
createdAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||
lastUserMessageAt: null,
|
||||
lifecycle: "idle",
|
||||
capabilities: {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
features: null,
|
||||
pendingPermissions: new Map(),
|
||||
persistence: null,
|
||||
labels: {},
|
||||
attention: { requiresAttention: false },
|
||||
} as unknown as ManagedAgent,
|
||||
]);
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["list_agents"];
|
||||
const response = await tool.callback({});
|
||||
const structured = response.structuredContent;
|
||||
|
||||
expect(structured).toEqual({
|
||||
agents: [
|
||||
expect.objectContaining({
|
||||
id: "agent-null-features",
|
||||
features: [],
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(Array.isArray(structured.agents[0].features)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
238
packages/server/src/server/agent/mcp-shared.ts
Normal file
238
packages/server/src/server/agent/mcp-shared.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { AgentPromptInput, AgentProvider, AgentPermissionRequest } from "./agent-sdk-types.js";
|
||||
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import { serializeAgentSnapshot } from "../messages.js";
|
||||
import { StoredScheduleSchema } from "../schedule/types.js";
|
||||
|
||||
export const AgentProviderEnum = z.enum(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [
|
||||
AgentProvider,
|
||||
...AgentProvider[],
|
||||
],
|
||||
);
|
||||
|
||||
export const AgentStatusEnum = z.enum(["initializing", "idle", "running", "error", "closed"]);
|
||||
|
||||
export const ProviderModeSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ProviderSummarySchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
modes: z.array(ProviderModeSchema),
|
||||
});
|
||||
|
||||
export const AgentSelectOptionSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const AgentModelSchema = z.object({
|
||||
provider: z.string(),
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
thinkingOptions: z.array(AgentSelectOptionSchema).optional(),
|
||||
defaultThinkingOptionId: z.string().optional(),
|
||||
});
|
||||
|
||||
// 30 seconds - surface friendly message before SDK tool timeout (~60s)
|
||||
export const AGENT_WAIT_TIMEOUT_MS = 30000;
|
||||
|
||||
export type StartAgentRunOptions = {
|
||||
replaceRunning?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps agentManager.waitForAgentEvent with a self-imposed timeout.
|
||||
* Returns a friendly message when timeout occurs, rather than letting
|
||||
* the SDK tool timeout trigger a generic "tool failed" error.
|
||||
*/
|
||||
export async function waitForAgentWithTimeout(
|
||||
agentManager: AgentManager,
|
||||
agentId: string,
|
||||
options?: {
|
||||
signal?: AbortSignal;
|
||||
waitForActive?: boolean;
|
||||
},
|
||||
): Promise<WaitForAgentResult> {
|
||||
const timeoutController = new AbortController();
|
||||
const combinedController = new AbortController();
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
timeoutController.abort(new Error("wait timeout"));
|
||||
}, AGENT_WAIT_TIMEOUT_MS);
|
||||
|
||||
const forwardAbort = (reason: unknown) => {
|
||||
if (!combinedController.signal.aborted) {
|
||||
combinedController.abort(reason);
|
||||
}
|
||||
};
|
||||
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
forwardAbort(options.signal.reason);
|
||||
} else {
|
||||
options.signal.addEventListener("abort", () => forwardAbort(options.signal!.reason), {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
timeoutController.signal.addEventListener(
|
||||
"abort",
|
||||
() => forwardAbort(timeoutController.signal.reason),
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await agentManager.waitForAgentEvent(agentId, {
|
||||
signal: combinedController.signal,
|
||||
waitForActive: options?.waitForActive,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "wait timeout") {
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
const timeline = agentManager.getTimeline(agentId);
|
||||
const recentActivity = curateAgentActivity(timeline.slice(-5));
|
||||
const waitedSeconds = Math.round(AGENT_WAIT_TIMEOUT_MS / 1000);
|
||||
const message = `Awaiting the agent timed out after ${waitedSeconds}s. This does not mean the agent failed - call wait_for_agent again to continue waiting.\n\nRecent activity:\n${recentActivity}`;
|
||||
return {
|
||||
status: snapshot?.lifecycle ?? "idle",
|
||||
permission: null,
|
||||
lastMessage: message,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export function startAgentRun(
|
||||
agentManager: AgentManager,
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
logger: Logger,
|
||||
options?: StartAgentRunOptions,
|
||||
): void {
|
||||
const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
|
||||
const iterator = shouldReplace
|
||||
? agentManager.replaceAgentRun(agentId, prompt)
|
||||
: agentManager.streamAgent(agentId, prompt);
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const _ of iterator) {
|
||||
// Events are broadcast via AgentManager subscribers.
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error, agentId }, "Agent stream failed");
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export function sanitizePermissionRequest(
|
||||
permission: AgentPermissionRequest | null | undefined,
|
||||
): AgentPermissionRequest | null {
|
||||
if (!permission) {
|
||||
return null;
|
||||
}
|
||||
const sanitized: AgentPermissionRequest = { ...permission };
|
||||
if (sanitized.title === undefined) {
|
||||
delete sanitized.title;
|
||||
}
|
||||
if (sanitized.description === undefined) {
|
||||
delete sanitized.description;
|
||||
}
|
||||
if (sanitized.input === undefined) {
|
||||
delete sanitized.input;
|
||||
}
|
||||
if (sanitized.suggestions === undefined) {
|
||||
delete sanitized.suggestions;
|
||||
}
|
||||
if (sanitized.actions === undefined) {
|
||||
delete sanitized.actions;
|
||||
}
|
||||
if (sanitized.metadata === undefined) {
|
||||
delete sanitized.metadata;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export async function resolveAgentTitle(
|
||||
agentStorage: AgentStorage,
|
||||
agentId: string,
|
||||
logger: Logger,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const record = await agentStorage.get(agentId);
|
||||
return record?.title ?? null;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, agentId }, "Failed to load agent title");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function serializeSnapshotWithMetadata(
|
||||
agentStorage: AgentStorage,
|
||||
snapshot: ManagedAgent,
|
||||
logger: Logger,
|
||||
) {
|
||||
const title = await resolveAgentTitle(agentStorage, snapshot.id, logger);
|
||||
return serializeAgentSnapshot(snapshot, { title });
|
||||
}
|
||||
|
||||
export function parseDurationString(input: string): number {
|
||||
const trimmed = input.trim();
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
return Number.parseInt(trimmed, 10) * 1000;
|
||||
}
|
||||
|
||||
let totalMs = 0;
|
||||
let hasMatch = false;
|
||||
const regex = /(\d+)([smh])/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(trimmed)) !== null) {
|
||||
hasMatch = true;
|
||||
const value = Number.parseInt(match[1], 10);
|
||||
switch (match[2]) {
|
||||
case "s":
|
||||
totalMs += value * 1000;
|
||||
break;
|
||||
case "m":
|
||||
totalMs += value * 60 * 1000;
|
||||
break;
|
||||
case "h":
|
||||
totalMs += value * 60 * 60 * 1000;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasMatch) {
|
||||
throw new Error(
|
||||
`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m`,
|
||||
);
|
||||
}
|
||||
|
||||
return totalMs;
|
||||
}
|
||||
|
||||
export function toScheduleSummary(schedule: z.infer<typeof StoredScheduleSchema>) {
|
||||
const { runs: _runs, ...summary } = schedule;
|
||||
return summary;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export type CliConfigOverrides = Partial<{
|
||||
listen: string;
|
||||
relayEnabled: boolean;
|
||||
mcpEnabled: boolean;
|
||||
mcpInjectIntoAgents: boolean;
|
||||
allowedHosts: AllowedHostsConfig;
|
||||
}>;
|
||||
|
||||
@@ -65,7 +66,9 @@ export function loadConfig(
|
||||
options?.cli?.allowedHosts,
|
||||
]);
|
||||
|
||||
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? false;
|
||||
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true;
|
||||
const mcpInjectIntoAgents =
|
||||
options?.cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? true;
|
||||
|
||||
const relayEnabled = options?.cli?.relayEnabled ?? persisted.daemon?.relay?.enabled ?? true;
|
||||
|
||||
@@ -100,6 +103,7 @@ export function loadConfig(
|
||||
),
|
||||
allowedHosts,
|
||||
mcpEnabled,
|
||||
mcpInjectIntoAgents,
|
||||
mcpDebug: env.MCP_DEBUG === "1",
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
staticDir: "public",
|
||||
|
||||
@@ -40,6 +40,9 @@ async function main() {
|
||||
if (process.argv.includes("--no-mcp")) {
|
||||
config.mcpEnabled = false;
|
||||
}
|
||||
if (process.argv.includes("--no-inject-mcp")) {
|
||||
config.mcpInjectIntoAgents = false;
|
||||
}
|
||||
|
||||
const installExitHook = () => {
|
||||
if (exitHookInstalled || !shutdownPromise) {
|
||||
|
||||
@@ -126,8 +126,9 @@ export const PersistedConfigSchema = z
|
||||
mcp: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
injectIntoAgents: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.passthrough()
|
||||
.optional(),
|
||||
cors: z
|
||||
.object({
|
||||
|
||||
@@ -65,11 +65,10 @@ import {
|
||||
extractTimestamps,
|
||||
} from "./persistence-hooks.js";
|
||||
import { experimental_createMCPClient } from "ai";
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
|
||||
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
|
||||
|
||||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
@@ -102,7 +101,6 @@ import type {
|
||||
AgentPromptContentBlock,
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
McpServerConfig,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
AgentProvider,
|
||||
@@ -132,7 +130,6 @@ import {
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import {
|
||||
buildVoiceAgentMcpServerConfig,
|
||||
buildVoiceModeSystemPrompt,
|
||||
stripVoiceModeSystemPrompt,
|
||||
wrapSpokenInput,
|
||||
@@ -374,12 +371,10 @@ const MIN_STREAMING_SEGMENT_BYTES = Math.round(
|
||||
PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS,
|
||||
);
|
||||
const AgentIdSchema = z.string().uuid();
|
||||
const VOICE_MCP_SERVER_NAME = "paseo_voice";
|
||||
const VOICE_INTERRUPT_CONFIRMATION_MS = 500;
|
||||
|
||||
type VoiceModeBaseConfig = {
|
||||
systemPrompt?: string;
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
};
|
||||
|
||||
interface AudioBufferState {
|
||||
@@ -420,13 +415,12 @@ export type SessionOptions = {
|
||||
loopService: LoopService;
|
||||
checkoutDiffManager: CheckoutDiffManager;
|
||||
backgroundGitFetchManager: BackgroundGitFetchManager;
|
||||
createAgentMcpTransport: AgentMcpTransportFactory;
|
||||
mcpBaseUrl?: string | null;
|
||||
stt: Resolvable<SpeechToTextProvider | null>;
|
||||
tts: Resolvable<TextToSpeechProvider | null>;
|
||||
terminalManager: TerminalManager | null;
|
||||
providerSnapshotManager?: ProviderSnapshotManager;
|
||||
voice?: {
|
||||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||||
turnDetection?: Resolvable<TurnDetectionProvider | null>;
|
||||
};
|
||||
voiceBridge?: {
|
||||
@@ -434,8 +428,6 @@ export type SessionOptions = {
|
||||
unregisterVoiceSpeakHandler?: (agentId: string) => void;
|
||||
registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void;
|
||||
unregisterVoiceCallerContext?: (agentId: string) => void;
|
||||
ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise<string>;
|
||||
removeVoiceMcpSocketForAgent?: (agentId: string) => Promise<void>;
|
||||
};
|
||||
dictation?: {
|
||||
finalTimeoutMs?: number;
|
||||
@@ -605,7 +597,7 @@ export class Session {
|
||||
private readonly loopService: LoopService;
|
||||
private readonly checkoutDiffManager: CheckoutDiffManager;
|
||||
private readonly backgroundGitFetchManager: BackgroundGitFetchManager;
|
||||
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
|
||||
private readonly mcpBaseUrl: string | null;
|
||||
private readonly downloadTokenStore: DownloadTokenStore;
|
||||
private readonly pushTokenStore: PushTokenStore;
|
||||
private readonly providerRegistry: ReturnType<typeof buildProviderRegistry>;
|
||||
@@ -634,7 +626,6 @@ export class Session {
|
||||
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
|
||||
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
|
||||
private readonly workspaceGitFetchSubscriptions = new Map<string, () => void>();
|
||||
private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null;
|
||||
private readonly registerVoiceSpeakHandler?: (
|
||||
agentId: string,
|
||||
handler: VoiceSpeakHandler,
|
||||
@@ -645,8 +636,6 @@ export class Session {
|
||||
context: VoiceCallerContext,
|
||||
) => void;
|
||||
private readonly unregisterVoiceCallerContext?: (agentId: string) => void;
|
||||
private readonly ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise<string>;
|
||||
private readonly removeVoiceMcpSocketForAgent?: (agentId: string) => Promise<void>;
|
||||
private readonly getSpeechReadiness?: () => SpeechReadinessSnapshot;
|
||||
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
||||
private voiceModeAgentId: string | null = null;
|
||||
@@ -672,7 +661,7 @@ export class Session {
|
||||
loopService,
|
||||
checkoutDiffManager,
|
||||
backgroundGitFetchManager,
|
||||
createAgentMcpTransport,
|
||||
mcpBaseUrl,
|
||||
stt,
|
||||
tts,
|
||||
terminalManager,
|
||||
@@ -700,7 +689,7 @@ export class Session {
|
||||
this.loopService = loopService;
|
||||
this.checkoutDiffManager = checkoutDiffManager;
|
||||
this.backgroundGitFetchManager = backgroundGitFetchManager;
|
||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||
this.mcpBaseUrl = mcpBaseUrl ?? null;
|
||||
this.terminalManager = terminalManager;
|
||||
this.providerSnapshotManager = providerSnapshotManager ?? null;
|
||||
if (this.terminalManager) {
|
||||
@@ -728,14 +717,11 @@ export class Session {
|
||||
this.providerSnapshotManager?.off("change", handleProviderSnapshotChange);
|
||||
};
|
||||
}
|
||||
this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null;
|
||||
this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null);
|
||||
this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler;
|
||||
this.unregisterVoiceSpeakHandler = voiceBridge?.unregisterVoiceSpeakHandler;
|
||||
this.registerVoiceCallerContext = voiceBridge?.registerVoiceCallerContext;
|
||||
this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext;
|
||||
this.ensureVoiceMcpSocketForAgent = voiceBridge?.ensureVoiceMcpSocketForAgent;
|
||||
this.removeVoiceMcpSocketForAgent = voiceBridge?.removeVoiceMcpSocketForAgent;
|
||||
this.getSpeechReadiness = dictation?.getSpeechReadiness;
|
||||
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
||||
this.abortController = new AbortController();
|
||||
@@ -940,12 +926,15 @@ export class Session {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Agent MCP client for this session using in-memory transport
|
||||
* Initialize Agent MCP client for this session using the daemon's HTTP MCP endpoint.
|
||||
*/
|
||||
private async initializeAgentMcp(): Promise<void> {
|
||||
try {
|
||||
// Create an in-memory transport connected to the Agent MCP server
|
||||
const transport = await this.createAgentMcpTransport();
|
||||
if (!this.mcpBaseUrl) {
|
||||
this.sessionLogger.info("Skipping Agent MCP initialization because no MCP base URL is configured");
|
||||
return;
|
||||
}
|
||||
const transport = new StreamableHTTPClientTransport(new URL(this.mcpBaseUrl));
|
||||
|
||||
this.agentMcpClient = await experimental_createMCPClient({
|
||||
transport,
|
||||
@@ -2615,41 +2604,8 @@ export class Session {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
private cloneMcpServers(
|
||||
servers: Record<string, McpServerConfig> | undefined,
|
||||
): Record<string, McpServerConfig> | undefined {
|
||||
if (!servers) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(JSON.stringify(servers)) as Record<string, McpServerConfig>;
|
||||
}
|
||||
|
||||
private buildVoiceModeMcpServers(
|
||||
existing: Record<string, McpServerConfig> | undefined,
|
||||
socketPath: string,
|
||||
): Record<string, McpServerConfig> {
|
||||
const mcpStdio = this.voiceAgentMcpStdio;
|
||||
if (!mcpStdio) {
|
||||
throw new Error("Voice MCP stdio bridge is not configured");
|
||||
}
|
||||
return {
|
||||
...(existing ?? {}),
|
||||
[VOICE_MCP_SERVER_NAME]: buildVoiceAgentMcpServerConfig({
|
||||
command: mcpStdio.command,
|
||||
baseArgs: mcpStdio.baseArgs,
|
||||
socketPath,
|
||||
env: mcpStdio.env,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private async enableVoiceModeForAgent(agentId: string): Promise<string> {
|
||||
const startedAt = Date.now();
|
||||
const ensureVoiceSocket = this.ensureVoiceMcpSocketForAgent;
|
||||
if (!ensureVoiceSocket) {
|
||||
throw new Error("Voice MCP socket bridge is not configured");
|
||||
}
|
||||
|
||||
this.sessionLogger.info({ agentId }, "enableVoiceModeForAgent.ensureAgentLoaded.start");
|
||||
const existing = await this.ensureAgentLoaded(agentId);
|
||||
this.sessionLogger.info(
|
||||
@@ -2657,22 +2613,14 @@ export class Session {
|
||||
"enableVoiceModeForAgent.ensureAgentLoaded.done",
|
||||
);
|
||||
|
||||
this.sessionLogger.info({ agentId }, "enableVoiceModeForAgent.ensureVoiceSocket.start");
|
||||
const socketPath = await ensureVoiceSocket(agentId);
|
||||
this.sessionLogger.info(
|
||||
{ agentId, socketPath, elapsedMs: Date.now() - startedAt },
|
||||
"enableVoiceModeForAgent.ensureVoiceSocket.done",
|
||||
);
|
||||
this.registerVoiceBridgeForAgent(agentId);
|
||||
|
||||
const baseConfig: VoiceModeBaseConfig = {
|
||||
systemPrompt: stripVoiceModeSystemPrompt(existing.config.systemPrompt),
|
||||
mcpServers: this.cloneMcpServers(existing.config.mcpServers),
|
||||
};
|
||||
this.voiceModeBaseConfig = baseConfig;
|
||||
const refreshOverrides: Partial<AgentSessionConfig> = {
|
||||
systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, true),
|
||||
mcpServers: this.buildVoiceModeMcpServers(baseConfig.mcpServers, socketPath),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -2689,7 +2637,6 @@ export class Session {
|
||||
} catch (error) {
|
||||
this.unregisterVoiceSpeakHandler?.(agentId);
|
||||
this.unregisterVoiceCallerContext?.(agentId);
|
||||
await this.removeVoiceMcpSocketForAgent?.(agentId).catch(() => undefined);
|
||||
this.voiceModeBaseConfig = null;
|
||||
throw error;
|
||||
}
|
||||
@@ -2706,19 +2653,12 @@ export class Session {
|
||||
|
||||
this.unregisterVoiceSpeakHandler?.(agentId);
|
||||
this.unregisterVoiceCallerContext?.(agentId);
|
||||
await this.removeVoiceMcpSocketForAgent?.(agentId).catch((error) => {
|
||||
this.sessionLogger.warn(
|
||||
{ err: error, agentId },
|
||||
"Failed to remove voice MCP socket bridge on disable",
|
||||
);
|
||||
});
|
||||
|
||||
if (restoreAgentConfig && this.voiceModeBaseConfig) {
|
||||
const baseConfig = this.voiceModeBaseConfig;
|
||||
try {
|
||||
await this.agentManager.reloadAgentSession(agentId, {
|
||||
systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, false),
|
||||
mcpServers: this.cloneMcpServers(baseConfig.mcpServers),
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.warn(
|
||||
|
||||
@@ -182,9 +182,7 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
dispose: () => {},
|
||||
} as any,
|
||||
backgroundGitFetchManager: backgroundGitFetchManager as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
mcpBaseUrl: null,
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
|
||||
@@ -123,9 +123,7 @@ function createSessionForWorkspaceTests(options: { appVersion?: string | null }
|
||||
}),
|
||||
dispose: () => {},
|
||||
} as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
mcpBaseUrl: null,
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
@@ -230,9 +228,7 @@ describe("workspace aggregation", () => {
|
||||
}),
|
||||
dispose: () => {},
|
||||
} as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
mcpBaseUrl: null,
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
@@ -377,9 +373,7 @@ describe("workspace aggregation", () => {
|
||||
}),
|
||||
dispose: () => {},
|
||||
} as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
mcpBaseUrl: null,
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: {
|
||||
@@ -542,9 +536,7 @@ describe("workspace aggregation", () => {
|
||||
}),
|
||||
dispose: () => {},
|
||||
} as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
mcpBaseUrl: null,
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: {
|
||||
@@ -678,9 +670,7 @@ describe("workspace aggregation", () => {
|
||||
}),
|
||||
dispose: () => {},
|
||||
} as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
mcpBaseUrl: null,
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: {
|
||||
@@ -949,9 +939,7 @@ describe("workspace aggregation", () => {
|
||||
}),
|
||||
dispose: () => {},
|
||||
} as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
mcpBaseUrl: null,
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
resolveVoiceMcpBridgeFromRuntime,
|
||||
resolveVoiceMcpBridgeScriptPath,
|
||||
} from "./voice-mcp-bridge-command.js";
|
||||
|
||||
describe("resolveVoiceMcpBridgeFromRuntime", () => {
|
||||
const bootstrapModuleUrl = new URL("./bootstrap.ts", import.meta.url).toString();
|
||||
|
||||
test("resolves default JS bridge script with node execPath", () => {
|
||||
const result = resolveVoiceMcpBridgeFromRuntime({
|
||||
bootstrapModuleUrl,
|
||||
execPath: "/usr/local/bin/node",
|
||||
});
|
||||
|
||||
const expectedScriptPath = fileURLToPath(
|
||||
new URL("../../scripts/mcp-stdio-socket-bridge-cli.mjs", bootstrapModuleUrl),
|
||||
);
|
||||
|
||||
expect(result.source).toBe("default-js-script");
|
||||
expect(result.resolved.command).toBe("/usr/local/bin/node");
|
||||
expect(result.resolved.baseArgs).toEqual([expectedScriptPath]);
|
||||
});
|
||||
|
||||
test("uses explicit script override when provided", () => {
|
||||
const explicitScriptPath = fileURLToPath(
|
||||
new URL("../../scripts/mcp-stdio-socket-bridge-cli.mjs", bootstrapModuleUrl),
|
||||
);
|
||||
|
||||
const result = resolveVoiceMcpBridgeFromRuntime({
|
||||
bootstrapModuleUrl,
|
||||
execPath: "/usr/local/bin/node",
|
||||
explicitScriptPath,
|
||||
});
|
||||
|
||||
expect(result.source).toBe("explicit-js-script");
|
||||
expect(result.resolved.command).toBe("/usr/local/bin/node");
|
||||
expect(result.resolved.baseArgs).toEqual([explicitScriptPath]);
|
||||
});
|
||||
|
||||
test("throws when explicit script path is missing", () => {
|
||||
expect(() =>
|
||||
resolveVoiceMcpBridgeScriptPath({
|
||||
bootstrapModuleUrl,
|
||||
explicitScriptPath: "/tmp/does-not-exist-voice-bridge-script.mjs",
|
||||
}),
|
||||
).toThrow("MCP stdio-socket bridge script not found");
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export type VoiceMcpBridgeCommand = { command: string; baseArgs: string[] };
|
||||
|
||||
const DEFAULT_BRIDGE_SCRIPT_RELATIVE_URL = "../../scripts/mcp-stdio-socket-bridge-cli.mjs";
|
||||
|
||||
export function resolveVoiceMcpBridgeScriptPath(params: {
|
||||
bootstrapModuleUrl: string;
|
||||
explicitScriptPath?: string | null;
|
||||
}): string {
|
||||
const explicitScriptPath = params.explicitScriptPath?.trim();
|
||||
if (explicitScriptPath) {
|
||||
if (!existsSync(explicitScriptPath)) {
|
||||
throw new Error(
|
||||
`MCP stdio-socket bridge script not found at configured path: ${explicitScriptPath}`,
|
||||
);
|
||||
}
|
||||
return explicitScriptPath;
|
||||
}
|
||||
|
||||
const scriptPath = fileURLToPath(
|
||||
new URL(DEFAULT_BRIDGE_SCRIPT_RELATIVE_URL, params.bootstrapModuleUrl),
|
||||
);
|
||||
if (!existsSync(scriptPath)) {
|
||||
throw new Error(`MCP stdio-socket bridge script not found: ${scriptPath}`);
|
||||
}
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
export function resolveVoiceMcpBridgeFromRuntime(params: {
|
||||
bootstrapModuleUrl: string;
|
||||
execPath: string;
|
||||
explicitScriptPath?: string | null;
|
||||
}): {
|
||||
resolved: VoiceMcpBridgeCommand;
|
||||
source: string;
|
||||
} {
|
||||
const scriptPath = resolveVoiceMcpBridgeScriptPath({
|
||||
bootstrapModuleUrl: params.bootstrapModuleUrl,
|
||||
explicitScriptPath: params.explicitScriptPath,
|
||||
});
|
||||
return {
|
||||
source: params.explicitScriptPath?.trim() ? "explicit-js-script" : "default-js-script",
|
||||
resolved: {
|
||||
command: params.execPath,
|
||||
baseArgs: [scriptPath],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { experimental_createMCPClient } from "ai";
|
||||
import { z } from "zod";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import pino from "pino";
|
||||
|
||||
import { createVoiceMcpSocketBridgeManager } from "./voice-mcp-bridge.js";
|
||||
import { resolveVoiceMcpBridgeScriptPath } from "./voice-mcp-bridge-command.js";
|
||||
|
||||
describe("voice MCP bridge", () => {
|
||||
test("proxies stdio MCP bytes through per-agent unix socket bridge", async () => {
|
||||
const tmpRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-voice-mcp-bridge-"));
|
||||
const callerAgentId = "voice-agent-bridge-test";
|
||||
|
||||
const bridgeManager = createVoiceMcpSocketBridgeManager({
|
||||
runtimeDir: tmpRoot,
|
||||
logger: pino({ level: "silent" }),
|
||||
createAgentMcpServerForCaller: async (callerId) => {
|
||||
const server = new McpServer({
|
||||
name: "bridge-test-server",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
server.registerTool(
|
||||
"echo_caller",
|
||||
{
|
||||
value: z.string().optional(),
|
||||
},
|
||||
async (args) => {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
callerAgentId: callerId,
|
||||
value: args.value ?? null,
|
||||
}),
|
||||
},
|
||||
],
|
||||
structuredContent: {
|
||||
callerAgentId: callerId,
|
||||
value: args.value ?? null,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
},
|
||||
});
|
||||
|
||||
const socketPath = await bridgeManager.ensureBridgeForCaller(callerAgentId);
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [
|
||||
resolveVoiceMcpBridgeScriptPath({
|
||||
bootstrapModuleUrl: import.meta.url,
|
||||
}),
|
||||
"--socket",
|
||||
socketPath,
|
||||
],
|
||||
});
|
||||
|
||||
const client = await experimental_createMCPClient({ transport });
|
||||
|
||||
try {
|
||||
const result = await client.callTool({
|
||||
name: "echo_caller",
|
||||
args: { value: "ok" },
|
||||
});
|
||||
|
||||
const payload =
|
||||
(result as { structuredContent?: { callerAgentId?: string; value?: string | null } })
|
||||
.structuredContent ?? null;
|
||||
|
||||
expect(payload?.callerAgentId).toBe(callerAgentId);
|
||||
} finally {
|
||||
await client.close();
|
||||
await bridgeManager.stop();
|
||||
await rm(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import type { Logger } from "pino";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
|
||||
type BridgeServer = {
|
||||
connect: (transport: StdioServerTransport) => Promise<void>;
|
||||
close?: () => Promise<void>;
|
||||
};
|
||||
|
||||
type BridgeEntry = {
|
||||
socketPath: string;
|
||||
server: net.Server;
|
||||
sockets: Set<net.Socket>;
|
||||
};
|
||||
|
||||
export type VoiceMcpSocketBridgeManager = {
|
||||
ensureBridgeForCaller: (callerAgentId: string) => Promise<string>;
|
||||
removeBridgeForCaller: (callerAgentId: string) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
function toSocketName(callerAgentId: string): string {
|
||||
return `voice-mcp-${callerAgentId}.sock`;
|
||||
}
|
||||
|
||||
export function createVoiceMcpSocketBridgeManager(params: {
|
||||
runtimeDir: string;
|
||||
logger: Logger;
|
||||
createAgentMcpServerForCaller: (callerAgentId: string) => Promise<BridgeServer>;
|
||||
}): VoiceMcpSocketBridgeManager {
|
||||
const logger = params.logger.child({ module: "voice-mcp-bridge" });
|
||||
const entries = new Map<string, BridgeEntry>();
|
||||
const pendingCreates = new Map<string, Promise<string>>();
|
||||
|
||||
const ensureBridgeForCaller = async (callerAgentId: string): Promise<string> => {
|
||||
const existing = entries.get(callerAgentId);
|
||||
if (existing) {
|
||||
return existing.socketPath;
|
||||
}
|
||||
|
||||
const pending = pendingCreates.get(callerAgentId);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
const createPromise = (async () => {
|
||||
const socketPath = path.join(params.runtimeDir, toSocketName(callerAgentId));
|
||||
const sockets = new Set<net.Socket>();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
const connectionLogger = logger.child({ callerAgentId, component: "connection" });
|
||||
|
||||
let mcpServer: BridgeServer | null = null;
|
||||
let transport: StdioServerTransport | null = null;
|
||||
|
||||
const cleanup = async () => {
|
||||
sockets.delete(socket);
|
||||
await Promise.all([
|
||||
transport?.close().catch(() => undefined),
|
||||
mcpServer?.close?.().catch(() => undefined),
|
||||
]);
|
||||
};
|
||||
|
||||
socket.on("error", (error) => {
|
||||
connectionLogger.error({ err: error }, "Voice MCP bridge socket error");
|
||||
});
|
||||
socket.on("close", () => {
|
||||
void cleanup();
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
mcpServer = await params.createAgentMcpServerForCaller(callerAgentId);
|
||||
transport = new StdioServerTransport(socket, socket);
|
||||
await mcpServer.connect(transport);
|
||||
} catch (error) {
|
||||
connectionLogger.error(
|
||||
{ err: error, callerAgentId },
|
||||
"Failed to initialize stream-level MCP bridge connection",
|
||||
);
|
||||
socket.destroy();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
await mkdir(params.runtimeDir, { recursive: true });
|
||||
await rm(socketPath, { force: true }).catch(() => undefined);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(socketPath, () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
entries.set(callerAgentId, { socketPath, server, sockets });
|
||||
logger.info({ callerAgentId, socketPath }, "Voice MCP per-agent socket bridge listening");
|
||||
return socketPath;
|
||||
})();
|
||||
|
||||
pendingCreates.set(callerAgentId, createPromise);
|
||||
try {
|
||||
return await createPromise;
|
||||
} finally {
|
||||
pendingCreates.delete(callerAgentId);
|
||||
}
|
||||
};
|
||||
|
||||
const removeBridgeForCaller = async (callerAgentId: string): Promise<void> => {
|
||||
const entry = entries.get(callerAgentId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
entries.delete(callerAgentId);
|
||||
|
||||
for (const socket of entry.sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
entry.server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
await rm(entry.socketPath, { force: true }).catch(() => undefined);
|
||||
logger.info({ callerAgentId, socketPath: entry.socketPath }, "Voice MCP socket bridge removed");
|
||||
};
|
||||
|
||||
const stop = async (): Promise<void> => {
|
||||
const activeCallerIds = Array.from(entries.keys());
|
||||
for (const callerAgentId of activeCallerIds) {
|
||||
await removeBridgeForCaller(callerAgentId).catch((error) => {
|
||||
logger.warn({ err: error, callerAgentId }, "Failed to stop voice MCP socket bridge");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ensureBridgeForCaller,
|
||||
removeBridgeForCaller,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
@@ -10,9 +10,3 @@ export type VoiceCallerContext = {
|
||||
allowCustomCwd?: boolean;
|
||||
enableVoiceTools?: boolean;
|
||||
};
|
||||
|
||||
export type VoiceMcpStdioConfig = {
|
||||
command: string;
|
||||
baseArgs: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -74,13 +74,12 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||
{} as any,
|
||||
{} as any,
|
||||
"/tmp/paseo-test",
|
||||
async () => ({}) as any,
|
||||
null,
|
||||
{ allowedOrigins: new Set() },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"1.2.3-test",
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
@@ -165,7 +165,7 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
|
||||
{} as any,
|
||||
{} as any,
|
||||
"/tmp/paseo-test",
|
||||
async () => ({}) as any,
|
||||
null,
|
||||
{ allowedOrigins: new Set() },
|
||||
speechReadiness
|
||||
? {
|
||||
@@ -176,7 +176,6 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
TEST_DAEMON_VERSION,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { WebSocketServer } from "ws";
|
||||
import type { Server as HTTPServer } from "http";
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { join } from "path";
|
||||
import { hostname as getHostname } from "node:os";
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
@@ -37,7 +36,7 @@ import { buildProviderRegistry } from "./agent/provider-registry.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import { PushService } from "./push/push-service.js";
|
||||
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
|
||||
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
|
||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
|
||||
import {
|
||||
computeShouldNotifyClient,
|
||||
computeShouldSendPush,
|
||||
@@ -49,7 +48,6 @@ import {
|
||||
findLatestPermissionRequest,
|
||||
} from "../shared/agent-attention-notification.js";
|
||||
|
||||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||
export type ExternalSocketMetadata = {
|
||||
transport: "relay";
|
||||
externalSessionKey?: string;
|
||||
@@ -243,17 +241,12 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly paseoHome: string;
|
||||
private readonly pushTokenStore: PushTokenStore;
|
||||
private readonly pushService: PushService;
|
||||
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
|
||||
private readonly mcpBaseUrl: string | null;
|
||||
private readonly speech: SpeechService | null;
|
||||
private readonly terminalManager: TerminalManager | null;
|
||||
private readonly dictation: {
|
||||
finalTimeoutMs?: number;
|
||||
} | null;
|
||||
private readonly voice: {
|
||||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||||
ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise<string>;
|
||||
removeVoiceMcpSocketForAgent?: (agentId: string) => Promise<void>;
|
||||
} | null;
|
||||
private readonly voiceSpeakHandlers = new Map<string, VoiceSpeakHandler>();
|
||||
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
|
||||
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
||||
@@ -292,15 +285,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
agentStorage: AgentStorage,
|
||||
downloadTokenStore: DownloadTokenStore,
|
||||
paseoHome: string,
|
||||
createAgentMcpTransport: AgentMcpTransportFactory,
|
||||
mcpBaseUrl: string | null,
|
||||
wsConfig: WebSocketServerConfig,
|
||||
speech?: SpeechService | null,
|
||||
terminalManager?: TerminalManager | null,
|
||||
voice?: {
|
||||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||||
ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise<string>;
|
||||
removeVoiceMcpSocketForAgent?: (agentId: string) => Promise<void>;
|
||||
},
|
||||
dictation?: {
|
||||
finalTimeoutMs?: number;
|
||||
},
|
||||
@@ -345,10 +333,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
});
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
this.paseoHome = paseoHome;
|
||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||
this.mcpBaseUrl = mcpBaseUrl;
|
||||
this.speech = speech ?? null;
|
||||
this.terminalManager = terminalManager ?? null;
|
||||
this.voice = voice ?? null;
|
||||
this.dictation = dictation ?? null;
|
||||
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
||||
const providerSnapshotLogger = this.logger.child({ module: "provider-snapshot-manager" });
|
||||
@@ -650,13 +637,12 @@ export class VoiceAssistantWebSocketServer {
|
||||
scheduleService: this.scheduleService,
|
||||
checkoutDiffManager: this.checkoutDiffManager,
|
||||
backgroundGitFetchManager: this.backgroundGitFetchManager,
|
||||
createAgentMcpTransport: this.createAgentMcpTransport,
|
||||
mcpBaseUrl: this.mcpBaseUrl,
|
||||
stt: () => this.speech?.resolveStt() ?? null,
|
||||
tts: () => this.speech?.resolveTts() ?? null,
|
||||
terminalManager: this.terminalManager,
|
||||
providerSnapshotManager: this.providerSnapshotManager,
|
||||
voice: {
|
||||
...(this.voice ?? {}),
|
||||
turnDetection: () => this.speech?.resolveTurnDetection() ?? null,
|
||||
},
|
||||
voiceBridge: {
|
||||
@@ -672,8 +658,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
unregisterVoiceCallerContext: (agentId) => {
|
||||
this.voiceCallerContexts.delete(agentId);
|
||||
},
|
||||
ensureVoiceMcpSocketForAgent: this.voice?.ensureVoiceMcpSocketForAgent,
|
||||
removeVoiceMcpSocketForAgent: this.voice?.removeVoiceMcpSocketForAgent,
|
||||
},
|
||||
dictation:
|
||||
this.dictation || this.speech
|
||||
|
||||
Reference in New Issue
Block a user