Compare commits

..

7 Commits

Author SHA1 Message Date
Mohamed Boudra
0ab41fbd9a chore(release): cut 0.1.83 2026-05-26 20:50:25 +07:00
Mohamed Boudra
ed6caa11c0 Update changelog for 0.1.83 2026-05-26 20:49:15 +07:00
Mohamed Boudra
74c8942a28 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.
2026-05-26 20:38:27 +07:00
Mohamed Boudra
3eb1ba7d73 Narrow MCP schedule cadence handling to blank values 2026-05-26 20:15:12 +07:00
Mohamed Boudra
1d2c8b1648 Fix MCP schedule cadence placeholder handling
Treat undefined, blank, whitespace-only, and /__omit__ cadence values as absent at the MCP boundary for schedule create and update.

That keeps placeholder inputs from tripping cadence validation while preserving the real invalid cases for conflicting or missing cadence values.
2026-05-26 20:15:11 +07:00
Mohamed Boudra
88914ccba6 Fix draft mode chip for non-thinking models
Keep the desktop controls row alive when desktop extras exist, so draft mode chips still render even when a model has no thinking options.\n\nRestore the replacement draft e2e assertion for the mode chip now that it renders again.
2026-05-26 20:15:11 +07:00
paseo-ai[bot]
f79101a1f0 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-26 10:13:47 +00:00
20 changed files with 784 additions and 76 deletions

View File

@@ -1,5 +1,13 @@
# Changelog
## 0.1.83 - 2026-05-26
### Fixed
- Creating an agent via MCP now waits for it to actually start, so failures surface as a clear create error
- Scheduling an agent via MCP no longer rejects blank cadence placeholders
- Draft messages show the agent mode chip again on models without thinking options
## 0.1.82 - 2026-05-26
### Added

View File

@@ -1 +1 @@
sha256-s1HwJgifRYWfnIVit2JVrJmFSyu8lL8TpX67I/33dQQ=
sha256-WAxd4FSYNGYPT7v0QYTkYF5AacMrpYU+zjMyMTcWufo=

