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:
@@ -11,6 +11,14 @@ import pino from "pino";
|
|||||||
import { withTimeout } from "../../utils/promise-timeout.js";
|
import { withTimeout } from "../../utils/promise-timeout.js";
|
||||||
import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js";
|
import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js";
|
||||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.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 {
|
interface StructuredContent {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -19,6 +27,7 @@ interface StructuredContent {
|
|||||||
interface McpToolResult {
|
interface McpToolResult {
|
||||||
structuredContent?: StructuredContent;
|
structuredContent?: StructuredContent;
|
||||||
content?: Array<{ structuredContent?: StructuredContent } | StructuredContent>;
|
content?: Array<{ structuredContent?: StructuredContent } | StructuredContent>;
|
||||||
|
isError?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface McpClient {
|
interface McpClient {
|
||||||
@@ -276,6 +285,222 @@ describe("agent MCP end-to-end (offline)", () => {
|
|||||||
}
|
}
|
||||||
}, 30_000);
|
}, 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 () => {
|
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 paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
|
||||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
import type { AgentPromptInput, AgentRunOptions } from "./agent-sdk-types.js";
|
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 type { AgentStorage } from "./agent-storage.js";
|
||||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||||
|
|
||||||
@@ -137,6 +137,31 @@ export interface SendPromptToAgentParams {
|
|||||||
logger: Logger;
|
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
|
* Full send-prompt orchestration: (optional unarchive) → load → (optional
|
||||||
* mode change) → start run.
|
* 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 {
|
export interface SetupFinishNotificationParams {
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
agentStorage: AgentStorage;
|
agentStorage: AgentStorage;
|
||||||
|
|||||||
@@ -124,7 +124,11 @@ interface TestDeps {
|
|||||||
function buildAgentManagerSpies() {
|
function buildAgentManagerSpies() {
|
||||||
return {
|
return {
|
||||||
createAgent: vi.fn(),
|
createAgent: vi.fn(),
|
||||||
waitForAgentEvent: vi.fn(),
|
waitForAgentEvent: vi.fn().mockResolvedValue({
|
||||||
|
status: "idle",
|
||||||
|
permission: null,
|
||||||
|
lastMessage: null,
|
||||||
|
}),
|
||||||
setAgentMode: vi.fn().mockResolvedValue(undefined),
|
setAgentMode: vi.fn().mockResolvedValue(undefined),
|
||||||
setAgentModel: vi.fn().mockResolvedValue(undefined),
|
setAgentModel: vi.fn().mockResolvedValue(undefined),
|
||||||
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
|
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -141,8 +145,10 @@ function buildAgentManagerSpies() {
|
|||||||
appendTimelineItem: vi.fn().mockResolvedValue(undefined),
|
appendTimelineItem: vi.fn().mockResolvedValue(undefined),
|
||||||
emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined),
|
emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined),
|
||||||
hasInFlightRun: vi.fn().mockReturnValue(false),
|
hasInFlightRun: vi.fn().mockReturnValue(false),
|
||||||
|
tryRunOutOfBand: vi.fn().mockReturnValue(false),
|
||||||
subscribe: vi.fn().mockReturnValue(() => {}),
|
subscribe: vi.fn().mockReturnValue(() => {}),
|
||||||
streamAgent: vi.fn(() => (async function* noop() {})()),
|
streamAgent: vi.fn(() => (async function* noop() {})()),
|
||||||
|
waitForAgentRunStart: vi.fn().mockResolvedValue(undefined),
|
||||||
respondToPermission: vi.fn(),
|
respondToPermission: vi.fn(),
|
||||||
cancelAgentRun: vi.fn(),
|
cancelAgentRun: vi.fn(),
|
||||||
getPendingPermissions: vi.fn(),
|
getPendingPermissions: vi.fn(),
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ import {
|
|||||||
toScheduleSummary,
|
toScheduleSummary,
|
||||||
waitForAgentWithTimeout,
|
waitForAgentWithTimeout,
|
||||||
} from "./mcp-shared.js";
|
} 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 { GitHubService } from "../../services/github-service.js";
|
||||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||||
import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js";
|
import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js";
|
||||||
@@ -1105,8 +1109,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let liveSnapshot = snapshot;
|
||||||
try {
|
try {
|
||||||
startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger);
|
liveSnapshot = await startCreatedAgentInitialPrompt({
|
||||||
|
agentManager,
|
||||||
|
agentId: snapshot.id,
|
||||||
|
snapshot,
|
||||||
|
prompt: trimmedPrompt,
|
||||||
|
logger: childLogger,
|
||||||
|
});
|
||||||
if (notifyOnFinish && callerAgentId) {
|
if (notifyOnFinish && callerAgentId) {
|
||||||
setupFinishNotification({
|
setupFinishNotification({
|
||||||
agentManager,
|
agentManager,
|
||||||
@@ -1124,12 +1135,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
});
|
});
|
||||||
|
|
||||||
const responseData = {
|
const responseData = {
|
||||||
agentId: snapshot.id,
|
agentId: liveSnapshot.id,
|
||||||
type: provider,
|
type: provider,
|
||||||
status: result.status,
|
status: result.status,
|
||||||
cwd: snapshot.cwd,
|
cwd: liveSnapshot.cwd,
|
||||||
currentModeId: snapshot.currentModeId,
|
currentModeId: liveSnapshot.currentModeId,
|
||||||
availableModes: snapshot.availableModes,
|
availableModes: liveSnapshot.availableModes,
|
||||||
lastMessage: result.lastMessage,
|
lastMessage: result.lastMessage,
|
||||||
permission: sanitizePermissionRequest(result.permission),
|
permission: sanitizePermissionRequest(result.permission),
|
||||||
};
|
};
|
||||||
@@ -1143,18 +1154,20 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
childLogger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt");
|
childLogger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt");
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return immediately if background=true
|
// Return immediately if background=true
|
||||||
|
const currentSnapshot = agentManager.getAgent(snapshot.id) ?? liveSnapshot;
|
||||||
const response = {
|
const response = {
|
||||||
content: [],
|
content: [],
|
||||||
structuredContent: ensureValidJson({
|
structuredContent: ensureValidJson({
|
||||||
agentId: snapshot.id,
|
agentId: currentSnapshot.id,
|
||||||
type: provider,
|
type: provider,
|
||||||
status: snapshot.lifecycle,
|
status: currentSnapshot.lifecycle,
|
||||||
cwd: snapshot.cwd,
|
cwd: currentSnapshot.cwd,
|
||||||
currentModeId: snapshot.currentModeId,
|
currentModeId: currentSnapshot.currentModeId,
|
||||||
availableModes: snapshot.availableModes,
|
availableModes: currentSnapshot.availableModes,
|
||||||
lastMessage: null,
|
lastMessage: null,
|
||||||
permission: 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>(
|
function waitForSignal<T>(
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
setup: (resolve: (value: T) => void, reject: (error: Error) => void) => () => void,
|
setup: (resolve: (value: T) => void, reject: (error: Error) => void) => () => void,
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ import { ensureAgentLoaded } from "./agent/agent-loading.js";
|
|||||||
import {
|
import {
|
||||||
formatSystemNotificationPrompt,
|
formatSystemNotificationPrompt,
|
||||||
sendPromptToAgent,
|
sendPromptToAgent,
|
||||||
|
startCreatedAgentInitialPrompt,
|
||||||
|
waitForAgentRunStartWithTimeout,
|
||||||
unarchiveAgentState,
|
unarchiveAgentState,
|
||||||
} from "./agent/agent-prompt.js";
|
} from "./agent/agent-prompt.js";
|
||||||
import { experimental_createMCPClient } from "ai";
|
import { experimental_createMCPClient } from "ai";
|
||||||
@@ -3188,7 +3190,7 @@ export class Session {
|
|||||||
createdWorktree,
|
createdWorktree,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.sendInitialCreateAgentPrompt({
|
const liveSnapshot = await this.sendInitialCreateAgentPrompt({
|
||||||
snapshot,
|
snapshot,
|
||||||
trimmedPrompt,
|
trimmedPrompt,
|
||||||
images,
|
images,
|
||||||
@@ -3199,12 +3201,12 @@ export class Session {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (requestId) {
|
if (requestId) {
|
||||||
const agentPayload = await this.buildAgentPayload(snapshot);
|
const agentPayload = await this.buildAgentPayload(liveSnapshot);
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "status",
|
type: "status",
|
||||||
payload: {
|
payload: {
|
||||||
status: "agent_created",
|
status: "agent_created",
|
||||||
agentId: snapshot.id,
|
agentId: liveSnapshot.id,
|
||||||
requestId,
|
requestId,
|
||||||
agent: agentPayload,
|
agent: agentPayload,
|
||||||
},
|
},
|
||||||
@@ -3250,20 +3252,20 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async sendInitialCreateAgentPrompt(params: {
|
private async sendInitialCreateAgentPrompt(params: {
|
||||||
snapshot: { id: string; cwd: string };
|
snapshot: ManagedAgent;
|
||||||
trimmedPrompt: string | undefined;
|
trimmedPrompt: string | undefined;
|
||||||
images: Array<{ data: string; mimeType: string }> | undefined;
|
images: Array<{ data: string; mimeType: string }> | undefined;
|
||||||
attachments: AgentAttachment[] | undefined;
|
attachments: AgentAttachment[] | undefined;
|
||||||
clientMessageId: string | undefined;
|
clientMessageId: string | undefined;
|
||||||
outputSchema: Record<string, unknown> | undefined;
|
outputSchema: Record<string, unknown> | undefined;
|
||||||
explicitTitle: string | null;
|
explicitTitle: string | null;
|
||||||
}): Promise<void> {
|
}): Promise<ManagedAgent> {
|
||||||
const { snapshot, trimmedPrompt, images, attachments, clientMessageId, outputSchema } = params;
|
const { snapshot, trimmedPrompt, images, attachments, clientMessageId, outputSchema } = params;
|
||||||
const hasPrompt = Boolean(trimmedPrompt);
|
const hasPrompt = Boolean(trimmedPrompt);
|
||||||
const hasImages = (images?.length ?? 0) > 0;
|
const hasImages = (images?.length ?? 0) > 0;
|
||||||
const hasAttachments = (attachments?.length ?? 0) > 0;
|
const hasAttachments = (attachments?.length ?? 0) > 0;
|
||||||
if (!hasPrompt && !hasImages && !hasAttachments) {
|
if (!hasPrompt && !hasImages && !hasAttachments) {
|
||||||
return;
|
return snapshot;
|
||||||
}
|
}
|
||||||
scheduleAgentMetadataGeneration({
|
scheduleAgentMetadataGeneration({
|
||||||
agentManager: this.agentManager,
|
agentManager: this.agentManager,
|
||||||
@@ -3275,18 +3277,18 @@ export class Session {
|
|||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
});
|
});
|
||||||
|
const prompt = this.buildAgentPrompt(trimmedPrompt || "", images, attachments);
|
||||||
|
|
||||||
const started = await this.handleSendAgentMessage(
|
return await startCreatedAgentInitialPrompt({
|
||||||
snapshot.id,
|
agentManager: this.agentManager,
|
||||||
trimmedPrompt || "",
|
agentId: snapshot.id,
|
||||||
resolveClientMessageId(clientMessageId),
|
snapshot,
|
||||||
images,
|
prompt,
|
||||||
attachments,
|
runOptions: outputSchema ? { outputSchema } : undefined,
|
||||||
outputSchema ? { outputSchema } : undefined,
|
logger: this.sessionLogger.child({
|
||||||
);
|
clientMessageId: resolveClientMessageId(clientMessageId),
|
||||||
if (!started.ok) {
|
}),
|
||||||
throw new Error(started.error);
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleResumeAgentRequest(
|
private async handleResumeAgentRequest(
|
||||||
@@ -7814,11 +7816,8 @@ export class Session {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startAbort = new AbortController();
|
|
||||||
const startTimeoutMs = 15_000;
|
|
||||||
const startTimeout = setTimeout(() => startAbort.abort("timeout"), startTimeoutMs);
|
|
||||||
try {
|
try {
|
||||||
await this.agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal });
|
await waitForAgentRunStartWithTimeout(this.agentManager, agentId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "send_agent_message_response",
|
type: "send_agent_message_response",
|
||||||
@@ -7830,8 +7829,6 @@ export class Session {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} finally {
|
|
||||||
clearTimeout(startTimeout);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
|
|||||||
Reference in New Issue
Block a user