refactor: consolidate agent wait methods and use in-memory MCP transport

This commit is contained in:
Mohamed Boudra
2026-01-30 14:50:01 +07:00
parent 0618dc6b0d
commit 25c7d5afa9
24 changed files with 191 additions and 469 deletions
+53 -170
View File
@@ -1906,196 +1906,79 @@ export class DaemonClientV2 {
);
}
async waitForAgentIdle(
async waitForFinish(
agentId: string,
timeout = 60000
): Promise<AgentSnapshotPayload> {
const current = this.agentIndex.get(agentId);
const pendingPermissionIds = new Set<string>();
if (current?.pendingPermissions) {
for (const request of current.pendingPermissions) {
pendingPermissionIds.add(request.id);
}
}
// We need to see the agent start (running/initializing) before we can
// consider idle/error as "finished". Otherwise we'd return the old idle
// state from before the task started.
//
// Permission requests are different - if there are pending permissions,
// the agent needs attention NOW regardless of whether we saw it start.
let sawStart = false;
let finishedState: AgentSnapshotPayload | null = null;
// Track whether we've seen a running state. Important: we only set this
// when we see running IN THE QUEUE, not based on current state. This prevents
// finding old idle states that occurred before the current run started.
let sawRunningInQueue = false;
let queuedIdle: AgentSnapshotPayload | null = null;
// Scan message queue for state changes
// We need to scan the ENTIRE queue to find the final state, not return early
for (const msg of this.messageQueue) {
if (
msg.type === "agent_update" &&
msg.payload.kind === "upsert" &&
msg.payload.agent.id === agentId
) {
const agent = msg.payload.agent;
const updatePendingPermissions = (msg: SessionOutboundMessage): void => {
if (
msg.type === "agent_permission_request" &&
msg.payload.agentId === agentId
) {
pendingPermissionIds.add(msg.payload.request.id);
return;
}
if (
msg.type === "agent_permission_resolved" &&
msg.payload.agentId === agentId
) {
pendingPermissionIds.delete(msg.payload.requestId);
return;
}
if (msg.type === "agent_stream" && msg.payload.agentId === agentId) {
if (msg.payload.event.type === "permission_requested") {
pendingPermissionIds.add(msg.payload.event.request.id);
} else if (msg.payload.event.type === "permission_resolved") {
pendingPermissionIds.delete(msg.payload.event.requestId);
// Track if agent has started processing a task
// Only "running" counts - "initializing" is the agent process starting up
if (agent.status === "running") {
sawStart = true;
finishedState = null; // Reset - any previous finished state was before this run
}
// Check for finished state - save it but don't return yet
// We need to scan the whole queue to find the FINAL state
const hasPendingPermissions = (agent.pendingPermissions?.length ?? 0) > 0;
if (hasPendingPermissions) {
// Permission means agent needs attention
finishedState = agent;
} else if (sawStart && (agent.status === "idle" || agent.status === "error")) {
finishedState = agent;
}
}
};
for (const msg of this.messageQueue) {
updatePendingPermissions(msg);
if (
msg.type !== "agent_update" ||
msg.payload.kind !== "upsert" ||
msg.payload.agent.id !== agentId
) {
continue;
}
const status = msg.payload.agent.status;
const hasPendingPermissions =
(msg.payload.agent.pendingPermissions?.length ?? 0) > 0 ||
pendingPermissionIds.size > 0;
if (status === "running" || hasPendingPermissions) {
sawRunningInQueue = true;
queuedIdle = null; // Reset: any previous idle was before this run
}
// Return immediately if we have pending permissions (even if still running)
if (sawRunningInQueue && hasPendingPermissions) {
return msg.payload.agent;
}
if (
sawRunningInQueue &&
(status === "idle" || status === "error") &&
!hasPendingPermissions
) {
queuedIdle = msg.payload.agent;
}
}
if (queuedIdle) {
return queuedIdle;
}
// If current state is running (or has pending permissions), we need to wait
// for the next idle. If current is already idle and we didn't see running
// in the queue, this is an edge case - the agent might not have started yet
// or was already idle. Use the current state's running status to seed the waiter.
let sawRunning =
sawRunningInQueue ||
current?.status === "running" ||
pendingPermissionIds.size > 0;
// Only return from queue if we have a definitive finished state (idle/error)
// Don't return from queue if the latest state has pending permissions -
// the permission might be getting resolved right now, so wait for new messages
const hasPendingPermissionsInQueue = (finishedState?.pendingPermissions?.length ?? 0) > 0;
if (finishedState && !hasPendingPermissionsInQueue) {
return finishedState;
}
// Wait for agent to finish
return this.waitFor(
(msg) => {
updatePendingPermissions(msg);
if (
msg.type === "agent_update" &&
msg.payload.kind === "upsert" &&
msg.payload.agent.id === agentId
) {
const status = msg.payload.agent.status;
const hasPendingPermissions =
(msg.payload.agent.pendingPermissions?.length ?? 0) > 0 ||
pendingPermissionIds.size > 0;
if (status === "running" || hasPendingPermissions) {
sawRunning = true;
}
// Return if we have pending permissions (even if still running)
// OR if agent is idle/error with no pending permissions (after having run)
if (sawRunning && hasPendingPermissions) {
return msg.payload.agent;
}
if (
sawRunning &&
(status === "idle" || status === "error") &&
!hasPendingPermissions
) {
return msg.payload.agent;
}
}
return null;
},
timeout,
{ skipQueue: true }
);
}
const agent = msg.payload.agent;
async waitForPermission(
agentId: string,
timeout = 30000
): Promise<AgentPermissionRequest> {
const snapshotPending = this.agentIndex.get(agentId)?.pendingPermissions?.[0];
if (snapshotPending) {
return snapshotPending;
}
let queuedRequest: AgentPermissionRequest | null = null;
const pendingById = new Map<string, AgentPermissionRequest>();
for (const msg of this.messageQueue) {
if (
msg.type === "agent_permission_request" &&
msg.payload.agentId === agentId
) {
pendingById.set(msg.payload.request.id, msg.payload.request);
queuedRequest = msg.payload.request;
continue;
}
if (msg.type === "agent_permission_resolved") {
if (msg.payload.agentId === agentId) {
pendingById.delete(msg.payload.requestId);
if (queuedRequest?.id === msg.payload.requestId) {
queuedRequest = null;
// Track if agent has started processing a task
// Only "running" counts - "initializing" is the agent process starting up
if (agent.status === "running") {
sawStart = true;
}
}
continue;
}
if (msg.type === "agent_stream" && msg.payload.agentId === agentId) {
if (msg.payload.event.type === "permission_requested") {
pendingById.set(
msg.payload.event.request.id,
msg.payload.event.request
);
queuedRequest = msg.payload.event.request;
continue;
}
if (msg.payload.event.type === "permission_resolved") {
pendingById.delete(msg.payload.event.requestId);
if (queuedRequest?.id === msg.payload.event.requestId) {
queuedRequest = null;
// Check for finished state
const hasPendingPermissions = (agent.pendingPermissions?.length ?? 0) > 0;
if (hasPendingPermissions) {
return agent;
}
}
}
}
if (queuedRequest && pendingById.has(queuedRequest.id)) {
return queuedRequest;
}
if (pendingById.size > 0) {
let mostRecent: AgentPermissionRequest | null = null;
for (const request of pendingById.values()) {
mostRecent = request;
}
if (mostRecent) {
return mostRecent;
}
}
return this.waitFor(
(msg) => {
if (
msg.type === "agent_permission_request" &&
msg.payload.agentId === agentId
) {
return msg.payload.request;
}
if (msg.type === "agent_stream" && msg.payload.agentId === agentId) {
if (msg.payload.event.type === "permission_requested") {
return msg.payload.event.request;
if (sawStart && (agent.status === "idle" || agent.status === "error")) {
return agent;
}
}
return null;
+18 -2
View File
@@ -4,6 +4,7 @@ import { createReadStream, unlinkSync, existsSync } from "fs";
import { stat } from "fs/promises";
import { randomUUID } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import type { Logger } from "pino";
@@ -264,6 +265,22 @@ export async function createPaseoDaemon(
return transport;
};
// Create in-memory transport for Session's Agent MCP client (voice assistant tools)
const createInMemoryAgentMcpTransport = async (): Promise<InMemoryTransport> => {
const agentMcpServer = await createAgentMcpServer({
agentManager,
agentStorage,
paseoHome: config.paseoHome,
logger,
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await agentMcpServer.connect(serverTransport);
return clientTransport;
};
const handleAgentMcpRequest: express.RequestHandler = async (req, res) => {
if (config.mcpDebug) {
logger.debug(
@@ -491,8 +508,7 @@ export async function createPaseoDaemon(
agentStorage,
downloadTokenStore,
config.paseoHome,
agentMcpRoute,
config.selfIdMcpSocketPath,
createInMemoryAgentMcpTransport,
{ allowedOrigins },
{ stt: sttService, tts: ttsService },
terminalManager
@@ -294,7 +294,7 @@ describe("daemon client v2 E2E", () => {
}
});
await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else");
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
unsubscribeStream();
unsubscribeRawStream();
expect(finalState.status).toBe("idle");
@@ -446,7 +446,9 @@ describe("daemon client v2 E2E", () => {
].join("\n")
);
const permission = await ctx.client.waitForPermission(agent.id, 60000);
const permissionState = await ctx.client.waitForFinish(agent.id, 60000);
expect(permissionState.pendingPermissions?.length).toBeGreaterThan(0);
const permission = permissionState.pendingPermissions[0];
expect(permission).toBeTruthy();
expect(permission.id).toBeTruthy();
@@ -460,7 +462,7 @@ describe("daemon client v2 E2E", () => {
const permissionResolved = await permissionResolvedPromise;
expect(permissionResolved.payload.requestId).toBe(permission.id);
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
expect(existsSync(filePath)).toBe(true);
} finally {
@@ -485,7 +487,7 @@ describe("daemon client v2 E2E", () => {
});
await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else");
await ctx.client.waitForAgentIdle(agent.id, 120000);
await ctx.client.waitForFinish(agent.id, 120000);
const snapshotPromise = waitForSignal(15000, (resolve) => {
const unsubscribe = ctx.client.on("agent_stream_snapshot", (message) => {
@@ -46,7 +46,7 @@ describe("daemon E2E", () => {
await ctx.client.sendMessage(agent.id, "Say 'hello world' and nothing else");
// Wait for the agent to complete
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
// Verify agent completed without error
expect(finalState.status).toBe("idle");
@@ -105,7 +105,7 @@ describe("daemon E2E", () => {
await ctx.client.sendMessage(agent.id, "Say 'test' and nothing else");
// Wait for agent to complete
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
// The timestamp SHOULD have been updated (should be later than initial)
@@ -328,7 +328,7 @@ describe("daemon E2E", () => {
ctx.client.clearMessageQueue();
await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else");
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
// Mode should still be "read-only" after the message
expect(finalState.currentModeId).toBe("read-only");
@@ -143,7 +143,7 @@ describe("daemon restart and agent resume", () => {
`Remember this marker string for a test: "${marker}". Just confirm you've remembered it with a short reply.`
);
const afterRemember = await currentDaemon.client.waitForAgentIdle(agent.id, 120000);
const afterRemember = await currentDaemon.client.waitForFinish(agent.id, 120000);
expect(afterRemember.status).toBe("idle");
expect(afterRemember.lastError).toBeUndefined();
@@ -208,7 +208,7 @@ describe("daemon restart and agent resume", () => {
"What was the marker string I asked you to remember earlier? Just reply with the exact string."
);
const afterMessage = await currentDaemon.client.waitForAgentIdle(resumedAgent.id, 120000);
const afterMessage = await currentDaemon.client.waitForFinish(resumedAgent.id, 120000);
expect(afterMessage.status).toBe("idle");
expect(afterMessage.lastError).toBeUndefined();
@@ -64,7 +64,7 @@ describe("daemon E2E", () => {
);
// Wait for agent to complete
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
expect(finalState.lastError).toBeUndefined();
@@ -135,7 +135,7 @@ describe("daemon E2E", () => {
);
// Wait for agent to complete
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
expect(finalState.lastError).toBeUndefined();
@@ -18,9 +18,6 @@ function tmpCwd(): string {
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
const hasClaudeCredentials =
!!process.env.CLAUDE_SESSION_TOKEN || !!process.env.ANTHROPIC_API_KEY;
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
@@ -32,7 +29,7 @@ describe("daemon E2E", () => {
await ctx.cleanup();
}, 60000);
(hasClaudeCredentials ? describe : describe.skip)("permission flow: Claude", () => {
describe("permission flow: Claude", () => {
// Use isolated Claude config to ensure permission prompts are triggered
// (user's real config may have allow rules that auto-approve commands)
let restoreClaudeConfig: () => void;
@@ -81,7 +78,9 @@ describe("daemon E2E", () => {
await ctx.client.sendMessage(agent.id, prompt);
// Wait for permission request
const permission = await ctx.client.waitForPermission(agent.id, 60000);
const permissionState = await ctx.client.waitForFinish(agent.id, 60000);
expect(permissionState.pendingPermissions?.length).toBeGreaterThan(0);
const permission = permissionState.pendingPermissions[0];
expect(permission).not.toBeNull();
expect(permission.id).toBeTruthy();
expect(permission.kind).toBe("tool");
@@ -92,7 +91,7 @@ describe("daemon E2E", () => {
});
// Wait for agent to complete
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
// Verify the file was deleted
@@ -152,7 +151,9 @@ describe("daemon E2E", () => {
await ctx.client.sendMessage(agent.id, prompt);
// Wait for permission request
const permission = await ctx.client.waitForPermission(agent.id, 60000);
const permissionState = await ctx.client.waitForFinish(agent.id, 60000);
expect(permissionState.pendingPermissions?.length).toBeGreaterThan(0);
const permission = permissionState.pendingPermissions[0];
expect(permission).not.toBeNull();
expect(permission.id).toBeTruthy();
@@ -163,7 +164,7 @@ describe("daemon E2E", () => {
});
// Wait for agent to complete
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
// Verify the file was NOT deleted
@@ -58,7 +58,9 @@ describe("daemon E2E", () => {
await ctx.client.sendMessage(agent.id, prompt);
// Wait for permission request
const permission = await ctx.client.waitForPermission(agent.id, 60000);
const permissionState = await ctx.client.waitForFinish(agent.id, 60000);
expect(permissionState.pendingPermissions?.length).toBeGreaterThan(0);
const permission = permissionState.pendingPermissions[0];
expect(permission).not.toBeNull();
expect(permission.id).toBeTruthy();
expect(permission.kind).toBe("tool");
@@ -69,7 +71,7 @@ describe("daemon E2E", () => {
});
// Wait for agent to complete
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
// Verify the file was created
@@ -122,7 +124,9 @@ describe("daemon E2E", () => {
await ctx.client.sendMessage(agent.id, prompt);
// Wait for permission request
const permission = await ctx.client.waitForPermission(agent.id, 60000);
const permissionState = await ctx.client.waitForFinish(agent.id, 60000);
expect(permissionState.pendingPermissions?.length).toBeGreaterThan(0);
const permission = permissionState.pendingPermissions[0];
expect(permission).not.toBeNull();
expect(permission.id).toBeTruthy();
@@ -133,7 +137,7 @@ describe("daemon E2E", () => {
});
// Wait for agent to complete
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
// Verify the file was NOT created
@@ -207,7 +211,7 @@ describe("daemon E2E", () => {
);
// Wait for this to complete
await ctx.client.waitForAgentIdle(agent.id, 60000);
await ctx.client.waitForFinish(agent.id, 60000);
// Verify we got an assistant message in the queue
const queue = ctx.client.getMessageQueue();
@@ -295,7 +299,9 @@ describe("daemon E2E", () => {
await ctx.client.sendMessage(agent.id, writePrompt);
// Step 3: Wait for permission request
const permission = await ctx.client.waitForPermission(agent.id, 60000);
const permissionState = await ctx.client.waitForFinish(agent.id, 60000);
expect(permissionState.pendingPermissions?.length).toBeGreaterThan(0);
const permission = permissionState.pendingPermissions[0];
expect(permission).not.toBeNull();
expect(permission.id).toBeTruthy();
@@ -306,7 +312,7 @@ describe("daemon E2E", () => {
});
// Wait for agent to complete after denial
await ctx.client.waitForAgentIdle(agent.id, 120000);
await ctx.client.waitForFinish(agent.id, 120000);
// Verify file was NOT created after denial
expect(existsSync(filePath)).toBe(false);
@@ -353,7 +359,7 @@ describe("daemon E2E", () => {
await ctx.client.sendMessage(agent.id, writePrompt2);
// Wait for agent to complete
await ctx.client.waitForAgentIdle(agent.id, 120000);
await ctx.client.waitForFinish(agent.id, 120000);
// Step 7: Verify file was created (mode switch worked)
expect(existsSync(filePath)).toBe(true);
@@ -52,7 +52,7 @@ describe("daemon E2E", () => {
);
// Wait for agent to complete
const afterMessage = await ctx.client.waitForAgentIdle(agent.id, 120000);
const afterMessage = await ctx.client.waitForFinish(agent.id, 120000);
expect(afterMessage.status).toBe("idle");
// Get the timeline to verify we have messages
@@ -118,7 +118,7 @@ describe("daemon E2E", () => {
"What did I ask you to say earlier?"
);
const afterResume = await ctx.client.waitForAgentIdle(
const afterResume = await ctx.client.waitForFinish(
resumedAgent.id,
120000
);
@@ -166,7 +166,7 @@ describe("daemon E2E", () => {
);
// Wait for agent to complete
const afterMessage = await ctx.client.waitForAgentIdle(agent.id, 120000);
const afterMessage = await ctx.client.waitForFinish(agent.id, 120000);
expect(afterMessage.status).toBe("idle");
// Verify we have timeline items before restart
@@ -424,7 +424,7 @@ describe("daemon E2E", () => {
"What was the number I asked you to remember earlier? Reply with just the number and nothing else."
);
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
expect(finalState.lastError).toBeUndefined();
@@ -495,7 +495,7 @@ describe("daemon E2E", () => {
ctx.client.clearMessageQueue();
await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else.");
const finalState = await ctx.client.waitForAgentIdle(agent.id, 60000);
const finalState = await ctx.client.waitForFinish(agent.id, 60000);
// Agent should complete (possibly with Codex warning about missing file,
// but should still function)
@@ -680,7 +680,7 @@ describe("daemon E2E", () => {
`Remember this number: ${magicNumber}. Just confirm you've remembered it and reply with a single short sentence.`
);
const afterRemember = await ctx.client.waitForAgentIdle(agent.id, 120000);
const afterRemember = await ctx.client.waitForFinish(agent.id, 120000);
expect(afterRemember.status).toBe("idle");
expect(afterRemember.lastError).toBeUndefined();
@@ -729,7 +729,7 @@ describe("daemon E2E", () => {
"What was the number I asked you to remember earlier? Reply with just the number and nothing else."
);
const afterRecall = await ctx.client.waitForAgentIdle(resumedAgent.id, 120000);
const afterRecall = await ctx.client.waitForFinish(resumedAgent.id, 120000);
expect(afterRecall.status).toBe("idle");
expect(afterRecall.lastError).toBeUndefined();
@@ -33,31 +33,19 @@ describe("self-id MCP e2e", () => {
"Use the set_title MCP tool to change your title to 'Updated via MCP'. Only call set_title, nothing else."
);
// Wait for agent to complete (MCP tools may auto-approve without permission)
// If a permission is requested, approve it
let finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
// Wait for permission request (default mode requires permission for MCP tools)
const state = await ctx.client.waitForFinish(agent.id, 60000);
expect(state.pendingPermissions?.length).toBeGreaterThan(0);
expect(state.pendingPermissions![0].name).toBe("mcp__paseo-self-id__set_title");
// Check if we got blocked on permission
if (finalState.status === "running" && finalState.pendingPermissions?.length) {
const permission = finalState.pendingPermissions[0];
await ctx.client.respondToPermission(agent.id, permission.id, {
behavior: "allow",
});
finalState = await ctx.client.waitForAgentIdle(agent.id, 60000);
}
// Log final state for debugging if not idle
if (finalState.status !== "idle") {
console.error(
"Agent did not reach idle state:",
JSON.stringify(finalState, null, 2)
);
}
// Approve the permission
await ctx.client.respondToPermission(agent.id, state.pendingPermissions![0].id, {
behavior: "allow",
});
// Wait for agent to complete
const finalState = await ctx.client.waitForFinish(agent.id, 60000);
expect(finalState.status).toBe("idle");
expect(finalState.lastError).toBeUndefined();
// Verify the title was changed via set_title
expect(finalState.title).toBe("Updated via MCP");
}, 180000);
});
@@ -17,12 +17,7 @@ function tmpCwd(): string {
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
const hasClaudeCredentials =
!!process.env.CLAUDE_SESSION_TOKEN || !!process.env.ANTHROPIC_API_KEY;
const describeWithClaude = hasClaudeCredentials ? describe : describe.skip;
describeWithClaude("daemon E2E", () => {
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
@@ -57,7 +52,7 @@ describeWithClaude("daemon E2E", () => {
);
// Wait for agent to complete
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
const finalState = await ctx.client.waitForFinish(agent.id, 120000);
expect(finalState.status).toBe("idle");
expect(finalState.lastError).toBeUndefined();
@@ -179,7 +174,7 @@ describeWithClaude("daemon E2E", () => {
"Remember the number 42. Just confirm you remember it."
);
let state = await ctx.client.waitForAgentIdle(agent.id, 120000);
let state = await ctx.client.waitForFinish(agent.id, 120000);
expect(state.status).toBe("idle");
expect(state.lastError).toBeUndefined();
@@ -191,7 +186,7 @@ describeWithClaude("daemon E2E", () => {
"Now remember the word 'elephant'. Just confirm you remember both the number and the word."
);
state = await ctx.client.waitForAgentIdle(agent.id, 120000);
state = await ctx.client.waitForFinish(agent.id, 120000);
expect(state.status).toBe("idle");
expect(state.lastError).toBeUndefined();
@@ -204,7 +199,7 @@ describeWithClaude("daemon E2E", () => {
"Write a complete sentence using both the number (42) and the word (elephant) you remembered. The sentence should be grammatically correct English."
);
state = await ctx.client.waitForAgentIdle(agent.id, 120000);
state = await ctx.client.waitForFinish(agent.id, 120000);
expect(state.status).toBe("idle");
expect(state.lastError).toBeUndefined();
@@ -594,7 +589,7 @@ describeWithClaude("daemon E2E", () => {
// Agent should go idle within 5 seconds after interrupt
log(`waiting for idle...`);
const finalState = await ctx.client.waitForAgentIdle(agent.id, 10000);
const finalState = await ctx.client.waitForFinish(agent.id, 10000);
const idleReceivedAt = Date.now();
log(`got idle state: ${finalState.status} (took ${idleReceivedAt - stopSentAt}ms after Stop)`);
expect(finalState.status).toBe("idle");
@@ -81,7 +81,7 @@ describe("daemon E2E", () => {
"Read the file /etc/hosts and tell me how many lines it has. Be brief."
);
await ctx.client.waitForAgentIdle(agent.id, 120000);
await ctx.client.waitForFinish(agent.id, 120000);
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
@@ -120,7 +120,7 @@ describe("daemon E2E", () => {
"Run `echo hello` and tell me what it outputs. Be brief."
);
await ctx.client.waitForAgentIdle(agent.id, 120000);
await ctx.client.waitForFinish(agent.id, 120000);
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
@@ -164,7 +164,7 @@ describe("daemon E2E", () => {
`Edit the file ${testFile} and change "hello" to "goodbye". Be brief.`
);
await ctx.client.waitForAgentIdle(agent.id, 120000);
await ctx.client.waitForFinish(agent.id, 120000);
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
@@ -202,7 +202,7 @@ describe("daemon E2E", () => {
"Run `echo hello` and tell me what it outputs. Be brief."
);
await ctx.client.waitForAgentIdle(agent.id, 120000);
await ctx.client.waitForFinish(agent.id, 120000);
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
@@ -248,7 +248,7 @@ describe("daemon E2E", () => {
"Read the file /etc/hosts and tell me how many lines it has. Be brief."
);
await ctx.client.waitForAgentIdle(agent.id, 120000);
await ctx.client.waitForFinish(agent.id, 120000);
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
@@ -292,7 +292,7 @@ describe("daemon E2E", () => {
`Edit the file ${testFile} and change "hello" to "goodbye". Be brief.`
);
await ctx.client.waitForAgentIdle(agent.id, 120000);
await ctx.client.waitForFinish(agent.id, 120000);
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
expect(toolCalls.length).toBeGreaterThan(0);
@@ -53,7 +53,7 @@ describe("two-cycle Codex agent resume", () => {
`For this test session, remember this project name: "${MARKER}". Just confirm you've noted it.`
);
const afterSecret = await ctx.client.waitForAgentIdle(agent.id, 120000);
const afterSecret = await ctx.client.waitForFinish(agent.id, 120000);
expect(afterSecret.status).toBe("idle");
expect(afterSecret.lastError).toBeUndefined();
@@ -103,7 +103,7 @@ describe("two-cycle Codex agent resume", () => {
"Acknowledge you still remember the project name. Just say yes or no."
);
const afterAck = await ctx.client.waitForAgentIdle(resumed1.id, 120000);
const afterAck = await ctx.client.waitForFinish(resumed1.id, 120000);
expect(afterAck.status).toBe("idle");
expect(afterAck.lastError).toBeUndefined();
@@ -134,7 +134,7 @@ describe("two-cycle Codex agent resume", () => {
"What was the project name I asked you to remember at the very beginning of our conversation? Reply with the exact name."
);
const afterRecall = await ctx.client.waitForAgentIdle(resumed2.id, 120000);
const afterRecall = await ctx.client.waitForFinish(resumed2.id, 120000);
expect(afterRecall.status).toBe("idle");
expect(afterRecall.lastError).toBeUndefined();
@@ -12,10 +12,10 @@ function tmpCwd(): string {
}
/**
* Tests for waitForAgentIdle edge cases.
* Tests for waitForFinish edge cases.
* Uses haiku for speed. Allow higher timeouts in CI / congested environments.
*/
describe("waitForAgentIdle edge cases", () => {
describe("waitForFinish edge cases", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
@@ -26,7 +26,7 @@ describe("waitForAgentIdle edge cases", () => {
await ctx.cleanup();
}, 30000);
test("waitForAgentIdle immediately after sendMessage", async () => {
test("waitForFinish immediately after sendMessage", async () => {
const cwd = tmpCwd();
const agent = await ctx.client.createAgent({
@@ -37,9 +37,9 @@ describe("waitForAgentIdle edge cases", () => {
modeId: "bypassPermissions",
});
// This was the original bug: waitForAgentIdle returned old idle states
// This was the original bug: waitForFinish returned old idle states
await ctx.client.sendMessage(agent.id, "Say 'hello'");
const state = await ctx.client.waitForAgentIdle(agent.id, 30000);
const state = await ctx.client.waitForFinish(agent.id, 30000);
expect(state.status).toBe("idle");
@@ -58,13 +58,13 @@ describe("waitForAgentIdle edge cases", () => {
modeId: "bypassPermissions",
});
// Send 3 messages without waiting - tests that waitForAgentIdle
// Send 3 messages without waiting - tests that waitForFinish
// finds the idle AFTER the last running state
await ctx.client.sendMessage(agent.id, "Say 'one'");
await ctx.client.sendMessage(agent.id, "Say 'two'");
await ctx.client.sendMessage(agent.id, "Say 'three'");
const state = await ctx.client.waitForAgentIdle(agent.id, 30000);
const state = await ctx.client.waitForFinish(agent.id, 30000);
expect(state.status).toBe("idle");
// Verify all 3 messages were recorded
@@ -82,7 +82,7 @@ describe("waitForAgentIdle edge cases", () => {
rmSync(cwd, { recursive: true, force: true });
}, 45000);
test("two agents: waitForAgentIdle filters by agent", async () => {
test("two agents: waitForFinish filters by agent", async () => {
const cwd1 = tmpCwd();
const cwd2 = tmpCwd();
@@ -107,11 +107,11 @@ describe("waitForAgentIdle edge cases", () => {
await ctx.client.sendMessage(agent2.id, "Say 'agent two'");
// Wait for each - should not be confused by the other's state
const state2 = await ctx.client.waitForAgentIdle(agent2.id, 30000);
const state2 = await ctx.client.waitForFinish(agent2.id, 30000);
expect(state2.status).toBe("idle");
expect(state2.id).toBe(agent2.id);
const state1 = await ctx.client.waitForAgentIdle(agent1.id, 30000);
const state1 = await ctx.client.waitForFinish(agent1.id, 30000);
expect(state1.status).toBe("idle");
expect(state1.id).toBe(agent1.id);
@@ -1,123 +0,0 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "wait-perm-e2e-"));
}
/**
* Tests for wait returning on permission request.
*
* The `paseo wait` command should return when:
* 1. Agent completes (goes idle)
* 2. Agent requests permission
*
* This test verifies that waitForAgentIdle correctly returns when
* an agent requests permission, not just when it goes idle.
*/
describe("wait returns on permission request", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
describe("Claude provider", () => {
test(
"waitForAgentIdle returns when permission is requested (not just when idle)",
async () => {
const cwd = tmpCwd();
const testFilePath = path.join(cwd, "test-wait-perm.txt");
// Create Claude agent with default mode (always ask for permissions)
const agent = await ctx.client.createAgent({
provider: "claude",
model: "haiku",
cwd,
title: "Wait Permission Test",
modeId: "default",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Clear message queue before sending prompt
ctx.client.clearMessageQueue();
// Send a prompt that requires file write permission
const prompt = [
`You must use the Write tool to create a file at "${testFilePath}" with content "hello".`,
"Do not respond before attempting to write the file.",
].join(" ");
await ctx.client.sendMessage(agent.id, prompt);
// CRITICAL: This is the behavior we're testing
// waitForAgentIdle should return when permission is requested,
// NOT wait until the agent is fully idle (which would timeout or
// require us to approve the permission first)
const startTime = Date.now();
const state = await ctx.client.waitForAgentIdle(agent.id, 60000);
const waitDuration = Date.now() - startTime;
// If wait returns because of permission request, we should have:
// 1. Agent status still "running" (not idle yet - waiting for permission)
// 2. Pending permissions in the state
// 3. Wait should return quickly (under 30 seconds, not timeout)
// Check that we have a pending permission
const hasPendingPermission =
(state.pendingPermissions && state.pendingPermissions.length > 0);
// Log for debugging
console.log("Wait returned after", waitDuration, "ms");
console.log("Agent status:", state.status);
console.log("Pending permissions:", state.pendingPermissions?.length ?? 0);
// THE ASSERTION:
// If waitForAgentIdle correctly yields on permission request,
// we should either:
// - Get a state with pending permissions (status might be "running")
// - Or the state should be from right when permission was requested
//
// If waitForAgentIdle does NOT yield on permission request,
// this test will either:
// - Timeout (60s)
// - Return only after we never approve permission and agent errors/gives up
// This test will FAIL if waitForAgentIdle waits for full idle
// instead of returning on permission request
expect(hasPendingPermission).toBe(true);
// Also verify we can get the permission via waitForPermission
// (This should return immediately since permission is already pending)
const permission = await ctx.client.waitForPermission(agent.id, 5000);
expect(permission).toBeTruthy();
expect(permission.kind).toBe("tool");
// Clean up: deny the permission so agent can finish
await ctx.client.respondToPermission(agent.id, permission.id, {
behavior: "deny",
message: "Test complete",
});
// Wait for agent to finish processing the denial
await ctx.client.waitForAgentIdle(agent.id, 30000);
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
120000
);
});
});
+9 -60
View File
@@ -3,7 +3,6 @@ import { readFile, mkdir, writeFile, stat } from "fs/promises";
import { exec } from "child_process";
import { promisify, inspect } from "util";
import { join, resolve, sep } from "path";
import http from "http";
import invariant from "tiny-invariant";
import { z } from "zod";
import { streamText, stepCountIs } from "ai";
@@ -46,7 +45,9 @@ import {
extractTimestamps,
} from "./persistence-hooks.js";
import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
export type AgentMcpTransportFactory = () => Promise<Transport>;
import { buildProviderRegistry } from "./agent/provider-registry.js";
import { AgentManager } from "./agent/agent-manager.js";
import type { ManagedAgent } from "./agent/agent-manager.js";
@@ -290,8 +291,7 @@ export class Session {
private agentTools: ToolSet | null = null;
private agentManager: AgentManager;
private readonly agentStorage: AgentStorage;
private readonly agentMcpRoute: string;
private readonly mcpSocketPath: string;
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly pushTokenStore: PushTokenStore;
private readonly providerRegistry: ReturnType<typeof buildProviderRegistry>;
@@ -320,8 +320,7 @@ export class Session {
paseoHome: string,
agentManager: AgentManager,
agentStorage: AgentStorage,
agentMcpRoute: string,
mcpSocketPath: string,
createAgentMcpTransport: AgentMcpTransportFactory,
stt: OpenAISTT | null,
tts: OpenAITTS | null,
terminalManager: TerminalManager | null,
@@ -335,8 +334,7 @@ export class Session {
this.paseoHome = paseoHome;
this.agentManager = agentManager;
this.agentStorage = agentStorage;
this.agentMcpRoute = agentMcpRoute;
this.mcpSocketPath = mcpSocketPath;
this.createAgentMcpTransport = createAgentMcpTransport;
this.terminalManager = terminalManager;
this.voiceConversationStore = voiceConversationStore;
this.abortController = new AbortController();
@@ -506,61 +504,12 @@ export class Session {
}
/**
* Initialize Agent MCP client for this session
* Initialize Agent MCP client for this session using in-memory transport
*/
private async initializeAgentMcp(): Promise<void> {
try {
// Create a custom fetch that uses the Unix socket
const socketFetch = async (
input: string | URL,
init?: RequestInit
): Promise<Response> => {
const url = new URL(input.toString());
const path = url.pathname + url.search;
return new Promise((resolve, reject) => {
const req = http.request(
{
socketPath: this.mcpSocketPath,
path,
method: init?.method ?? "GET",
headers: {
...Object.fromEntries(
new Headers(init?.headers).entries()
),
},
},
(res: import("http").IncomingMessage) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => {
const body = Buffer.concat(chunks);
resolve(
new Response(body, {
status: res.statusCode ?? 500,
statusText: res.statusMessage ?? "",
headers: new Headers(
res.headers as Record<string, string>
),
})
);
});
res.on("error", reject);
}
);
req.on("error", reject);
if (init?.body) {
req.write(init.body);
}
req.end();
});
};
// Connect to the local MCP server using Unix socket
const transport = new StreamableHTTPClientTransport(
new URL(`http://localhost${this.agentMcpRoute}`),
{ fetch: socketFetch as typeof fetch }
);
// Create an in-memory transport connected to the Agent MCP server
const transport = await this.createAgentMcpTransport();
this.agentMcpClient = await experimental_createMCPClient({
transport,
@@ -1,5 +1,6 @@
import { WebSocketServer } from "ws";
import type { Server as HTTPServer } from "http";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import type { DownloadTokenStore } from "./file-download/token-store.js";
@@ -10,6 +11,8 @@ import type pino from "pino";
import type { WSOutboundMessage } from "./messages.js";
import { WebSocketSessionBridge } from "./websocket-session-bridge.js";
export type AgentMcpTransportFactory = () => Promise<Transport>;
type WebSocketServerConfig = {
allowedOrigins: Set<string>;
};
@@ -29,8 +32,7 @@ export class VoiceAssistantWebSocketServer {
agentStorage: AgentStorage,
downloadTokenStore: DownloadTokenStore,
paseoHome: string,
agentMcpRoute: string,
selfIdMcpSocketPath: string,
createAgentMcpTransport: AgentMcpTransportFactory,
wsConfig: WebSocketServerConfig,
speech?: { stt: OpenAISTT | null; tts: OpenAITTS | null },
terminalManager?: TerminalManager | null
@@ -42,8 +44,7 @@ export class VoiceAssistantWebSocketServer {
agentStorage,
downloadTokenStore,
paseoHome,
agentMcpRoute,
selfIdMcpSocketPath,
createAgentMcpTransport,
speech,
terminalManager
);
@@ -1,5 +1,6 @@
import type { IncomingMessage } from "http";
import type { WebSocket } from "ws";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { join } from "path";
import {
WSInboundMessageSchema,
@@ -19,6 +20,8 @@ import type { OpenAITTS } from "./agent/tts-openai.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type pino from "pino";
export type AgentMcpTransportFactory = () => Promise<Transport>;
export class WebSocketSessionBridge {
private readonly logger: pino.Logger;
private readonly sessions: Map<WebSocket, Session> = new Map();
@@ -29,8 +32,7 @@ export class WebSocketSessionBridge {
private readonly paseoHome: string;
private readonly pushTokenStore: PushTokenStore;
private readonly pushService: PushService;
private readonly agentMcpRoute: string;
private readonly selfIdMcpSocketPath: string;
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
private readonly stt: OpenAISTT | null;
private readonly tts: OpenAITTS | null;
private readonly terminalManager: TerminalManager | null;
@@ -42,8 +44,7 @@ export class WebSocketSessionBridge {
agentStorage: AgentStorage,
downloadTokenStore: DownloadTokenStore,
paseoHome: string,
agentMcpRoute: string,
selfIdMcpSocketPath: string,
createAgentMcpTransport: AgentMcpTransportFactory,
speech?: { stt: OpenAISTT | null; tts: OpenAITTS | null },
terminalManager?: TerminalManager | null
) {
@@ -52,8 +53,7 @@ export class WebSocketSessionBridge {
this.agentStorage = agentStorage;
this.downloadTokenStore = downloadTokenStore;
this.paseoHome = paseoHome;
this.agentMcpRoute = agentMcpRoute;
this.selfIdMcpSocketPath = selfIdMcpSocketPath;
this.createAgentMcpTransport = createAgentMcpTransport;
this.stt = speech?.stt ?? null;
this.tts = speech?.tts ?? null;
this.terminalManager = terminalManager ?? null;
@@ -89,8 +89,7 @@ export class WebSocketSessionBridge {
this.paseoHome,
this.agentManager,
this.agentStorage,
this.agentMcpRoute,
this.selfIdMcpSocketPath,
this.createAgentMcpTransport,
this.stt,
this.tts,
this.terminalManager,