26
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.82",
"version": "0.1.83",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.82",
"version": "0.1.83",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -36323,7 +36323,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.82",
"version": "0.1.83",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36544,10 +36544,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.82",
"version": "0.1.83",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.82",
"@getpaseo/server": "0.1.83",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36902,7 +36902,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.82",
"version": "0.1.83",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37263,7 +37263,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.82",
"version": "0.1.83",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -37299,7 +37299,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.82",
"version": "0.1.83",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -37637,7 +37637,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.82",
"version": "0.1.83",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37964,12 +37964,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.82",
"version": "0.1.83",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.82",
"@getpaseo/relay": "0.1.82",
"@getpaseo/highlight": "0.1.83",
"@getpaseo/relay": "0.1.83",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38843,7 +38843,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.82",
"version": "0.1.83",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.82",
"version": "0.1.83",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [

View File

@@ -126,10 +126,7 @@ async function expectReplacementDraftMatchesPreviousSetup(page: Page): Promise<v
await expect(
page.getByRole("button", { name: "Select model (Ten second stream)" }),
).toBeVisible();
// TODO(boudra): the replacement draft's mode picker stopped rendering after
// the composer refactor — the model carries over but modes aren't surfaced
// in the draft's provider snapshot. Restore this assertion once the draft
// mode picker is fixed.
await expect(page.getByRole("button", { name: "Select agent mode (Load test)" })).toBeVisible();
}
async function createAgentFromReplacementDraft(page: Page): Promise<void> {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.82",
"version": "0.1.83",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -195,17 +195,20 @@ function resolveHasAnyControl({
canSelectModel,
thinkingOptions,
features,
hasDesktopExtras,
}: {
providerOptions: AgentControlOption[] | undefined;
canSelectModel: boolean;
thinkingOptions: AgentControlOption[] | undefined;
features: AgentFeature[] | undefined;
hasDesktopExtras: boolean;
}) {
return (
Boolean(providerOptions?.length) ||
canSelectModel ||
Boolean(thinkingOptions?.length) ||
Boolean(features?.length)
Boolean(features?.length) ||
hasDesktopExtras
);
}
@@ -440,6 +443,7 @@ function ControlledAgentControls({
canSelectModel,
thinkingOptions,
features,
hasDesktopExtras: desktopExtras !== null && desktopExtras !== undefined,
});
const modelDisabled = disabled;

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.82",
"version": "0.1.83",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -24,7 +24,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.82",
"@getpaseo/server": "0.1.83",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.82",
"version": "0.1.83",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.82",
"version": "0.1.83",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.82",
"version": "0.1.83",
"files": [
"dist"
],

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.82",
"version": "0.1.83",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.82",
"version": "0.1.83",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -63,8 +63,8 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.82",
"@getpaseo/relay": "0.1.82",
"@getpaseo/highlight": "0.1.83",
"@getpaseo/relay": "0.1.83",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",

View File

@@ -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-"));

View File

@@ -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;

View File

@@ -94,6 +94,15 @@ function registeredTool(
return { ...tool, handler };
}
async function invokeToolWithParsedInput(
tool: RegisteredMcpToolWithHandler,
input: Record<string, unknown>,
) {
const parsed = await tool.inputSchema.safeParseAsync(input);
expect(parsed.success).toBe(true);
return tool.handler(parsed.data);
}
function agentsOf(response: {
structuredContent: LooseStructuredContent;
}): Array<Record<string, unknown>> {
@@ -115,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),
@@ -132,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(),
@@ -1718,6 +1733,131 @@ describe("create_schedule MCP tool", () => {
}),
);
});
it("accepts a blank cron field when every is provided", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn(async (scheduleInput: CreateScheduleInput) =>
createStoredSchedule(scheduleInput),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
scheduleService: { create } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
await invokeToolWithParsedInput(tool, {
prompt: "say hello",
every: "10m",
cron: "",
provider: "codex",
});
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
cadence: { type: "every", everyMs: 600000 },
}),
);
});
it.each([
{
label: "whitespace cron field",
input: { prompt: "say hello", every: "10m", cron: " ", provider: "codex" },
cadence: { type: "every", everyMs: 600000 },
},
{
label: "blank every field for cron cadence",
input: {
prompt: "say hello",
every: "",
cron: "*/10 * * * *",
provider: "codex",
},
cadence: { type: "cron", expression: "*/10 * * * *" },
},
{
label: "whitespace every field for cron cadence",
input: {
prompt: "say hello",
every: " ",
cron: "*/10 * * * *",
provider: "codex",
},
cadence: { type: "cron", expression: "*/10 * * * *" },
},
])("normalizes create_schedule blank cadence input for $label", async ({ input, cadence }) => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn(async (scheduleInput: CreateScheduleInput) =>
createStoredSchedule(scheduleInput),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
scheduleService: { create } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
await invokeToolWithParsedInput(tool, input);
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
cadence,
}),
);
});
it("still rejects both real every and cron inputs", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
scheduleService: { create } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
await expect(
invokeToolWithParsedInput(tool, {
prompt: "say hello",
every: "10m",
cron: "*/10 * * * *",
provider: "codex",
}),
).rejects.toThrow("Specify exactly one of every or cron");
expect(create).not.toHaveBeenCalled();
});
it.each([
{
label: "missing both cadence fields",
input: { prompt: "say hello", provider: "codex" },
},
{
label: "blank cadence fields",
input: { prompt: "say hello", every: " ", cron: "", provider: "codex" },
},
])("still rejects create_schedule when $label", async ({ input }) => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
scheduleService: { create } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
await expect(invokeToolWithParsedInput(tool, input)).rejects.toThrow(
"Specify exactly one of every or cron",
);
expect(create).not.toHaveBeenCalled();
});
});
describe("update_schedule MCP tool", () => {
@@ -1795,6 +1935,66 @@ describe("update_schedule MCP tool", () => {
});
});
it("accepts a blank cron field when updating every cadence", async () => {
const { agentManager, agentStorage } = createTestDeps();
const stored = makeStoredSchedule();
const update = vi.fn(async (_input: UpdateScheduleInput) => stored);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
scheduleService: { update } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "update_schedule");
await invokeToolWithParsedInput(tool, {
id: "schedule-1",
every: "10m",
cron: "",
});
expect(update).toHaveBeenCalledWith({
id: "schedule-1",
cadence: { type: "every", everyMs: 600000 },
});
});
it.each([
{
label: "whitespace cron field",
input: { id: "schedule-1", every: "10m", cron: " " },
cadence: { type: "every", everyMs: 600000 },
},
{
label: "blank every field for cron cadence",
input: { id: "schedule-1", every: "", cron: "*/10 * * * *" },
cadence: { type: "cron", expression: "*/10 * * * *" },
},
{
label: "whitespace every field for cron cadence",
input: { id: "schedule-1", every: " ", cron: "*/10 * * * *" },
cadence: { type: "cron", expression: "*/10 * * * *" },
},
])("normalizes update_schedule blank cadence input for $label", async ({ input, cadence }) => {
const { agentManager, agentStorage } = createTestDeps();
const stored = makeStoredSchedule();
const update = vi.fn(async (_input: UpdateScheduleInput) => stored);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
scheduleService: { update } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "update_schedule");
await invokeToolWithParsedInput(tool, input);
expect(update).toHaveBeenCalledWith({
id: "schedule-1",
cadence,
});
});
it("rejects both every and cron", async () => {
const { agentManager, agentStorage } = createTestDeps();
const update = vi.fn();

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";
@@ -370,15 +374,31 @@ interface ScheduleUpdateToolInput {
clearExpires?: boolean;
}
function normalizeScheduleCadenceArg(value: string | undefined): string | undefined {
if (value === undefined) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
return trimmed;
}
function resolveScheduleUpdateCadence(input: ScheduleUpdateToolInput): ScheduleCadence | undefined {
if (input.every !== undefined && input.cron !== undefined) {
const every = normalizeScheduleCadenceArg(input.every);
const cron = normalizeScheduleCadenceArg(input.cron);
if (every !== undefined && cron !== undefined) {
throw new Error("Specify at most one of every or cron");
}
if (input.every !== undefined) {
return { type: "every", everyMs: parseDurationString(input.every) };
if (every !== undefined) {
return { type: "every", everyMs: parseDurationString(every) };
}
if (input.cron !== undefined) {
return { type: "cron", expression: input.cron.trim() };
if (cron !== undefined) {
return { type: "cron", expression: cron };
}
return undefined;
}
@@ -1089,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,
@@ -1108,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),
};
@@ -1127,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,
}),
@@ -1779,7 +1808,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
throw new Error("Schedule service is not configured");
}
const cadenceCount = Number(every !== undefined) + Number(cron !== undefined);
const normalizedEvery = normalizeScheduleCadenceArg(every);
const normalizedCron = normalizeScheduleCadenceArg(cron);
const cadenceCount =
Number(normalizedEvery !== undefined) + Number(normalizedCron !== undefined);
if (cadenceCount !== 1) {
throw new Error("Specify exactly one of every or cron");
}
@@ -1817,9 +1849,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
const schedule = await scheduleService.create({
prompt: prompt.trim(),
cadence: every
? { type: "every" as const, everyMs: parseDurationString(every) }
: { type: "cron" as const, expression: cron!.trim() },
cadence:
normalizedEvery !== undefined
? { type: "every" as const, everyMs: parseDurationString(normalizedEvery) }
: { type: "cron" as const, expression: normalizedCron! },
target: scheduleTarget,
...(name?.trim() ? { name: name.trim() } : {}),
...(maxRuns === undefined ? {} : { maxRuns }),

View File

@@ -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,

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({

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.82",
"version": "0.1.83",
"private": true,
"type": "module",
"scripts": {