Confirm create-agent start before returning

Wait for the initial background turn to actually start before create returns a snapshot, without waiting for completion.

Freshly created agents now surface start failures as create failures, and both the WS/client and MCP surfaces reflect the shared create-agent lifecycle helper.
This commit is contained in:
Mohamed Boudra
2026-05-26 20:38:27 +07:00
parent 3eb1ba7d73
commit 74c8942a28
6 changed files with 521 additions and 36 deletions
@@ -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<AgentRunResult> {
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<AgentStreamEvent> {
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<string | null> {
return "full-access";
}
async setMode(): Promise<void> {}
getPendingPermissions() {
return [];
}
async respondToPermission(): Promise<void> {}
describePersistence(): AgentPersistenceHandle | null {
return { provider: "codex", sessionId: this.id };
}
async interrupt(): Promise<void> {}
async close(): Promise<void> {}
}
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<boolean> {
return true;
}
async createSession(_config: AgentSessionConfig): Promise<AgentSession> {
return new StartTurnFailureSession();
}
async resumeSession(
_handle: AgentPersistenceHandle,
_config?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
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-"));
@@ -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<void> {
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<ManagedAgent> {
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;
@@ -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(),
+24 -11
View File
@@ -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,
}),
@@ -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<AgentRunResult> {
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<AgentStreamEvent> {
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<string | null> {
return "full-access";
}
async setMode(): Promise<void> {}
getPendingPermissions() {
return [];
}
async respondToPermission(): Promise<void> {}
describePersistence(): AgentPersistenceHandle | null {
return { provider: "codex", sessionId: this.id };
}
async interrupt(): Promise<void> {}
async close(): Promise<void> {}
}
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<boolean> {
return true;
}
async createSession(_config: AgentSessionConfig): Promise<AgentSession> {
return new StartTurnFailureSession();
}
async resumeSession(): Promise<AgentSession> {
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<T>(
timeoutMs: number,
setup: (resolve: (value: T) => void, reject: (error: Error) => void) => () => void,
+20 -23
View File
@@ -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<string, unknown> | undefined;
explicitTitle: string | null;
}): Promise<void> {
}): Promise<ManagedAgent> {
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({