diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index 9d231bd06..d36412b6b 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -11,6 +11,14 @@ import pino from "pino"; import { withTimeout } from "../../utils/promise-timeout.js"; import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; +import type { + AgentClient, + AgentPersistenceHandle, + AgentRunResult, + AgentSession, + AgentSessionConfig, + AgentStreamEvent, +} from "./agent-sdk-types.js"; interface StructuredContent { [key: string]: unknown; @@ -19,6 +27,7 @@ interface StructuredContent { interface McpToolResult { structuredContent?: StructuredContent; content?: Array<{ structuredContent?: StructuredContent } | StructuredContent>; + isError?: boolean; } interface McpClient { @@ -276,6 +285,222 @@ describe("agent MCP end-to-end (offline)", () => { } }, 30_000); + test("create_agent with background initialPrompt reflects running state once the first turn starts", 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: [], + hostnames: 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 client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`); + + let agentId: string | null = null; + try { + const result = await client.callTool({ + name: "create_agent", + args: { + cwd: agentCwd, + title: "MCP background create", + provider: "codex/gpt-5.4-mini", + mode: "full-access", + initialPrompt: "Run exactly: sleep 30", + background: true, + }, + }); + + const payload = getStructuredContent(result); + agentId = typeof payload?.agentId === "string" ? payload.agentId : null; + expect(agentId).toBeTruthy(); + expect(payload?.status).toBe("running"); + + const statusResult = await client.callTool({ + name: "get_agent_status", + args: { agentId }, + }); + const statusPayload = getStructuredContent(statusResult); + expect(statusPayload?.status).toBe("running"); + } finally { + if (agentId) { + await client.callTool({ name: "kill_agent", args: { agentId } }); + } + 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 propagates initial-turn start failure instead of returning success", async () => { + class StartTurnFailureSession implements AgentSession { + readonly provider = "codex" as const; + readonly id = "mcp-start-turn-failure-session"; + readonly capabilities = { + supportsStreaming: false, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + supportsRewindConversation: false, + supportsRewindFiles: false, + supportsRewindBoth: false, + } as const; + + async run(): Promise { + return { + sessionId: this.id, + finalText: "", + timeline: [], + }; + } + + async startTurn(): Promise<{ turnId: string }> { + throw new Error("Initial turn failed to start"); + } + + subscribe(_callback: (event: AgentStreamEvent) => void): () => void { + return () => undefined; + } + + async *streamHistory(): AsyncGenerator { + yield* []; + } + + async getRuntimeInfo() { + return { + provider: "codex" as const, + sessionId: this.id, + model: "gpt-5.4-mini", + modeId: "full-access", + }; + } + + async getAvailableModes(): Promise< + Array<{ id: string; label: string; description: string }> + > { + return [{ id: "full-access", label: "Full access", description: "No prompts" }]; + } + + async getCurrentMode(): Promise { + return "full-access"; + } + + async setMode(): Promise {} + + getPendingPermissions() { + return []; + } + + async respondToPermission(): Promise {} + + describePersistence(): AgentPersistenceHandle | null { + return { provider: "codex", sessionId: this.id }; + } + + async interrupt(): Promise {} + + async close(): Promise {} + } + + class StartTurnFailureClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = { + supportsStreaming: false, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + supportsRewindConversation: false, + supportsRewindFiles: false, + supportsRewindBoth: false, + } as const; + + async isAvailable(): Promise { + return true; + } + + async createSession(_config: AgentSessionConfig): Promise { + return new StartTurnFailureSession(); + } + + async resumeSession( + _handle: AgentPersistenceHandle, + _config?: Partial, + ): Promise { + return new StartTurnFailureSession(); + } + } + + 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: [], + hostnames: true, + mcpEnabled: true, + staticDir, + mcpDebug: false, + agentClients: { + ...createTestAgentClients(), + codex: new StartTurnFailureClient(), + }, + agentStoragePath: path.join(paseoHome, "agents"), + }; + + const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" })); + await daemon.start(); + + const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`); + + try { + const result = await client.callTool({ + name: "create_agent", + args: { + cwd: agentCwd, + title: "MCP start failure", + provider: "codex/gpt-5.4-mini", + mode: "full-access", + initialPrompt: "Run exactly: sleep 30", + background: true, + }, + }); + + expect(result.isError).toBe(true); + const contentItem = result.content?.[0]; + const contentText: string | undefined = + contentItem != null && typeof contentItem === "object" + ? Reflect.get(contentItem, "text") + : undefined; + expect(contentText ?? "").toContain("Initial turn failed to start"); + } finally { + 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-")); diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 97d4afc52..2c4a7c016 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -1,7 +1,7 @@ import type { Logger } from "pino"; import type { AgentPromptInput, AgentRunOptions } from "./agent-sdk-types.js"; -import type { AgentManager } from "./agent-manager.js"; +import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; import { ensureAgentLoaded } from "./agent-loading.js"; @@ -137,6 +137,31 @@ export interface SendPromptToAgentParams { logger: Logger; } +export interface StartCreatedAgentInitialPromptParams { + agentManager: AgentManager; + agentId: string; + snapshot?: ManagedAgent; + prompt: AgentPromptInput | null; + runOptions?: AgentRunOptions; + logger: Logger; +} + +const AGENT_RUN_START_TIMEOUT_MS = 15_000; + +export async function waitForAgentRunStartWithTimeout( + agentManager: AgentManager, + agentId: string, +): Promise { + const startAbort = new AbortController(); + const startTimeout = setTimeout(() => startAbort.abort("timeout"), AGENT_RUN_START_TIMEOUT_MS); + + try { + await agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal }); + } finally { + clearTimeout(startTimeout); + } +} + /** * Full send-prompt orchestration: (optional unarchive) → load → (optional * mode change) → start run. @@ -177,6 +202,39 @@ export async function sendPromptToAgent( }); } +export async function startCreatedAgentInitialPrompt( + params: StartCreatedAgentInitialPromptParams, +): Promise { + const currentSnapshot = params.agentManager.getAgent(params.agentId) ?? params.snapshot ?? null; + if (!currentSnapshot) { + throw new Error(`Agent ${params.agentId} not found`); + } + + if (params.prompt === null) { + return currentSnapshot; + } + + const dispatchResult = startAgentRun( + params.agentManager, + params.agentId, + params.prompt, + params.logger, + { + runOptions: params.runOptions, + }, + ); + + if (!dispatchResult.outOfBand) { + await waitForAgentRunStartWithTimeout(params.agentManager, params.agentId); + } + + const refreshedSnapshot = params.agentManager.getAgent(params.agentId) ?? params.snapshot ?? null; + if (!refreshedSnapshot) { + throw new Error(`Agent ${params.agentId} not found`); + } + return refreshedSnapshot; +} + export interface SetupFinishNotificationParams { agentManager: AgentManager; agentStorage: AgentStorage; diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 7cba061a1..5eefbfabb 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -124,7 +124,11 @@ interface TestDeps { function buildAgentManagerSpies() { return { createAgent: vi.fn(), - waitForAgentEvent: vi.fn(), + waitForAgentEvent: vi.fn().mockResolvedValue({ + status: "idle", + permission: null, + lastMessage: null, + }), setAgentMode: vi.fn().mockResolvedValue(undefined), setAgentModel: vi.fn().mockResolvedValue(undefined), setAgentThinkingOption: vi.fn().mockResolvedValue(undefined), @@ -141,8 +145,10 @@ function buildAgentManagerSpies() { appendTimelineItem: vi.fn().mockResolvedValue(undefined), emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined), hasInFlightRun: vi.fn().mockReturnValue(false), + tryRunOutOfBand: vi.fn().mockReturnValue(false), subscribe: vi.fn().mockReturnValue(() => {}), streamAgent: vi.fn(() => (async function* noop() {})()), + waitForAgentRunStart: vi.fn().mockResolvedValue(undefined), respondToPermission: vi.fn(), cancelAgentRun: vi.fn(), getPendingPermissions: vi.fn(), diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index a146d31ca..8d24b6139 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -75,7 +75,11 @@ import { toScheduleSummary, waitForAgentWithTimeout, } from "./mcp-shared.js"; -import { sendPromptToAgent, setupFinishNotification, startAgentRun } from "./agent-prompt.js"; +import { + sendPromptToAgent, + setupFinishNotification, + startCreatedAgentInitialPrompt, +} from "./agent-prompt.js"; import type { GitHubService } from "../../services/github-service.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js"; @@ -1105,8 +1109,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom logger: childLogger, }); + let liveSnapshot = snapshot; try { - startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger); + liveSnapshot = await startCreatedAgentInitialPrompt({ + agentManager, + agentId: snapshot.id, + snapshot, + prompt: trimmedPrompt, + logger: childLogger, + }); if (notifyOnFinish && callerAgentId) { setupFinishNotification({ agentManager, @@ -1124,12 +1135,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }); const responseData = { - agentId: snapshot.id, + agentId: liveSnapshot.id, type: provider, status: result.status, - cwd: snapshot.cwd, - currentModeId: snapshot.currentModeId, - availableModes: snapshot.availableModes, + cwd: liveSnapshot.cwd, + currentModeId: liveSnapshot.currentModeId, + availableModes: liveSnapshot.availableModes, lastMessage: result.lastMessage, permission: sanitizePermissionRequest(result.permission), }; @@ -1143,18 +1154,20 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom } } catch (error) { childLogger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt"); + throw error; } // Return immediately if background=true + const currentSnapshot = agentManager.getAgent(snapshot.id) ?? liveSnapshot; const response = { content: [], structuredContent: ensureValidJson({ - agentId: snapshot.id, + agentId: currentSnapshot.id, type: provider, - status: snapshot.lifecycle, - cwd: snapshot.cwd, - currentModeId: snapshot.currentModeId, - availableModes: snapshot.availableModes, + status: currentSnapshot.lifecycle, + cwd: currentSnapshot.cwd, + currentModeId: currentSnapshot.currentModeId, + availableModes: currentSnapshot.availableModes, lastMessage: null, permission: null, }), diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index b6b864a21..ad9d78aa8 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -121,6 +121,192 @@ test("DaemonClient surfaces password auth failures from WebSocket close reasons" } }); +test("createAgent without an initial prompt returns an idle snapshot", async () => { + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + try { + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "create-no-prompt" } }); + + const agent = await client.createAgent({ + provider: "codex", + cwd: tmpCwd(), + title: "No prompt agent", + modeId: "full-access", + model: "gpt-5.4-mini", + }); + + expect(agent.status).toBe("idle"); + } finally { + await client.close(); + await daemon.close(); + } +}); + +test("createAgent with background initialPrompt returns a running snapshot before turn completion", async () => { + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + try { + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "create-background-prompt" } }); + + const agent = await client.createAgent({ + provider: "codex", + cwd: tmpCwd(), + title: "Background prompt agent", + modeId: "full-access", + model: "gpt-5.4-mini", + initialPrompt: "Run exactly: sleep 30", + }); + + expect(agent.status).toBe("running"); + + const fetchedWhileRunning = await client.fetchAgent(agent.id); + expect(fetchedWhileRunning?.agent.status).toBe("running"); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + const fetchedAfterCompletion = await client.fetchAgent(agent.id); + expect(fetchedAfterCompletion?.agent.status).toBe("idle"); + } finally { + await client.close(); + await daemon.close(); + } +}); + +test("createAgent fails when the initial turn cannot start", async () => { + class StartTurnFailureSession implements AgentSession { + readonly provider = "codex" as const; + readonly id = "start-turn-failure-session"; + readonly capabilities = { + supportsStreaming: false, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + supportsRewindConversation: false, + supportsRewindFiles: false, + supportsRewindBoth: false, + } as const; + + async run(): Promise { + return { + sessionId: this.id, + finalText: "", + timeline: [], + }; + } + + async startTurn(): Promise<{ turnId: string }> { + throw new Error("Initial turn failed to start"); + } + + subscribe(): () => void { + return () => undefined; + } + + async *streamHistory(): AsyncGenerator { + yield* []; + } + + async getRuntimeInfo() { + return { + provider: "codex" as const, + sessionId: this.id, + model: "gpt-5.4-mini", + modeId: "full-access", + }; + } + + async getAvailableModes(): Promise> { + return [{ id: "full-access", label: "Full access", description: "No prompts" }]; + } + + async getCurrentMode(): Promise { + return "full-access"; + } + + async setMode(): Promise {} + + getPendingPermissions() { + return []; + } + + async respondToPermission(): Promise {} + + describePersistence(): AgentPersistenceHandle | null { + return { provider: "codex", sessionId: this.id }; + } + + async interrupt(): Promise {} + + async close(): Promise {} + } + + class StartTurnFailureClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = { + supportsStreaming: false, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + supportsRewindConversation: false, + supportsRewindFiles: false, + supportsRewindBoth: false, + } as const; + + async isAvailable(): Promise { + return true; + } + + async createSession(_config: AgentSessionConfig): Promise { + return new StartTurnFailureSession(); + } + + async resumeSession(): Promise { + return new StartTurnFailureSession(); + } + } + + const daemon = await createTestPaseoDaemon({ + agentClients: { codex: new StartTurnFailureClient() }, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + try { + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "create-start-failure" } }); + + await expect( + client.createAgent({ + provider: "codex", + cwd: tmpCwd(), + title: "Start failure agent", + modeId: "full-access", + model: "gpt-5.4-mini", + initialPrompt: "Run exactly: sleep 30", + }), + ).rejects.toThrow("Initial turn failed to start"); + } finally { + await client.close(); + await daemon.close(); + } +}); + function waitForSignal( timeoutMs: number, setup: (resolve: (value: T) => void, reject: (error: Error) => void) => () => void, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index e4c392cde..460d62c2e 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -68,6 +68,8 @@ import { ensureAgentLoaded } from "./agent/agent-loading.js"; import { formatSystemNotificationPrompt, sendPromptToAgent, + startCreatedAgentInitialPrompt, + waitForAgentRunStartWithTimeout, unarchiveAgentState, } from "./agent/agent-prompt.js"; import { experimental_createMCPClient } from "ai"; @@ -3188,7 +3190,7 @@ export class Session { createdWorktree, }); - await this.sendInitialCreateAgentPrompt({ + const liveSnapshot = await this.sendInitialCreateAgentPrompt({ snapshot, trimmedPrompt, images, @@ -3199,12 +3201,12 @@ export class Session { }); if (requestId) { - const agentPayload = await this.buildAgentPayload(snapshot); + const agentPayload = await this.buildAgentPayload(liveSnapshot); this.emit({ type: "status", payload: { status: "agent_created", - agentId: snapshot.id, + agentId: liveSnapshot.id, requestId, agent: agentPayload, }, @@ -3250,20 +3252,20 @@ export class Session { } private async sendInitialCreateAgentPrompt(params: { - snapshot: { id: string; cwd: string }; + snapshot: ManagedAgent; trimmedPrompt: string | undefined; images: Array<{ data: string; mimeType: string }> | undefined; attachments: AgentAttachment[] | undefined; clientMessageId: string | undefined; outputSchema: Record | undefined; explicitTitle: string | null; - }): Promise { + }): Promise { const { snapshot, trimmedPrompt, images, attachments, clientMessageId, outputSchema } = params; const hasPrompt = Boolean(trimmedPrompt); const hasImages = (images?.length ?? 0) > 0; const hasAttachments = (attachments?.length ?? 0) > 0; if (!hasPrompt && !hasImages && !hasAttachments) { - return; + return snapshot; } scheduleAgentMetadataGeneration({ agentManager: this.agentManager, @@ -3275,18 +3277,18 @@ export class Session { paseoHome: this.paseoHome, logger: this.sessionLogger, }); + const prompt = this.buildAgentPrompt(trimmedPrompt || "", images, attachments); - const started = await this.handleSendAgentMessage( - snapshot.id, - trimmedPrompt || "", - resolveClientMessageId(clientMessageId), - images, - attachments, - outputSchema ? { outputSchema } : undefined, - ); - if (!started.ok) { - throw new Error(started.error); - } + return await startCreatedAgentInitialPrompt({ + agentManager: this.agentManager, + agentId: snapshot.id, + snapshot, + prompt, + runOptions: outputSchema ? { outputSchema } : undefined, + logger: this.sessionLogger.child({ + clientMessageId: resolveClientMessageId(clientMessageId), + }), + }); } private async handleResumeAgentRequest( @@ -7814,11 +7816,8 @@ export class Session { return; } - const startAbort = new AbortController(); - const startTimeoutMs = 15_000; - const startTimeout = setTimeout(() => startAbort.abort("timeout"), startTimeoutMs); try { - await this.agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal }); + await waitForAgentRunStartWithTimeout(this.agentManager, agentId); } catch (error) { this.emit({ type: "send_agent_message_response", @@ -7830,8 +7829,6 @@ export class Session { }, }); return; - } finally { - clearTimeout(startTimeout); } this.emit({