Merge branch 'main' into dev

Resolve 38 conflicts preserving both sides' work:
- Server: DB-based registries (dev) + WorkspaceGitService/DaemonConfigStore (main)
- App: script proxy/voice MCP (dev) + pin/hide tabs/bootstrap boundary (main)
- Shared: WorkspaceScriptPayload (dev) + git/GitHub runtime schemas (main)
- Deleted workspace-registry-bootstrap.ts (dev's DB approach supersedes)
This commit is contained in:
Mohamed Boudra
2026-04-14 03:05:45 +07:00
333 changed files with 13490 additions and 6317 deletions
@@ -1,3 +1,4 @@
import { EventEmitter } from "node:events";
import { describe, expect, test, vi } from "vitest";
import {
createEncryptedTransport,
@@ -114,6 +115,22 @@ describe("daemon-client transport helpers", () => {
expect(ws.removeEventListener).toHaveBeenCalledWith("message", expect.any(Function));
});
test("createWebSocketTransportFactory suppresses close-before-open ws errors", () => {
class MockNodeWebSocket extends EventEmitter {
readyState = 0;
send = vi.fn();
close = vi.fn((code?: number, reason?: string) => {
this.emit("error", new Error("WebSocket was closed before the connection was established"));
this.emit("close", { code, reason });
});
}
const ws = new MockNodeWebSocket();
const transport = createWebSocketTransportFactory(() => ws)({ url: "ws://example.test" });
expect(() => transport.close(1001, "Connection timed out")).not.toThrow();
});
test("describeTransportClose prefers reason, then message, then code", () => {
expect(describeTransportClose({ reason: "peer closed" })).toBe("peer closed");
expect(describeTransportClose({ message: "closed" })).toBe("closed");
@@ -32,7 +32,19 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem
}
ws.send(data);
},
close: (code?: number, reason?: string) => ws.close(code, reason),
close: (code?: number, reason?: string) => {
// Node's `ws` may emit an `error` when a connecting socket is closed before the
// handshake completes. Keep a temporary no-op handler attached so cleanup during
// connect timeouts does not crash the CLI with an unhandled error event.
const suppressEarlyCloseError = bindTemporaryEarlyCloseErrorHandler(ws);
try {
ws.close(code, reason);
} finally {
if (typeof ws.on !== "function" && typeof ws.addEventListener !== "function") {
suppressEarlyCloseError();
}
}
},
onOpen: (handler) => bindWsHandler(ws, "open", handler),
onClose: (handler) => bindWsHandler(ws, "close", handler),
onError: (handler) => bindWsHandler(ws, "error", handler),
@@ -41,6 +53,52 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem
};
}
function bindTemporaryEarlyCloseErrorHandler(ws: WebSocketLike): () => void {
const noop = () => {};
if (typeof ws.addEventListener === "function") {
ws.addEventListener("error", noop);
const removeOnClose = bindWsHandler(ws, "close", () => {
removeOnClose();
if (typeof ws.removeEventListener === "function") {
ws.removeEventListener("error", noop);
}
});
return () => {
removeOnClose();
if (typeof ws.removeEventListener === "function") {
ws.removeEventListener("error", noop);
}
};
}
if (typeof ws.on === "function") {
ws.on("error", noop);
const removeOnClose = bindWsHandler(ws, "close", () => {
removeOnClose();
if (typeof ws.off === "function") {
ws.off("error", noop);
return;
}
if (typeof ws.removeListener === "function") {
ws.removeListener("error", noop);
}
});
return () => {
removeOnClose();
if (typeof ws.off === "function") {
ws.off("error", noop);
return;
}
if (typeof ws.removeListener === "function") {
ws.removeListener("error", noop);
}
};
}
return () => {};
}
export function bindWsHandler(
ws: WebSocketLike,
event: "open" | "close" | "error" | "message",
@@ -759,7 +759,7 @@ describe("DaemonClient", () => {
expect(request.message.type).toBe("subscribe_checkout_diff_request");
expect(request.message.subscriptionId).toBe("checkout-sub-1");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.compare).toEqual({ mode: "uncommitted" });
expect(request.message.compare).toEqual({ mode: "uncommitted", ignoreWhitespace: false });
mock.triggerMessage(
JSON.stringify({
@@ -818,7 +818,11 @@ describe("DaemonClient", () => {
};
expect(subscribeRequest.message.type).toBe("subscribe_checkout_diff_request");
expect(subscribeRequest.message.cwd).toBe("/tmp/project");
expect(subscribeRequest.message.compare).toEqual({ mode: "base", baseRef: "main" });
expect(subscribeRequest.message.compare).toEqual({
mode: "base",
baseRef: "main",
ignoreWhitespace: false,
});
mock.triggerMessage(
JSON.stringify({
+49 -46
View File
@@ -74,7 +74,7 @@ import type {
AgentProvider,
AgentSessionConfig,
} from "../server/agent/agent-sdk-types.js";
import { getAgentProviderDefinition } from "../server/agent/provider-manifest.js";
import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "../shared/messages.js";
import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js";
import {
asUint8Array,
@@ -285,10 +285,7 @@ type ChatCreatePayload = Extract<
SessionOutboundMessage,
{ type: "chat/create/response" }
>["payload"];
type ChatListPayload = Extract<
SessionOutboundMessage,
{ type: "chat/list/response" }
>["payload"];
type ChatListPayload = Extract<SessionOutboundMessage, { type: "chat/list/response" }>["payload"];
type ChatInspectPayload = Extract<
SessionOutboundMessage,
{ type: "chat/inspect/response" }
@@ -297,38 +294,17 @@ type ChatDeletePayload = Extract<
SessionOutboundMessage,
{ type: "chat/delete/response" }
>["payload"];
type ChatPostPayload = Extract<
SessionOutboundMessage,
{ type: "chat/post/response" }
>["payload"];
type ChatReadPayload = Extract<
SessionOutboundMessage,
{ type: "chat/read/response" }
>["payload"];
type ChatWaitPayload = Extract<
SessionOutboundMessage,
{ type: "chat/wait/response" }
>["payload"];
type LoopRunPayload = Extract<
SessionOutboundMessage,
{ type: "loop/run/response" }
>["payload"];
type LoopListPayload = Extract<
SessionOutboundMessage,
{ type: "loop/list/response" }
>["payload"];
type ChatPostPayload = Extract<SessionOutboundMessage, { type: "chat/post/response" }>["payload"];
type ChatReadPayload = Extract<SessionOutboundMessage, { type: "chat/read/response" }>["payload"];
type ChatWaitPayload = Extract<SessionOutboundMessage, { type: "chat/wait/response" }>["payload"];
type LoopRunPayload = Extract<SessionOutboundMessage, { type: "loop/run/response" }>["payload"];
type LoopListPayload = Extract<SessionOutboundMessage, { type: "loop/list/response" }>["payload"];
type LoopInspectPayload = Extract<
SessionOutboundMessage,
{ type: "loop/inspect/response" }
>["payload"];
type LoopLogsPayload = Extract<
SessionOutboundMessage,
{ type: "loop/logs/response" }
>["payload"];
type LoopStopPayload = Extract<
SessionOutboundMessage,
{ type: "loop/stop/response" }
>["payload"];
type LoopLogsPayload = Extract<SessionOutboundMessage, { type: "loop/logs/response" }>["payload"];
type LoopStopPayload = Extract<SessionOutboundMessage, { type: "loop/stop/response" }>["payload"];
type ScheduleCreatePayload = Extract<
SessionOutboundMessage,
{ type: "schedule/create/response" }
@@ -529,10 +505,18 @@ type WaitHandle<T> = {
};
type RpcWaitResult<T> = { kind: "ok"; value: T } | { kind: "error"; error: DaemonRpcError };
type CorrelatedResponseMessage = Extract<
type GetDaemonConfigResponse = Extract<
SessionOutboundMessage,
{ payload: { requestId: string } }
{ type: "get_daemon_config_response" }
>;
type SetDaemonConfigResponse = Extract<
SessionOutboundMessage,
{ type: "set_daemon_config_response" }
>;
type CorrelatedResponseMessage =
| Extract<SessionOutboundMessage, { payload: { requestId: string } }>
| GetDaemonConfigResponse
| SetDaemonConfigResponse;
type CorrelatedResponseType = CorrelatedResponseMessage["type"];
type CorrelatedResponsePayload<TType extends CorrelatedResponseType> = Extract<
CorrelatedResponseMessage,
@@ -2484,11 +2468,7 @@ export class DaemonClient {
});
}
async stashPop(
cwd: string,
stashIndex: number,
requestId?: string,
): Promise<StashPopPayload> {
async stashPop(cwd: string, stashIndex: number, requestId?: string): Promise<StashPopPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
@@ -2784,6 +2764,34 @@ export class DaemonClient {
});
}
async getDaemonConfig(
requestId?: string,
): Promise<{ requestId: string; config: MutableDaemonConfig }> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "get_daemon_config_request",
},
responseType: "get_daemon_config_response",
timeout: 10000,
});
}
async patchDaemonConfig(
config: MutableDaemonConfigPatch,
requestId?: string,
): Promise<{ requestId: string; config: MutableDaemonConfig }> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "set_daemon_config_request",
config,
},
responseType: "set_daemon_config_response",
timeout: 10000,
});
}
async refreshProvidersSnapshot(options?: {
cwd?: string;
requestId?: string;
@@ -3433,8 +3441,7 @@ export class DaemonClient {
}
async loopLogs(options: string | LoopLogsOptions, afterSeq?: number): Promise<LoopLogsPayload> {
const normalized =
typeof options === "string" ? { id: options, afterSeq } : options;
const normalized = typeof options === "string" ? { id: options, afterSeq } : options;
return this.sendCorrelatedSessionRequest({
requestId: normalized.requestId,
message: {
@@ -4018,10 +4025,6 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
throw new Error("createAgent requires provider and cwd");
}
if (!merged.modeId) {
merged.modeId = getAgentProviderDefinition(merged.provider).defaultModeId ?? undefined;
}
return {
...merged,
provider: merged.provider,
@@ -16,10 +16,7 @@
*/
import { describe, expect, test } from "vitest";
import {
query,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { isCommandAvailableSync } from "../utils/executable.js";
const hasClaudeCredentials =
@@ -34,79 +31,87 @@ function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
describe("Claude Agent SDK Commands POC", () => {
describe("supportedCommands() API", () => {
test.runIf(canRunClaudeIntegration)("should return an array of SlashCommand objects", async () => {
// Use the pattern from claude-agent.ts:
// Create a query with empty prompt generator for control methods
const emptyPrompt = createEmptyPrompt();
test.runIf(canRunClaudeIntegration)(
"should return an array of SlashCommand objects",
async () => {
// Use the pattern from claude-agent.ts:
// Create a query with empty prompt generator for control methods
const emptyPrompt = createEmptyPrompt();
const claudeQuery = query({
prompt: emptyPrompt,
options: {
cwd: process.cwd(),
permissionMode: "plan",
includePartialMessages: false,
settingSources: ["user", "project"], // Required to load skills
},
});
const claudeQuery = query({
prompt: emptyPrompt,
options: {
cwd: process.cwd(),
permissionMode: "plan",
includePartialMessages: false,
settingSources: ["user", "project"], // Required to load skills
},
});
try {
// supportedCommands() is a control method - works without iterating
const commands = await claudeQuery.supportedCommands();
try {
// supportedCommands() is a control method - works without iterating
const commands = await claudeQuery.supportedCommands();
// Should be an array
expect(Array.isArray(commands)).toBe(true);
// Should be an array
expect(Array.isArray(commands)).toBe(true);
// Verify structure
if (commands.length > 0) {
const firstCommand = commands[0];
expect(typeof firstCommand.name).toBe("string");
expect(typeof firstCommand.description).toBe("string");
expect(typeof firstCommand.argumentHint).toBe("string");
expect(firstCommand.name.startsWith("/")).toBe(false);
}
} finally {
if (typeof claudeQuery.return === "function") {
try {
await claudeQuery.return();
} catch {
// ignore shutdown errors
// Verify structure
if (commands.length > 0) {
const firstCommand = commands[0];
expect(typeof firstCommand.name).toBe("string");
expect(typeof firstCommand.description).toBe("string");
expect(typeof firstCommand.argumentHint).toBe("string");
expect(firstCommand.name.startsWith("/")).toBe(false);
}
} finally {
if (typeof claudeQuery.return === "function") {
try {
await claudeQuery.return();
} catch {
// ignore shutdown errors
}
}
}
}
}, 30000);
},
30000,
);
test.runIf(canRunClaudeIntegration)("should have valid SlashCommand structure for all commands", async () => {
const emptyPrompt = createEmptyPrompt();
test.runIf(canRunClaudeIntegration)(
"should have valid SlashCommand structure for all commands",
async () => {
const emptyPrompt = createEmptyPrompt();
const claudeQuery = query({
prompt: emptyPrompt,
options: {
cwd: process.cwd(),
permissionMode: "plan",
settingSources: ["user", "project"],
},
});
const claudeQuery = query({
prompt: emptyPrompt,
options: {
cwd: process.cwd(),
permissionMode: "plan",
settingSources: ["user", "project"],
},
});
try {
const commands = await claudeQuery.supportedCommands();
try {
const commands = await claudeQuery.supportedCommands();
expect(commands.length).toBeGreaterThan(0);
expect(commands.length).toBeGreaterThan(0);
// Verify all commands have valid structure
for (const cmd of commands) {
expect(cmd).toHaveProperty("name");
expect(cmd).toHaveProperty("description");
expect(cmd).toHaveProperty("argumentHint");
expect(typeof cmd.name).toBe("string");
expect(typeof cmd.description).toBe("string");
expect(typeof cmd.argumentHint).toBe("string");
expect(cmd.name.length).toBeGreaterThan(0);
expect(cmd.name.startsWith("/")).toBe(false);
// Verify all commands have valid structure
for (const cmd of commands) {
expect(cmd).toHaveProperty("name");
expect(cmd).toHaveProperty("description");
expect(cmd).toHaveProperty("argumentHint");
expect(typeof cmd.name).toBe("string");
expect(typeof cmd.description).toBe("string");
expect(typeof cmd.argumentHint).toBe("string");
expect(cmd.name.length).toBeGreaterThan(0);
expect(cmd.name.startsWith("/")).toBe(false);
}
} finally {
await claudeQuery.return?.();
}
} finally {
await claudeQuery.return?.();
}
}, 30000);
},
30000,
);
});
describe("Command Execution", () => {
@@ -26,17 +26,15 @@ import { z } from "zod";
import { ensureValidJson } from "../json-utils.js";
import type { Logger } from "pino";
import type { AgentPromptInput, AgentProvider, AgentPermissionRequest } from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
import type { AgentProvider } from "./agent-sdk-types.js";
import type { AgentManager, WaitForAgentResult } from "./agent-manager.js";
import {
AgentPermissionRequestPayloadSchema,
AgentPermissionResponseSchema,
AgentSnapshotPayloadSchema,
serializeAgentSnapshot,
} from "../messages.js";
import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import {
appendTimelineItemIfAgentKnown,
@@ -48,171 +46,49 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import { expandUserPath } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
import type { ScheduleService } from "../schedule/service.js";
import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
import { AGENT_PROVIDER_DEFINITIONS, type ProviderDefinition } from "./provider-registry.js";
import {
AgentModelSchema,
AgentProviderEnum,
AgentStatusEnum,
ProviderSummarySchema,
parseDurationString,
sanitizePermissionRequest,
serializeSnapshotWithMetadata,
startAgentRun,
toScheduleSummary,
waitForAgentWithTimeout,
} from "./mcp-shared.js";
export interface AgentManagementMcpOptions {
agentManager: AgentManager;
agentStorage: AgentSnapshotStore;
terminalManager?: TerminalManager | null;
getDaemonTcpPort?: () => number | null;
scheduleService?: ScheduleService | null;
providerRegistry?: Record<AgentProvider, ProviderDefinition> | null;
paseoHome?: string;
logger: Logger;
}
const AgentProviderEnum = z.enum(
AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [
AgentProvider,
...AgentProvider[],
],
);
const AgentStatusEnum = z.enum(["initializing", "idle", "running", "error", "closed"]);
// 50 seconds - surface friendly message before SDK tool timeout (~60s)
const AGENT_WAIT_TIMEOUT_MS = 50000;
async function waitForAgentWithTimeout(
agentManager: AgentManager,
agentId: string,
options?: {
signal?: AbortSignal;
waitForActive?: boolean;
},
): Promise<WaitForAgentResult> {
const timeoutController = new AbortController();
const combinedController = new AbortController();
const timeoutId = setTimeout(() => {
timeoutController.abort(new Error("wait timeout"));
}, AGENT_WAIT_TIMEOUT_MS);
const forwardAbort = (reason: unknown) => {
if (!combinedController.signal.aborted) {
combinedController.abort(reason);
}
};
if (options?.signal) {
if (options.signal.aborted) {
forwardAbort(options.signal.reason);
} else {
options.signal.addEventListener("abort", () => forwardAbort(options.signal!.reason), {
once: true,
});
}
}
timeoutController.signal.addEventListener(
"abort",
() => forwardAbort(timeoutController.signal.reason),
{ once: true },
);
try {
const result = await agentManager.waitForAgentEvent(agentId, {
signal: combinedController.signal,
waitForActive: options?.waitForActive,
});
return result;
} catch (error) {
if (error instanceof Error && error.message === "wait timeout") {
const snapshot = agentManager.getAgent(agentId);
const timeline = agentManager.getTimeline(agentId);
const recentActivity = curateAgentActivity(timeline.slice(-5));
const message = `Awaiting the agent timed out. This does not mean the agent failed - call wait_for_agent again to continue waiting.\n\nRecent activity:\n${recentActivity}`;
return {
status: snapshot?.lifecycle ?? "idle",
permission: null,
lastMessage: message,
};
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
function startAgentRun(
agentManager: AgentManager,
agentId: string,
prompt: AgentPromptInput,
logger: Logger,
options?: { replaceRunning?: boolean },
): void {
const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
const iterator = shouldReplace
? agentManager.replaceAgentRun(agentId, prompt)
: agentManager.streamAgent(agentId, prompt);
void (async () => {
try {
for await (const _ of iterator) {
// Events are broadcast via AgentManager subscribers.
}
} catch (error) {
logger.error({ err: error, agentId }, "Agent stream failed");
}
})();
}
function sanitizePermissionRequest(
permission: AgentPermissionRequest | null | undefined,
): AgentPermissionRequest | null {
if (!permission) {
return null;
}
const sanitized: AgentPermissionRequest = { ...permission };
if (sanitized.title === undefined) {
delete sanitized.title;
}
if (sanitized.description === undefined) {
delete sanitized.description;
}
if (sanitized.input === undefined) {
delete sanitized.input;
}
if (sanitized.suggestions === undefined) {
delete sanitized.suggestions;
}
if (sanitized.actions === undefined) {
delete sanitized.actions;
}
if (sanitized.metadata === undefined) {
delete sanitized.metadata;
}
return sanitized;
}
async function resolveAgentTitle(
agentStorage: AgentSnapshotStore,
agentId: string,
logger: Logger,
): Promise<string | null> {
try {
const record = await agentStorage.get(agentId);
return record?.title ?? null;
} catch (error) {
logger.error({ err: error, agentId }, "Failed to load agent title");
return null;
}
}
async function serializeSnapshotWithMetadata(
agentStorage: AgentSnapshotStore,
snapshot: ManagedAgent,
logger: Logger,
) {
const title = await resolveAgentTitle(agentStorage, snapshot.id, logger);
return serializeAgentSnapshot(snapshot, { title });
}
export async function createAgentManagementMcpServer(
options: AgentManagementMcpOptions,
): Promise<McpServer> {
const { agentManager, agentStorage, logger } = options;
const { agentManager, agentStorage, scheduleService, providerRegistry, logger } = options;
const childLogger = logger.child({
module: "agent",
component: "agent-management-mcp",
});
const waitTracker = new WaitForAgentTracker(logger);
const resolveNewAgentScheduleTarget = (params?: { provider?: AgentProvider; cwd?: string }) => ({
type: "new-agent" as const,
config: {
provider: params?.provider ?? ("claude" as AgentProvider),
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
},
});
const server = new McpServer({
name: "paseo-agent-management",
@@ -229,14 +105,20 @@ export async function createAgentManagementMcpServer(
.min(1, "Title is required")
.max(60, "Title must be 60 characters or fewer")
.describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."),
agentType: AgentProviderEnum.optional().describe(
provider: AgentProviderEnum.optional().describe(
"Optional agent implementation to spawn. Defaults to 'claude'.",
),
model: z.string().optional().describe("Model to use (e.g. claude-sonnet-4-20250514)"),
thinking: z.string().optional().describe("Thinking option ID"),
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
initialPrompt: z
.string()
.optional()
.describe("Optional task to start immediately after creation (non-blocking)."),
initialMode: z.string().describe("Required session mode to configure before the first run."),
mode: z
.string()
.optional()
.describe("Optional session mode to configure before the first run."),
worktreeName: z
.string()
.optional()
@@ -257,7 +139,7 @@ export async function createAgentManagementMcpServer(
server.registerTool(
"create_agent",
{
title: "Create Agent",
title: "Create agent",
description:
"Create a new Claude or Codex agent tied to a working directory. Optionally run an initial prompt immediately or create a git worktree for the agent.",
inputSchema,
@@ -281,22 +163,28 @@ export async function createAgentManagementMcpServer(
async (args) => {
const {
cwd,
agentType,
provider,
initialPrompt,
initialMode,
mode,
worktreeName,
baseBranch,
background = false,
title,
model,
thinking,
labels,
} = args as {
cwd: string;
agentType?: AgentProvider;
provider?: AgentProvider;
initialPrompt?: string;
initialMode: string;
mode?: string;
worktreeName?: string;
baseBranch?: string;
background?: boolean;
title: string;
model?: string;
thinking?: string;
labels?: Record<string, string>;
};
let resolvedCwd = expandUserPath(cwd);
@@ -321,14 +209,20 @@ export async function createAgentManagementMcpServer(
resolvedCwd = worktreeBootstrap.worktree.worktreePath;
}
const provider: AgentProvider = agentType ?? "claude";
const resolvedProvider: AgentProvider = provider ?? "claude";
const normalizedTitle = title?.trim() ?? null;
const snapshot = await agentManager.createAgent({
provider,
cwd: resolvedCwd,
modeId: initialMode,
title: normalizedTitle ?? undefined,
});
const snapshot = await agentManager.createAgent(
{
provider: resolvedProvider,
cwd: resolvedCwd,
modeId: mode,
title: normalizedTitle ?? undefined,
model,
thinkingOptionId: thinking,
},
undefined,
labels ? { labels } : undefined,
);
if (worktreeBootstrap) {
void runAsyncWorktreeBootstrap({
@@ -424,7 +318,7 @@ export async function createAgentManagementMcpServer(
server.registerTool(
"wait_for_agent",
{
title: "Wait For Agent",
title: "Wait for agent",
description:
"Block until the agent requests permission or the current run completes. Returns the pending permission (if any) and recent activity summary.",
inputSchema: {
@@ -502,7 +396,7 @@ export async function createAgentManagementMcpServer(
server.registerTool(
"send_agent_prompt",
{
title: "Send Agent Prompt",
title: "Send agent prompt",
description:
"Send a task to a running agent. Returns immediately after the agent begins processing.",
inputSchema: {
@@ -592,7 +486,7 @@ export async function createAgentManagementMcpServer(
server.registerTool(
"get_agent_status",
{
title: "Get Agent Status",
title: "Get agent status",
description:
"Return the latest snapshot for an agent, including lifecycle state, capabilities, and pending permissions.",
inputSchema: {
@@ -627,7 +521,7 @@ export async function createAgentManagementMcpServer(
server.registerTool(
"list_agents",
{
title: "List Agents",
title: "List agents",
description: "List all live agents managed by the server.",
inputSchema: {},
outputSchema: {
@@ -651,7 +545,7 @@ export async function createAgentManagementMcpServer(
server.registerTool(
"cancel_agent",
{
title: "Cancel Agent Run",
title: "Cancel agent run",
description: "Abort the agent's current run but keep the agent alive for future tasks.",
inputSchema: {
agentId: z.string(),
@@ -672,10 +566,33 @@ export async function createAgentManagementMcpServer(
},
);
server.registerTool(
"archive_agent",
{
title: "Archive agent",
description:
"Archive an agent (soft-delete). The agent is interrupted if running and removed from the active list.",
inputSchema: {
agentId: z.string(),
},
outputSchema: {
success: z.boolean(),
},
},
async ({ agentId }) => {
await agentManager.archiveAgent(agentId);
waitTracker.cancel(agentId, "Agent archived");
return {
content: [],
structuredContent: ensureValidJson({ success: true }),
};
},
);
server.registerTool(
"kill_agent",
{
title: "Kill Agent",
title: "Kill agent",
description: "Terminate an agent session permanently.",
inputSchema: {
agentId: z.string(),
@@ -694,10 +611,284 @@ export async function createAgentManagementMcpServer(
},
);
server.registerTool(
"update_agent",
{
title: "Update agent",
description: "Update an agent name and/or labels.",
inputSchema: {
agentId: z.string(),
name: z.string().optional(),
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
},
outputSchema: {
success: z.boolean(),
},
},
async ({ agentId, name, labels }) => {
const trimmedName = name?.trim();
if (trimmedName) {
const record = await agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent ${agentId} not found`);
}
await agentStorage.upsert({
...record,
title: trimmedName,
updatedAt: new Date().toISOString(),
});
agentManager.notifyAgentState(agentId);
}
if (labels) {
await agentManager.setLabels(agentId, labels);
}
return {
content: [],
structuredContent: ensureValidJson({ success: true }),
};
},
);
server.registerTool(
"create_schedule",
{
title: "Create schedule",
description: "Create a recurring schedule that runs on an agent or a new agent.",
inputSchema: {
prompt: z.string().trim().min(1, "prompt is required"),
every: z.string().optional(),
cron: z.string().optional(),
name: z.string().optional(),
target: z.enum(["self", "new-agent"]).optional(),
provider: AgentProviderEnum.optional(),
cwd: z.string().optional(),
maxRuns: z.number().int().positive().optional(),
expiresIn: z.string().optional(),
},
outputSchema: ScheduleSummarySchema.shape,
},
async ({ prompt, every, cron, name, target, provider, cwd, maxRuns, expiresIn }) => {
if (!scheduleService) {
throw new Error("Schedule service is not configured");
}
const cadenceCount = Number(every !== undefined) + Number(cron !== undefined);
if (cadenceCount !== 1) {
throw new Error("Specify exactly one of every or cron");
}
if (target === "self") {
throw new Error("target=self requires a caller agent");
}
const schedule = await scheduleService.create({
prompt: prompt.trim(),
cadence: every
? { type: "every" as const, everyMs: parseDurationString(every) }
: { type: "cron" as const, expression: cron!.trim() },
target: resolveNewAgentScheduleTarget({ provider, cwd }),
...(name?.trim() ? { name: name.trim() } : {}),
...(maxRuns === undefined ? {} : { maxRuns }),
...(expiresIn === undefined
? {}
: { expiresAt: new Date(Date.now() + parseDurationString(expiresIn)).toISOString() }),
});
return {
content: [],
structuredContent: ensureValidJson(toScheduleSummary(schedule)),
};
},
);
server.registerTool(
"list_schedules",
{
title: "List schedules",
description: "List all schedules managed by the daemon.",
inputSchema: {},
outputSchema: {
schedules: z.array(ScheduleSummarySchema),
},
},
async () => {
if (!scheduleService) {
throw new Error("Schedule service is not configured");
}
const schedules = (await scheduleService.list()).map((schedule) =>
toScheduleSummary(schedule),
);
return {
content: [],
structuredContent: ensureValidJson({ schedules }),
};
},
);
server.registerTool(
"inspect_schedule",
{
title: "Inspect schedule",
description: "Inspect a schedule and its run history.",
inputSchema: {
id: z.string(),
},
outputSchema: StoredScheduleSchema.shape,
},
async ({ id }) => {
if (!scheduleService) {
throw new Error("Schedule service is not configured");
}
const schedule = await scheduleService.inspect(id);
return {
content: [],
structuredContent: ensureValidJson(schedule),
};
},
);
server.registerTool(
"pause_schedule",
{
title: "Pause schedule",
description: "Pause an active schedule.",
inputSchema: {
id: z.string(),
},
outputSchema: {
success: z.boolean(),
},
},
async ({ id }) => {
if (!scheduleService) {
throw new Error("Schedule service is not configured");
}
await scheduleService.pause(id);
return {
content: [],
structuredContent: ensureValidJson({ success: true }),
};
},
);
server.registerTool(
"resume_schedule",
{
title: "Resume schedule",
description: "Resume a paused schedule.",
inputSchema: {
id: z.string(),
},
outputSchema: {
success: z.boolean(),
},
},
async ({ id }) => {
if (!scheduleService) {
throw new Error("Schedule service is not configured");
}
await scheduleService.resume(id);
return {
content: [],
structuredContent: ensureValidJson({ success: true }),
};
},
);
server.registerTool(
"delete_schedule",
{
title: "Delete schedule",
description: "Delete a schedule permanently.",
inputSchema: {
id: z.string(),
},
outputSchema: {
success: z.boolean(),
},
},
async ({ id }) => {
if (!scheduleService) {
throw new Error("Schedule service is not configured");
}
await scheduleService.delete(id);
return {
content: [],
structuredContent: ensureValidJson({ success: true }),
};
},
);
server.registerTool(
"list_providers",
{
title: "List providers",
description: "List available agent providers and their modes.",
inputSchema: {},
outputSchema: {
providers: z.array(ProviderSummarySchema),
},
},
async () => ({
content: [],
structuredContent: ensureValidJson({
providers: AGENT_PROVIDER_DEFINITIONS.map((provider) => ({
id: provider.id,
label: provider.label,
modes: provider.modes.map((mode) => ({
id: mode.id,
label: mode.label,
...(mode.description ? { description: mode.description } : {}),
})),
})),
}),
}),
);
server.registerTool(
"list_models",
{
title: "List models",
description: "List models for an agent provider.",
inputSchema: {
provider: AgentProviderEnum,
},
outputSchema: {
provider: z.string(),
models: z.array(AgentModelSchema),
},
},
async ({ provider }) => {
if (!providerRegistry) {
throw new Error("Provider registry is not configured");
}
const definition = providerRegistry[provider];
if (!definition) {
throw new Error(`Provider ${provider} is not configured`);
}
const models = await definition.fetchModels();
return {
content: [],
structuredContent: ensureValidJson({
provider,
models,
}),
};
},
);
server.registerTool(
"get_agent_activity",
{
title: "Get Agent Activity",
title: "Get agent activity",
description: "Return recent agent timeline entries as a curated summary.",
inputSchema: {
agentId: z.string(),
@@ -747,7 +938,7 @@ export async function createAgentManagementMcpServer(
server.registerTool(
"set_agent_mode",
{
title: "Set Agent Session Mode",
title: "Set agent session mode",
description:
"Switch the agent's session mode (plan, bypassPermissions, read-only, auto, etc.).",
inputSchema: {
@@ -771,7 +962,7 @@ export async function createAgentManagementMcpServer(
server.registerTool(
"list_pending_permissions",
{
title: "List Pending Permissions",
title: "List pending permissions",
description:
"Return all pending permission requests across all agents with the normalized payloads.",
inputSchema: {},
@@ -805,7 +996,7 @@ export async function createAgentManagementMcpServer(
server.registerTool(
"respond_to_permission",
{
title: "Respond To Permission",
title: "Respond to permission",
description:
"Approve or deny a pending permission request with an AgentManager-compatible response payload.",
inputSchema: {
@@ -100,6 +100,22 @@ class TestAgentClient implements AgentClient {
return new TestAgentSession(config);
}
async listModels() {
return [
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
isDefault: true,
},
{
provider: "codex",
id: "gpt-5.4-mini",
label: "GPT-5.4 Mini",
},
];
}
async resumeSession(
_handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
@@ -317,7 +333,7 @@ class StreamingAssistantClient implements AgentClient {
describe("AgentManager", () => {
const logger = createTestLogger();
test("normalizeConfig does not inject default model when omitted", async () => {
test("normalizeConfig injects the provider default model when omitted", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -335,7 +351,8 @@ describe("AgentManager", () => {
cwd: workdir,
});
expect(snapshot.model).toBeUndefined();
expect(snapshot.config.model).toBe("gpt-5.4");
expect(snapshot.config.modeId).toBe("auto");
});
test("normalizeConfig strips legacy 'default' model id", async () => {
@@ -357,7 +374,8 @@ describe("AgentManager", () => {
model: "default",
});
expect(snapshot.model).toBeUndefined();
expect(snapshot.config.model).toBe("gpt-5.4");
expect(snapshot.config.modeId).toBe("auto");
});
test("createAgent passes daemon launch env through the provider launch context", async () => {
@@ -397,6 +415,8 @@ describe("AgentManager", () => {
expect(client.lastConfig).toEqual({
provider: "codex",
cwd: workdir,
model: "gpt-5.4",
modeId: "auto",
});
expect(client.lastLaunchContext).toEqual({
env: {
@@ -405,6 +425,100 @@ describe("AgentManager", () => {
});
});
test("createAgent injects paseo MCP server when manager has an MCP base URL", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class CaptureClient extends TestAgentClient {
lastConfig: AgentSessionConfig | null = null;
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.lastConfig = config;
return new TestAgentSession(config);
}
}
const client = new CaptureClient();
const manager = new AgentManager({
clients: {
codex: client,
},
registry: storage,
logger,
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
idFactory: () => "00000000-0000-4000-8000-000000000103",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
mcpServers: {
custom: {
type: "stdio",
command: "custom-mcp",
},
},
});
expect(snapshot.config.mcpServers).toEqual({
paseo: {
type: "http",
url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`,
},
custom: {
type: "stdio",
command: "custom-mcp",
},
});
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
});
test("createAgent preserves a user-provided paseo MCP config", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class CaptureClient extends TestAgentClient {
lastConfig: AgentSessionConfig | null = null;
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.lastConfig = config;
return new TestAgentSession(config);
}
}
const client = new CaptureClient();
const manager = new AgentManager({
clients: {
codex: client,
},
registry: storage,
logger,
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
idFactory: () => "00000000-0000-4000-8000-000000000104",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
mcpServers: {
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
},
},
});
expect(snapshot.config.mcpServers).toEqual({
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
},
});
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
});
test("createAgent fails when cwd does not exist", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -1067,7 +1181,12 @@ describe("AgentManager", () => {
});
await this.gate;
if (this.delayedInterrupted) {
this.pushEvent({ type: "turn_canceled", provider: this.provider, reason: "Interrupted", turnId });
this.pushEvent({
type: "turn_canceled",
provider: this.provider,
reason: "Interrupted",
turnId,
});
} else {
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
}
@@ -1890,7 +2009,7 @@ describe("AgentManager", () => {
cwd: workdir,
});
expect(snapshot.runtimeInfo?.model ?? null).toBeNull();
expect(snapshot.runtimeInfo?.model).toBe("gpt-5.4");
await manager.runAgent(snapshot.id, "hello");
@@ -2013,7 +2132,12 @@ describe("AgentManager", () => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
if (turnNum === 1) {
await allowFirstRunToEnd.promise;
this.pushEvent({ type: "turn_canceled", provider: this.provider, reason: "interrupted", turnId });
this.pushEvent({
type: "turn_canceled",
provider: this.provider,
reason: "interrupted",
turnId,
});
} else {
await allowSecondRunToEnd.promise;
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
@@ -2197,7 +2321,7 @@ describe("AgentManager", () => {
await secondStartEntered.promise;
const replaceGapSnapshot = manager.getAgent(snapshot.id) as
| ({ pendingReplacement: boolean; activeForegroundTurnId: string | null; lifecycle: string })
| { pendingReplacement: boolean; activeForegroundTurnId: string | null; lifecycle: string }
| undefined;
expect(replaceGapSnapshot?.pendingReplacement).toBe(false);
expect(replaceGapSnapshot?.activeForegroundTurnId).toBeNull();
@@ -2269,14 +2393,22 @@ describe("AgentManager", () => {
// Push autonomous events through the session's subscribe() callbacks
const autonomousTurnId = "autonomous-turn-1";
capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: autonomousTurnId });
capturedSession!.pushEvent({
type: "turn_started",
provider: "codex",
turnId: autonomousTurnId,
});
capturedSession!.pushEvent({
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "AUTONOMOUS_PUMP_MESSAGE" },
turnId: autonomousTurnId,
});
capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: autonomousTurnId });
capturedSession!.pushEvent({
type: "turn_completed",
provider: "codex",
turnId: autonomousTurnId,
});
await settled;
const updated = manager.getAgent(snapshot.id);
@@ -2346,7 +2478,11 @@ describe("AgentManager", () => {
},
{ agentId: snapshot.id, replayState: false },
);
capturedSession.pushEvent({ type: "turn_started", provider: "codex", turnId: "autonomous-cancel-1" });
capturedSession.pushEvent({
type: "turn_started",
provider: "codex",
turnId: "autonomous-cancel-1",
});
});
const beforeCancel = manager.getAgent(snapshot.id);
@@ -2389,8 +2525,16 @@ describe("AgentManager", () => {
const autonomousTurnId = "autonomous-wait-1";
const waitPromise = manager.waitForAgentEvent(snapshot.id, { waitForActive: true });
capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: autonomousTurnId });
capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: autonomousTurnId });
capturedSession!.pushEvent({
type: "turn_started",
provider: "codex",
turnId: autonomousTurnId,
});
capturedSession!.pushEvent({
type: "turn_completed",
provider: "codex",
turnId: autonomousTurnId,
});
const result = await waitPromise;
expect(result.status).toBe("idle");
@@ -2451,7 +2595,11 @@ describe("AgentManager", () => {
await new Promise<void>((resolve) => {
const unsub = manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === snapshot.id && event.agent.lifecycle === "running") {
if (
event.type === "agent_state" &&
event.agent.id === snapshot.id &&
event.agent.lifecycle === "running"
) {
unsub();
resolve();
}
@@ -2462,14 +2610,22 @@ describe("AgentManager", () => {
// Push autonomous events while foreground is active
const autonomousTurnId = "autonomous-during-fg-1";
capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: autonomousTurnId });
capturedSession!.pushEvent({
type: "turn_started",
provider: "codex",
turnId: autonomousTurnId,
});
capturedSession!.pushEvent({
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "AUTONOMOUS_DURING_FOREGROUND" },
turnId: autonomousTurnId,
});
capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: autonomousTurnId });
capturedSession!.pushEvent({
type: "turn_completed",
provider: "codex",
turnId: autonomousTurnId,
});
releaseForeground.resolve();
const foregroundEvents = await foregroundResults;
@@ -2525,7 +2681,11 @@ describe("AgentManager", () => {
const settled = new Promise<void>((resolve) => {
manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === snapshot.id && event.agent.lifecycle === "idle") {
if (
event.type === "agent_state" &&
event.agent.id === snapshot.id &&
event.agent.lifecycle === "idle"
) {
resolve();
}
if (event.type === "agent_stream" && event.agentId === snapshot.id) {
@@ -2537,14 +2697,22 @@ describe("AgentManager", () => {
});
const autonomousTurnId = "autonomous-isolation-1";
capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: autonomousTurnId });
capturedSession!.pushEvent({
type: "turn_started",
provider: "codex",
turnId: autonomousTurnId,
});
capturedSession!.pushEvent({
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "EVENT_AFTER_ERROR" },
turnId: autonomousTurnId,
});
capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: autonomousTurnId });
capturedSession!.pushEvent({
type: "turn_completed",
provider: "codex",
turnId: autonomousTurnId,
});
await settled;
@@ -2684,7 +2852,9 @@ describe("AgentManager", () => {
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subs.add(callback);
return () => { this.subs.delete(callback); };
return () => {
this.subs.delete(callback);
};
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
@@ -2967,7 +3137,12 @@ describe("AgentManager", () => {
const turnId = `fail-turn-${attempt}`;
setTimeout(() => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
this.pushEvent({ type: "turn_failed", provider: this.provider, error: `boom-${attempt}`, turnId });
this.pushEvent({
type: "turn_failed",
provider: this.provider,
error: `boom-${attempt}`,
turnId,
});
}, 0);
return { turnId };
}
@@ -3112,7 +3287,12 @@ describe("AgentManager", () => {
const turnId = "turn-failed-1";
setTimeout(() => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
this.pushEvent({ type: "turn_failed", provider: this.provider, error: "invalid model id", turnId });
this.pushEvent({
type: "turn_failed",
provider: this.provider,
error: "invalid model id",
turnId,
});
}, 0);
return { turnId };
}
@@ -3364,7 +3544,9 @@ describe("AgentManager", () => {
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subs.add(callback);
return () => { this.subs.delete(callback); };
return () => {
this.subs.delete(callback);
};
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
@@ -3658,7 +3840,9 @@ describe("AgentManager", () => {
}
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode");
seen.push(`state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`);
seen.push(
`state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`,
);
return;
}
if (event.type === "agent_stream" && event.event.type === "permission_resolved") {
@@ -3706,12 +3890,18 @@ describe("AgentManager", () => {
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => { this.subscribers.delete(callback); };
return () => {
this.subscribers.delete(callback);
};
}
private pushEvent(event: AgentStreamEvent): void {
for (const cb of this.subscribers) {
try { cb(event); } catch { /* isolation */ }
try {
cb(event);
} catch {
/* isolation */
}
}
}
@@ -45,7 +45,7 @@ import type {
AgentTimelineRow,
AgentTimelineStore,
} from "./agent-timeline-store-types.js";
import { AGENT_PROVIDER_IDS } from "./provider-manifest.js";
import { AGENT_PROVIDER_IDS, getAgentProviderDefinition } from "./provider-manifest.js";
export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus };
export type {
@@ -96,6 +96,7 @@ export type AgentManagerOptions = {
onAgentAttention?: AgentAttentionCallback;
durableTimelineStore?: AgentTimelineStore;
terminalManager?: TerminalManager | null;
mcpBaseUrl?: string;
logger: Logger;
};
@@ -309,6 +310,7 @@ export class AgentManager {
private readonly durableTimelineStore?: AgentTimelineStore;
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
private readonly backgroundTasks = new Set<Promise<void>>();
private mcpBaseUrl: string | null;
private onAgentAttention?: AgentAttentionCallback;
private logger: Logger;
@@ -317,6 +319,7 @@ export class AgentManager {
this.registry = options?.registry;
this.durableTimelineStore = options?.durableTimelineStore;
this.onAgentAttention = options?.onAgentAttention;
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
if (options?.clients) {
for (const [provider, client] of Object.entries(options.clients)) {
@@ -335,6 +338,10 @@ export class AgentManager {
this.onAgentAttention = callback;
}
setMcpBaseUrl(url: string | null): void {
this.mcpBaseUrl = url;
}
public getMetricsSnapshot(): AgentMetricsSnapshot {
const byLifecycle: Record<string, number> = {};
let withActiveForegroundTurn = 0;
@@ -599,9 +606,21 @@ export class AgentManager {
initialPrompt?: string;
},
): Promise<ManagedAgent> {
// Generate agent ID early so we can use it in MCP config
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
const normalizedConfig = await this.normalizeConfig(config);
const injectedConfig =
this.mcpBaseUrl == null
? config
: {
...config,
mcpServers: {
paseo: {
type: "http" as const,
url: `${this.mcpBaseUrl}?callerAgentId=${resolvedAgentId}`,
},
...(config.mcpServers ?? {}),
},
};
const normalizedConfig = await this.normalizeConfig(injectedConfig);
const launchContext = this.buildLaunchContext(resolvedAgentId);
const client = this.requireClient(normalizedConfig.provider);
const available = await client.isAvailable();
@@ -1273,10 +1292,7 @@ export class AgentManager {
}
const pendingRun = this.getPendingForegroundRun(agentId);
if (
(snapshot.lifecycle === "running" || pendingRun?.started) &&
!snapshot.pendingReplacement
) {
if ((snapshot.lifecycle === "running" || pendingRun?.started) && !snapshot.pendingReplacement) {
return;
}
@@ -1353,11 +1369,7 @@ export class AgentManager {
return true;
}
if (
!currentPendingRun &&
!current.activeForegroundTurnId &&
!current.pendingReplacement
) {
if (!currentPendingRun && !current.activeForegroundTurnId && !current.pendingReplacement) {
finishErr(new Error(`Agent ${agentId} run finished before starting`));
return true;
}
@@ -1419,8 +1431,7 @@ export class AgentManager {
const pendingRun = this.getPendingForegroundRun(agentId);
const foregroundTurnId = agent.activeForegroundTurnId;
const hasForegroundTurn = Boolean(foregroundTurnId);
const isAutonomousRunning =
agent.lifecycle === "running" && !hasForegroundTurn && !pendingRun;
const isAutonomousRunning = agent.lifecycle === "running" && !hasForegroundTurn && !pendingRun;
if (!hasForegroundTurn && !isAutonomousRunning && !pendingRun) {
return false;
@@ -2186,9 +2197,7 @@ export class AgentManager {
},
): Promise<void> {
const eventTurnId = (event as { turnId?: string }).turnId;
const isForegroundEvent = Boolean(
eventTurnId && agent.activeForegroundTurnId === eventTurnId,
);
const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId);
// Only update timestamp for live events, not history replay
if (!options?.fromHistory) {
@@ -2229,11 +2238,7 @@ export class AgentManager {
}
// Suppress user_message echoes for the active foreground turn —
// these are already recorded by recordUserMessage().
if (
!options?.fromHistory &&
event.item.type === "user_message" &&
isForegroundEvent
) {
if (!options?.fromHistory && event.item.type === "user_message" && isForegroundEvent) {
const eventMessageId = normalizeMessageId(event.item.messageId);
const eventText = event.item.text;
if (eventMessageId) {
@@ -2274,7 +2279,7 @@ export class AgentManager {
void this.refreshRuntimeInfo(agent);
break;
case "turn_failed":
this.logger.trace(
this.logger.warn(
{
agentId: agent.id,
lifecycle: agent.lifecycle,
@@ -2631,9 +2636,7 @@ export class AgentManager {
}
}
private async normalizeConfig(
config: AgentSessionConfig,
): Promise<AgentSessionConfig> {
private async normalizeConfig(config: AgentSessionConfig): Promise<AgentSessionConfig> {
const normalized: AgentSessionConfig = { ...config };
// Always resolve cwd to absolute path for consistent history file lookup
@@ -2661,7 +2664,31 @@ export class AgentManager {
if (typeof normalized.model === "string") {
const trimmed = normalized.model.trim();
normalized.model = trimmed.length > 0 ? trimmed : undefined;
normalized.model = trimmed.length > 0 && trimmed !== "default" ? trimmed : undefined;
}
if (!normalized.model) {
const client = this.clients.get(normalized.provider);
if (client) {
try {
const models = await client.listModels();
const defaultModel = models.find((model) => model.isDefault) ?? models[0];
if (defaultModel) {
normalized.model = defaultModel.id;
}
} catch {
// Provider may not support model listing — leave model undefined
}
}
}
if (!normalized.modeId) {
try {
normalized.modeId =
getAgentProviderDefinition(normalized.provider).defaultModeId ?? undefined;
} catch {
// Unknown provider
}
}
return normalized;
@@ -151,8 +151,8 @@ describe("agent MCP end-to-end (offline)", () => {
args: {
cwd: agentCwd,
title: "MCP e2e smoke",
agentType: "claude",
initialMode: "bypassPermissions",
provider: "claude",
mode: "bypassPermissions",
initialPrompt,
background: false,
},
@@ -182,6 +182,121 @@ describe("agent MCP end-to-end (offline)", () => {
}
}, 30_000);
test("create_agent auto-injects paseo MCP by default and can be disabled", 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: [],
allowedHosts: 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 transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${port}/mcp/agents`),
);
const client = (await experimental_createMCPClient({ transport })) as McpClient;
const disabledPaseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-disabled-"));
const disabledStaticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-disabled-"));
const disabledAgentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-disabled-"));
const disabledPort = await getAvailablePort();
const disabledDaemonConfig: PaseoDaemonConfig = {
listen: `127.0.0.1:${disabledPort}`,
paseoHome: disabledPaseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
mcpEnabled: true,
mcpInjectIntoAgents: false,
staticDir: disabledStaticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(disabledPaseoHome, "agents"),
};
const disabledDaemon = await createPaseoDaemon(disabledDaemonConfig, pino({ level: "silent" }));
await disabledDaemon.start();
const disabledTransport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${disabledPort}/mcp/agents`),
);
const disabledClient = (await experimental_createMCPClient({
transport: disabledTransport,
})) as McpClient;
let agentId: string | null = null;
let disabledAgentId: string | null = null;
try {
const result = (await client.callTool({
name: "create_agent",
args: {
cwd: agentCwd,
title: "Injected MCP",
provider: "claude",
mode: "bypassPermissions",
initialPrompt: "reply with done and stop",
background: true,
},
})) as McpToolResult;
const payload = getStructuredContent(result);
agentId = (payload?.agentId as string | undefined) ?? null;
expect(agentId).toBeTruthy();
const injectedAgent = daemon.agentManager.getAgent(agentId!);
expect(injectedAgent?.config.mcpServers).toMatchObject({
paseo: {
type: "http",
url: `http://127.0.0.1:${port}/mcp/agents?callerAgentId=${agentId!}`,
},
});
const disabledResult = (await disabledClient.callTool({
name: "create_agent",
args: {
cwd: disabledAgentCwd,
title: "No injected MCP",
provider: "claude",
mode: "bypassPermissions",
initialPrompt: "reply with done and stop",
background: true,
},
})) as McpToolResult;
const disabledPayload = getStructuredContent(disabledResult);
disabledAgentId = (disabledPayload?.agentId as string | undefined) ?? null;
expect(disabledAgentId).toBeTruthy();
const disabledAgent = disabledDaemon.agentManager.getAgent(disabledAgentId!);
expect(disabledAgent?.config.mcpServers?.paseo).toBeUndefined();
} finally {
if (agentId) {
await client.callTool({ name: "kill_agent", args: { agentId } });
}
if (disabledAgentId) {
await disabledClient.callTool({ name: "kill_agent", args: { agentId: disabledAgentId } });
}
await disabledClient.close();
await disabledDaemon.stop();
await rm(disabledPaseoHome, { recursive: true, force: true });
await rm(disabledStaticDir, { recursive: true, force: true });
await rm(disabledAgentCwd, { recursive: true, force: true });
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-"));
@@ -247,8 +362,8 @@ describe("agent MCP end-to-end (offline)", () => {
args: {
cwd: repoRoot,
title: "MCP worktree setup terminals",
agentType: "claude",
initialMode: "bypassPermissions",
provider: "claude",
mode: "bypassPermissions",
initialPrompt: "say done and stop",
worktreeName: "mcp-worktree-setup-test",
baseBranch: "main",
@@ -11,7 +11,7 @@ import { createAllClients, shutdownProviders } from "./provider-registry.js";
import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js";
import { createWorktree, validateBranchSlug } from "../../utils/worktree.js";
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
const shouldRun = !process.env.CI && !!process.env.OPENAI_API_KEY;
@@ -2,6 +2,7 @@ import type { AgentSnapshotPayload } from "../messages.js";
import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js";
import type {
AgentCapabilityFlags,
AgentFeature,
AgentMetadata,
AgentMode,
AgentPermissionRequest,
@@ -61,7 +62,7 @@ export function toStoredAgentRecord(
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
config: config ?? null,
runtimeInfo,
features: agent.features,
features: normalizeFeatures(agent.features),
persistence,
lastError: agent.lastError ?? undefined,
requiresAttention: agent.attention.requiresAttention,
@@ -99,7 +100,7 @@ export function toAgentPayload(
capabilities: cloneCapabilities(agent.capabilities),
currentModeId: agent.currentModeId,
availableModes: cloneAvailableModes(agent.availableModes),
features: agent.features,
features: normalizeFeatures(agent.features),
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
persistence: sanitizePersistenceHandle(agent.persistence),
title: options?.title ?? null,
@@ -201,6 +202,10 @@ function cloneAvailableModes(modes: AgentMode[]): AgentMode[] {
return modes.map((mode) => ({ ...mode }));
}
function normalizeFeatures(features: AgentFeature[] | null | undefined): AgentFeature[] {
return Array.isArray(features) ? features.map((feature) => ({ ...feature })) : [];
}
function sanitizeOptionalJson(value: unknown): JsonValue | undefined {
if (value === undefined) {
return undefined;
@@ -15,7 +15,7 @@ import { createAgentMcpServer } from "./mcp-server.js";
import { createAllClients, shutdownProviders } from "./provider-registry.js";
import pino from "pino";
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
const hasOpenAICredentials = !!process.env.OPENAI_API_KEY;
@@ -156,7 +156,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
schema,
providers: [
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.1-codex-mini" },
{ provider: "codex", model: "gpt-5.4-mini" },
],
runner: async (options) => {
calls.push({
@@ -186,7 +186,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
schema,
providers: [
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.1-codex-mini" },
{ provider: "codex", model: "gpt-5.4-mini" },
],
runner: async (options) => {
calls.push({
@@ -198,7 +198,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
});
expect(result).toEqual({ summary: "ok" });
expect(calls).toEqual([{ provider: "codex", model: "gpt-5.1-codex-mini" }]);
expect(calls).toEqual([{ provider: "codex", model: "gpt-5.4-mini" }]);
});
it("falls back when an available provider fails", async () => {
@@ -216,7 +216,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
schema,
providers: [
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.1-codex-mini" },
{ provider: "codex", model: "gpt-5.4-mini" },
],
runner: async (options) => {
calls.push({
@@ -233,7 +233,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
expect(result).toEqual({ summary: "ok" });
expect(calls).toEqual([
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.1-codex-mini" },
{ provider: "codex", model: "gpt-5.4-mini" },
]);
});
@@ -252,7 +252,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
schema,
providers: [
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.1-codex-mini" },
{ provider: "codex", model: "gpt-5.4-mini" },
{ provider: "opencode", model: "opencode/gpt-5-nano" },
],
runner: async () => {
@@ -93,7 +93,7 @@ export interface StructuredAgentGenerationWithFallbackOptions<T> {
export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
{ provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" },
{ provider: "opencode", model: "opencode/gpt-5-nano" },
] as const;
@@ -315,7 +315,12 @@ export type AgentStreamEvent =
}
| { type: "turn_canceled"; provider: AgentProvider; reason: string; turnId?: string }
| { type: "timeline"; item: AgentTimelineItem; provider: AgentProvider; turnId?: string }
| { type: "permission_requested"; provider: AgentProvider; request: AgentPermissionRequest; turnId?: string }
| {
type: "permission_requested";
provider: AgentProvider;
request: AgentPermissionRequest;
turnId?: string;
}
| {
type: "permission_resolved";
provider: AgentProvider;
@@ -0,0 +1,672 @@
import os from "node:os";
import path from "node:path";
import { execSync } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { AGENT_WAIT_TIMEOUT_MS } from "./mcp-shared.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
type StructuredContent = { [key: string]: unknown };
type McpToolResult = {
structuredContent?: StructuredContent;
content?: Array<{ structuredContent?: StructuredContent } | StructuredContent>;
isError?: boolean;
};
type McpClient = {
callTool: (input: { name: string; args?: StructuredContent }) => Promise<unknown>;
close: () => Promise<void>;
};
function formatHostForHttpUrl(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
function buildExpectedAgentMcpUrl(params: { host: string; port: number; agentId: string }): string {
const baseUrl = new URL(
"/mcp/agents",
`http://${formatHostForHttpUrl(params.host)}:${params.port}`,
);
baseUrl.searchParams.set("callerAgentId", params.agentId);
return baseUrl.toString();
}
function getStructuredContent(result: McpToolResult): StructuredContent | null {
if (result.structuredContent && typeof result.structuredContent === "object") {
return result.structuredContent;
}
const content = result.content?.[0];
if (content && typeof content === "object" && "structuredContent" in content) {
const structured = (content as { structuredContent?: StructuredContent }).structuredContent;
if (structured) {
return structured;
}
}
if (content && typeof content === "object") {
return content as StructuredContent;
}
return null;
}
async function createMcpClient(url: string): Promise<McpClient> {
const transport = new StreamableHTTPClientTransport(new URL(url));
return (await experimental_createMCPClient({ transport })) as McpClient;
}
async function callToolStructured(
client: McpClient,
name: string,
args?: StructuredContent,
): Promise<StructuredContent> {
const result = (await client.callTool({ name, args: args ?? {} })) as McpToolResult;
const payload = getStructuredContent(result);
if (!payload) {
throw new Error(`${name} returned no structured payload`);
}
return payload;
}
async function expectToolError(
client: McpClient,
name: string,
args: StructuredContent,
pattern: RegExp,
): Promise<void> {
const result = (await client.callTool({ name, args })) as McpToolResult;
expect(result.isError).toBe(true);
const content = result.content?.[0] as { text?: string } | undefined;
expect(content?.text ?? "").toMatch(pattern);
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitFor<T>(options: {
timeoutMs: number;
intervalMs?: number;
check: () => Promise<T | null> | T | null;
label: string;
}): Promise<T> {
const start = Date.now();
while (Date.now() - start < options.timeoutMs) {
const result = await options.check();
if (result !== null) {
return result;
}
await sleep(options.intervalMs ?? 50);
}
throw new Error(`Timed out after ${options.timeoutMs}ms waiting for ${options.label}`);
}
describe("MCP parity end-to-end", () => {
let tempRoot: string;
let daemonHandle: TestPaseoDaemon;
let topLevelClient: McpClient;
let agentScopedClient: McpClient;
let parentAgentId: string;
let parentAgentCwd: string;
let worktreeRepoCwd: string;
async function makeCwd(prefix: string): Promise<string> {
return await mkdtemp(path.join(tempRoot, `${prefix}-`));
}
async function createTopLevelAgent(args?: Partial<StructuredContent>): Promise<string> {
const cwd = (args?.cwd as string | undefined) ?? (await makeCwd("agent-cwd"));
const payload = await callToolStructured(topLevelClient, "create_agent", {
cwd,
title: "Parity agent",
provider: "claude",
initialPrompt: "say done and stop",
mode: "bypassPermissions",
background: true,
...args,
});
return payload.agentId as string;
}
async function createChildAgent(args?: Partial<StructuredContent>): Promise<string> {
const payload = await callToolStructured(agentScopedClient, "create_agent", {
title: "Parity child",
provider: "claude",
initialPrompt: "say done and stop",
background: true,
...args,
});
return payload.agentId as string;
}
async function archiveAgentIfPresent(agentId: string | null | undefined): Promise<void> {
if (!agentId) {
return;
}
try {
await topLevelClient.callTool({ name: "archive_agent", args: { agentId } });
} catch {
// ignore cleanup errors
}
}
async function deleteScheduleIfPresent(id: string | null | undefined): Promise<void> {
if (!id) {
return;
}
try {
await topLevelClient.callTool({ name: "delete_schedule", args: { id } });
} catch {
// ignore cleanup errors
}
}
async function killTerminalIfPresent(terminalId: string | null | undefined): Promise<void> {
if (!terminalId) {
return;
}
try {
await agentScopedClient.callTool({ name: "kill_terminal", args: { terminalId } });
} catch {
// ignore cleanup errors
}
}
async function archiveWorktreeIfPresent(params: {
cwd: string;
worktreePath?: string | null;
worktreeSlug?: string | null;
}): Promise<void> {
if (!params.worktreePath && !params.worktreeSlug) {
return;
}
try {
await topLevelClient.callTool({
name: "archive_worktree",
args: {
cwd: params.cwd,
...(params.worktreePath ? { worktreePath: params.worktreePath } : {}),
...(params.worktreeSlug ? { worktreeSlug: params.worktreeSlug } : {}),
},
});
} catch {
// ignore cleanup errors
}
}
beforeAll(async () => {
tempRoot = await mkdtemp(path.join(os.tmpdir(), "mcp-parity-e2e-"));
parentAgentCwd = await makeCwd("parent-agent-cwd");
worktreeRepoCwd = await makeCwd("worktree-repo");
daemonHandle = await createTestPaseoDaemon();
topLevelClient = await createMcpClient(`http://127.0.0.1:${daemonHandle.port}/mcp/agents`);
const parentPayload = await callToolStructured(topLevelClient, "create_agent", {
cwd: parentAgentCwd,
title: "MCP parity parent",
provider: "claude",
initialPrompt: "say done and stop",
mode: "bypassPermissions",
background: true,
});
parentAgentId = parentPayload.agentId as string;
agentScopedClient = await createMcpClient(
`http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${parentAgentId}`,
);
execSync("git init -b main", { cwd: worktreeRepoCwd, stdio: "pipe" });
execSync("git config user.email 'test@example.com'", { cwd: worktreeRepoCwd, stdio: "pipe" });
execSync("git config user.name 'Test User'", { cwd: worktreeRepoCwd, stdio: "pipe" });
await writeFile(path.join(worktreeRepoCwd, "README.md"), "# repo\n", "utf8");
execSync("git add README.md", { cwd: worktreeRepoCwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'init'", {
cwd: worktreeRepoCwd,
stdio: "pipe",
});
}, 30_000);
afterAll(async () => {
await archiveAgentIfPresent(parentAgentId);
await agentScopedClient?.close();
await topLevelClient?.close();
await daemonHandle?.close();
await rm(tempRoot, { recursive: true, force: true });
});
describe("Suite A: Core Fixes", () => {
test("AGENT_WAIT_TIMEOUT_MS is 30000", () => {
expect(AGENT_WAIT_TIMEOUT_MS).toBe(30_000);
});
test("create_agent with callerAgentId sets paseo.parent-agent-id label", async () => {
let agentId: string | null = null;
try {
agentId = await createChildAgent();
const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
expect(snapshot?.labels).toMatchObject({
"paseo.parent-agent-id": parentAgentId,
});
} finally {
await archiveAgentIfPresent(agentId);
}
});
test("agentManager.createAgent injects paseo MCP using the daemon listen target", async () => {
let agentId: string | null = null;
try {
const listenTarget = daemonHandle.daemon.getListenTarget();
expect(listenTarget?.type).toBe("tcp");
const snapshot = await daemonHandle.daemon.agentManager.createAgent({
provider: "claude",
cwd: await makeCwd("manager-direct-agent-cwd"),
title: "Manager direct parity agent",
modeId: "bypassPermissions",
});
agentId = snapshot.id;
const expectedUrl = buildExpectedAgentMcpUrl({
host: listenTarget!.host,
port: listenTarget!.port,
agentId,
});
expect(snapshot.config.mcpServers).toMatchObject({
paseo: {
type: "http",
url: expectedUrl,
},
});
const liveAgent = daemonHandle.daemon.agentManager.getAgent(agentId);
expect(liveAgent?.config.mcpServers).toMatchObject({
paseo: {
type: "http",
url: expectedUrl,
},
});
} finally {
await archiveAgentIfPresent(agentId);
}
});
test("create_agent accepts model param", async () => {
let agentId: string | null = null;
try {
agentId = await createTopLevelAgent({ model: "claude-test-model" });
const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
expect(snapshot?.config.model).toBe("claude-test-model");
} finally {
await archiveAgentIfPresent(agentId);
}
});
test("create_agent accepts labels param", async () => {
let agentId: string | null = null;
try {
agentId = await createTopLevelAgent({ labels: { team: "infra" } });
const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
expect(snapshot?.labels).toMatchObject({ team: "infra" });
} finally {
await archiveAgentIfPresent(agentId);
}
});
test("archive_agent archives an agent", async () => {
let agentId: string | null = null;
try {
agentId = await createTopLevelAgent();
const archivedAgentId = agentId;
await callToolStructured(topLevelClient, "archive_agent", { agentId });
agentId = null;
const agents = daemonHandle.daemon.agentManager.listAgents();
expect(agents.some((agent) => agent.id === archivedAgentId)).toBe(false);
} finally {
await archiveAgentIfPresent(agentId);
}
});
test("update_agent updates name and labels", async () => {
let agentId: string | null = null;
try {
agentId = await createTopLevelAgent();
await callToolStructured(topLevelClient, "update_agent", {
agentId,
name: "Renamed parity agent",
labels: { team: "infra", surface: "mcp" },
});
const stored = await daemonHandle.daemon.agentStorage.get(agentId);
const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
expect(stored?.title).toBe("Renamed parity agent");
expect(snapshot?.labels).toMatchObject({
team: "infra",
surface: "mcp",
});
} finally {
await archiveAgentIfPresent(agentId);
}
});
});
describe("Suite B: Terminal Tools", () => {
test("create_terminal and list_terminals", async () => {
let terminalId: string | null = null;
try {
const created = await callToolStructured(agentScopedClient, "create_terminal", {
name: "Parity terminal",
});
terminalId = created.id as string;
const listed = await callToolStructured(agentScopedClient, "list_terminals");
const terminals = listed.terminals as Array<StructuredContent>;
expect(terminals).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: terminalId,
name: "Parity terminal",
cwd: parentAgentCwd,
}),
]),
);
} finally {
await killTerminalIfPresent(terminalId);
}
});
test("send_terminal_keys and capture_terminal", async () => {
let terminalId: string | null = null;
try {
const created = await callToolStructured(agentScopedClient, "create_terminal", {
name: "Parity capture terminal",
});
terminalId = created.id as string;
await callToolStructured(agentScopedClient, "send_terminal_keys", {
terminalId,
keys: "echo hello\r",
literal: true,
});
await sleep(500);
const captured = await waitFor({
timeoutMs: 10_000,
intervalMs: 100,
label: "terminal output to contain hello",
check: async () => {
const payload = await callToolStructured(agentScopedClient, "capture_terminal", {
terminalId,
scrollback: true,
});
const lines = (payload.lines as string[] | undefined) ?? [];
return lines.some((line) => line.includes("hello")) ? payload : null;
},
});
expect(captured.lines).toEqual(expect.arrayContaining([expect.stringContaining("hello")]));
} finally {
await killTerminalIfPresent(terminalId);
}
});
test("kill_terminal removes terminal", async () => {
let terminalId: string | null = null;
try {
const created = await callToolStructured(agentScopedClient, "create_terminal", {
name: "Parity kill terminal",
});
terminalId = created.id as string;
await callToolStructured(agentScopedClient, "kill_terminal", { terminalId });
terminalId = null;
const listed = await waitFor({
timeoutMs: 5_000,
intervalMs: 100,
label: "terminal removal",
check: async () => {
const payload = await callToolStructured(agentScopedClient, "list_terminals");
const terminals = payload.terminals as Array<StructuredContent>;
return terminals.some((terminal) => terminal.id === created.id) ? null : payload;
},
});
const terminals = listed.terminals as Array<StructuredContent>;
expect(terminals.some((terminal) => terminal.id === created.id)).toBe(false);
} finally {
await killTerminalIfPresent(terminalId);
}
});
test("kill_terminal with invalid id throws", async () => {
await expectToolError(
agentScopedClient,
"kill_terminal",
{ terminalId: "missing-terminal-id" },
/not found/i,
);
});
});
describe("Suite C: Schedule Tools", () => {
test("create_schedule and list_schedules", async () => {
let scheduleId: string | null = null;
try {
const created = await callToolStructured(topLevelClient, "create_schedule", {
prompt: "say hello",
every: "5m",
name: "Parity schedule list",
});
scheduleId = created.id as string;
const listed = await callToolStructured(topLevelClient, "list_schedules");
const schedules = listed.schedules as Array<StructuredContent>;
expect(schedules).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: scheduleId,
name: "Parity schedule list",
}),
]),
);
} finally {
await deleteScheduleIfPresent(scheduleId);
}
});
test("inspect_schedule returns details", async () => {
let scheduleId: string | null = null;
try {
const created = await callToolStructured(topLevelClient, "create_schedule", {
prompt: "say hello",
every: "5m",
name: "Parity inspect schedule",
});
scheduleId = created.id as string;
const inspected = await callToolStructured(topLevelClient, "inspect_schedule", {
id: scheduleId,
});
expect(inspected).toMatchObject({
id: scheduleId,
name: "Parity inspect schedule",
prompt: "say hello",
status: "active",
});
} finally {
await deleteScheduleIfPresent(scheduleId);
}
});
test("pause and resume schedule", async () => {
let scheduleId: string | null = null;
try {
const created = await callToolStructured(topLevelClient, "create_schedule", {
prompt: "say hello",
every: "5m",
name: "Parity pause schedule",
});
scheduleId = created.id as string;
await callToolStructured(topLevelClient, "pause_schedule", { id: scheduleId });
const paused = await callToolStructured(topLevelClient, "inspect_schedule", {
id: scheduleId,
});
expect(paused.status).toBe("paused");
await callToolStructured(topLevelClient, "resume_schedule", { id: scheduleId });
const resumed = await callToolStructured(topLevelClient, "inspect_schedule", {
id: scheduleId,
});
expect(resumed.status).toBe("active");
} finally {
await deleteScheduleIfPresent(scheduleId);
}
});
test("delete_schedule removes schedule", async () => {
let scheduleId: string | null = null;
try {
const created = await callToolStructured(topLevelClient, "create_schedule", {
prompt: "say hello",
every: "5m",
name: "Parity delete schedule",
});
scheduleId = created.id as string;
await callToolStructured(topLevelClient, "delete_schedule", { id: scheduleId });
scheduleId = null;
const listed = await callToolStructured(topLevelClient, "list_schedules");
const schedules = listed.schedules as Array<StructuredContent>;
expect(schedules.some((schedule) => schedule.id === created.id)).toBe(false);
} finally {
await deleteScheduleIfPresent(scheduleId);
}
});
test("create_schedule target self with callerAgentId", async () => {
let scheduleId: string | null = null;
try {
const created = await callToolStructured(agentScopedClient, "create_schedule", {
prompt: "say hello",
every: "5m",
name: "Parity self schedule",
target: "self",
});
scheduleId = created.id as string;
expect(created.target).toMatchObject({
type: "agent",
agentId: parentAgentId,
});
} finally {
await deleteScheduleIfPresent(scheduleId);
}
});
test("create_schedule target self without callerAgentId throws", async () => {
await expectToolError(
topLevelClient,
"create_schedule",
{
prompt: "say hello",
every: "5m",
target: "self",
},
/requires a caller agent/i,
);
});
});
describe("Suite D: Provider Tools", () => {
test("list_providers returns providers", async () => {
const payload = await callToolStructured(topLevelClient, "list_providers");
const providers = payload.providers as Array<StructuredContent>;
expect(Array.isArray(providers)).toBe(true);
expect(providers.length).toBeGreaterThan(0);
expect(providers[0]).toEqual(
expect.objectContaining({
id: expect.any(String),
label: expect.any(String),
modes: expect.any(Array),
}),
);
});
test("list_models returns models for provider", async () => {
const payload = await callToolStructured(topLevelClient, "list_models", {
provider: "claude",
});
expect(payload.provider).toBe("claude");
expect(Array.isArray(payload.models)).toBe(true);
});
});
describe("Suite E: Worktree Tools", () => {
test("list_worktrees on empty repo", async () => {
const payload = await callToolStructured(topLevelClient, "list_worktrees", {
cwd: worktreeRepoCwd,
});
expect(payload.worktrees).toEqual([]);
});
test("create_worktree and list_worktrees", async () => {
let worktreePath: string | null = null;
const branchName = `parity-create-${Date.now()}`;
try {
const created = await callToolStructured(topLevelClient, "create_worktree", {
cwd: worktreeRepoCwd,
branchName,
baseBranch: "main",
});
worktreePath = created.worktreePath as string;
const listed = await callToolStructured(topLevelClient, "list_worktrees", {
cwd: worktreeRepoCwd,
});
const worktrees = listed.worktrees as Array<StructuredContent>;
expect(worktrees).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: worktreePath,
branchName,
}),
]),
);
} finally {
await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath });
}
});
test("archive_worktree removes worktree", async () => {
let worktreePath: string | null = null;
const branchName = `parity-archive-${Date.now()}`;
try {
const created = await callToolStructured(topLevelClient, "create_worktree", {
cwd: worktreeRepoCwd,
branchName,
baseBranch: "main",
});
worktreePath = created.worktreePath as string;
await callToolStructured(topLevelClient, "archive_worktree", {
cwd: worktreeRepoCwd,
worktreePath,
});
worktreePath = null;
const listed = await callToolStructured(topLevelClient, "list_worktrees", {
cwd: worktreeRepoCwd,
});
const worktrees = listed.worktrees as Array<StructuredContent>;
expect(worktrees.some((worktree) => worktree.path === created.worktreePath)).toBe(false);
} finally {
await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath });
}
});
});
});
@@ -23,7 +23,10 @@ function createTestDeps(): TestDeps {
waitForAgentEvent: vi.fn(),
recordUserMessage: vi.fn(),
setAgentMode: vi.fn(),
setLabels: vi.fn().mockResolvedValue(undefined),
setTitle: vi.fn().mockResolvedValue(undefined),
archiveAgent: vi.fn().mockResolvedValue({ archivedAt: new Date().toISOString() }),
notifyAgentState: vi.fn(),
getAgent: vi.fn(),
streamAgent: vi.fn(() => (async function* noop() {})()),
respondToPermission: vi.fn(),
@@ -34,6 +37,7 @@ function createTestDeps(): TestDeps {
const agentStorageSpies = {
get: vi.fn().mockResolvedValue(null),
setTitle: vi.fn().mockResolvedValue(undefined),
upsert: vi.fn().mockResolvedValue(undefined),
applySnapshot: vi.fn(),
list: vi.fn(),
remove: vi.fn(),
@@ -61,7 +65,7 @@ describe("create_agent MCP tool", () => {
const missingTitle = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
initialMode: "default",
mode: "default",
initialPrompt: "test",
});
expect(missingTitle.success).toBe(false);
@@ -69,7 +73,7 @@ describe("create_agent MCP tool", () => {
const tooLong = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
initialMode: "default",
mode: "default",
title: "x".repeat(61),
initialPrompt: "test",
});
@@ -78,7 +82,7 @@ describe("create_agent MCP tool", () => {
const ok = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
initialMode: "default",
mode: "default",
title: "Short title",
initialPrompt: "test",
});
@@ -91,7 +95,7 @@ describe("create_agent MCP tool", () => {
const tool = (server as any)._registeredTools["create_agent"];
const parsed = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
initialMode: "default",
mode: "default",
title: "Short title",
});
expect(parsed.success).toBe(false);
@@ -174,6 +178,41 @@ describe("create_agent MCP tool", () => {
);
});
it("passes optional model, thinking, and labels through createAgent", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "agent-789",
cwd: "/tmp/repo",
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Config test", model: "claude-sonnet-4-20250514" },
} as ManagedAgent);
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = (server as any)._registeredTools["create_agent"];
await tool.callback({
cwd: existingCwd,
title: "Config test",
mode: "default",
initialPrompt: "Do work",
model: "claude-sonnet-4-20250514",
thinking: "think-hard",
labels: { source: "mcp" },
});
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: existingCwd,
title: "Config test",
model: "claude-sonnet-4-20250514",
thinkingOptionId: "think-hard",
}),
undefined,
{ labels: { source: "mcp" } },
);
});
it("allows caller agents to override cwd and applies caller context labels", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const baseDir = await mkdtemp(join(tmpdir(), "paseo-mcp-test-"));
@@ -209,7 +248,7 @@ describe("create_agent MCP tool", () => {
await tool.callback({
cwd: "subdir",
title: "Child",
agentType: "codex",
provider: "codex",
initialPrompt: "Do work",
});
@@ -218,10 +257,49 @@ describe("create_agent MCP tool", () => {
cwd: subdir,
}),
undefined,
{ labels: { source: "voice" } },
{
labels: {
"paseo.parent-agent-id": "voice-agent",
source: "voice",
},
},
);
await rm(baseDir, { recursive: true, force: true });
});
it("delegates MCP injection to AgentManager and passes through an undefined agent ID", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "agent-injected-123",
cwd: "/tmp/repo",
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Injected config test" },
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
});
const tool = (server as any)._registeredTools["create_agent"];
await tool.callback({
cwd: existingCwd,
title: "Injected config test",
mode: "default",
initialPrompt: "Do work",
});
const [configArg, agentIdArg, optionsArg] = spies.agentManager.createAgent.mock.calls[0];
expect(configArg).toMatchObject({
cwd: existingCwd,
title: "Injected config test",
});
expect(configArg.mcpServers).toBeUndefined();
expect(agentIdArg).toBeUndefined();
expect(optionsArg).toBeUndefined();
});
});
describe("speak MCP tool", () => {
@@ -278,3 +356,54 @@ describe("speak MCP tool", () => {
expect(tool).toBeUndefined();
});
});
describe("agent snapshot MCP serialization", () => {
const logger = createTestLogger();
it("normalizes null features to an empty array for list_agents", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.listAgents = vi.fn().mockReturnValue([
{
id: "agent-null-features",
provider: "claude",
cwd: "/tmp/repo",
config: {},
runtimeInfo: undefined,
createdAt: new Date("2026-04-11T00:00:00.000Z"),
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
lastUserMessageAt: null,
lifecycle: "idle",
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: true,
supportsReasoningStream: false,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
features: null,
pendingPermissions: new Map(),
persistence: null,
labels: {},
attention: { requiresAttention: false },
} as unknown as ManagedAgent,
]);
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = (server as any)._registeredTools["list_agents"];
const response = await tool.callback({});
const structured = response.structuredContent;
expect(structured).toEqual({
agents: [
expect.objectContaining({
id: "agent-null-features",
features: [],
}),
],
});
expect(Array.isArray(structured.agents[0].features)).toBe(true);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,319 @@
import { z } from "zod";
import type { Logger } from "pino";
import type { AgentPromptInput, AgentProvider, AgentPermissionRequest } from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import type { AgentStorage } from "./agent-storage.js";
import { serializeAgentSnapshot } from "../messages.js";
import { StoredScheduleSchema } from "../schedule/types.js";
export const AgentProviderEnum = z.enum(
AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [
AgentProvider,
...AgentProvider[],
],
);
export const AgentStatusEnum = z.enum(["initializing", "idle", "running", "error", "closed"]);
export const ProviderModeSchema = z.object({
id: z.string(),
label: z.string(),
description: z.string().optional(),
});
export const ProviderSummarySchema = z.object({
id: z.string(),
label: z.string(),
modes: z.array(ProviderModeSchema),
});
export const AgentSelectOptionSchema = z.object({
id: z.string(),
label: z.string(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
metadata: z.record(z.unknown()).optional(),
});
export const AgentModelSchema = z.object({
provider: z.string(),
id: z.string(),
label: z.string(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
metadata: z.record(z.unknown()).optional(),
thinkingOptions: z.array(AgentSelectOptionSchema).optional(),
defaultThinkingOptionId: z.string().optional(),
});
// 30 seconds - surface friendly message before SDK tool timeout (~60s)
export const AGENT_WAIT_TIMEOUT_MS = 30000;
export type StartAgentRunOptions = {
replaceRunning?: boolean;
};
/**
* Wraps agentManager.waitForAgentEvent with a self-imposed timeout.
* Returns a friendly message when timeout occurs, rather than letting
* the SDK tool timeout trigger a generic "tool failed" error.
*/
export async function waitForAgentWithTimeout(
agentManager: AgentManager,
agentId: string,
options?: {
signal?: AbortSignal;
waitForActive?: boolean;
},
): Promise<WaitForAgentResult> {
const timeoutController = new AbortController();
const combinedController = new AbortController();
const timeoutId = setTimeout(() => {
timeoutController.abort(new Error("wait timeout"));
}, AGENT_WAIT_TIMEOUT_MS);
const forwardAbort = (reason: unknown) => {
if (!combinedController.signal.aborted) {
combinedController.abort(reason);
}
};
if (options?.signal) {
if (options.signal.aborted) {
forwardAbort(options.signal.reason);
} else {
options.signal.addEventListener("abort", () => forwardAbort(options.signal!.reason), {
once: true,
});
}
}
timeoutController.signal.addEventListener(
"abort",
() => forwardAbort(timeoutController.signal.reason),
{ once: true },
);
try {
const result = await agentManager.waitForAgentEvent(agentId, {
signal: combinedController.signal,
waitForActive: options?.waitForActive,
});
return result;
} catch (error) {
if (error instanceof Error && error.message === "wait timeout") {
const snapshot = agentManager.getAgent(agentId);
const timeline = agentManager.getTimeline(agentId);
const recentActivity = curateAgentActivity(timeline.slice(-5));
const waitedSeconds = Math.round(AGENT_WAIT_TIMEOUT_MS / 1000);
const message = `Awaiting the agent timed out after ${waitedSeconds}s. This does not mean the agent failed - call wait_for_agent again to continue waiting.\n\nRecent activity:\n${recentActivity}`;
return {
status: snapshot?.lifecycle ?? "idle",
permission: null,
lastMessage: message,
};
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
export function startAgentRun(
agentManager: AgentManager,
agentId: string,
prompt: AgentPromptInput,
logger: Logger,
options?: StartAgentRunOptions,
): void {
const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
const iterator = shouldReplace
? agentManager.replaceAgentRun(agentId, prompt)
: agentManager.streamAgent(agentId, prompt);
void (async () => {
try {
for await (const _ of iterator) {
// Events are broadcast via AgentManager subscribers.
}
} catch (error) {
logger.error({ err: error, agentId }, "Agent stream failed");
}
})();
}
interface SetupFinishNotificationParams {
agentManager: AgentManager;
childAgentId: string;
callerAgentId: string;
logger: Logger;
}
export function setupFinishNotification(params: SetupFinishNotificationParams): void {
const { agentManager, childAgentId, callerAgentId, logger } = params;
let hasSeenRunning = false;
let fired = false;
let unsubscribe: (() => void) | null = null;
function notify(reason: "finished" | "errored" | "needs permission"): void {
if (fired) {
return;
}
fired = true;
unsubscribe?.();
if (!agentManager.getAgent(callerAgentId)) {
return;
}
const title = agentManager.getAgent(childAgentId)?.config?.title ?? childAgentId;
const prompt = `<paseo-system>\nAgent ${childAgentId} (${title}) ${reason}.\n</paseo-system>`;
startAgentRun(agentManager, callerAgentId, prompt, logger, {
replaceRunning: true,
});
}
unsubscribe = agentManager.subscribe(
(event) => {
if (fired) {
return;
}
if (event.type === "agent_state") {
if (event.agent.lifecycle === "running") {
hasSeenRunning = true;
return;
}
if (event.agent.lifecycle === "error") {
notify("errored");
return;
}
if (event.agent.lifecycle === "idle" && hasSeenRunning) {
notify("finished");
return;
}
if (event.agent.lifecycle === "closed") {
fired = true;
unsubscribe?.();
return;
}
return;
}
if (event.event.type === "permission_requested") {
notify("needs permission");
}
},
{ agentId: childAgentId, replayState: false },
);
// Check if the child is already running (catches the case where
// the lifecycle flipped before our subscribe call was processed).
// Do NOT treat an immediate "idle" as "finished" — the agent may
// not have started yet (streamAgent sets a pending run before
// transitioning to "running").
const childSnapshot = agentManager.getAgent(childAgentId);
if (!childSnapshot || childSnapshot.lifecycle === "closed") {
unsubscribe();
return;
}
if (childSnapshot.lifecycle === "running") {
hasSeenRunning = true;
} else if (childSnapshot.lifecycle === "error") {
notify("errored");
}
}
export function sanitizePermissionRequest(
permission: AgentPermissionRequest | null | undefined,
): AgentPermissionRequest | null {
if (!permission) {
return null;
}
const sanitized: AgentPermissionRequest = { ...permission };
if (sanitized.title === undefined) {
delete sanitized.title;
}
if (sanitized.description === undefined) {
delete sanitized.description;
}
if (sanitized.input === undefined) {
delete sanitized.input;
}
if (sanitized.suggestions === undefined) {
delete sanitized.suggestions;
}
if (sanitized.actions === undefined) {
delete sanitized.actions;
}
if (sanitized.metadata === undefined) {
delete sanitized.metadata;
}
return sanitized;
}
export async function resolveAgentTitle(
agentStorage: AgentStorage,
agentId: string,
logger: Logger,
): Promise<string | null> {
try {
const record = await agentStorage.get(agentId);
return record?.title ?? null;
} catch (error) {
logger.error({ err: error, agentId }, "Failed to load agent title");
return null;
}
}
export async function serializeSnapshotWithMetadata(
agentStorage: AgentStorage,
snapshot: ManagedAgent,
logger: Logger,
) {
const title = await resolveAgentTitle(agentStorage, snapshot.id, logger);
return serializeAgentSnapshot(snapshot, { title });
}
export function parseDurationString(input: string): number {
const trimmed = input.trim();
if (/^\d+$/.test(trimmed)) {
return Number.parseInt(trimmed, 10) * 1000;
}
let totalMs = 0;
let hasMatch = false;
const regex = /(\d+)([smh])/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(trimmed)) !== null) {
hasMatch = true;
const value = Number.parseInt(match[1], 10);
switch (match[2]) {
case "s":
totalMs += value * 1000;
break;
case "m":
totalMs += value * 60 * 1000;
break;
case "h":
totalMs += value * 60 * 60 * 1000;
break;
}
}
if (!hasMatch) {
throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m`);
}
return totalMs;
}
export function toScheduleSummary(schedule: z.infer<typeof StoredScheduleSchema>) {
const { runs: _runs, ...summary } = schedule;
return summary;
}
@@ -1,8 +1,8 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { describe, expect, test } from "vitest";
import { execFileSync } from "node:child_process";
import type { AgentModelDefinition } from "../agent-sdk-types.js";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
import { createDaemonTestContext } from "../test-utils/index.js";
function isBinaryInstalled(binary: string): boolean {
try {
@@ -16,10 +16,7 @@ function isBinaryInstalled(binary: string): boolean {
const hasCodex = isBinaryInstalled("codex");
const hasOpenCode = isBinaryInstalled("opencode");
function modelMatchesFamily(
model: AgentModelDefinition,
family: "sonnet" | "haiku",
): boolean {
function modelMatchesFamily(model: AgentModelDefinition, family: "sonnet" | "haiku"): boolean {
const haystacks = [model.id, model.label, model.description ?? ""].map((value) =>
value.toLowerCase(),
);
@@ -27,34 +24,34 @@ function modelMatchesFamily(
}
describe("provider model catalogs (e2e)", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60_000);
test("Claude catalog exposes Sonnet and Haiku variants", async () => {
const result = await ctx.client.listProviderModels("claude");
const ctx = await createDaemonTestContext();
try {
const result = await ctx.client.listProviderModels("claude");
expect(result.error).toBeNull();
expect(result.models.length).toBeGreaterThan(0);
expect(result.error).toBeNull();
expect(result.models.length).toBeGreaterThan(0);
expect(result.models.some((model) => modelMatchesFamily(model, "sonnet"))).toBe(true);
expect(result.models.some((model) => modelMatchesFamily(model, "haiku"))).toBe(true);
expect(result.models.some((model) => modelMatchesFamily(model, "sonnet"))).toBe(true);
expect(result.models.some((model) => modelMatchesFamily(model, "haiku"))).toBe(true);
} finally {
await ctx.cleanup();
}
}, 180_000);
test.runIf(hasCodex)(
"Codex catalog exposes gpt-5.1-codex",
async () => {
const result = await ctx.client.listProviderModels("codex");
const ctx = await createDaemonTestContext();
try {
const result = await ctx.client.listProviderModels("codex");
expect(result.error).toBeNull();
const ids = result.models.map((model) => model.id);
expect(ids.some((id) => id.startsWith("gpt-5.1-codex"))).toBe(true);
expect(result.error).toBeNull();
const ids = result.models.map((model) => model.id);
expect(ids.some((id) => id.includes("codex"))).toBe(true);
} finally {
await ctx.cleanup();
}
},
180_000,
);
@@ -62,22 +59,27 @@ describe("provider model catalogs (e2e)", () => {
test.runIf(hasOpenCode)(
"OpenCode catalog returns models from multiple providers",
async () => {
const result = await ctx.client.listProviderModels("opencode");
const ctx = await createDaemonTestContext();
try {
const result = await ctx.client.listProviderModels("opencode");
expect(result.error).toBeNull();
expect(result.models.length).toBeGreaterThan(0);
expect(result.error).toBeNull();
expect(result.models.length).toBeGreaterThan(0);
for (const model of result.models) {
expect(model.provider).toBe("opencode");
expect(model.id).toContain("/");
expect(model.label).toBeTruthy();
expect(model.metadata).toBeDefined();
expect(model.metadata?.providerId).toBeTruthy();
expect(model.metadata?.modelId).toBeTruthy();
for (const model of result.models) {
expect(model.provider).toBe("opencode");
expect(model.id).toContain("/");
expect(model.label).toBeTruthy();
expect(model.metadata).toBeDefined();
expect(model.metadata?.providerId).toBeTruthy();
expect(model.metadata?.modelId).toBeTruthy();
}
const providerIds = new Set(result.models.map((m) => m.metadata?.providerId));
expect(providerIds.size).toBeGreaterThan(0);
} finally {
await ctx.cleanup();
}
const providerIds = new Set(result.models.map((m) => m.metadata?.providerId));
expect(providerIds.size).toBeGreaterThan(0);
},
180_000,
);
@@ -138,7 +138,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
voice: {
enabled: true,
defaultModeId: "auto",
defaultModel: "gpt-5.1-codex-mini",
defaultModel: "gpt-5.4-mini",
},
},
{
@@ -59,7 +59,8 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
runtimeSettings: runtimeSettings?.copilot,
}),
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
pi: (logger, runtimeSettings) => new PiACPAgentClient({ logger, runtimeSettings: runtimeSettings?.pi }),
pi: (logger, runtimeSettings) =>
new PiACPAgentClient({ logger, runtimeSettings: runtimeSettings?.pi }),
};
function getProviderClientFactory(provider: string): ProviderClientFactory {
@@ -236,9 +236,7 @@ describe("ProviderSnapshotManager", () => {
});
manager.refresh("/tmp/project");
expect(manager.getSnapshot("/tmp/project")).toEqual([
{ provider: "codex", status: "loading" },
]);
expect(manager.getSnapshot("/tmp/project")).toEqual([{ provider: "codex", status: "loading" }]);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe(
@@ -267,9 +265,7 @@ describe("ProviderSnapshotManager", () => {
manager.refresh("/tmp/project");
expect(manager.getSnapshot("/tmp/project")).toEqual([
{ provider: "codex", status: "loading" },
]);
expect(manager.getSnapshot("/tmp/project")).toEqual([{ provider: "codex", status: "loading" }]);
manager.refresh("/tmp/project");
manager.refresh("/tmp/project");
@@ -347,8 +343,12 @@ describe("ProviderSnapshotManager", () => {
manager.getSnapshot("/tmp/project-b");
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.status).toBe("ready");
expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.status).toBe("ready");
expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.status).toBe(
"ready",
);
expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.status).toBe(
"ready",
);
});
expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.models?.[0]?.id).toBe(
@@ -381,19 +381,24 @@ function createRegistry(handles: MockProviderHandle[]): {
registry: Object.fromEntries(
handles.map((handle) => [handle.definition.id, handle.definition]),
) as Record<AgentProvider, ProviderDefinition>,
handles: Object.fromEntries(
handles.map((handle) => [handle.definition.id, handle]),
) as Record<AgentProvider, MockProviderHandle>,
handles: Object.fromEntries(handles.map((handle) => [handle.definition.id, handle])) as Record<
AgentProvider,
MockProviderHandle
>,
};
}
function createMockProvider(options: MockProviderOptions): MockProviderHandle {
const isAvailable = vi.fn(async () => options.isAvailable?.() ?? true);
const fetchModels = vi.fn(async (listOptions?: { cwd?: string }) =>
options.fetchModels?.(listOptions?.cwd) ?? [createModel(options.provider, `${options.provider}-default`)],
const fetchModels = vi.fn(
async (listOptions?: { cwd?: string }) =>
options.fetchModels?.(listOptions?.cwd) ?? [
createModel(options.provider, `${options.provider}-default`),
],
);
const fetchModes = vi.fn(async (listOptions?: { cwd?: string }) =>
options.fetchModes?.(listOptions?.cwd) ?? [createMode(`${options.provider}-mode`)],
const fetchModes = vi.fn(
async (listOptions?: { cwd?: string }) =>
options.fetchModes?.(listOptions?.cwd) ?? [createMode(`${options.provider}-mode`)],
);
const definition: ProviderDefinition = {
@@ -3,19 +3,13 @@ import { resolve } from "node:path";
import type { Logger } from "pino";
import type {
AgentProvider,
ProviderSnapshotEntry,
} from "./agent-sdk-types.js";
import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js";
import type { ProviderDefinition } from "./provider-registry.js";
import { AGENT_PROVIDER_IDS } from "./provider-manifest.js";
const DEFAULT_CWD_KEY = "__default__";
type ProviderSnapshotChangeListener = (
entries: ProviderSnapshotEntry[],
cwd?: string,
) => void;
type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd?: string) => void;
export class ProviderSnapshotManager {
private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>();
@@ -146,7 +140,10 @@ export class ProviderSnapshotManager {
status: "error",
error: toErrorMessage(error),
});
this.logger.warn({ err: error, provider, cwd: cwdKey }, "Failed to refresh provider snapshot");
this.logger.warn(
{ err: error, provider, cwd: cwdKey },
"Failed to refresh provider snapshot",
);
this.emitChange(cwdKey);
}
}
@@ -55,10 +55,7 @@ function eventsForTurn(events: AgentStreamEvent[], turnId: string): AgentStreamE
function userMessagesWithText(events: AgentStreamEvent[], text: string): AgentStreamEvent[] {
return events.filter(
(e) =>
e.type === "timeline" &&
e.item.type === "user_message" &&
e.item.text === text,
(e) => e.type === "timeline" && e.item.type === "user_message" && e.item.text === text,
);
}
@@ -185,12 +182,8 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
}
// Invariant 4: Autonomous turns have distinct turnIds from foreground turns
const allTurnIds = new Set(
events.filter(hasTurnId).map((e) => e.turnId),
);
const autonomousTurnIds = [...allTurnIds].filter(
(id) => !foregroundTurnIds.includes(id),
);
const allTurnIds = new Set(events.filter(hasTurnId).map((e) => e.turnId));
const autonomousTurnIds = [...allTurnIds].filter((id) => !foregroundTurnIds.includes(id));
for (const autoId of autonomousTurnIds) {
expect(foregroundTurnIds).not.toContain(autoId);
}
@@ -201,300 +194,332 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
// ---------------------------------------------------------------------------
describe("Agent event stream redesign — integration", () => {
test.skipIf(!canRun)("Test 1: Basic foreground turn", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-basic-" });
test.skipIf(!canRun)(
"Test 1: Basic foreground turn",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-basic-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(
handle.session,
"respond with just the word hello",
);
try {
const { turnId, events } = await startTurnAndCollectEvents(
handle.session,
"respond with just the word hello",
);
const turnStarted = events.find(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
);
expect(turnStarted).toBeDefined();
const terminal = events.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
expect(terminal).toBeDefined();
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test.skipIf(!canRun)("Test 2: No duplicate user_messages — THE BUG", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-dedup-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
extraMs: 3_000,
});
expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1);
// No turn_started after terminal for the same turnId
const terminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
const staleTurnStarted = events.slice(terminalIdx + 1).filter(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
);
expect(staleTurnStarted.length).toBe(0);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test.skipIf(!canRun)("Test 3: Lifecycle doesn't get stuck in running", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
extraMs: 3_000,
});
const terminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
const afterTerminal = events.slice(terminalIdx + 1);
// No subsequent turn_started for same turnId
expect(
afterTerminal.filter(
const turnStarted = events.find(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
).length,
).toBe(0);
);
expect(turnStarted).toBeDefined();
// Any turn_started after terminal must have a different turnId
for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
expect((ts as EventWithTurnId).turnId).not.toBe(turnId);
const terminal = events.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
expect(terminal).toBeDefined();
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
},
60_000,
);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test.skipIf(!canRun)(
"Test 2: No duplicate user_messages — THE BUG",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-dedup-" });
test.skipIf(!canRun)("Test 4: Autonomous run", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" });
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
try {
const { turnId: fgTurnId, events } = await startTurnAndCollectEvents(
handle.session,
[
"Use the Task tool to start a background sub-agent.",
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
"Do not wait for task completion.",
"Reply immediately with exactly: SPAWNED",
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
].join(" "),
{
extraMs: 10_000,
timeoutMs: 60_000,
},
);
const fgTerminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId,
);
const afterForeground = events.slice(fgTerminalIdx + 1);
// Autonomous turn_started with a different turnId
const autoStarts = afterForeground.filter(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
) as EventWithTurnId[];
if (autoStarts.length === 0) {
assertInvariants(events, [fgTurnId]);
return;
}
const autoTurnId = autoStarts[0]!.turnId;
expect(fgTurnId).not.toBe(autoTurnId);
// Autonomous turn reaches terminal
expect(
afterForeground.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId,
),
).toBeDefined();
assertInvariants(events, [fgTurnId]);
} finally {
await cleanupSession(handle);
}
}, 90_000);
test.skipIf(!canRun)("Test 5: Interruption", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" });
try {
let turnId: string | null = null;
const events = await new Promise<AgentStreamEvent[]>((resolve, reject) => {
const collected: AgentStreamEvent[] = [];
let interrupted = false;
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out after 45000ms waiting for terminal event"));
}, 45_000);
const unsubscribe = handle.session.subscribe((event) => {
collected.push(event);
if (!turnId && event.type === "turn_started" && hasTurnId(event)) {
turnId = event.turnId;
}
// Once we see turn_started, fire the interrupt
if (
!interrupted &&
turnId &&
event.type === "turn_started" &&
hasTurnId(event) &&
event.turnId === turnId
) {
interrupted = true;
handle.session.interrupt().catch(() => undefined);
}
// Resolve when we get a terminal event for this turn
if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
clearTimeout(timeout);
unsubscribe();
resolve(collected);
}
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
extraMs: 3_000,
});
void handle.session
.startTurn("write a very long essay about the history of computing")
.then((result) => {
if (turnId && turnId !== result.turnId) {
expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1);
// No turn_started after terminal for the same turnId
const terminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
const staleTurnStarted = events
.slice(terminalIdx + 1)
.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId);
expect(staleTurnStarted.length).toBe(0);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
},
60_000,
);
test.skipIf(!canRun)(
"Test 3: Lifecycle doesn't get stuck in running",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
extraMs: 3_000,
});
const terminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
const afterTerminal = events.slice(terminalIdx + 1);
// No subsequent turn_started for same turnId
expect(
afterTerminal.filter(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
).length,
).toBe(0);
// Any turn_started after terminal must have a different turnId
for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
expect((ts as EventWithTurnId).turnId).not.toBe(turnId);
}
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
},
60_000,
);
test.skipIf(!canRun)(
"Test 4: Autonomous run",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" });
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
try {
const { turnId: fgTurnId, events } = await startTurnAndCollectEvents(
handle.session,
[
"Use the Task tool to start a background sub-agent.",
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
"Do not wait for task completion.",
"Reply immediately with exactly: SPAWNED",
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
].join(" "),
{
extraMs: 10_000,
timeoutMs: 60_000,
},
);
const fgTerminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId,
);
const afterForeground = events.slice(fgTerminalIdx + 1);
// Autonomous turn_started with a different turnId
const autoStarts = afterForeground.filter(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
) as EventWithTurnId[];
if (autoStarts.length === 0) {
assertInvariants(events, [fgTurnId]);
return;
}
const autoTurnId = autoStarts[0]!.turnId;
expect(fgTurnId).not.toBe(autoTurnId);
// Autonomous turn reaches terminal
expect(
afterForeground.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId,
),
).toBeDefined();
assertInvariants(events, [fgTurnId]);
} finally {
await cleanupSession(handle);
}
},
90_000,
);
test.skipIf(!canRun)(
"Test 5: Interruption",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" });
try {
let turnId: string | null = null;
const events = await new Promise<AgentStreamEvent[]>((resolve, reject) => {
const collected: AgentStreamEvent[] = [];
let interrupted = false;
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out after 45000ms waiting for terminal event"));
}, 45_000);
const unsubscribe = handle.session.subscribe((event) => {
collected.push(event);
if (!turnId && event.type === "turn_started" && hasTurnId(event)) {
turnId = event.turnId;
}
// Once we see turn_started, fire the interrupt
if (
!interrupted &&
turnId &&
event.type === "turn_started" &&
hasTurnId(event) &&
event.turnId === turnId
) {
interrupted = true;
handle.session.interrupt().catch(() => undefined);
}
// Resolve when we get a terminal event for this turn
if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
clearTimeout(timeout);
unsubscribe();
reject(
new Error(
`Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`,
),
);
return;
resolve(collected);
}
turnId = result.turnId;
})
.catch((error) => {
clearTimeout(timeout);
unsubscribe();
reject(error);
});
});
expect(turnId).toBeDefined();
void handle.session
.startTurn("write a very long essay about the history of computing")
.then((result) => {
if (turnId && turnId !== result.turnId) {
clearTimeout(timeout);
unsubscribe();
reject(
new Error(
`Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`,
),
);
return;
}
turnId = result.turnId;
})
.catch((error) => {
clearTimeout(timeout);
unsubscribe();
reject(error);
});
});
// turn_canceled or turn_failed arrives for that turnId
const terminal = events.find(
(e) =>
(e.type === "turn_canceled" || e.type === "turn_failed") &&
hasTurnId(e) &&
e.turnId === turnId,
);
expect(terminal).toBeDefined();
expect(turnId).toBeDefined();
// No further events for that turnId after terminal
const terminalIdx = events.indexOf(terminal!);
expect(
events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length,
).toBe(0);
// turn_canceled or turn_failed arrives for that turnId
const terminal = events.find(
(e) =>
(e.type === "turn_canceled" || e.type === "turn_failed") &&
hasTurnId(e) &&
e.turnId === turnId,
);
expect(terminal).toBeDefined();
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
// No further events for that turnId after terminal
const terminalIdx = events.indexOf(terminal!);
expect(
events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length,
).toBe(0);
test.skipIf(!canRun)("Test 6: Sequential foreground turns", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-sequential-" });
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
},
60_000,
);
try {
const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents(
handle.session,
"say first",
);
test.skipIf(!canRun)(
"Test 6: Sequential foreground turns",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-sequential-" });
const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents(
handle.session,
"say second",
);
try {
const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents(
handle.session,
"say first",
);
const allEvents = [...events1, ...events2];
const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents(
handle.session,
"say second",
);
expect(turnId1).not.toBe(turnId2);
const allEvents = [...events1, ...events2];
// No events from turn 1 after turn 2 starts
const turn2StartIdx = allEvents.findIndex(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2,
);
expect(
allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1)
.length,
).toBe(0);
expect(turnId1).not.toBe(turnId2);
assertInvariants(allEvents, [turnId1, turnId2]);
} finally {
await cleanupSession(handle);
}
}, 90_000);
// No events from turn 1 after turn 2 starts
const turn2StartIdx = allEvents.findIndex(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2,
);
expect(
allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1)
.length,
).toBe(0);
test.skipIf(!canRun)("Test 7: Fast-fail", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" });
assertInvariants(allEvents, [turnId1, turnId2]);
} finally {
await cleanupSession(handle);
}
},
90_000,
);
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", {
extraMs: 3_000,
});
test.skipIf(!canRun)(
"Test 7: Fast-fail",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" });
// At most one turn_started
expect(
events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId)
.length,
).toBeLessThanOrEqual(1);
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", {
extraMs: 3_000,
});
// Terminal present
const terminal = events.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
expect(terminal).toBeDefined();
// At most one turn_started
expect(
events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId)
.length,
).toBeLessThanOrEqual(1);
// No stale turn_started after terminal
const terminalIdx = events.indexOf(terminal!);
expect(
events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length,
).toBe(0);
// Terminal present
const terminal = events.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
expect(terminal).toBeDefined();
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
// No stale turn_started after terminal
const terminalIdx = events.indexOf(terminal!);
expect(events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length).toBe(
0,
);
test.skipIf(!canRun)("Test 8: User message dedup by text", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" });
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
},
60_000,
);
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", {
extraMs: 3_000,
});
test.skipIf(!canRun)(
"Test 8: User message dedup by text",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" });
expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1);
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", {
extraMs: 3_000,
});
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
},
60_000,
);
});
@@ -75,23 +75,19 @@ describe("deriveModesFromACP", () => {
});
test("falls back to config options when explicit mode state is absent", () => {
const result = deriveModesFromACP(
[{ id: "fallback", label: "Fallback" }],
null,
[
{
id: "mode",
name: "Mode",
category: "mode",
type: "select",
currentValue: "acceptEdits",
options: [
{ value: "default", name: "Always Ask" },
{ value: "acceptEdits", name: "Accept File Edits" },
],
},
],
);
const result = deriveModesFromACP([{ id: "fallback", label: "Fallback" }], null, [
{
id: "mode",
name: "Mode",
category: "mode",
type: "select",
currentValue: "acceptEdits",
options: [
{ value: "default", name: "Always Ask" },
{ value: "acceptEdits", name: "Accept File Edits" },
],
},
]);
expect(result).toEqual({
modes: [
@@ -103,13 +99,43 @@ describe("deriveModesFromACP", () => {
});
test("returns an empty mode list when fallback modes are empty and config only exposes thought levels", () => {
const result = deriveModesFromACP(
[],
null,
const result = deriveModesFromACP([], null, [
{
id: "thought_level",
name: "Thinking",
category: "thought_level",
type: "select",
currentValue: "medium",
options: [
{ value: "low", name: "Low" },
{ value: "medium", name: "Medium" },
{ value: "high", name: "High" },
],
},
]);
expect(result).toEqual({
modes: [],
currentModeId: null,
});
});
});
describe("deriveModelDefinitionsFromACP", () => {
test("attaches shared thinking options to ACP model state", () => {
const result = deriveModelDefinitionsFromACP(
"claude-acp",
{
availableModels: [
{ modelId: "haiku", name: "Haiku", description: "Fast" },
{ modelId: "sonnet", name: "Sonnet", description: "Balanced" },
],
currentModelId: "haiku",
},
[
{
id: "thought_level",
name: "Thinking",
id: "reasoning",
name: "Reasoning",
category: "thought_level",
type: "select",
currentValue: "medium",
@@ -122,36 +148,6 @@ describe("deriveModesFromACP", () => {
],
);
expect(result).toEqual({
modes: [],
currentModeId: null,
});
});
});
describe("deriveModelDefinitionsFromACP", () => {
test("attaches shared thinking options to ACP model state", () => {
const result = deriveModelDefinitionsFromACP("claude-acp", {
availableModels: [
{ modelId: "haiku", name: "Haiku", description: "Fast" },
{ modelId: "sonnet", name: "Sonnet", description: "Balanced" },
],
currentModelId: "haiku",
}, [
{
id: "reasoning",
name: "Reasoning",
category: "thought_level",
type: "select",
currentValue: "medium",
options: [
{ value: "low", name: "Low" },
{ value: "medium", name: "Medium" },
{ value: "high", name: "High" },
],
},
]);
expect(result).toEqual([
{
provider: "claude-acp",
@@ -160,9 +156,27 @@ describe("deriveModelDefinitionsFromACP", () => {
description: "Fast",
isDefault: true,
thinkingOptions: [
{ id: "low", label: "Low", description: undefined, isDefault: false, metadata: undefined },
{ id: "medium", label: "Medium", description: undefined, isDefault: true, metadata: undefined },
{ id: "high", label: "High", description: undefined, isDefault: false, metadata: undefined },
{
id: "low",
label: "Low",
description: undefined,
isDefault: false,
metadata: undefined,
},
{
id: "medium",
label: "Medium",
description: undefined,
isDefault: true,
metadata: undefined,
},
{
id: "high",
label: "High",
description: undefined,
isDefault: false,
metadata: undefined,
},
],
defaultThinkingOptionId: "medium",
},
@@ -173,9 +187,27 @@ describe("deriveModelDefinitionsFromACP", () => {
description: "Balanced",
isDefault: false,
thinkingOptions: [
{ id: "low", label: "Low", description: undefined, isDefault: false, metadata: undefined },
{ id: "medium", label: "Medium", description: undefined, isDefault: true, metadata: undefined },
{ id: "high", label: "High", description: undefined, isDefault: false, metadata: undefined },
{
id: "low",
label: "Low",
description: undefined,
isDefault: false,
metadata: undefined,
},
{
id: "medium",
label: "Medium",
description: undefined,
isDefault: true,
metadata: undefined,
},
{
id: "high",
label: "High",
description: undefined,
isDefault: false,
metadata: undefined,
},
],
defaultThinkingOptionId: "medium",
},
@@ -604,9 +636,7 @@ describe("ACPAgentSession", () => {
thinkingOptionId: null,
modeId: "xhigh",
})),
getAvailableModes: vi.fn(async () => [
{ id: "xhigh", label: "xhigh" },
]),
getAvailableModes: vi.fn(async () => [{ id: "xhigh", label: "xhigh" }]),
getCurrentMode: vi.fn(async () => "xhigh"),
setMode: vi.fn(),
getPendingPermissions: vi.fn(() => []),
@@ -1,7 +1,4 @@
import {
type ChildProcess,
type ChildProcessWithoutNullStreams,
} from "node:child_process";
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
@@ -108,8 +105,7 @@ const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
terminal: true,
};
const COPILOT_AUTOPILOT_MODE =
"https://agentclientprotocol.com/protocol/session-modes#autopilot";
const COPILOT_AUTOPILOT_MODE = "https://agentclientprotocol.com/protocol/session-modes#autopilot";
type ACPAgentClientOptions = {
provider: string;
@@ -486,14 +482,15 @@ export class ACPAgentClient implements AgentClient {
}
}
protected async spawnProcess(
launchEnv?: Record<string, string>,
): Promise<SpawnedACPProcess> {
protected async spawnProcess(launchEnv?: Record<string, string>): Promise<SpawnedACPProcess> {
const { command, args } = await this.resolveLaunchCommand();
const child = spawnProcess(command, args, {
cwd: process.cwd(),
env: {
...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
...applyProviderEnv(
process.env as Record<string, string | undefined>,
this.runtimeSettings,
),
...(launchEnv ?? {}),
},
stdio: ["pipe", "pipe", "pipe"],
@@ -805,7 +802,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
};
}
async startTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<{ turnId: string }> {
async startTurn(
prompt: AgentPromptInput,
_options?: AgentRunOptions,
): Promise<{ turnId: string }> {
if (this.closed) {
throw new Error(`${this.provider} session is closed`);
}
@@ -1156,9 +1156,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.activeForegroundTurnId = null;
}
async requestPermission(
params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
if (shouldAutoApprovePermissionRequest(this.provider, this.currentMode)) {
const selectedOption = selectPermissionOption(params.options, { behavior: "allow" });
return selectedOption
@@ -1178,12 +1176,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (this.toolSnapshotTransformer) {
toolSnapshot = this.toolSnapshotTransformer(toolSnapshot);
}
const request = mapPermissionRequest(
this.provider,
requestId,
params,
toolSnapshot,
);
const request = mapPermissionRequest(this.provider, requestId, params, toolSnapshot);
const promise = new Promise<RequestPermissionResponse>((resolve, reject) => {
this.pendingPermissions.set(requestId, {
@@ -1243,11 +1236,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
async createTerminal(params: CreateTerminalRequest): Promise<{ terminalId: string }> {
const terminalId = randomUUID();
const env = Object.fromEntries((params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]));
const env = Object.fromEntries(
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
);
const child = spawnProcess(params.command, params.args ?? [], {
cwd: params.cwd ?? this.config.cwd,
env: {
...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
...applyProviderEnv(
process.env as Record<string, string | undefined>,
this.runtimeSettings,
),
...env,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -1272,9 +1270,15 @@ export class ACPAgentSession implements AgentSession, ACPClient {
rejectExit,
};
child.stdout!.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString()));
child.stderr!.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString()));
child.once("error", (error) => rejectExit(error instanceof Error ? error : new Error(String(error))));
child.stdout!.on("data", (chunk: Buffer | string) =>
appendTerminalOutput(entry, chunk.toString()),
);
child.stderr!.on("data", (chunk: Buffer | string) =>
appendTerminalOutput(entry, chunk.toString()),
);
child.once("error", (error) =>
rejectExit(error instanceof Error ? error : new Error(String(error))),
);
child.once("exit", (code, signal) => {
const exit = { exitCode: code, signal };
entry.exit = exit;
@@ -1329,7 +1333,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
const child = spawnProcess(command, args, {
cwd: this.config.cwd,
env: {
...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
...applyProviderEnv(
process.env as Record<string, string | undefined>,
this.runtimeSettings,
),
...(this.launchEnv ?? {}),
},
stdio: ["pipe", "pipe", "pipe"],
@@ -1519,7 +1526,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private deriveAvailableModels(
models: SessionModelState | null | undefined,
): AgentModelDefinition[] {
const availableModels = deriveModelDefinitionsFromACP(this.provider, models, this.configOptions);
const availableModels = deriveModelDefinitionsFromACP(
this.provider,
models,
this.configOptions,
);
return this.modelTransformer ? this.modelTransformer(availableModels) : availableModels;
}
@@ -1579,7 +1590,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
}
private finishTurn(event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>): void {
private finishTurn(
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
): void {
this.activeForegroundTurnId = null;
this.suppressUserEchoMessageId = null;
this.suppressUserEchoText = null;
@@ -1624,7 +1637,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return parts.length > 0 ? parts.join(" | ") : undefined;
}
private getSelectConfigOption(category: string): Extract<SessionConfigOption, { type: "select" }> | null {
private getSelectConfigOption(
category: string,
): Extract<SessionConfigOption, { type: "select" }> | null {
const option = this.configOptions.find(
(entry): entry is Extract<SessionConfigOption, { type: "select" }> =>
entry.type === "select" && entry.category === category,
@@ -1644,7 +1659,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
function flattenSelectOptions(
options: Extract<SessionConfigOption, { type: "select" }>["options"],
): Array<{ value: string; name: string; description?: string | null; group?: string }> {
const flattened: Array<{ value: string; name: string; description?: string | null; group?: string }> = [];
const flattened: Array<{
value: string;
name: string;
description?: string | null;
group?: string;
}> = [];
for (const option of options) {
if ("value" in option) {
flattened.push(option);
@@ -1756,7 +1776,9 @@ function extractPromptText(prompt: AgentPromptInput): string {
return prompt;
}
return prompt
.filter((block): block is Extract<AgentPromptContentBlock, { type: "text" }> => block.type === "text")
.filter(
(block): block is Extract<AgentPromptContentBlock, { type: "text" }> => block.type === "text",
)
.map((block) => block.text)
.join("");
}
@@ -1768,7 +1790,9 @@ function contentBlockToText(content: ContentBlock): string {
case "resource_link":
return content.title ?? content.uri;
case "resource":
return "text" in content.resource ? content.resource.text : `[resource:${content.resource.mimeType ?? "binary"}]`;
return "text" in content.resource
? content.resource.text
: `[resource:${content.resource.mimeType ?? "binary"}]`;
case "image":
return "[image]";
case "audio":
@@ -1789,8 +1813,8 @@ function mergeToolSnapshot(
title: (update.title ?? previous?.title ?? toolCallId) as string,
kind: update.kind ?? previous?.kind ?? null,
status: update.status ?? previous?.status ?? null,
content: update.content !== undefined ? update.content : previous?.content ?? null,
locations: update.locations !== undefined ? update.locations : previous?.locations ?? null,
content: update.content !== undefined ? update.content : (previous?.content ?? null),
locations: update.locations !== undefined ? update.locations : (previous?.locations ?? null),
rawInput: update.rawInput !== undefined ? update.rawInput : previous?.rawInput,
rawOutput: update.rawOutput !== undefined ? update.rawOutput : previous?.rawOutput,
...(isFull ? {} : {}),
@@ -1857,7 +1881,10 @@ function mapToolStatus(status: ToolCallStatus | null | undefined): ToolCallTimel
}
}
function mapToolDetail(snapshot: ACPToolSnapshot, terminals: Map<string, TerminalEntry>): ToolCallDetail {
function mapToolDetail(
snapshot: ACPToolSnapshot,
terminals: Map<string, TerminalEntry>,
): ToolCallDetail {
const firstLocation = snapshot.locations?.[0]?.path;
const textContent = extractToolText(snapshot.content);
const diffContent = extractDiffContent(snapshot.content);
@@ -1885,7 +1912,7 @@ function mapToolDetail(snapshot: ACPToolSnapshot, terminals: Map<string, Termina
newString:
snapshot.kind === "delete"
? ""
: diffContent?.newText ?? readString(rawInput, ["newText", "newString"]),
: (diffContent?.newText ?? readString(rawInput, ["newText", "newString"])),
unifiedDiff: textContent ?? undefined,
};
case "search":
@@ -1975,7 +2002,9 @@ function extractToolText(content: ToolCallContent[] | null | undefined): string
function extractDiffContent(
content: ToolCallContent[] | null | undefined,
): { oldText?: string | null; newText: string } | null {
const diff = content?.find((item): item is Extract<ToolCallContent, { type: "diff" }> => item.type === "diff");
const diff = content?.find(
(item): item is Extract<ToolCallContent, { type: "diff" }> => item.type === "diff",
);
return diff ? { oldText: diff.oldText ?? undefined, newText: diff.newText } : null;
}
@@ -2067,10 +2096,7 @@ function readRecord(value: unknown): Record<string, unknown> | null {
: null;
}
function readString(
record: Record<string, unknown> | null,
keys: string[],
): string | undefined {
function readString(record: Record<string, unknown> | null, keys: string[]): string | undefined {
if (!record) {
return undefined;
}
@@ -2083,10 +2109,7 @@ function readString(
return undefined;
}
function readNumber(
record: Record<string, unknown> | null,
keys: string[],
): number | undefined {
function readNumber(record: Record<string, unknown> | null, keys: string[]): number | undefined {
if (!record) {
return undefined;
}
@@ -2135,7 +2158,9 @@ function stringifyUnknown(value: unknown): string | undefined {
}
}
function coerceSessionConfigMetadata(metadata: AgentMetadata | undefined): Partial<AgentSessionConfig> {
function coerceSessionConfigMetadata(
metadata: AgentMetadata | undefined,
): Partial<AgentSessionConfig> {
if (!metadata || typeof metadata !== "object") {
return {};
}
@@ -182,36 +182,40 @@ describe("ClaudeAgentSession integration", () => {
}
});
test.runIf(canRunClaudeIntegration)("streams a basic response turn end-to-end", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-response-",
});
try {
const events = await collectUntilTerminal(
streamSession(handle.session, "Respond with exactly: HELLO_WORLD"),
);
expect(events[0]).toMatchObject({
type: "turn_started",
provider: "claude",
test.runIf(canRunClaudeIntegration)(
"streams a basic response turn end-to-end",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-response-",
});
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
compactText(event.item.text).includes("hello_world"),
),
).toBe(true);
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
}, 60_000);
try {
const events = await collectUntilTerminal(
streamSession(handle.session, "Respond with exactly: HELLO_WORLD"),
);
expect(events[0]).toMatchObject({
type: "turn_started",
provider: "claude",
});
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
compactText(event.item.text).includes("hello_world"),
),
).toBe(true);
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000,
);
test.runIf(canRunClaudeIntegration)(
"keeps bypassPermissions available after a thinking-option restart",
@@ -276,100 +280,104 @@ describe("ClaudeAgentSession integration", () => {
60_000,
);
test.runIf(canRunClaudeIntegration)("runs a real Bash tool call and completes it", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-tool-",
});
try {
const events = await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Bash tool.",
"Run exactly: echo TOOL_TEST_OUTPUT",
"After the command completes, reply with exactly: TOOL_DONE",
].join(" "),
),
);
const bashCalls = getToolCalls(events).filter((item) => item.name.toLowerCase() === "bash");
const completedBashCall = getLatestCompletedBashCall(events);
expect(bashCalls.length).toBeGreaterThan(0);
expect(completedBashCall).toBeDefined();
expect(completedBashCall?.detail.type).toBe("shell");
expect(
completedBashCall?.detail.type === "shell" &&
completedBashCall.detail.output?.includes("TOOL_TEST_OUTPUT"),
).toBe(true);
expect(compactText(getAssistantText(events))).toContain("tool_done");
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
test.runIf(canRunClaudeIntegration)(
"runs a real Bash tool call and completes it",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-tool-",
});
} finally {
await cleanupSession(handle);
}
}, 60_000);
try {
const events = await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Bash tool.",
"Run exactly: echo TOOL_TEST_OUTPUT",
"After the command completes, reply with exactly: TOOL_DONE",
].join(" "),
),
);
const bashCalls = getToolCalls(events).filter((item) => item.name.toLowerCase() === "bash");
const completedBashCall = getLatestCompletedBashCall(events);
expect(bashCalls.length).toBeGreaterThan(0);
expect(completedBashCall).toBeDefined();
expect(completedBashCall?.detail.type).toBe("shell");
expect(
completedBashCall?.detail.type === "shell" &&
completedBashCall.detail.output?.includes("TOOL_TEST_OUTPUT"),
).toBe(true);
expect(compactText(getAssistantText(events))).toContain("tool_done");
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000,
);
test.runIf(canRunClaudeIntegration)(
"interrupts a running Bash turn and continues on the same query",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-interrupt-continue-",
});
try {
const firstStream = streamSession(
handle.session,
[
"Use the Bash tool.",
"Run exactly: sleep 10",
"Do not use a background task.",
"Do not do anything after starting the command.",
].join(" "),
);
const initialEvents = await collectUntil(
firstStream,
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name.toLowerCase() === "bash",
45_000,
);
const firstQuery = getInternalQuery(handle.session);
expect(firstQuery).toBeTruthy();
await handle.session.interrupt();
const canceledEvents = await collectUntilTerminal(firstStream, {
timeoutMs: 20_000,
const handle = await createSession({
cwdPrefix: "claude-agent-interrupt-continue-",
});
const allFirstTurnEvents = [...initialEvents, ...canceledEvents];
expect(
allFirstTurnEvents.some(
(event) => event.type === "turn_canceled" && event.provider === "claude",
),
).toBe(true);
try {
const firstStream = streamSession(
handle.session,
[
"Use the Bash tool.",
"Run exactly: sleep 10",
"Do not use a background task.",
"Do not do anything after starting the command.",
].join(" "),
);
const followUpEvents = await collectUntilTerminal(
streamSession(handle.session, "Respond with exactly: AFTER_INTERRUPT_OK"),
);
const secondQuery = getInternalQuery(handle.session);
const initialEvents = await collectUntil(
firstStream,
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name.toLowerCase() === "bash",
45_000,
);
const firstQuery = getInternalQuery(handle.session);
expect(secondQuery).toBe(firstQuery);
expect(compactText(getAssistantText(followUpEvents))).toContain("after_interrupt_ok");
expect(followUpEvents.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
expect(firstQuery).toBeTruthy();
await handle.session.interrupt();
const canceledEvents = await collectUntilTerminal(firstStream, {
timeoutMs: 20_000,
});
const allFirstTurnEvents = [...initialEvents, ...canceledEvents];
expect(
allFirstTurnEvents.some(
(event) => event.type === "turn_canceled" && event.provider === "claude",
),
).toBe(true);
const followUpEvents = await collectUntilTerminal(
streamSession(handle.session, "Respond with exactly: AFTER_INTERRUPT_OK"),
);
const secondQuery = getInternalQuery(handle.session);
expect(secondQuery).toBe(firstQuery);
expect(compactText(getAssistantText(followUpEvents))).toContain("after_interrupt_ok");
expect(followUpEvents.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000,
);
@@ -377,106 +385,110 @@ describe("ClaudeAgentSession integration", () => {
test.runIf(canRunClaudeIntegration)(
"creates an autonomous live turn when a background task completes",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-autonomous-",
});
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
try {
const foregroundEvents = await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Task tool to start a background sub-agent.",
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
"Do not wait for task completion.",
"Reply immediately with exactly: SPAWNED",
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
].join(" "),
),
{ timeoutMs: 45_000 },
);
expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
const liveEvents = await collectSubscribedUntil(
handle.session,
(event) => isTerminalEvent(event),
45_000,
);
expect(
liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"),
).toBe(true);
expect(compactText(getAssistantText(liveEvents))).toContain(
autonomousWakeToken.toLowerCase(),
);
expect(liveEvents.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
const handle = await createSession({
cwdPrefix: "claude-agent-autonomous-",
});
} finally {
await cleanupSession(handle);
}
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
try {
const foregroundEvents = await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Task tool to start a background sub-agent.",
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
"Do not wait for task completion.",
"Reply immediately with exactly: SPAWNED",
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
].join(" "),
),
{ timeoutMs: 45_000 },
);
expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
const liveEvents = await collectSubscribedUntil(
handle.session,
(event) => isTerminalEvent(event),
45_000,
);
expect(
liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"),
).toBe(true);
expect(compactText(getAssistantText(liveEvents))).toContain(
autonomousWakeToken.toLowerCase(),
);
expect(liveEvents.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000,
);
test.runIf(canRunClaudeIntegration)("surfaces permission requests and resumes after approval", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-permission-",
modeId: "default",
});
const permissionFile = path.join(handle.cwd, "permission.txt");
test.runIf(canRunClaudeIntegration)(
"surfaces permission requests and resumes after approval",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-permission-",
modeId: "default",
});
const permissionFile = path.join(handle.cwd, "permission.txt");
try {
const events = await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt",
"If approval is required, wait for approval.",
"After the command succeeds, reply with exactly: PERM_DONE",
].join(" "),
),
{
timeoutMs: 45_000,
onEvent: async (event) => {
if (event.type !== "permission_requested") {
return;
}
await handle.session.respondToPermission(event.request.id, {
behavior: "allow",
});
try {
const events = await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt",
"If approval is required, wait for approval.",
"After the command succeeds, reply with exactly: PERM_DONE",
].join(" "),
),
{
timeoutMs: 45_000,
onEvent: async (event) => {
if (event.type !== "permission_requested") {
return;
}
await handle.session.respondToPermission(event.request.id, {
behavior: "allow",
});
},
},
},
);
);
const permissionRequest = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested",
);
const permissionResolved = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> =>
event.type === "permission_resolved",
);
const completedBashCall = getLatestCompletedBashCall(events);
const permissionRequest = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested",
);
const permissionResolved = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> =>
event.type === "permission_resolved",
);
const completedBashCall = getLatestCompletedBashCall(events);
expect(permissionRequest?.request.kind).toBe("tool");
expect(permissionResolved).toMatchObject({
type: "permission_resolved",
provider: "claude",
resolution: { behavior: "allow" },
});
expect(completedBashCall).toBeDefined();
expect(readFileSync(permissionFile, "utf8")).toBe("PERM_TEST");
expect(compactText(getAssistantText(events))).toContain("perm_done");
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
}, 60_000);
expect(permissionRequest?.request.kind).toBe("tool");
expect(permissionResolved).toMatchObject({
type: "permission_resolved",
provider: "claude",
resolution: { behavior: "allow" },
});
expect(completedBashCall).toBeDefined();
expect(readFileSync(permissionFile, "utf8")).toBe("PERM_TEST");
expect(compactText(getAssistantText(events))).toContain("perm_done");
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000,
);
});
@@ -212,7 +212,9 @@ function collectAssistantText(events: AgentStreamEvent[]): string {
.join("");
}
function subscribeToEvents(session: { subscribe: (callback: (event: AgentStreamEvent) => void) => () => void }) {
function subscribeToEvents(session: {
subscribe: (callback: (event: AgentStreamEvent) => void) => () => void;
}) {
const queue = createAsyncQueue<AgentStreamEvent>();
const unsubscribe = session.subscribe((event) => {
queue.push(event);
@@ -484,9 +486,9 @@ describe("ClaudeAgentSession interrupt regression", () => {
expect(secondTurnEvents.some((event) => event.type === "turn_canceled")).toBe(false);
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
expect(
observedSecondTurnEvents.filter((event) => event.type === "turn_started").length,
).toBe(1);
expect(observedSecondTurnEvents.filter((event) => event.type === "turn_started").length).toBe(
1,
);
expect(
observedSecondTurnEvents.some(
(event) => event.type === "turn_failed" || event.type === "turn_canceled",
@@ -806,9 +806,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
effort: options.effort,
});
return createBaseQueryMock(
vi.fn(async () => ({ done: true, value: undefined })),
);
return createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
},
);
@@ -165,8 +165,7 @@ describe("convertClaudeHistoryEntry", () => {
type: "user",
message: {
role: "user",
content:
"<local-command-stdout>Set model to claude-opus-4-6</local-command-stdout>",
content: "<local-command-stdout>Set model to claude-opus-4-6</local-command-stdout>",
},
userType: "external",
};
@@ -176,7 +175,7 @@ describe("convertClaudeHistoryEntry", () => {
message: {
role: "user",
content:
'<local-command-stdout>Set model to \u001b[1mopus (claude-opus-4-6)\u001b[22m</local-command-stdout>',
"<local-command-stdout>Set model to \u001b[1mopus (claude-opus-4-6)\u001b[22m</local-command-stdout>",
},
};
@@ -10,7 +10,6 @@ import {
type AgentDefinition,
type CanUseTool,
type McpServerConfig as ClaudeSdkMcpServerConfig,
type Options,
type PermissionMode,
type PermissionResult,
@@ -35,10 +34,7 @@ import {
mapTaskNotificationSystemRecordToToolCall,
mapTaskNotificationUserContentToToolCall,
} from "./claude/task-notification-tool-call.js";
import {
getClaudeModels,
normalizeClaudeRuntimeModelId,
} from "./claude/claude-models.js";
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude/claude-models.js";
import { parsePartialJsonObject } from "./claude/partial-json.js";
import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
import {
@@ -77,10 +73,7 @@ import type {
McpServerConfig,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js";
import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
@@ -222,8 +215,7 @@ function applyRuntimeSettingsToClaudeOptions(
// PATH lookup failures in the managed runtime bundle.
// When the SDK passes a native binary path (from pathToClaudeCodeExecutable)
// or the user overrides the command via runtime settings, use that directly.
const isDefaultRuntime =
resolved.command === "node" || resolved.command === "bun";
const isDefaultRuntime = resolved.command === "node" || resolved.command === "bun";
const command = isDefaultRuntime ? process.execPath : resolved.command;
const child = spawnProcess(command, resolved.args, {
cwd: spawnOptions.cwd,
@@ -233,6 +225,10 @@ function applyRuntimeSettingsToClaudeOptions(
},
signal: spawnOptions.signal,
stdio: ["pipe", "pipe", "pipe"],
// Bypass cmd.exe on Windows: the SDK passes --mcp-config with inline JSON
// containing double quotes, which cmd.exe mangles (strips quotes, breaks parsing).
// The command is always a resolved binary path, so shell routing is unnecessary.
shell: false,
});
if (typeof options.stderr === "function") {
child.stderr?.on("data", (chunk: Buffer | string) => {
@@ -413,7 +409,8 @@ function isClaudeNoResponsePlaceholderText(value: unknown): boolean {
return normalizeClaudeTranscriptText(value) === NO_RESPONSE_REQUESTED_PLACEHOLDER;
}
const LOCAL_COMMAND_STDOUT_PATTERN = /^\s*<local-command-stdout>[\s\S]*<\/local-command-stdout>\s*$/;
const LOCAL_COMMAND_STDOUT_PATTERN =
/^\s*<local-command-stdout>[\s\S]*<\/local-command-stdout>\s*$/;
function isClaudeLocalCommandStdout(value: unknown): boolean {
const normalized = normalizeClaudeTranscriptText(value);
@@ -1141,7 +1138,7 @@ export class ClaudeAgentClient implements AgentClient {
async getDiagnostic(): Promise<{ diagnostic: string }> {
try {
const resolvedBinary = await findExecutable("claude") ?? "not found";
const resolvedBinary = (await findExecutable("claude")) ?? "not found";
const available = await this.isAvailable();
const version = await resolveClaudeVersion(this.runtimeSettings);
let modelsValue = "Not checked";
@@ -1183,16 +1180,22 @@ export class ClaudeAgentClient implements AgentClient {
}
}
async function resolveClaudeVersion(runtimeSettings?: ProviderRuntimeSettings): Promise<string | null> {
async function resolveClaudeVersion(
runtimeSettings?: ProviderRuntimeSettings,
): Promise<string | null> {
const command = runtimeSettings?.command;
try {
if (command?.mode === "replace") {
const { stdout } = await execFileAsync(command.argv[0]!, [...command.argv.slice(1), "--version"], {
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
});
const { stdout } = await execFileAsync(
command.argv[0]!,
[...command.argv.slice(1), "--version"],
{
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
},
);
return stdout.trim() || null;
}
@@ -1236,9 +1239,7 @@ function extractContextWindowSize(modelUsage: unknown): number | undefined {
return maxContextWindow;
}
function readUsageTotalTokens(
usage: unknown,
): number | undefined {
function readUsageTotalTokens(usage: unknown): number | undefined {
if (!usage || typeof usage !== "object") {
return undefined;
}
@@ -2094,10 +2095,9 @@ class ClaudeAgentSession implements AgentSession {
: process.env["PATH"] !== undefined
? "PATH"
: null,
pathIncludesClaudeLocalBin:
(process.env["Path"] ?? process.env["PATH"] ?? "")
.toLowerCase()
.includes("\\.local\\bin"),
pathIncludesClaudeLocalBin: (process.env["Path"] ?? process.env["PATH"] ?? "")
.toLowerCase()
.includes("\\.local\\bin"),
},
"Resolved Claude executable",
);
@@ -2231,8 +2231,7 @@ class ClaudeAgentSession implements AgentSession {
}
private isAbortError(message: SDKMessage): boolean {
const errors =
"errors" in message && Array.isArray(message.errors) ? message.errors : [];
const errors = "errors" in message && Array.isArray(message.errors) ? message.errors : [];
return errors.some((e: string) => /\baborted\b/i.test(e));
}
@@ -2273,9 +2272,11 @@ class ClaudeAgentSession implements AgentSession {
if (this.getRecentStderrDiagnostic()) {
return;
}
const message =
typeof error === "string" ? error : error instanceof Error ? error.message : "";
if (!/\bprocess exited with code\b/i.test(message) && !/\bterminated by signal\b/i.test(message)) {
const message = typeof error === "string" ? error : error instanceof Error ? error.message : "";
if (
!/\bprocess exited with code\b/i.test(message) &&
!/\bterminated by signal\b/i.test(message)
) {
return;
}
@@ -2518,11 +2519,7 @@ class ClaudeAgentSession implements AgentSession {
return;
}
}
if (
message.type === "result" &&
message.subtype !== "success" &&
this.isAbortError(message)
) {
if (message.type === "result" && message.subtype !== "success" && this.isAbortError(message)) {
this.logger.debug("Suppressing abort result by content");
return;
}
@@ -2990,9 +2987,7 @@ class ClaudeAgentSession implements AgentSession {
outputTokens: message.usage.output_tokens,
totalCostUsd: message.total_cost_usd,
};
const contextWindowMaxTokens = extractContextWindowSize(
modelUsage ?? message.modelUsage,
);
const contextWindowMaxTokens = extractContextWindowSize(modelUsage ?? message.modelUsage);
if (contextWindowMaxTokens !== undefined) {
this.lastContextWindowMaxTokens = contextWindowMaxTokens;
usage.contextWindowMaxTokens = contextWindowMaxTokens;
@@ -3109,8 +3104,7 @@ class ClaudeAgentSession implements AgentSession {
input,
detail: toolDetail,
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
actions:
kind === "plan" ? buildClaudePlanPermissionActions(this.planResumeMode) : undefined,
actions: kind === "plan" ? buildClaudePlanPermissionActions(this.planResumeMode) : undefined,
metadata: Object.keys(metadata).length ? metadata : undefined,
};
@@ -65,88 +65,92 @@ describe("Claude SDK direct behavior", () => {
}
});
test.runIf(canRunClaudeIntegration)("shows what happens after interrupt()", async () => {
const cwd = tmpCwd();
const input = new Pushable<SDKUserMessage>();
const claudeBinary = await findExecutable("claude");
test.runIf(canRunClaudeIntegration)(
"shows what happens after interrupt()",
async () => {
const cwd = tmpCwd();
const input = new Pushable<SDKUserMessage>();
const claudeBinary = await findExecutable("claude");
// Use same options as claude-agent.ts
const q = query({
prompt: input,
options: {
cwd,
includePartialMessages: true,
permissionMode: "bypassPermissions",
...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
systemPrompt: {
type: "preset",
preset: "claude_code",
// Use same options as claude-agent.ts
const q = query({
prompt: input,
options: {
cwd,
includePartialMessages: true,
permissionMode: "bypassPermissions",
...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
systemPrompt: {
type: "preset",
preset: "claude_code",
},
settingSources: ["user", "project"],
},
settingSources: ["user", "project"],
},
});
try {
// Send first message
input.push({
type: "user",
message: { role: "user", content: "Say exactly: MESSAGE_ONE" },
parent_tool_use_id: null,
session_id: "",
});
// Collect events until we see assistant, then interrupt
const msg1Events: SDKMessage[] = [];
for await (const event of q) {
msg1Events.push(event);
try {
// Send first message
input.push({
type: "user",
message: { role: "user", content: "Say exactly: MESSAGE_ONE" },
parent_tool_use_id: null,
session_id: "",
});
if (event.type === "assistant") {
// Push MSG2 BEFORE interrupt (like our wrapper does when a new message comes in)
input.push({
type: "user",
message: { role: "user", content: "Say exactly: MESSAGE_TWO" },
parent_tool_use_id: null,
session_id: "",
});
await q.interrupt();
break;
// Collect events until we see assistant, then interrupt
const msg1Events: SDKMessage[] = [];
for await (const event of q) {
msg1Events.push(event);
if (event.type === "assistant") {
// Push MSG2 BEFORE interrupt (like our wrapper does when a new message comes in)
input.push({
type: "user",
message: { role: "user", content: "Say exactly: MESSAGE_TWO" },
parent_tool_use_id: null,
session_id: "",
});
await q.interrupt();
break;
}
if (event.type === "result") {
break;
}
}
if (event.type === "result") {
break;
// MSG2 was already pushed before interrupt
const msg2Events: SDKMessage[] = [];
for await (const event of q) {
msg2Events.push(event);
if (event.type === "result") {
break;
}
}
}
// MSG2 was already pushed before interrupt
const msg2Events: SDKMessage[] = [];
for await (const event of q) {
msg2Events.push(event);
if (event.type === "result") {
break;
}
}
// Analyze response
let responseText = "";
for (const event of msg2Events) {
if (event.type === "assistant" && "message" in event && event.message?.content) {
const content = event.message.content;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text" && block.text) {
responseText += block.text;
// Analyze response
let responseText = "";
for (const event of msg2Events) {
if (event.type === "assistant" && "message" in event && event.message?.content) {
const content = event.message.content;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text" && block.text) {
responseText += block.text;
}
}
}
}
}
}
const sawResult = msg2Events.some((event) => event.type === "result");
// The SDK may short-circuit after interrupt without a result event.
expect(sawResult || responseText.length === 0).toBe(true);
} finally {
input.end();
rmSync(cwd, { recursive: true, force: true });
}
}, 120000);
const sawResult = msg2Events.some((event) => event.type === "result");
// The SDK may short-circuit after interrupt without a result event.
expect(sawResult || responseText.length === 0).toBe(true);
} finally {
input.end();
rmSync(cwd, { recursive: true, force: true });
}
},
120000,
);
});
@@ -45,9 +45,7 @@ export function getClaudeModels(): AgentModelDefinition[] {
* Normalize a runtime model string (from SDK init message) to a known model ID.
* Handles the `[1m]` suffix that the SDK appends for 1M context sessions.
*/
export function normalizeClaudeRuntimeModelId(
value: string | null | undefined,
): string | null {
export function normalizeClaudeRuntimeModelId(value: string | null | undefined): string | null {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) {
return null;
@@ -61,7 +61,11 @@ function responseCompleted(id: string): Record<string, unknown> {
};
}
function functionCallEvent(callId: string, name: string, argumentsJson: string): Record<string, unknown> {
function functionCallEvent(
callId: string,
name: string,
argumentsJson: string,
): Record<string, unknown> {
return {
type: "response.output_item.done",
item: {
@@ -116,7 +120,11 @@ function requestUserInputSse(callId: string): string {
}
function assistantMessageSse(text: string): string {
return sse([responseCreated("resp-2"), assistantMessageEvent("msg-1", text), responseCompleted("resp-2")]);
return sse([
responseCreated("resp-2"),
assistantMessageEvent("msg-1", text),
responseCompleted("resp-2"),
]);
}
async function startMockResponsesServer(sequence: string[]): Promise<{
@@ -276,10 +284,7 @@ describe("Codex app-server provider (e2e)", () => {
label: "question permission request",
predicate: (
event,
): event is Extract<
AgentStreamEvent,
{ type: "permission_requested" }
> =>
): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" &&
event.request.provider === "codex" &&
event.request.kind === "question" &&
@@ -1,7 +1,12 @@
import { describe, expect, test, vi } from "vitest";
import { existsSync, rmSync } from "node:fs";
import type { AgentLaunchContext, AgentSession, AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js";
import type {
AgentLaunchContext,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
} from "../agent-sdk-types.js";
import {
__codexAppServerInternals,
codexAppServerTurnInputFromPrompt,
@@ -381,10 +386,7 @@ describe("Codex app-server provider", () => {
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [
{ label: "Coffee", description: "Default" },
{ label: "Tea" },
],
options: [{ label: "Coffee", description: "Default" }, { label: "Tea" }],
},
],
});
@@ -411,10 +413,7 @@ describe("Codex app-server provider", () => {
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [
{ label: "Coffee", description: "Default" },
{ label: "Tea" },
],
options: [{ label: "Coffee", description: "Default" }, { label: "Tea" }],
},
],
},
@@ -441,10 +440,7 @@ describe("Codex app-server provider", () => {
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [
{ label: "Coffee", description: "Default" },
{ label: "Tea" },
],
options: [{ label: "Coffee", description: "Default" }, { label: "Tea" }],
},
],
},
@@ -457,10 +453,7 @@ describe("Codex app-server provider", () => {
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [
{ label: "Coffee", description: "Default" },
{ label: "Tea" },
],
options: [{ label: "Coffee", description: "Default" }, { label: "Tea" }],
},
],
},
@@ -158,10 +158,7 @@ function isObjectSchemaNode(schema: Record<string, unknown>): boolean {
);
}
function normalizeCodexOutputSchemaNode(
schema: unknown,
path: string,
): unknown {
function normalizeCodexOutputSchemaNode(schema: unknown, path: string): unknown {
if (Array.isArray(schema)) {
return schema.map((entry, index) => normalizeCodexOutputSchemaNode(entry, `${path}[${index}]`));
}
@@ -820,7 +817,10 @@ function planStepsToMarkdown(steps: Array<{ step: string; status: string }>): st
return normalizePlanMarkdown(lines.join("\n"));
}
function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolCallTimelineItem | null {
function mapCodexPlanToToolCall(params: {
callId: string;
text: string;
}): ToolCallTimelineItem | null {
const text = normalizePlanMarkdown(params.text);
if (!text) {
return null;
@@ -838,9 +838,10 @@ function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolC
};
}
function buildPlanPermissionActions(
options?: { includeResumeAction?: boolean; resumeLabel?: string },
): AgentPermissionAction[] {
function buildPlanPermissionActions(options?: {
includeResumeAction?: boolean;
resumeLabel?: string;
}): AgentPermissionAction[] {
const actions: AgentPermissionAction[] = [
{
id: "reject",
@@ -2569,9 +2570,7 @@ class CodexAppServerAgentSession implements AgentSession {
}
}
private findCollaborationMode(
target: "code" | "plan",
): {
private findCollaborationMode(target: "code" | "plan"): {
name: string;
mode?: string | null;
model?: string | null;
@@ -3199,7 +3198,9 @@ class CodexAppServerAgentSession implements AgentSession {
const questions = pending.questions ?? [];
const itemId =
typeof pendingRequest?.metadata?.itemId === "string" ? pendingRequest.metadata.itemId : requestId;
typeof pendingRequest?.metadata?.itemId === "string"
? pendingRequest.metadata.itemId
: requestId;
if (response.behavior === "allow") {
const mappedAnswers = mapCodexQuestionResponseByHeader({
questions,
@@ -3211,9 +3212,7 @@ class CodexAppServerAgentSession implements AgentSession {
questions
.map((question) => {
const fallback = question.options[0]?.label?.trim();
return fallback
? [question.id, { answers: [fallback] }]
: null;
return fallback ? [question.id, { answers: [fallback] }] : null;
})
.filter((entry): entry is [string, { answers: string[] }] => entry !== null),
);
@@ -4009,7 +4008,9 @@ export class CodexAppServerAgentClient implements AgentClient {
private readonly runtimeSettings?: ProviderRuntimeSettings,
) {}
private async spawnAppServer(launchEnv?: Record<string, string>): Promise<ChildProcessWithoutNullStreams> {
private async spawnAppServer(
launchEnv?: Record<string, string>,
): Promise<ChildProcessWithoutNullStreams> {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
this.logger.trace(
{
@@ -4226,7 +4227,10 @@ export class CodexAppServerAgentClient implements AgentClient {
label: "Binary",
value: resolvedBinary ?? "not found",
},
{ label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" },
{
label: "Version",
value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
},
];
let status = formatDiagnosticStatus(available);
@@ -1,11 +1,6 @@
import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js";
const CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES = [
"gpt-5",
"gpt-4.1",
"o3",
"o4-mini",
] as const;
const CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES = ["gpt-5", "gpt-4.1", "o3", "o4-mini"] as const;
export const CODEX_FAST_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
type: "toggle",
@@ -68,7 +68,10 @@ const CodexEditToolNameSchema = z.union([
z.literal("apply_diff"),
]);
const CodexSearchToolNameSchema = z.union([z.literal("search"), z.literal("web_search")]);
const CodexSpeakToolNameSchema = z.string().min(1).refine((name) => isSpeakToolName(name.trim()));
const CodexSpeakToolNameSchema = z
.string()
.min(1)
.refine((name) => isSpeakToolName(name.trim()));
const CodexToolKindSchema = z.enum([
"shell",
@@ -734,9 +737,10 @@ function parseFileChangeEntries(
.filter((entry): entry is CodexFileChangeEntry => entry !== null);
}
function resolveFileChangeTextFields(
file: CodexFileChangeEntry | undefined,
): { unifiedDiff?: string; newString?: string } {
function resolveFileChangeTextFields(file: CodexFileChangeEntry | undefined): {
unifiedDiff?: string;
newString?: string;
} {
if (!file) {
return {};
}
@@ -97,7 +97,10 @@ export class CopilotACPAgentClient extends ACPAgentClient {
label: "Binary",
value: resolvedBinary ?? "not found",
},
{ label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" },
{
label: "Version",
value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
},
{ label: "Models", value: modelsValue },
{ label: "Status", value: status },
]),
@@ -10,17 +10,11 @@ type DiagnosticEntry = {
value: string;
};
export function formatProviderDiagnostic(
providerName: string,
entries: DiagnosticEntry[],
): string {
export function formatProviderDiagnostic(providerName: string, entries: DiagnosticEntry[]): string {
return [providerName, ...entries.map((entry) => ` ${entry.label}: ${entry.value}`)].join("\n");
}
export function formatProviderDiagnosticError(
providerName: string,
error: unknown,
): string {
export function formatProviderDiagnosticError(providerName: string, error: unknown): string {
return formatProviderDiagnostic(providerName, [
{
label: "Error",
@@ -79,10 +79,7 @@ describe("opencode agent commands E2E", () => {
});
const token = `RAW_PROMPT_TOKEN_${Date.now()}`;
await ctx.client.sendMessage(
agent.id,
`/not-a-real-command respond with exactly: ${token}`,
);
await ctx.client.sendMessage(agent.id, `/not-a-real-command respond with exactly: ${token}`);
const state = await ctx.client.waitForFinish(agent.id, 30_000);
expect(state.status).toBe("idle");
@@ -144,9 +144,7 @@ describe("opencode agent error handling (real)", () => {
const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined();
expect(elapsed).toBeLessThan(30_000);
console.log(
`[nonexistent model] elapsed=${elapsed}ms terminal=${terminal!.type}`,
);
console.log(`[nonexistent model] elapsed=${elapsed}ms terminal=${terminal!.type}`);
} finally {
await session.close().catch(() => undefined);
}
@@ -176,9 +174,9 @@ describe("opencode agent error handling (real)", () => {
const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined();
expect(terminal!.type).toBe("turn_failed");
expect(
(terminal!.type === "turn_failed" ? terminal!.error : "").toLowerCase(),
).toMatch(/insufficient balance|resource package|recharge/);
expect((terminal!.type === "turn_failed" ? terminal!.error : "").toLowerCase()).toMatch(
/insufficient balance|resource package|recharge/,
);
} finally {
await session.close().catch(() => undefined);
}
@@ -124,7 +124,7 @@ const hasOpenCode = isBinaryInstalled("opencode");
m.id.includes("gpt-4.1-nano") ||
m.id.includes("gpt-4.1-mini") ||
m.id.includes("gpt-5-nano") ||
m.id.includes("gpt-5.1-codex-mini") ||
m.id.includes("gpt-5.4-mini") ||
m.id.includes("gpt-4o-mini"),
);
@@ -379,13 +379,16 @@ function buildOpenCodeModelDefinition(
}
function resolveOpenCodeSelectedModelContextWindow(
providers: {
connected?: string[];
all?: Array<{
id: string;
models?: Record<string, unknown>;
}>;
} | null | undefined,
providers:
| {
connected?: string[];
all?: Array<{
id: string;
models?: Record<string, unknown>;
}>;
}
| null
| undefined,
modelId: string | null | undefined,
): number | undefined {
if (!providers) {
@@ -399,13 +402,18 @@ function resolveOpenCodeSelectedModelContextWindow(
return lookup.get(modelLookupKey);
}
function buildOpenCodeModelContextWindowLookup(providers: {
connected?: string[];
all?: Array<{
id: string;
models?: Record<string, unknown>;
}>;
} | null | undefined): Map<string, number> {
function buildOpenCodeModelContextWindowLookup(
providers:
| {
connected?: string[];
all?: Array<{
id: string;
models?: Record<string, unknown>;
}>;
}
| null
| undefined,
): Map<string, number> {
const lookup = new Map<string, number>();
if (!providers) {
return lookup;
@@ -881,16 +889,10 @@ export class OpenCodeAgentClient implements AgentClient {
const client = createOpencodeClient({ baseUrl: url, directory });
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error("OpenCode app.agents timed out after 10s")),
10_000,
);
setTimeout(() => reject(new Error("OpenCode app.agents timed out after 10s")), 10_000);
});
const response = await Promise.race([
client.app.agents({ directory }),
timeoutPromise,
]);
const response = await Promise.race([client.app.agents({ directory }), timeoutPromise]);
if (response.error || !response.data) {
return DEFAULT_MODES;
@@ -970,7 +972,10 @@ export class OpenCodeAgentClient implements AgentClient {
label: "Binary",
value: resolvedBinary ?? "not found",
},
{ label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" },
{
label: "Version",
value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
},
{ label: "Server", value: serverStatus },
{ label: "Models", value: modelsValue },
{ label: "Status", value: status },
@@ -1043,8 +1048,6 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export function translateOpenCodeEvent(
event: OpenCodeEvent,
state: OpenCodeEventTranslationState,
@@ -1080,10 +1083,7 @@ export function translateOpenCodeEvent(
}
}
if (
!state.emittedStructuredMessageIds.has(info.id) &&
info.time?.completed !== undefined
) {
if (!state.emittedStructuredMessageIds.has(info.id) && info.time?.completed !== undefined) {
const text = stringifyStructuredAssistantMessage(info.structured);
if (text) {
state.emittedStructuredMessageIds.add(info.id);
@@ -1229,16 +1229,19 @@ export function translateOpenCodeEvent(
if (!q.question || !q.header) {
return [];
}
const options = q.options?.map((o) => ({
label: o.label,
...(o.description ? { description: o.description } : {}),
})) ?? [];
return [{
question: q.question,
header: q.header,
options,
...(q.multiple === true ? { multiSelect: true } : {}),
}];
const options =
q.options?.map((o) => ({
label: o.label,
...(o.description ? { description: o.description } : {}),
})) ?? [];
return [
{
question: q.question,
header: q.header,
options,
...(q.multiple === true ? { multiSelect: true } : {}),
},
];
});
if (questions.length === 0) {
@@ -1362,8 +1365,9 @@ class OpenCodeAgentSession implements AgentSession {
this.logger = logger;
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
this.currentMode = normalizeOpenCodeModeId(config.modeId);
this.selectedModelContextWindowMaxTokens =
this.resolveConfiguredModelContextWindowMaxTokens(config.model);
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
config.model,
);
}
get id(): string | null {
@@ -1383,8 +1387,9 @@ class OpenCodeAgentSession implements AgentSession {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
this.config.model = normalizedModelId ?? undefined;
this.selectedModelContextWindowMaxTokens =
this.resolveConfiguredModelContextWindowMaxTokens(this.config.model);
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
this.config.model,
);
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
@@ -1499,8 +1504,7 @@ class OpenCodeAgentSession implements AgentSession {
this.abortController = turnAbortController;
await this.ensureMcpServersConfigured();
const contextWindowMaxTokens = this.resolveSelectedModelContextWindowMaxTokens();
this.accumulatedUsage =
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
this.accumulatedUsage = contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
const parts = buildOpenCodePromptParts(prompt);
const model = this.parseModel(this.config.model);
@@ -1520,71 +1524,77 @@ class OpenCodeAgentSession implements AgentSession {
// Handle both success and error in the response handler as a fallback —
// finishForegroundTurn's guard prevents duplicate terminal events if the
// SSE stream already delivered the event.
void this.client.session.command({
sessionID: this.sessionId,
directory: this.config.cwd,
command: slashCommand.commandName,
arguments: slashCommand.args ?? "",
...(this.config.model ? { model: this.config.model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
}).then((response) => {
if (response.error) {
const errorMsg = normalizeTurnFailureError(response.error);
void this.client.session
.command({
sessionID: this.sessionId,
directory: this.config.cwd,
command: slashCommand.commandName,
arguments: slashCommand.args ?? "",
...(this.config.model ? { model: this.config.model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
})
.then((response) => {
if (response.error) {
const errorMsg = normalizeTurnFailureError(response.error);
this.finishForegroundTurn(
{ type: "turn_failed", provider: "opencode", error: errorMsg },
turnId,
);
} else {
this.finishForegroundTurn(
{ type: "turn_completed", provider: "opencode", usage: undefined },
turnId,
);
}
})
.catch((err) => {
this.finishForegroundTurn(
{ type: "turn_failed", provider: "opencode", error: errorMsg },
{ type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) },
turnId,
);
} else {
this.finishForegroundTurn(
{ type: "turn_completed", provider: "opencode", usage: undefined },
turnId,
);
}
}).catch((err) => {
this.finishForegroundTurn(
{ type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) },
turnId,
);
});
});
} else {
void this.client.session.promptAsync({
sessionID: this.sessionId,
directory: this.config.cwd,
parts,
...(options?.outputSchema
? {
format: {
type: "json_schema" as const,
schema: options.outputSchema as Record<string, unknown>,
void this.client.session
.promptAsync({
sessionID: this.sessionId,
directory: this.config.cwd,
parts,
...(options?.outputSchema
? {
format: {
type: "json_schema" as const,
schema: options.outputSchema as Record<string, unknown>,
},
}
: {}),
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
...(model ? { model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
})
.then((promptResponse) => {
if (promptResponse.error) {
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: normalizeTurnFailureError(promptResponse.error),
},
}
: {}),
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
...(model ? { model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
}).then((promptResponse) => {
if (promptResponse.error) {
turnId,
);
}
})
.catch((error) => {
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: normalizeTurnFailureError(promptResponse.error),
error: normalizeTurnFailureError(error),
},
turnId,
);
}
}).catch((error) => {
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: normalizeTurnFailureError(error),
},
turnId,
);
});
});
}
return { turnId };
@@ -1620,7 +1630,11 @@ class OpenCodeAgentSession implements AgentSession {
if (e.type === "timeline" && e.item.type === "tool_call") {
this.trackToolCall(e.item);
}
if (e.type === "turn_completed" || e.type === "turn_failed" || e.type === "turn_canceled") {
if (
e.type === "turn_completed" ||
e.type === "turn_failed" ||
e.type === "turn_canceled"
) {
if (e.type === "turn_failed") {
this.finishForegroundTurn(
{
@@ -2070,12 +2084,9 @@ class OpenCodeAgentSession implements AgentSession {
if (hasNormalizedOpenCodeUsage(this.accumulatedUsage)) {
translatedEvent.usage = this.accumulatedUsage;
}
const contextWindowMaxTokens =
this.resolveSelectedModelContextWindowMaxTokens();
const contextWindowMaxTokens = this.resolveSelectedModelContextWindowMaxTokens();
this.accumulatedUsage =
contextWindowMaxTokens !== undefined
? { contextWindowMaxTokens }
: {};
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
}
}
@@ -26,9 +26,7 @@ describe("OpenCode assistant message", () => {
const result = await session.run("Say hello back in one sentence.");
const assistantItems = result.timeline.filter(
(item) => item.type === "assistant_message",
);
const assistantItems = result.timeline.filter((item) => item.type === "assistant_message");
expect(assistantItems.length).toBeGreaterThan(0);
expect(result.finalText.length).toBeGreaterThan(0);
} finally {
@@ -69,5 +67,4 @@ describe("OpenCode assistant message", () => {
},
60_000,
);
});
@@ -3,11 +3,7 @@ import { existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import type { Logger } from "pino";
import type {
ClientSideConnection,
SessionConfigOption,
ToolKind,
} from "@agentclientprotocol/sdk";
import type { ClientSideConnection, SessionConfigOption, ToolKind } from "@agentclientprotocol/sdk";
import type {
AgentLaunchContext,
@@ -30,11 +26,7 @@ import type {
} from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import {
ACPAgentClient,
type ACPToolSnapshot,
type SessionStateResponse,
} from "./acp-agent.js";
import { ACPAgentClient, type ACPToolSnapshot, type SessionStateResponse } from "./acp-agent.js";
import {
formatDiagnosticStatus,
formatProviderDiagnostic,
@@ -102,9 +94,7 @@ function transformPiToolSnapshot(snapshot: ACPToolSnapshot): ACPToolSnapshot {
* This transformer remaps them so the base ACP class treats them as thinking
* options instead of permission modes.
*/
export function transformPiSessionResponse(
response: SessionStateResponse,
): SessionStateResponse {
export function transformPiSessionResponse(response: SessionStateResponse): SessionStateResponse {
const modes = response.modes;
if (!modes?.availableModes?.length) {
return response;
@@ -126,10 +116,7 @@ export function transformPiSessionResponse(
return {
...response,
modes: undefined,
configOptions: [
thinkingOption,
...(response.configOptions ?? []),
],
configOptions: [thinkingOption, ...(response.configOptions ?? [])],
};
}
@@ -228,10 +215,7 @@ class PiACPAgentSession implements AgentSession {
return this.inner.getPendingPermissions();
}
async respondToPermission(
requestId: string,
response: AgentPermissionResponse,
): Promise<void> {
async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> {
await this.inner.respondToPermission(requestId, response);
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { getPaseoToolLeafName, isPaseoToolName } from "./tool-name-normalization.js";
describe("isPaseoToolName", () => {
it("detects Claude Code format", () => {
expect(isPaseoToolName("mcp__paseo__create_agent")).toBe(true);
expect(isPaseoToolName("mcp__paseo__list_agents")).toBe(true);
});
it("detects paseo_voice variant", () => {
expect(isPaseoToolName("mcp__paseo_voice__create_agent")).toBe(true);
expect(isPaseoToolName("paseo_voice.create_agent")).toBe(true);
});
it("excludes speak tools", () => {
expect(isPaseoToolName("mcp__paseo_voice__speak")).toBe(false);
expect(isPaseoToolName("mcp__paseo__speak")).toBe(false);
expect(isPaseoToolName("paseo.speak")).toBe(false);
});
it("detects Codex dot format", () => {
expect(isPaseoToolName("paseo.create_agent")).toBe(true);
});
it("rejects non-paseo tools", () => {
expect(isPaseoToolName("Bash")).toBe(false);
expect(isPaseoToolName("Read")).toBe(false);
expect(isPaseoToolName("mcp__other_server__some_tool")).toBe(false);
});
});
describe("getPaseoToolLeafName", () => {
it("extracts leaf from Claude Code format", () => {
expect(getPaseoToolLeafName("mcp__paseo__create_agent")).toBe("create_agent");
});
it("extracts leaf from Codex format", () => {
expect(getPaseoToolLeafName("paseo.create_agent")).toBe("create_agent");
expect(getPaseoToolLeafName("paseo.list_agents")).toBe("list_agents");
});
it("returns null for non-paseo tools", () => {
expect(getPaseoToolLeafName("Bash")).toBeNull();
});
});
@@ -39,6 +39,49 @@ export function isLikelyNamespacedToolName(name: string): boolean {
return false;
}
export function isPaseoToolName(name: string): boolean {
const normalized = normalizeToolName(name);
if (isSpeakToolName(normalized)) {
return false;
}
if (normalized.includes("__")) {
const segments = normalized.split("__").filter((s) => s.length > 0);
return (
segments.length >= 3 &&
segments[0] === "mcp" &&
(segments[1] === "paseo" || segments[1]!.startsWith("paseo_"))
);
}
if (normalized.includes(".")) {
const firstSegment = normalized.split(".")[0]!;
return firstSegment === "paseo" || firstSegment.startsWith("paseo_");
}
return false;
}
export function getPaseoToolLeafName(name: string): string | null {
const normalized = normalizeToolName(name);
if (normalized.includes("__")) {
const segments = normalized.split("__").filter((s) => s.length > 0);
if (
segments.length >= 3 &&
segments[0] === "mcp" &&
(segments[1] === "paseo" || segments[1]!.startsWith("paseo_"))
) {
return segments.slice(2).join("__");
}
return null;
}
if (normalized.includes(".")) {
const firstSegment = normalized.split(".")[0]!;
if (firstSegment === "paseo" || firstSegment.startsWith("paseo_")) {
return normalized.split(".").slice(1).join(".");
}
return null;
}
return null;
}
export function isLikelyExternalToolName(name: string): boolean {
const normalized = normalizeToolName(name);
if (!normalized) {
@@ -1,248 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const execFileMock = vi.hoisted(() =>
vi.fn(
(
_file: string,
_args: string[],
_options: unknown,
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
) => {
callback(null, "", "");
},
),
);
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
execFile: execFileMock,
};
});
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
function createLogger() {
const logger = {
child: () => logger,
debug: vi.fn(),
warn: vi.fn(),
};
return logger;
}
describe("BackgroundGitFetchManager", () => {
beforeEach(() => {
vi.useFakeTimers();
execFileMock.mockReset();
execFileMock.mockImplementation(
(
_file: string,
_args: string[],
_options: unknown,
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
) => {
callback(null, "", "");
},
);
});
afterEach(() => {
vi.useRealTimers();
});
test("creates a fetch timer for a repo with an origin remote", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const subscription = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
vi.fn(),
);
await flushPromises();
const managerAny = manager as any;
const target = managerAny.targets.get("/tmp/repo/.git");
expect(target).toBeDefined();
expect(target.intervalId).toBeTruthy();
expect(execFileMock).toHaveBeenNthCalledWith(
1,
"git",
["remote", "get-url", "origin"],
expect.objectContaining({
cwd: "/tmp/repo",
env: expect.objectContaining({ GIT_TERMINAL_PROMPT: "0" }),
}),
expect.any(Function),
);
expect(execFileMock).toHaveBeenNthCalledWith(
2,
"git",
["fetch", "origin", "--prune"],
expect.objectContaining({
cwd: "/tmp/repo",
env: expect.objectContaining({ GIT_TERMINAL_PROMPT: "0" }),
}),
expect.any(Function),
);
subscription.unsubscribe();
manager.dispose();
});
test("dedupes multiple subscribers for the same repo root behind one timer", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const listenerOne = vi.fn();
const listenerTwo = vi.fn();
const subscriptionOne = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
listenerOne,
);
const subscriptionTwo = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo-worktree" },
listenerTwo,
);
await flushPromises();
const managerAny = manager as any;
const target = managerAny.targets.get("/tmp/repo/.git");
expect(managerAny.targets.size).toBe(1);
expect(target.listeners).toEqual(new Set([listenerOne, listenerTwo]));
expect(execFileMock.mock.calls.filter((call) => call[1][0] === "remote")).toHaveLength(1);
subscriptionOne.unsubscribe();
subscriptionTwo.unsubscribe();
manager.dispose();
});
test("cleans up the timer when the last subscriber unsubscribes", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const subscription = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
vi.fn(),
);
await flushPromises();
const managerAny = manager as any;
const target = managerAny.targets.get("/tmp/repo/.git");
const intervalId = target.intervalId;
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
subscription.unsubscribe();
expect(clearIntervalSpy).toHaveBeenCalledWith(intervalId);
expect(managerAny.targets.size).toBe(0);
clearIntervalSpy.mockRestore();
manager.dispose();
});
test("logs fetch errors without crashing", async () => {
const logger = createLogger();
execFileMock.mockImplementation(
(
_file: string,
args: string[],
_options: unknown,
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
) => {
if (args[0] === "remote") {
callback(null, "", "");
return;
}
callback(new Error("fetch failed"));
},
);
const manager = new BackgroundGitFetchManager({ logger: logger as any });
await manager.subscribe({ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
expect(logger.debug).toHaveBeenCalledWith(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
"Running background git fetch",
);
expect(logger.warn).toHaveBeenCalledWith(
{
err: expect.any(Error),
repoGitRoot: "/tmp/repo/.git",
cwd: "/tmp/repo",
},
"Background git fetch failed",
);
manager.dispose();
});
test("calls listeners when a fetch completes", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const listener = vi.fn();
await manager.subscribe({ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, listener);
await flushPromises();
expect(listener).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(180_000);
await flushPromises();
expect(listener).toHaveBeenCalledTimes(2);
manager.dispose();
});
test("does not create a timer when the repo has no origin remote", async () => {
const logger = createLogger();
execFileMock.mockImplementation(
(
_file: string,
_args: string[],
_options: unknown,
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
) => {
callback(new Error("missing origin"));
},
);
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const subscription = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
vi.fn(),
);
expect((manager as any).targets.size).toBe(0);
subscription.unsubscribe();
manager.dispose();
});
test("dispose clears timers and listeners", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const listener = vi.fn();
await manager.subscribe({ repoGitRoot: "/tmp/repo-one/.git", cwd: "/tmp/repo-one" }, listener);
await manager.subscribe({ repoGitRoot: "/tmp/repo-two/.git", cwd: "/tmp/repo-two" }, vi.fn());
await flushPromises();
const managerAny = manager as any;
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
manager.dispose();
expect(clearIntervalSpy).toHaveBeenCalledTimes(2);
expect(managerAny.targets.size).toBe(0);
clearIntervalSpy.mockRestore();
});
});
@@ -1,150 +0,0 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type pino from "pino";
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
const execFileAsync = promisify(execFile);
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
type BackgroundGitFetchTarget = {
repoGitRoot: string;
cwd: string;
listeners: Set<() => void>;
intervalId: NodeJS.Timeout | null;
fetchInFlight: boolean;
};
export class BackgroundGitFetchManager {
private readonly logger: pino.Logger;
private readonly targets = new Map<string, BackgroundGitFetchTarget>();
constructor(options: { logger: pino.Logger }) {
this.logger = options.logger.child({ module: "background-git-fetch-manager" });
}
async subscribe(
params: { repoGitRoot: string; cwd: string },
listener: () => void,
): Promise<{ unsubscribe: () => void }> {
const existingTarget = this.targets.get(params.repoGitRoot);
if (existingTarget) {
existingTarget.listeners.add(listener);
return {
unsubscribe: () => {
this.removeListener(params.repoGitRoot, listener);
},
};
}
const hasOrigin = await this.hasOriginRemote(params.cwd);
if (!hasOrigin) {
return { unsubscribe: () => {} };
}
const targetAfterProbe = this.targets.get(params.repoGitRoot);
if (targetAfterProbe) {
targetAfterProbe.listeners.add(listener);
return {
unsubscribe: () => {
this.removeListener(params.repoGitRoot, listener);
},
};
}
const target: BackgroundGitFetchTarget = {
repoGitRoot: params.repoGitRoot,
cwd: params.cwd,
listeners: new Set([listener]),
intervalId: setInterval(() => {
void this.runFetch(target);
}, BACKGROUND_GIT_FETCH_INTERVAL_MS),
fetchInFlight: false,
};
this.targets.set(params.repoGitRoot, target);
void this.runFetch(target);
return {
unsubscribe: () => {
this.removeListener(params.repoGitRoot, listener);
},
};
}
dispose(): void {
for (const target of this.targets.values()) {
this.closeTarget(target);
}
this.targets.clear();
}
private closeTarget(target: BackgroundGitFetchTarget): void {
if (target.intervalId) {
clearInterval(target.intervalId);
target.intervalId = null;
}
target.listeners.clear();
}
private removeListener(targetKey: string, listener: () => void): void {
const target = this.targets.get(targetKey);
if (!target) {
return;
}
target.listeners.delete(listener);
if (target.listeners.size > 0) {
return;
}
this.closeTarget(target);
this.targets.delete(targetKey);
}
private async hasOriginRemote(cwd: string): Promise<boolean> {
try {
await execFileAsync("git", ["remote", "get-url", "origin"], {
cwd,
env: {
...READ_ONLY_GIT_ENV,
GIT_TERMINAL_PROMPT: "0",
},
});
return true;
} catch {
return false;
}
}
private async runFetch(target: BackgroundGitFetchTarget): Promise<void> {
if (target.fetchInFlight) {
return;
}
target.fetchInFlight = true;
this.logger.debug(
{ repoGitRoot: target.repoGitRoot, cwd: target.cwd },
"Running background git fetch",
);
try {
await execFileAsync("git", ["fetch", "origin", "--prune"], {
cwd: target.cwd,
env: {
...process.env,
GIT_TERMINAL_PROMPT: "0",
},
});
} catch (error) {
this.logger.warn(
{ err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
"Background git fetch failed",
);
} finally {
target.fetchInFlight = false;
for (const listener of target.listeners) {
listener();
}
}
}
}
+92 -77
View File
@@ -6,7 +6,6 @@ import { randomUUID } from "node:crypto";
import { hostname as getHostname } from "node:os";
import path from "node:path";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import type { Logger } from "pino";
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
@@ -96,7 +95,11 @@ import { createSpeechService } from "./speech/speech-runtime.js";
import { AgentManager } from "./agent/agent-manager.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
import {
buildProviderRegistry,
createAllClients,
shutdownProviders,
} from "./agent/provider-registry.js";
import { DbAgentSnapshotStore } from "./db/db-agent-snapshot-store.js";
import { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js";
import { DbProjectRegistry } from "./db/db-project-registry.js";
@@ -109,6 +112,7 @@ import { FileBackedChatService } from "./chat/chat-service.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
import { DaemonConfigStore } from "./daemon-config-store.js";
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
@@ -134,28 +138,18 @@ import { resolveVoiceMcpBridgeFromRuntime } from "./voice-mcp-bridge-command.js"
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
function resolveVoiceMcpBridgeCommand(
logger: Logger,
): { command: string; baseArgs: string[] } | null {
try {
const decision = resolveVoiceMcpBridgeFromRuntime({
bootstrapModuleUrl: import.meta.url,
execPath: process.execPath,
explicitScriptPath: process.env.PASEO_MCP_STDIO_SOCKET_BRIDGE_SCRIPT,
});
logger.info(
{
source: decision.source,
command: decision.resolved.command,
baseArgs: decision.resolved.baseArgs,
},
"Resolved voice MCP bridge command",
);
return decision.resolved;
} catch (err) {
logger.warn({ err }, "Voice MCP bridge script not available — voice MCP via stdio disabled");
function formatHostForHttpUrl(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
function createAgentMcpBaseUrl(listenTarget: ListenTarget | null): string | null {
if (!listenTarget || listenTarget.type !== "tcp") {
return null;
}
return new URL(
"/mcp/agents",
`http://${formatHostForHttpUrl(listenTarget.host)}:${listenTarget.port}`,
).toString();
}
export type PaseoOpenAIConfig = OpenAiSpeechProviderConfig;
@@ -185,6 +179,7 @@ export type PaseoDaemonConfig = {
corsAllowedOrigins: string[];
allowedHosts?: AllowedHostsConfig;
mcpEnabled?: boolean;
mcpInjectIntoAgents?: boolean;
staticDir: string;
mcpDebug: boolean;
agentClients: Partial<Record<AgentProvider, AgentClient>>;
@@ -225,6 +220,13 @@ export async function createPaseoDaemon(
const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`;
const daemonVersion = resolveDaemonVersion(import.meta.url);
let database: PaseoDatabaseHandle | null = null;
const daemonConfigStore = new DaemonConfigStore(
config.paseoHome,
{
mcp: { injectIntoAgents: config.mcpInjectIntoAgents ?? true },
},
logger,
);
try {
const serverId = getOrCreateServerId(config.paseoHome, { logger });
@@ -418,6 +420,9 @@ export async function createPaseoDaemon(
terminalManager,
logger,
});
const providerRegistry = buildProviderRegistry(logger, {
runtimeSettings: config.agentProviderSettings,
});
const projectRegistry = new DbProjectRegistry(database.db);
const workspaceRegistry = new DbWorkspaceRegistry(database.db);
@@ -508,6 +513,7 @@ export async function createPaseoDaemon(
};
const mcpEnabled = config.mcpEnabled ?? true;
let agentMcpBaseUrl: string | null = null;
if (mcpEnabled) {
const agentMcpRoute = "/mcp/agents";
const agentMcpTransports: AgentMcpTransportMap = new Map();
@@ -519,6 +525,8 @@ export async function createPaseoDaemon(
terminalManager,
getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
scheduleService,
providerRegistry,
paseoHome: config.paseoHome,
callerAgentId,
enableVoiceTools: false,
@@ -657,61 +665,6 @@ export async function createPaseoDaemon(
});
logger.info({ elapsed: elapsed() }, "Speech service created");
wsServer = new VoiceAssistantWebSocketServer(
httpServer,
logger,
serverId,
agentManager,
agentStorage,
downloadTokenStore,
config.paseoHome,
createInMemoryAgentMcpTransport,
{ allowedOrigins, allowedHosts: config.allowedHosts },
speechService,
terminalManager,
{
voiceAgentMcpStdio: voiceMcpBridgeCommand
? {
command: voiceMcpBridgeCommand.command,
baseArgs: [...voiceMcpBridgeCommand.baseArgs],
env: {
ELECTRON_RUN_AS_NODE: "1",
PASEO_HOME: config.paseoHome,
},
}
: null,
ensureVoiceMcpSocketForAgent: (agentId) =>
voiceMcpBridgeManager?.ensureBridgeForCaller(agentId) ??
Promise.reject(new Error("Voice MCP bridge manager is not initialized")),
removeVoiceMcpSocketForAgent: (agentId) =>
voiceMcpBridgeManager?.removeBridgeForCaller(agentId) ?? Promise.resolve(),
},
{
finalTimeoutMs: config.dictationFinalTimeoutMs,
},
config.agentProviderSettings,
daemonVersion,
(intent) => {
try {
config.onLifecycleIntent?.(intent);
} catch (error) {
logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
}
},
projectRegistry,
workspaceRegistry,
chatService,
loopService,
scheduleService,
checkoutDiffManager,
scriptRouteStore,
scriptRuntimeStore,
handleBranchChange,
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
);
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
const start = async () => {
@@ -725,6 +678,12 @@ export async function createPaseoDaemon(
httpServer.off("error", onError);
const logAndResolve = async () => {
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
agentManager.setMcpBaseUrl(agentMcpBaseUrl);
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
});
const relayEnabled = config.relayEnabled ?? true;
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
@@ -746,6 +705,62 @@ export async function createPaseoDaemon(
);
}
wsServer = new VoiceAssistantWebSocketServer(
httpServer,
logger,
serverId,
agentManager,
agentStorage,
downloadTokenStore,
config.paseoHome,
daemonConfigStore,
mcpBaseUrl,
{ allowedOrigins, allowedHosts: config.allowedHosts },
speechService,
terminalManager,
{
voiceAgentMcpStdio: voiceMcpBridgeCommand
? {
command: voiceMcpBridgeCommand.command,
baseArgs: [...voiceMcpBridgeCommand.baseArgs],
env: {
ELECTRON_RUN_AS_NODE: "1",
PASEO_HOME: config.paseoHome,
},
}
: null,
ensureVoiceMcpSocketForAgent: (agentId) =>
voiceMcpBridgeManager?.ensureBridgeForCaller(agentId) ??
Promise.reject(new Error("Voice MCP bridge manager is not initialized")),
removeVoiceMcpSocketForAgent: (agentId) =>
voiceMcpBridgeManager?.removeBridgeForCaller(agentId) ?? Promise.resolve(),
},
{
finalTimeoutMs: config.dictationFinalTimeoutMs,
},
config.agentProviderSettings,
daemonVersion,
(intent) => {
try {
config.onLifecycleIntent?.(intent);
} catch (error) {
logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
}
},
projectRegistry,
workspaceRegistry,
chatService,
loopService,
scheduleService,
checkoutDiffManager,
scriptRouteStore,
scriptRuntimeStore,
handleBranchChange,
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
);
if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") {
process.send({
type: "paseo:ready",
@@ -260,7 +260,9 @@ export class FileBackedChatService {
if (existing.length > 0) {
return existing;
}
const knownMessage = this.getRoomMessages(room.id).some((message) => message.id === afterMessageId);
const knownMessage = this.getRoomMessages(room.id).some(
(message) => message.id === afterMessageId,
);
if (!knownMessage) {
throw new ChatServiceError(
"chat_message_not_found",
@@ -341,7 +343,9 @@ export class FileBackedChatService {
private async persist(): Promise<void> {
const payload: ChatStorePayload = {
rooms: Array.from(this.rooms.values()).sort((left, right) => left.createdAt.localeCompare(right.createdAt)),
rooms: Array.from(this.rooms.values()).sort((left, right) =>
left.createdAt.localeCompare(right.createdAt),
),
messages: Array.from(this.messagesByRoomId.values())
.flat()
.sort((left, right) => left.createdAt.localeCompare(right.createdAt)),
@@ -5,9 +5,15 @@ const { execMock, getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, w
vi.hoisted(() => {
const hoistedWatchCalls: Array<{ path: string; close: ReturnType<typeof vi.fn> }> = [];
return {
execMock: vi.fn((_command: string, _options: unknown, callback: (error: null, result: { stdout: string; stderr: string }) => void) => {
callback(null, { stdout: "/tmp/repo\n", stderr: "" });
}),
execMock: vi.fn(
(
_command: string,
_options: unknown,
callback: (error: null, result: { stdout: string; stderr: string }) => void,
) => {
callback(null, { stdout: "/tmp/repo\n", stderr: "" });
},
),
getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
resolveCheckoutGitDirMock: vi.fn(async () => "/tmp/repo/.git"),
readdirMock: vi.fn(async (directory: string) => {
+30 -3
View File
@@ -16,10 +16,27 @@ const DEFAULT_PORT = 6767;
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
const DEFAULT_APP_BASE_URL = "https://app.paseo.sh";
function parseBooleanEnv(value: string | undefined): boolean | undefined {
if (value === undefined) {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) {
return true;
}
if (["0", "false", "no", "off"].includes(normalized)) {
return false;
}
return undefined;
}
export type CliConfigOverrides = Partial<{
listen: string;
relayEnabled: boolean;
mcpEnabled: boolean;
mcpInjectIntoAgents: boolean;
allowedHosts: AllowedHostsConfig;
}>;
@@ -51,7 +68,10 @@ export function loadConfig(
// - unix:///path/to/socket (Unix socket)
// Default is TCP at 127.0.0.1:6767
const listen =
options?.cli?.listen ?? env.PASEO_LISTEN ?? persisted.daemon?.listen ?? `127.0.0.1:${env.PORT ?? DEFAULT_PORT}`;
options?.cli?.listen ??
env.PASEO_LISTEN ??
persisted.daemon?.listen ??
`127.0.0.1:${env.PORT ?? DEFAULT_PORT}`;
const envCorsOrigins = env.PASEO_CORS_ORIGINS
? env.PASEO_CORS_ORIGINS.split(",").map((s) => s.trim())
@@ -65,9 +85,15 @@ export function loadConfig(
options?.cli?.allowedHosts,
]);
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? false;
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true;
const mcpInjectIntoAgents =
options?.cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false;
const relayEnabled = options?.cli?.relayEnabled ?? persisted.daemon?.relay?.enabled ?? true;
const relayEnabled =
options?.cli?.relayEnabled ??
parseBooleanEnv(env.PASEO_RELAY_ENABLED) ??
persisted.daemon?.relay?.enabled ??
true;
const relayEndpoint =
env.PASEO_RELAY_ENDPOINT ?? persisted.daemon?.relay?.endpoint ?? DEFAULT_RELAY_ENDPOINT;
@@ -100,6 +126,7 @@ export function loadConfig(
),
allowedHosts,
mcpEnabled,
mcpInjectIntoAgents,
mcpDebug: env.MCP_DEBUG === "1",
agentStoragePath: path.join(paseoHome, "agents"),
staticDir: "public",
@@ -0,0 +1,161 @@
import {
loadPersistedConfig,
savePersistedConfig,
type PersistedConfig,
} from "./persisted-config.js";
import { MutableDaemonConfigSchema, MutableDaemonConfigPatchSchema } from "../shared/messages.js";
export type { MutableDaemonConfig, MutableDaemonConfigPatch } from "../shared/messages.js";
type MutableDaemonConfig = import("../shared/messages.js").MutableDaemonConfig;
type MutableDaemonConfigPatch = import("../shared/messages.js").MutableDaemonConfigPatch;
type LoggerLike = {
child(bindings: Record<string, unknown>): LoggerLike;
info(...args: any[]): void;
};
type ConfigListener = (config: MutableDaemonConfig) => void;
type FieldChangeHandler = (value: unknown) => void;
function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined {
return logger?.child({ module: "daemon-config-store" });
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function deepMerge<T extends Record<string, unknown>>(
current: T,
patch: Record<string, unknown>,
): T {
const next: Record<string, unknown> = { ...current };
for (const [key, patchValue] of Object.entries(patch)) {
if (patchValue === undefined) {
continue;
}
const currentValue = next[key];
if (isRecord(currentValue) && isRecord(patchValue)) {
next[key] = deepMerge(currentValue, patchValue);
continue;
}
next[key] = patchValue;
}
return next as T;
}
function getValueAtPath(config: MutableDaemonConfig, path: string): unknown {
return path
.split(".")
.reduce<unknown>((value, segment) => (isRecord(value) ? value[segment] : undefined), config);
}
function isEqualValue(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
export class DaemonConfigStore {
private current: MutableDaemonConfig;
private readonly paseoHome: string;
private readonly logger: LoggerLike | undefined;
private readonly changeListeners = new Set<ConfigListener>();
private readonly fieldChangeHandlers = new Map<string, Set<FieldChangeHandler>>();
constructor(paseoHome: string, initial: MutableDaemonConfig, logger?: LoggerLike) {
this.paseoHome = paseoHome;
this.logger = getLogger(logger);
this.current = MutableDaemonConfigSchema.parse(initial);
}
public get(): MutableDaemonConfig {
return this.current;
}
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
const parsedPatch = MutableDaemonConfigPatchSchema.parse(partial);
const next = MutableDaemonConfigSchema.parse(deepMerge(this.current, parsedPatch));
const changedFieldPaths = Array.from(this.fieldChangeHandlers.keys()).filter((path) => {
return !isEqualValue(getValueAtPath(this.current, path), getValueAtPath(next, path));
});
if (changedFieldPaths.length === 0 && isEqualValue(this.current, next)) {
return this.current;
}
// Persist before updating in-memory state so that if persistence fails,
// runtime and disk stay consistent.
this.persistConfig(next);
this.current = next;
for (const path of changedFieldPaths) {
const handlers = this.fieldChangeHandlers.get(path);
if (!handlers) {
continue;
}
const value = getValueAtPath(next, path);
for (const handler of handlers) {
handler(value);
}
}
for (const listener of this.changeListeners) {
listener(next);
}
return next;
}
public onFieldChange(path: string, handler: FieldChangeHandler): () => void {
const handlers = this.fieldChangeHandlers.get(path) ?? new Set<FieldChangeHandler>();
handlers.add(handler);
this.fieldChangeHandlers.set(path, handlers);
return () => {
const currentHandlers = this.fieldChangeHandlers.get(path);
if (!currentHandlers) {
return;
}
currentHandlers.delete(handler);
if (currentHandlers.size === 0) {
this.fieldChangeHandlers.delete(path);
}
};
}
public onChange(listener: ConfigListener): () => void {
this.changeListeners.add(listener);
return () => {
this.changeListeners.delete(listener);
};
}
private persistConfig(config: MutableDaemonConfig): void {
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
const nextPersisted = mergeMutableConfigIntoPersistedConfig({
persisted,
mutable: config,
});
savePersistedConfig(this.paseoHome, nextPersisted, this.logger);
}
}
function mergeMutableConfigIntoPersistedConfig(params: {
persisted: PersistedConfig;
mutable: MutableDaemonConfig;
}): PersistedConfig {
const { persisted, mutable } = params;
return {
...persisted,
daemon: {
...persisted.daemon,
mcp: {
...persisted.daemon?.mcp,
injectIntoAgents: mutable.mcp.injectIntoAgents,
},
},
};
}
@@ -19,8 +19,8 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
// Use gpt-5.4-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
@@ -34,7 +34,7 @@ export const agentConfigs = {
},
codex: {
provider: "codex",
model: "gpt-5.1-codex-mini",
model: "gpt-5.4-mini",
thinkingOptionId: "low",
modes: {
full: "full-access",
@@ -131,10 +131,4 @@ export function isProviderAvailable(provider: AgentProvider): boolean {
/**
* Helper to run a test for each provider.
*/
export const allProviders: AgentProvider[] = [
"claude",
"codex",
"copilot",
"opencode",
"pi",
];
export const allProviders: AgentProvider[] = ["claude", "codex", "copilot", "opencode", "pi"];
@@ -18,8 +18,8 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
// Use gpt-5.4-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
@@ -7,7 +7,7 @@ import { execSync } from "child_process";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
import { createWorktree } from "../../utils/worktree.js";
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
function tmpCwd(prefix: string): string {
@@ -467,11 +467,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
// wake; if none arrives within the expected sleep window, verify the
// agent settled to idle (notification was already processed).
const autonomousWake = await client
.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
15_000,
)
.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 15_000)
.catch(() => null);
if (autonomousWake) {
@@ -536,11 +532,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
// HELLO. When it races with HELLO, the notification is handled during
// the foreground turn and there is no separate autonomous running edge.
const autonomousWake = await client
.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
15_000,
)
.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 15_000)
.catch(() => null);
if (autonomousWake) {
@@ -18,8 +18,8 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
// Use gpt-5.4-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
@@ -18,8 +18,8 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
// Use gpt-5.4-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
@@ -174,8 +174,8 @@ function getWorktreeTerminalBootstrapEntries(
return terminals as WorktreeTerminalBootstrapEntry[];
}
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
// Use gpt-5.4-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
@@ -19,7 +19,7 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_MODEL = "gpt-5.4-mini";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
@@ -24,7 +24,10 @@ function collectAgentUpdates(client: DaemonClient): {
return { updates, unsub };
}
function lastUpsertFor(updates: AgentUpdatePayload[], agentId: string): AgentUpsertPayload | undefined {
function lastUpsertFor(
updates: AgentUpdatePayload[],
agentId: string,
): AgentUpsertPayload | undefined {
return updates
.filter((u): u is AgentUpsertPayload => u.kind === "upsert" && u.agent.id === agentId)
.at(-1);
@@ -125,9 +128,7 @@ describe("mode-switch update propagation", () => {
const modeUpdate = client2Updates.find(
(u): u is AgentUpsertPayload =>
u.kind === "upsert" &&
u.agent.id === agent.id &&
u.agent.currentModeId === "acceptEdits",
u.kind === "upsert" && u.agent.id === agent.id && u.agent.currentModeId === "acceptEdits",
);
expect(modeUpdate).toBeDefined();
@@ -1,25 +1,6 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import {
mkdtempSync,
writeFileSync,
existsSync,
rmSync,
mkdirSync,
readFileSync,
readdirSync,
} from "fs";
import { tmpdir } from "os";
import path from "path";
import { describe, test, expect } from "vitest";
import { execFileSync } from "node:child_process";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
import { createDaemonTestContext } from "../test-utils/index.js";
function isBinaryInstalled(binary: string): boolean {
try {
@@ -34,25 +15,44 @@ const hasCodex = isBinaryInstalled("codex");
const hasOpenCode = isBinaryInstalled("opencode");
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
describe("listProviderModels", () => {
test.runIf(hasCodex)(
"returns model list for Codex provider",
async () => {
// List models for Codex provider - no agent needed
const result = await ctx.client.listProviderModels("codex");
const ctx = await createDaemonTestContext();
try {
// List models for Codex provider - no agent needed
const result = await ctx.client.listProviderModels("codex");
// Verify response structure
expect(result.provider).toBe("codex");
expect(result.error).toBeNull();
expect(result.fetchedAt).toBeTruthy();
// Should return at least one model
expect(result.models).toBeTruthy();
expect(result.models.length).toBeGreaterThan(0);
// Verify model structure
const model = result.models[0];
expect(model.provider).toBe("codex");
expect(model.id).toBeTruthy();
expect(model.label).toBeTruthy();
} finally {
await ctx.cleanup();
}
},
60000, // 1 minute timeout
);
test("returns model list for Claude provider", async () => {
const ctx = await createDaemonTestContext();
try {
// List models for Claude provider - no agent needed
const result = await ctx.client.listProviderModels("claude");
// Verify response structure
expect(result.provider).toBe("codex");
expect(result.provider).toBe("claude");
expect(result.error).toBeNull();
expect(result.fetchedAt).toBeTruthy();
@@ -62,49 +62,35 @@ describe("daemon E2E", () => {
// Verify model structure
const model = result.models[0];
expect(model.provider).toBe("codex");
expect(model.provider).toBe("claude");
expect(model.id).toBeTruthy();
expect(model.label).toBeTruthy();
},
60000, // 1 minute timeout
);
test("returns model list for Claude provider", async () => {
// List models for Claude provider - no agent needed
const result = await ctx.client.listProviderModels("claude");
// Verify response structure
expect(result.provider).toBe("claude");
expect(result.error).toBeNull();
expect(result.fetchedAt).toBeTruthy();
// Should return at least one model
expect(result.models).toBeTruthy();
expect(result.models.length).toBeGreaterThan(0);
// Verify model structure
const model = result.models[0];
expect(model.provider).toBe("claude");
expect(model.id).toBeTruthy();
expect(model.label).toBeTruthy();
}, 60000); // 1 minute timeout
} finally {
await ctx.cleanup();
}
}, 180000);
test.runIf(hasOpenCode)(
"returns model list for OpenCode provider",
async () => {
const result = await ctx.client.listProviderModels("opencode");
const ctx = await createDaemonTestContext();
try {
const result = await ctx.client.listProviderModels("opencode");
expect(result.provider).toBe("opencode");
expect(result.error).toBeNull();
expect(result.fetchedAt).toBeTruthy();
expect(result.provider).toBe("opencode");
expect(result.error).toBeNull();
expect(result.fetchedAt).toBeTruthy();
expect(result.models).toBeTruthy();
expect(result.models.length).toBeGreaterThan(0);
expect(result.models).toBeTruthy();
expect(result.models.length).toBeGreaterThan(0);
const model = result.models[0];
expect(model.provider).toBe("opencode");
expect(model.id).toBeTruthy();
expect(model.label).toBeTruthy();
const model = result.models[0];
expect(model.provider).toBe("opencode");
expect(model.id).toBeTruthy();
expect(model.label).toBeTruthy();
} finally {
await ctx.cleanup();
}
},
60000,
);
@@ -64,9 +64,9 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => {
);
expect(assistantMessages.length).toBeGreaterThan(0);
expect(
assistantMessages.some((entry) => entry.item.text.includes("BIG_PICKLE_OK")),
).toBe(true);
expect(assistantMessages.some((entry) => entry.item.text.includes("BIG_PICKLE_OK"))).toBe(
true,
);
} finally {
await client.close().catch(() => undefined);
await daemon.close();
@@ -17,7 +17,9 @@ function pickOpenCodeModel(
models: Array<{ id: string }>,
preferences: string[] = ["gpt-5-nano", "gpt-4.1-nano", "mini", "free"],
): string {
const preferred = models.find((model) => preferences.some((fragment) => model.id.includes(fragment)));
const preferred = models.find((model) =>
preferences.some((fragment) => model.id.includes(fragment)),
);
return preferred?.id ?? models[0]!.id;
}
@@ -325,12 +325,10 @@ describe("daemon E2E (real opencode) - send while working and interrupt", () =>
expect(finish.status).toBe("idle");
const postSendAssistantTexts = getAssistantTexts(collector.messages, agent.id);
expect(
postSendAssistantTexts.some((text) => text.includes("[System Error]")),
).toBe(false);
expect(
postSendAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET)),
).toBe(false);
expect(postSendAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
expect(postSendAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(
false,
);
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 160 });
const assistantTexts = getTimelineAssistantTexts(timeline);
@@ -400,9 +398,9 @@ describe("daemon E2E (real opencode) - send while working and interrupt", () =>
expect(finish.status).toBe("idle");
const postInterruptAssistantTexts = getAssistantTexts(collector.messages, agent.id);
expect(
postInterruptAssistantTexts.some((text) => text.includes("[System Error]")),
).toBe(false);
expect(postInterruptAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(
false,
);
expect(
postInterruptAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET)),
).toBe(false);
@@ -18,8 +18,8 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
// Use gpt-5.4-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
@@ -71,7 +71,10 @@ function getAgentStatuses(messages: SessionOutboundMessage[], agentId: string):
.map((message) => message.payload.agent.status);
}
function getStatusesBeforeFirstAssistant(messages: SessionOutboundMessage[], agentId: string): string[] {
function getStatusesBeforeFirstAssistant(
messages: SessionOutboundMessage[],
agentId: string,
): string[] {
const firstAssistantIndex = messages.findIndex(
(message) =>
message.type === "agent_stream" &&
@@ -126,7 +129,9 @@ async function waitForRunningToolCall(
.map((entry) => entry.item.text) ?? [];
const limitText = assistantTexts.find((text) => hasProviderLimitText(text));
if (limitText) {
throw new Error(`Claude could not reach the tool call because the provider rejected the run: ${limitText}`);
throw new Error(
`Claude could not reach the tool call because the provider rejected the run: ${limitText}`,
);
}
if (
timeline?.entries.some(
@@ -252,9 +257,7 @@ describe("daemon E2E (real claude) - send message during tool call", () => {
});
// No system error messages should leak into the timeline
const hasSystemError = assistantTexts.some((text) =>
text.includes("[System Error]"),
);
const hasSystemError = assistantTexts.some((text) => text.includes("[System Error]"));
expect(hasSystemError).toBe(false);
expect(postSendAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
@@ -1106,7 +1106,12 @@ describe("daemon E2E terminal", () => {
type: "input",
data: "echo hello world\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("hello world"), 15000);
await waitForTerminalOutput(
ctx.client,
terminalId,
(text) => text.includes("hello world"),
15000,
);
const capture = await ctx.client.captureTerminal(terminalId);
@@ -1188,7 +1193,12 @@ describe("daemon E2E terminal", () => {
type: "input",
data: "printf '\\033[31mred text\\033[0m\\n'\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("red text"), 15000);
await waitForTerminalOutput(
ctx.client,
terminalId,
(text) => text.includes("red text"),
15000,
);
const capture = await ctx.client.captureTerminal(terminalId);
const capturedText = capture.lines.join("\n");
@@ -19,8 +19,8 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
// Use gpt-5.4-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
type ToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
+4 -6
View File
@@ -6,11 +6,7 @@ import type {
EditorTargetId,
KnownEditorTargetId,
} from "../shared/messages.js";
import {
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "../utils/executable.js";
import { findExecutable, quoteWindowsArgument, quoteWindowsCommand } from "../utils/executable.js";
type EditorTargetDefinition = {
id: KnownEditorTargetId;
@@ -148,7 +144,9 @@ export async function openInEditorTarget(
const command = platform === "win32" ? quoteWindowsCommand(launch.command) : launch.command;
const args =
platform === "win32" ? launch.args.map((argument) => quoteWindowsArgument(argument)) : launch.args;
platform === "win32"
? launch.args.map((argument) => quoteWindowsArgument(argument))
: launch.args;
await new Promise<void>((resolve, reject) => {
let child: ChildProcess;
+6 -3
View File
@@ -30,10 +30,13 @@ export {
} from "./speech/providers/local/sherpa/sherpa-runtime-env.js";
// Provider binary resolution
export { applyProviderEnv } from "./agent/provider-launch-config.js";
export {
applyProviderEnv,
} from "./agent/provider-launch-config.js";
export { findExecutable, findExecutableSync, quoteWindowsArgument, quoteWindowsCommand } from "../utils/executable.js";
findExecutable,
findExecutableSync,
quoteWindowsArgument,
quoteWindowsCommand,
} from "../utils/executable.js";
export { spawnProcess } from "../utils/spawn.js";
// Provider manifest (source of truth for provider definitions)
+34 -1
View File
@@ -3,7 +3,7 @@ import { loadConfig } from "./config.js";
import { resolvePaseoHome } from "./paseo-home.js";
import { createRootLogger } from "./logger.js";
import { loadPersistedConfig } from "./persisted-config.js";
import { PidLockError } from "./pid-lock.js";
import { acquirePidLock, PidLockError, releasePidLock, updatePidLock } from "./pid-lock.js";
import type { DaemonLifecycleIntent } from "./bootstrap.js";
type SupervisorLifecycleMessage =
@@ -22,6 +22,8 @@ async function main() {
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>> | null = null;
let shutdownPromise: Promise<number> | null = null;
let exitHookInstalled = false;
const supervised = process.env.PASEO_SUPERVISED === "1" && typeof process.send === "function";
let pidLockAcquired = false;
try {
paseoHome = resolvePaseoHome();
@@ -40,6 +42,9 @@ async function main() {
if (process.argv.includes("--no-mcp")) {
config.mcpEnabled = false;
}
if (process.argv.includes("--no-inject-mcp")) {
config.mcpInjectIntoAgents = false;
}
const installExitHook = () => {
if (exitHookInstalled || !shutdownPromise) {
@@ -73,6 +78,10 @@ async function main() {
return 1;
}
await daemon.stop();
if (pidLockAcquired) {
await releasePidLock(paseoHome);
pidLockAcquired = false;
}
clearTimeout(forceExit);
logger.info("Server closed");
return options?.successExitCode ?? 0;
@@ -131,6 +140,11 @@ async function main() {
};
try {
if (!supervised) {
await acquirePidLock(paseoHome, null);
pidLockAcquired = true;
}
daemon = await createPaseoDaemon(
{
...config,
@@ -139,6 +153,10 @@ async function main() {
logger,
);
} catch (err) {
if (pidLockAcquired) {
await releasePidLock(paseoHome);
pidLockAcquired = false;
}
if (err instanceof PidLockError) {
logger.error({ pid: err.existingLock?.pid }, err.message);
process.exit(1);
@@ -149,7 +167,22 @@ async function main() {
try {
await daemon.start();
if (!supervised) {
const listenTarget = daemon.getListenTarget();
const listen =
listenTarget?.type === "tcp"
? `${listenTarget.host}:${listenTarget.port}`
: listenTarget?.path;
if (!listen) {
throw new Error("Daemon did not expose a listen target after startup");
}
await updatePidLock(paseoHome, { listen });
}
} catch (err) {
if (pidLockAcquired) {
await releasePidLock(paseoHome);
pidLockAcquired = false;
}
if (err instanceof PidLockError) {
logger.error({ pid: err.existingLock?.pid }, err.message);
process.exit(1);
+4 -4
View File
@@ -25,13 +25,13 @@ describe("resolveLogConfig", () => {
it("returns dual-sink defaults when no config or env vars", () => {
const result = resolveLogConfig(undefined, { paseoHome });
expect(result).toEqual({
level: "trace",
level: "debug",
console: {
level: "info",
format: "pretty",
},
file: {
level: "trace",
level: "debug",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
@@ -179,13 +179,13 @@ describe("resolveLogConfig", () => {
const result = resolveLogConfig(config, { paseoHome });
expect(result).toEqual({
level: "trace",
level: "debug",
console: {
level: "warn",
format: "pretty",
},
file: {
level: "trace",
level: "debug",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
@@ -102,7 +102,10 @@ class ScriptedAgentSession implements AgentSession {
};
}
async startTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<{ turnId: string }> {
async startTurn(
prompt: AgentPromptInput,
_options?: AgentRunOptions,
): Promise<{ turnId: string }> {
const promptText = typeof prompt === "string" ? prompt : JSON.stringify(prompt);
const turnId = `turn-${++this.turnCount}`;
this.interrupted = false;
@@ -183,7 +186,12 @@ class ScriptedAgentSession implements AgentSession {
turnId,
});
if (this.interrupted) {
this.emit({ type: "turn_canceled", provider: this.provider, reason: "interrupted", turnId });
this.emit({
type: "turn_canceled",
provider: this.provider,
reason: "interrupted",
turnId,
});
return;
}
this.emit({
@@ -236,7 +244,7 @@ describe("LoopService", () => {
if (config.title?.includes("worker")) {
return `worker run ${state.workerRuns}`;
}
return "{\"passed\":true,\"reason\":\"not used\"}";
return '{"passed":true,"reason":"not used"}';
},
}),
},
@@ -282,7 +290,7 @@ describe("LoopService", () => {
claude: new ScriptedAgentClient("claude", {
async onRun({ config }) {
verifierConfigs.push(config);
return "{\"passed\":true,\"reason\":\"verified\"}";
return '{"passed":true,"reason":"verified"}';
},
}),
},
@@ -338,7 +346,7 @@ describe("LoopService", () => {
writeFileSync(path.join(workspaceDir, "done.txt"), "ok");
return "created done.txt";
}
return "{\"passed\":true,\"reason\":\"done.txt exists\"}";
return '{"passed":true,"reason":"done.txt exists"}';
},
}),
},
@@ -394,8 +402,8 @@ describe("LoopService", () => {
}
const exists = pathExists(path.join(workspaceDir, "done.txt"));
return exists
? "{\"passed\":true,\"reason\":\"done.txt exists\"}"
: "{\"passed\":false,\"reason\":\"done.txt missing\"}";
? '{"passed":true,"reason":"done.txt exists"}'
: '{"passed":false,"reason":"done.txt missing"}';
},
}),
},
@@ -437,7 +445,7 @@ describe("LoopService", () => {
await blocker;
return "finished";
}
return "{\"passed\":true,\"reason\":\"ok\"}";
return '{"passed":true,"reason":"ok"}';
},
}),
},
+21 -12
View File
@@ -598,7 +598,9 @@ export class LoopService {
iteration: LoopIterationRecord,
signal: AbortSignal,
): Promise<boolean> {
const agent = await this.options.agentManager.createAgent(this.buildWorkerConfig(loop, iteration));
const agent = await this.options.agentManager.createAgent(
this.buildWorkerConfig(loop, iteration),
);
iteration.workerAgentId = agent.id;
loop.activeWorkerAgentId = agent.id;
loop.updatedAt = nowIso();
@@ -688,9 +690,7 @@ export class LoopService {
iteration: iteration.index,
source: "verify-check",
level: result.passed ? "info" : "error",
text: output
? `exit ${result.exitCode}\n${output}`
: `exit ${result.exitCode}`,
text: output ? `exit ${result.exitCode}\n${output}` : `exit ${result.exitCode}`,
});
loop.updatedAt = nowIso();
await this.persist();
@@ -736,7 +736,10 @@ export class LoopService {
try {
const result = await getStructuredAgentResponse({
caller: async (nextPrompt) => {
const run = await this.options.agentManager.runAgent(verifierAgent.id, this.toPrompt(nextPrompt));
const run = await this.options.agentManager.runAgent(
verifierAgent.id,
this.toPrompt(nextPrompt),
);
return this.resolveFinalText(run.timeline, run.finalText);
},
prompt: loop.verifyPrompt,
@@ -788,7 +791,10 @@ export class LoopService {
};
}
private buildVerifierConfig(loop: LoopRecord, iteration: LoopIterationRecord): AgentSessionConfig {
private buildVerifierConfig(
loop: LoopRecord,
iteration: LoopIterationRecord,
): AgentSessionConfig {
return {
provider: loop.verifierProvider ?? loop.provider,
cwd: loop.cwd,
@@ -815,7 +821,11 @@ export class LoopService {
return text;
}
private finishLoop(loop: LoopRecord, status: Exclude<LoopStatus, "running">, message: string): void {
private finishLoop(
loop: LoopRecord,
status: Exclude<LoopStatus, "running">,
message: string,
): void {
loop.status = status;
loop.completedAt = nowIso();
loop.updatedAt = loop.completedAt;
@@ -830,10 +840,7 @@ export class LoopService {
});
}
private appendLog(
loop: LoopRecord,
entry: Omit<LoopLogEntry, "seq" | "timestamp">,
): void {
private appendLog(loop: LoopRecord, entry: Omit<LoopLogEntry, "seq" | "timestamp">): void {
loop.logs.push({
seq: loop.nextLogSeq,
timestamp: nowIso(),
@@ -852,7 +859,9 @@ export class LoopService {
if (exact) {
return exact;
}
const matches = Array.from(this.loops.values()).filter((record) => record.id.startsWith(trimmed));
const matches = Array.from(this.loops.values()).filter((record) =>
record.id.startsWith(trimmed),
);
if (matches.length === 1) {
return matches[0]!;
}
@@ -126,8 +126,9 @@ export const PersistedConfigSchema = z
mcp: z
.object({
enabled: z.boolean().optional(),
injectIntoAgents: z.boolean().optional(),
})
.strict()
.passthrough()
.optional(),
cors: z
.object({
@@ -29,7 +29,7 @@ describe("persistence hooks", () => {
config: {
title: "Voice agent (created)",
modeId: "default",
model: "gpt-5.1-codex-mini",
model: "gpt-5.4-mini",
thinkingOptionId: "minimal",
systemPrompt: "Use speak first.",
mcpServers: {
@@ -45,7 +45,7 @@ describe("persistence hooks", () => {
expect(buildConfigOverrides(record)).toMatchObject({
cwd: "/tmp/project",
modeId: "plan",
model: "gpt-5.1-codex-mini",
model: "gpt-5.4-mini",
thinkingOptionId: "minimal",
title: "Voice agent (created)",
systemPrompt: "Use speak first.",
@@ -66,7 +66,7 @@ describe("persistence hooks", () => {
config: {
title: "Creation title",
modeId: "default",
model: "gpt-5.1-codex-mini",
model: "gpt-5.4-mini",
systemPrompt: "Confirm and speak first.",
mcpServers: {
paseo: {
@@ -82,7 +82,7 @@ describe("persistence hooks", () => {
provider: "codex",
cwd: "/tmp/project",
modeId: "plan",
model: "gpt-5.1-codex-mini",
model: "gpt-5.4-mini",
title: "Creation title",
systemPrompt: "Confirm and speak first.",
mcpServers: {
+1 -4
View File
@@ -127,10 +127,7 @@ export async function updatePidLock(
const existingLock = JSON.parse(content) as PidLockInfo;
if (existingLock.pid !== lockOwnerPid) {
throw new PidLockError(
`Cannot update PID lock owned by PID ${existingLock.pid}`,
existingLock,
);
throw new PidLockError(`Cannot update PID lock owned by PID ${existingLock.pid}`, existingLock);
}
const updatedLock: PidLockInfo = {
@@ -149,7 +149,7 @@ export function startRelayTransport({
clearTimeout(controlReadyTimeout);
controlReadyTimeout = null;
}
relayLogger.info({ url, connectionId }, "relay_control_connected");
relayLogger.info({ connectionId }, "relay_control_connected");
};
socket.on("open", () => {
@@ -203,7 +203,7 @@ export function startRelayTransport({
try {
socket.send(JSON.stringify({ type: "ping", ts: now }));
} catch (error) {
relayLogger.warn({ err: error, url, connectionId }, "relay_control_ping_send_failed");
relayLogger.warn({ err: error, connectionId }, "relay_control_ping_send_failed");
try {
socket.terminate();
} catch {
@@ -214,14 +214,14 @@ export function startRelayTransport({
try {
socket.send(JSON.stringify({ type: "ping", ts: Date.now() }));
} catch (error) {
relayLogger.warn({ err: error, url, connectionId }, "relay_control_ping_send_failed");
relayLogger.warn({ err: error, connectionId }, "relay_control_ping_send_failed");
try {
socket.terminate();
} catch {
// ignore
}
}
relayLogger.debug({ url, connectionId }, "relay_control_open_waiting_for_ready");
relayLogger.debug({ connectionId }, "relay_control_open_waiting_for_ready");
});
socket.on("close", (code, reason) => {
@@ -244,7 +244,7 @@ export function startRelayTransport({
socket.on("error", (err) => {
if (controlWs !== socket) return;
relayLogger.warn({ err, url, connectionId }, "relay_error");
relayLogger.warn({ err, connectionId }, "relay_error");
// close event will schedule reconnect
});
@@ -319,7 +319,7 @@ export function startRelayTransport({
const openTimeout = setTimeout(() => {
if (stopped) return;
if (socket.readyState === WebSocket.OPEN) return;
relayLogger.warn({ url, connectionId }, "relay_data_open_timeout_terminating");
relayLogger.warn({ connectionId }, "relay_data_open_timeout_terminating");
try {
socket.terminate();
} catch {
@@ -329,7 +329,7 @@ export function startRelayTransport({
socket.on("open", () => {
clearTimeout(openTimeout);
relayLogger.info({ url, connectionId }, "relay_data_connected");
relayLogger.info({ connectionId }, "relay_data_connected");
if (attached) return;
attached = true;
const externalMetadata: ExternalSocketMetadata = {
@@ -361,7 +361,7 @@ export function startRelayTransport({
});
socket.on("error", (err) => {
relayLogger.warn({ err, url, connectionId }, "relay_data_error");
relayLogger.warn({ err, connectionId }, "relay_data_error");
});
};
@@ -21,8 +21,8 @@ describe("schedule cron cadence", () => {
});
test("rejects invalid cron expressions", () => {
expect(() =>
validateScheduleCadence({ type: "cron", expression: "not-a-valid-cron" }),
).toThrow("Cron expressions must have 5 fields");
expect(() => validateScheduleCadence({ type: "cron", expression: "not-a-valid-cron" })).toThrow(
"Cron expressions must have 5 fields",
);
});
});
+1 -2
View File
@@ -33,8 +33,7 @@ function parseField(
}
const [base, stepSource] = part.split("/");
const step =
stepSource === undefined ? 1 : Number.parseInt(stepSource, 10);
const step = stepSource === undefined ? 1 : Number.parseInt(stepSource, 10);
if (!Number.isInteger(step) || step <= 0) {
throw new Error(`Invalid cron ${bounds.name} step`);
}
@@ -171,6 +171,45 @@ describe("ScheduleService", () => {
);
});
test("advances stale nextRunAt on daemon restart", async () => {
const service1 = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
const created = await service1.create({
prompt: "Periodic check",
cadence: { type: "every", everyMs: 60_000 },
target: {
type: "new-agent",
config: { provider: "claude", cwd: tempDir },
},
});
expect(created.nextRunAt).toBe("2026-01-01T00:01:00.000Z");
await service1.stop();
// Simulate daemon restart 10 minutes later
now = new Date("2026-01-01T00:10:00.000Z");
const service2 = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
await service2.start();
const inspected = await service2.inspect(created.id);
expect(new Date(inspected.nextRunAt!).getTime()).toBeGreaterThan(now.getTime());
await service2.stop();
});
test("keeps schedules paused when an in-flight run finishes after pause", async () => {
let releaseRun: (() => void) | null = null;
const runStarted = new Promise<void>((resolve) => {
+40 -21
View File
@@ -4,12 +4,13 @@ import type { Logger } from "pino";
import { AgentManager } from "../agent/agent-manager.js";
import type { ManagedAgent } from "../agent/agent-manager.js";
import type { AgentSnapshotStore } from "../agent/agent-snapshot-store.js";
import type {
AgentPromptInput,
AgentSessionConfig,
} from "../agent/agent-sdk-types.js";
import type { AgentPromptInput, AgentSessionConfig } from "../agent/agent-sdk-types.js";
import { curateAgentActivity } from "../agent/activity-curator.js";
import { buildConfigOverrides, buildSessionConfig, extractTimestamps } from "../persistence-hooks.js";
import {
buildConfigOverrides,
buildSessionConfig,
extractTimestamps,
} from "../persistence-hooks.js";
import { ScheduleStore } from "./store.js";
import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
import type {
@@ -250,23 +251,41 @@ export class ScheduleService {
const schedules = await this.store.list();
const now = this.now();
for (const schedule of schedules) {
const runningIndex = schedule.runs.findIndex((run) => run.status === "running");
if (runningIndex === -1) {
continue;
let updated = { ...schedule };
let dirty = false;
// Mark any in-flight runs as failed
const runningIndex = updated.runs.findIndex((run) => run.status === "running");
if (runningIndex !== -1) {
const runs = [...updated.runs];
runs[runningIndex] = {
...runs[runningIndex],
status: "failed",
endedAt: now.toISOString(),
error: "Daemon restarted before the scheduled run completed",
};
updated = { ...updated, runs };
dirty = true;
}
// Advance stale nextRunAt for active schedules
if (
updated.status === "active" &&
updated.nextRunAt &&
new Date(updated.nextRunAt).getTime() <= now.getTime()
) {
let nextRunAt = computeNextRunAt(updated.cadence, new Date(updated.nextRunAt));
while (nextRunAt.getTime() <= now.getTime()) {
nextRunAt = computeNextRunAt(updated.cadence, nextRunAt);
}
updated = { ...updated, nextRunAt: nextRunAt.toISOString() };
dirty = true;
}
if (dirty) {
updated = { ...updated, updatedAt: now.toISOString() };
await this.store.put(updated);
}
const runs = [...schedule.runs];
runs[runningIndex] = {
...runs[runningIndex],
status: "failed",
endedAt: now.toISOString(),
error: "Daemon restarted before the scheduled run completed",
};
const nextSchedule = {
...schedule,
runs,
updatedAt: now.toISOString(),
};
await this.store.put(nextSchedule);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,98 +1,84 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { Session } from "./session.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
import type {
WorkspaceGitListener,
WorkspaceGitRuntimeSnapshot,
WorkspaceGitService,
} from "./workspace-git-service.js";
const { watchCalls, watchMock } = vi.hoisted(() => {
const hoistedWatchCalls: Array<{
path: string;
listener: () => void;
close: ReturnType<typeof vi.fn>;
}> = [];
const hoistedWatchMock = vi.fn(
(watchPath: string, _options: { recursive: boolean }, listener: () => void) => {
const close = vi.fn();
const watcher = {
close,
on: vi.fn().mockReturnThis(),
};
hoistedWatchCalls.push({
path: watchPath,
listener,
close,
});
return watcher as any;
},
);
return {
watchCalls: hoistedWatchCalls,
watchMock: hoistedWatchMock,
};
});
const resolveCheckoutGitDirMock = vi.hoisted(() => vi.fn(async () => null));
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
watch: watchMock,
};
});
vi.mock("./checkout-git-utils.js", () => ({
READ_ONLY_GIT_ENV: {
...process.env,
GIT_OPTIONAL_LOCKS: "0",
function createWorkspaceRuntimeSnapshot(
cwd: string,
overrides?: {
git?: Partial<WorkspaceGitRuntimeSnapshot["git"]>;
github?: Partial<WorkspaceGitRuntimeSnapshot["github"]>;
},
resolveCheckoutGitDir: resolveCheckoutGitDirMock,
toCheckoutError: vi.fn((error: unknown) => ({
message: error instanceof Error ? error.message : String(error),
})),
}));
): WorkspaceGitRuntimeSnapshot {
const base: WorkspaceGitRuntimeSnapshot = {
cwd,
git: {
isGit: true,
repoRoot: cwd,
mainRepoRoot: null,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false,
isDirty: false,
aheadBehind: { ahead: 0, behind: 0 },
aheadOfOrigin: 0,
behindOfOrigin: 0,
diffStat: { additions: 1, deletions: 0 },
},
github: {
featuresEnabled: true,
pullRequest: null,
error: null,
refreshedAt: "2026-04-12T00:00:00.000Z",
},
};
vi.mock("@getpaseo/highlight", () => ({
highlightCode: vi.fn(async () => ""),
isLanguageSupported: vi.fn(() => false),
}));
return {
cwd,
git: {
...base.git,
...overrides?.git,
},
github: {
...base.github,
...overrides?.github,
pullRequest:
overrides?.github && "pullRequest" in overrides.github
? (overrides.github.pullRequest ?? null)
: base.github.pullRequest,
error:
overrides?.github && "error" in overrides.github
? (overrides.github.error ?? null)
: base.github.error,
},
};
}
function createSessionForWorkspaceGitWatchTests(): {
session: Session;
emitted: Array<{ type: string; payload: unknown }>;
projects: Map<number, ReturnType<typeof createPersistedProjectRecord>>;
workspaces: Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>;
backgroundGitFetchManager: {
workspaceGitService: WorkspaceGitService & {
subscribe: ReturnType<typeof vi.fn>;
subscriptions: Array<{
params: { repoGitRoot: string; cwd: string };
listener: () => void;
unsubscribe: ReturnType<typeof vi.fn>;
}>;
};
logger: {
child: () => unknown;
trace: ReturnType<typeof vi.fn>;
debug: ReturnType<typeof vi.fn>;
info: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
peekSnapshot: ReturnType<typeof vi.fn>;
getSnapshot: ReturnType<typeof vi.fn>;
refresh: ReturnType<typeof vi.fn>;
dispose: ReturnType<typeof vi.fn>;
};
subscriptions: Array<{
params: { cwd: string };
listener: WorkspaceGitListener;
unsubscribe: ReturnType<typeof vi.fn>;
}>;
} {
const emitted: Array<{ type: string; payload: unknown }> = [];
const projects = new Map<number, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>();
let nextProjectId = 1;
let nextWorkspaceId = 1;
const backgroundGitFetchSubscriptions: Array<{
params: { repoGitRoot: string; cwd: string };
listener: () => void;
const projects = new Map<string, any>();
const workspaces = new Map<string, any>();
const subscriptions: Array<{
params: { cwd: string };
listener: WorkspaceGitListener;
unsubscribe: ReturnType<typeof vi.fn>;
}> = [];
const logger = {
@@ -103,18 +89,23 @@ function createSessionForWorkspaceGitWatchTests(): {
warn: vi.fn(),
error: vi.fn(),
};
const backgroundGitFetchManager = {
subscribe: vi.fn(
async (params: { repoGitRoot: string; cwd: string }, listener: () => void) => {
const unsubscribe = vi.fn();
backgroundGitFetchSubscriptions.push({
params,
listener,
unsubscribe,
});
return { unsubscribe };
},
),
const workspaceGitService = {
subscribe: vi.fn(async (params: { cwd: string }, listener: WorkspaceGitListener) => {
const unsubscribe = vi.fn();
subscriptions.push({
params,
listener,
unsubscribe,
});
return {
initial: createWorkspaceRuntimeSnapshot(params.cwd),
unsubscribe,
};
}),
peekSnapshot: vi.fn((cwd: string) => createWorkspaceRuntimeSnapshot(cwd)),
getSnapshot: vi.fn(async (cwd: string) => createWorkspaceRuntimeSnapshot(cwd)),
refresh: vi.fn(async () => {}),
dispose: vi.fn(),
};
const session = new Session({
@@ -203,10 +194,8 @@ function createSessionForWorkspaceGitWatchTests(): {
}),
dispose: () => {},
} as any,
backgroundGitFetchManager: backgroundGitFetchManager as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
workspaceGitService: workspaceGitService as any,
mcpBaseUrl: null,
stt: null,
tts: null,
terminalManager: null,
@@ -217,13 +206,8 @@ function createSessionForWorkspaceGitWatchTests(): {
return {
session,
emitted,
projects,
workspaces,
backgroundGitFetchManager: {
subscribe: backgroundGitFetchManager.subscribe,
subscriptions: backgroundGitFetchSubscriptions,
},
logger,
workspaceGitService: workspaceGitService as any,
subscriptions,
};
}
@@ -262,20 +246,9 @@ function seedGitWorkspace(input: {
}
describe("workspace git watch targets", () => {
beforeEach(() => {
watchCalls.length = 0;
watchMock.mockClear();
resolveCheckoutGitDirMock.mockReset();
resolveCheckoutGitDirMock.mockResolvedValue(null);
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("debounces watcher events and skips unchanged branch/diff snapshots", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceGitWatchTests();
test("emits one workspace_update when the workspace git service emits a changed snapshot", async () => {
const { session, emitted, workspaceGitService, subscriptions } =
createSessionForWorkspaceGitWatchTests();
const sessionAny = session as any;
seedGitWorkspace({
projects,
@@ -285,13 +258,12 @@ describe("workspace git watch targets", () => {
cwd: "/tmp/repo",
name: "main",
});
resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git");
sessionAny.workspaceUpdatesSubscription = {
subscriptionId: "sub-1",
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
lastEmittedByWorkspaceId: new Map(),
};
let descriptor = {
@@ -308,28 +280,30 @@ describe("workspace git watch targets", () => {
workspaceDirectory: "/tmp/repo",
};
sessionAny.buildWorkspaceDescriptorMap = async () =>
new Map([[descriptor.id, descriptor]]);
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]);
await sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
await sessionAny.ensureWorkspaceRegistered("/tmp/repo");
expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
"/tmp/repo/.git/HEAD",
"/tmp/repo/.git/refs/heads",
]);
watchCalls[0]!.listener();
watchCalls[1]!.listener();
await vi.advanceTimersByTimeAsync(500);
expect(emitted.filter((message) => message.type === "workspace_update")).toHaveLength(0);
expect(workspaceGitService.subscribe).toHaveBeenCalledWith(
{ cwd: "/tmp/repo" },
expect.any(Function),
);
descriptor = {
...descriptor,
name: "renamed-branch",
};
watchCalls[0]!.listener();
await vi.advanceTimersByTimeAsync(500);
subscriptions[0]?.listener(
createWorkspaceRuntimeSnapshot("/tmp/repo", {
git: {
currentBranch: "renamed-branch",
},
}),
);
await Promise.resolve();
await Promise.resolve();
const workspaceUpdates = emitted.filter(
(message) => message.type === "workspace_update",
@@ -344,245 +318,110 @@ describe("workspace git watch targets", () => {
},
});
descriptor = {
...descriptor,
diffStat: { additions: 3, deletions: 1 },
};
watchCalls[1]!.listener();
await vi.advanceTimersByTimeAsync(500);
expect(emitted.filter((message) => message.type === "workspace_update")).toHaveLength(2);
await session.cleanup();
});
test("closes watchers when a workspace is archived and when the session closes", async () => {
const { session, projects, workspaces } = createSessionForWorkspaceGitWatchTests();
const sessionAny = session as any;
test("checkout_pr_status_request reads pull request status from the workspace git service snapshot", async () => {
const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests();
seedGitWorkspace({
projects,
workspaces,
projectId: 2,
workspaceId: 20,
cwd: "/tmp/repo-one",
name: "main",
});
seedGitWorkspace({
projects,
workspaces,
projectId: 3,
workspaceId: 30,
cwd: "/tmp/repo-two",
name: "main",
});
resolveCheckoutGitDirMock.mockImplementation(async (cwd: string) => path.join(cwd, ".git"));
await sessionAny.primeWorkspaceGitWatchFingerprints([
{
id: "/tmp/repo-one",
projectId: "/tmp/repo-one",
projectDisplayName: "repo-one",
projectRootPath: "/tmp/repo-one",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
workspaceDirectory: "/tmp/repo-one",
},
]);
expect(sessionAny.workspaceGitWatchTargets.size).toBe(1);
expect(watchCalls).toHaveLength(2);
await sessionAny.archiveWorkspaceRecord(20, "2026-03-21T00:00:00.000Z");
expect(sessionAny.workspaceGitWatchTargets.size).toBe(0);
expect(watchCalls.every((entry) => entry.close.mock.calls.length === 1)).toBe(true);
watchCalls.length = 0;
watchMock.mockClear();
await sessionAny.primeWorkspaceGitWatchFingerprints([
{
id: "/tmp/repo-two",
projectId: "/tmp/repo-two",
projectDisplayName: "repo-two",
projectRootPath: "/tmp/repo-two",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
workspaceDirectory: "/tmp/repo-two",
},
]);
expect(sessionAny.workspaceGitWatchTargets.size).toBe(1);
expect(watchCalls).toHaveLength(2);
await session.cleanup();
expect(sessionAny.workspaceGitWatchTargets.size).toBe(0);
expect(watchCalls.every((entry) => entry.close.mock.calls.length === 1)).toBe(true);
});
test("resolves refs from the shared git dir for linked worktrees", async () => {
const { session } = createSessionForWorkspaceGitWatchTests();
const sessionAny = session as any;
const tempDir = mkdtempSync(path.join(tmpdir(), "session-workspace-git-watch-"));
const gitDir = path.join(tempDir, "repo", ".git", "worktrees", "feature");
mkdirSync(gitDir, { recursive: true });
writeFileSync(path.join(gitDir, "commondir"), "../..\n");
try {
expect(await sessionAny.resolveWorkspaceGitRefsRoot(gitDir)).toBe(
path.join(tempDir, "repo", ".git"),
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
await session.cleanup();
}
});
test("subscribes to the background fetch manager when a git watch target is created", async () => {
const { session, projects, workspaces, backgroundGitFetchManager } =
createSessionForWorkspaceGitWatchTests();
const sessionAny = session as any;
seedGitWorkspace({
projects,
workspaces,
projectId: 4,
workspaceId: 40,
cwd: "/tmp/repo",
name: "main",
});
resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git");
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
expect(backgroundGitFetchManager.subscribe).toHaveBeenCalledWith(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
expect.any(Function),
workspaceGitService.getSnapshot.mockResolvedValue(
createWorkspaceRuntimeSnapshot("/tmp/repo", {
github: {
featuresEnabled: true,
pullRequest: {
url: "https://github.com/acme/repo/pull/456",
title: "Runtime centralization",
state: "merged",
baseRefName: "main",
headRefName: "workspace-git-service",
isMerged: true,
},
refreshedAt: "2026-04-12T00:05:00.000Z",
},
}),
);
expect(sessionAny.workspaceGitFetchSubscriptions.size).toBe(1);
await session.cleanup();
});
test("stores separate background fetch subscriptions per workspace and unsubscribes removed targets", async () => {
const { session, projects, workspaces, backgroundGitFetchManager } =
createSessionForWorkspaceGitWatchTests();
const sessionAny = session as any;
seedGitWorkspace({
projects,
workspaces,
projectId: 5,
workspaceId: 50,
await session.handleMessage({
type: "checkout_pr_status_request",
cwd: "/tmp/repo",
name: "main",
requestId: "req-pr-status",
});
seedGitWorkspace({
projects,
workspaces,
projectId: 6,
workspaceId: 60,
cwd: "/tmp/repo-feature",
name: "feature",
});
resolveCheckoutGitDirMock.mockImplementation(async (cwd: string) =>
cwd === "/tmp/repo" ? "/tmp/repo/.git" : "/tmp/repo/.git/worktrees/feature",
);
sessionAny.resolveWorkspaceGitRefsRoot = vi.fn(async () => "/tmp/repo/.git");
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo-feature", { isGit: true });
expect(backgroundGitFetchManager.subscribe).toHaveBeenCalledTimes(2);
expect(backgroundGitFetchManager.subscriptions[0]?.params).toEqual({
repoGitRoot: "/tmp/repo/.git",
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
expect(
emitted.find((message) => message.type === "checkout_pr_status_response")?.payload,
).toEqual({
cwd: "/tmp/repo",
});
expect(backgroundGitFetchManager.subscriptions[1]?.params).toEqual({
repoGitRoot: "/tmp/repo/.git",
cwd: "/tmp/repo-feature",
});
sessionAny.removeWorkspaceGitWatchTarget("/tmp/repo");
expect(backgroundGitFetchManager.subscriptions[0]?.unsubscribe).toHaveBeenCalledTimes(1);
expect(backgroundGitFetchManager.subscriptions[1]?.unsubscribe).not.toHaveBeenCalled();
expect(sessionAny.workspaceGitFetchSubscriptions.size).toBe(1);
await session.cleanup();
});
test("refreshes the workspace when the background fetch manager callback fires and unsubscribes on cleanup", async () => {
const { session, emitted, projects, workspaces, backgroundGitFetchManager } =
createSessionForWorkspaceGitWatchTests();
const sessionAny = session as any;
seedGitWorkspace({
projects,
workspaces,
projectId: 7,
workspaceId: 70,
cwd: "/tmp/repo",
name: "main",
});
resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git");
sessionAny.workspaceUpdatesSubscription = {
subscriptionId: "sub-1",
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
sessionAny.reconcileActiveWorkspaceRecords = async () => new Set();
let descriptor = {
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: { additions: 1, deletions: 0 },
};
sessionAny.buildWorkspaceDescriptorMap = async () =>
new Map([[descriptor.id, descriptor]]);
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
descriptor = {
...descriptor,
name: "updated-after-fetch",
};
backgroundGitFetchManager.subscriptions[0]?.listener();
await vi.advanceTimersByTimeAsync(500);
const workspaceUpdates = emitted.filter(
(message) => message.type === "workspace_update",
) as any[];
expect(workspaceUpdates).toHaveLength(1);
expect(workspaceUpdates[0]?.payload).toMatchObject({
kind: "upsert",
workspace: {
id: "/tmp/repo",
name: "updated-after-fetch",
status: {
url: "https://github.com/acme/repo/pull/456",
title: "Runtime centralization",
state: "merged",
baseRefName: "main",
headRefName: "workspace-git-service",
isMerged: true,
},
githubFeaturesEnabled: true,
error: null,
requestId: "req-pr-status",
});
});
test("checkout_pr_status_request explicitly refreshes the focused workspace before reading runtime data", async () => {
const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests();
let refreshed = false;
workspaceGitService.refresh.mockImplementation(async () => {
refreshed = true;
});
workspaceGitService.getSnapshot.mockImplementation(async (cwd: string) =>
createWorkspaceRuntimeSnapshot(cwd, {
github: {
pullRequest: refreshed
? {
url: "https://github.com/acme/repo/pull/457",
title: "After explicit refresh",
state: "merged",
baseRefName: "main",
headRefName: "workspace-git-service",
isMerged: true,
}
: {
url: "https://github.com/acme/repo/pull/456",
title: "Before explicit refresh",
state: "open",
baseRefName: "main",
headRefName: "workspace-git-service",
isMerged: false,
},
refreshedAt: refreshed ? "2026-04-12T00:10:00.000Z" : "2026-04-12T00:05:00.000Z",
},
}),
);
await session.handleMessage({
type: "checkout_pr_status_request",
cwd: "/tmp/repo",
requestId: "req-pr-refresh",
});
await session.cleanup();
expect(backgroundGitFetchManager.subscriptions[0]?.unsubscribe).toHaveBeenCalledTimes(1);
expect(workspaceGitService.refresh).toHaveBeenCalledWith("/tmp/repo", {
priority: "high",
});
expect(
emitted.find((message) => message.type === "checkout_pr_status_response")?.payload,
).toEqual({
cwd: "/tmp/repo",
status: {
url: "https://github.com/acme/repo/pull/457",
title: "After explicit refresh",
state: "merged",
baseRefName: "main",
headRefName: "workspace-git-service",
isMerged: true,
},
githubFeaturesEnabled: true,
error: null,
requestId: "req-pr-refresh",
});
});
});
@@ -5,6 +5,7 @@ import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { Session } from "./session.js";
import type { AgentSnapshotPayload } from "../shared/messages.js";
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
@@ -66,7 +67,123 @@ function makeAgent(input: {
};
}
function createSessionForWorkspaceTests(options: { appVersion?: string | null } = {}): {
function createNoopWorkspaceGitService() {
return {
subscribe: async (params: { cwd: string }) => ({
initial: {
cwd: params.cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
refreshedAt: null,
},
},
unsubscribe: () => {},
}),
peekSnapshot: (_cwd: string) => null,
getSnapshot: async (cwd: string) => ({
cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
refreshedAt: null,
},
}),
refresh: async () => {},
dispose: () => {},
};
}
function createWorkspaceRuntimeSnapshot(
cwd: string,
overrides?: {
git?: Partial<WorkspaceGitRuntimeSnapshot["git"]>;
github?: Partial<WorkspaceGitRuntimeSnapshot["github"]>;
},
): WorkspaceGitRuntimeSnapshot {
const base: WorkspaceGitRuntimeSnapshot = {
cwd,
git: {
isGit: true,
repoRoot: cwd,
mainRepoRoot: null,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false,
isDirty: false,
aheadBehind: { ahead: 0, behind: 0 },
aheadOfOrigin: 0,
behindOfOrigin: 0,
diffStat: { additions: 1, deletions: 0 },
},
github: {
featuresEnabled: true,
pullRequest: {
url: "https://github.com/acme/repo/pull/123",
title: "Runtime payloads",
state: "open",
baseRefName: "main",
headRefName: "feature/runtime-payloads",
isMerged: false,
},
error: null,
refreshedAt: "2026-04-12T00:00:00.000Z",
},
};
return {
cwd,
git: {
...base.git,
...overrides?.git,
},
github: {
...base.github,
...overrides?.github,
pullRequest:
overrides?.github && "pullRequest" in overrides.github
? (overrides.github.pullRequest ?? null)
: base.github.pullRequest,
error:
overrides?.github && "error" in overrides.github
? (overrides.github.error ?? null)
: base.github.error,
},
};
}
function createSessionForWorkspaceTests(options: {
appVersion?: string | null;
workspaceGitService?: ReturnType<typeof createNoopWorkspaceGitService>;
} = {}): {
session: Session;
emitted: Array<{ type: string; payload: unknown }>;
projects: Map<number, ReturnType<typeof createPersistedProjectRecord>>;
@@ -85,10 +202,6 @@ function createSessionForWorkspaceTests(options: { appVersion?: string | null }
warn: vi.fn(),
error: vi.fn(),
};
const backgroundGitFetchManager = {
subscribe: vi.fn(async () => ({ unsubscribe: vi.fn() })),
};
const session = new Session({
clientId: "test-client",
appVersion: options.appVersion ?? null,
@@ -179,10 +292,8 @@ function createSessionForWorkspaceTests(options: { appVersion?: string | null }
}),
dispose: () => {},
} as any,
backgroundGitFetchManager: backgroundGitFetchManager as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
workspaceGitService: (options.workspaceGitService ?? createNoopWorkspaceGitService()) as any,
mcpBaseUrl: null,
stt: null,
tts: null,
terminalManager: null,
@@ -361,9 +472,8 @@ describe("workspace aggregation", () => {
}),
dispose: () => {},
} as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
workspaceGitService: createNoopWorkspaceGitService() as any,
mcpBaseUrl: null,
stt: null,
tts: null,
terminalManager: null,
@@ -498,9 +608,8 @@ describe("workspace aggregation", () => {
}),
dispose: () => {},
} as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
workspaceGitService: createNoopWorkspaceGitService() as any,
mcpBaseUrl: null,
stt: null,
tts: null,
terminalManager: {
@@ -526,9 +635,8 @@ describe("workspace aggregation", () => {
expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-1");
expect(session.terminalManager.killTerminal).toHaveBeenCalledWith("term-1");
const closePayload = emitted.find((message) => message.type === "close_items_response")?.payload;
expect(closePayload).toEqual({
agents: [{ agentId: "agent-1", archivedAt: expect.any(String) }],
expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({
agents: [{ agentId: "agent-1", archivedAt }],
terminals: [{ terminalId: "term-1", success: true }],
requestId: "req-close-items",
});
@@ -693,9 +801,8 @@ describe("workspace aggregation", () => {
}),
dispose: () => {},
} as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
workspaceGitService: createNoopWorkspaceGitService() as any,
mcpBaseUrl: null,
stt: null,
tts: null,
terminalManager: {
@@ -838,9 +945,8 @@ describe("workspace aggregation", () => {
}),
dispose: () => {},
} as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
workspaceGitService: createNoopWorkspaceGitService() as any,
mcpBaseUrl: null,
stt: null,
tts: null,
terminalManager: {
@@ -867,9 +973,8 @@ describe("workspace aggregation", () => {
expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-bad");
expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-good");
expect(session.terminalManager.killTerminal).toHaveBeenCalledWith("term-1");
const closePayload = emitted.find((message) => message.type === "close_items_response")?.payload;
expect(closePayload).toEqual({
agents: [{ agentId: "agent-good", archivedAt: expect.any(String) }],
expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({
agents: [{ agentId: "agent-good", archivedAt }],
terminals: [{ terminalId: "term-1", success: true }],
requestId: "req-close-best-effort",
});
@@ -967,10 +1072,9 @@ describe("workspace aggregation", () => {
});
expect(result.entries[0]).toMatchObject({
id: "/tmp/repo/.paseo/worktrees/feature-name",
name: "feature-name",
projectKind: "git",
workspaceKind: "worktree",
id: "/tmp/repo",
status: "running",
activityAt: null,
});
});
@@ -995,6 +1099,7 @@ describe("workspace aggregation", () => {
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
lastEmittedByWorkspaceId: new Map(),
};
(session as any).reconcileActiveWorkspaceRecords = async () => new Set();
(session as any).buildWorkspaceDescriptorMap = async () =>
@@ -1159,6 +1264,7 @@ describe("workspace aggregation", () => {
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
lastEmittedByWorkspaceId: new Map(),
};
sessionAny.reconcileActiveWorkspaceRecords = async () => new Set();
sessionAny.buildWorkspaceDescriptorMap = async () =>
@@ -1342,9 +1448,7 @@ describe("workspace aggregation", () => {
});
expect(calls).toEqual([{ editorId: "vscode", path: "/tmp/repo" }]);
const response = emitted.find(
(message) => message.type === "open_in_editor_response",
) as any;
const response = emitted.find((message) => message.type === "open_in_editor_response") as any;
expect(response?.payload.error).toBeNull();
});
@@ -1527,6 +1631,102 @@ describe("workspace aggregation", () => {
}
});
test("fetch_workspaces_request reconciles remote URL changes for existing workspaces", async () => {
const session = createSessionForWorkspaceTests().session as any;
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-fetch-")));
const mainWorkspaceId = path.join(tempDir, "inkwell");
const worktreeWorkspaceId = path.join(mainWorkspaceId, ".paseo", "worktrees", "feature-a");
const oldProjectId = "remote:github.com/old-owner/inkwell";
const newProjectId = "remote:github.com/new-owner/inkwell";
execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`);
projects.set(
oldProjectId,
createPersistedProjectRecord({
projectId: oldProjectId,
rootPath: mainWorkspaceId,
kind: "git",
displayName: "old-owner/inkwell",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
);
for (const [workspaceId, displayName] of [
[mainWorkspaceId, "main"],
[worktreeWorkspaceId, "feature-a"],
] as const) {
workspaces.set(
workspaceId,
createPersistedWorkspaceRecord({
workspaceId,
projectId: oldProjectId,
cwd: workspaceId,
kind: workspaceId === mainWorkspaceId ? "local_checkout" : "worktree",
displayName,
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
);
}
session.listAgentPayloads = async () => [];
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
session.projectRegistry.list = async () => Array.from(projects.values());
session.projectRegistry.upsert = async (
record: ReturnType<typeof createPersistedProjectRecord>,
) => {
projects.set(record.projectId, record);
};
session.projectRegistry.archive = async (projectId: string, archivedAt: string) => {
const existing = projects.get(projectId);
if (!existing) return;
projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt });
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
workspaces.set(record.workspaceId, record);
};
session.buildProjectPlacement = async (cwd: string) => ({
projectKey: newProjectId,
projectName: "new-owner/inkwell",
checkout: {
cwd,
isGit: true,
currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a",
remoteUrl: "https://github.com/new-owner/inkwell.git",
worktreeRoot: cwd,
isPaseoOwnedWorktree: cwd !== mainWorkspaceId,
mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId,
},
});
try {
await session.reconcileWorkspaceRecord(mainWorkspaceId);
await session.reconcileWorkspaceRecord(worktreeWorkspaceId);
const result = await session.listFetchWorkspacesEntries({
type: "fetch_workspaces_request",
requestId: "req-fetch-reconcile",
});
expect(result.entries.map((entry: any) => entry.projectId)).toEqual([
newProjectId,
newProjectId,
]);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("open_project_request treats non-git directories as directory projects", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-dir-")));
@@ -1837,14 +2037,234 @@ describe("backward compatibility", () => {
sessionAny.describeWorkspaceRecord = vi.fn(async () => baselineDescriptor);
sessionAny.describeWorkspaceRecordWithGitData = vi.fn(async () => gitDescriptor);
const descriptors = await sessionAny.listWorkspaceDescriptorsSnapshot();
const descriptors = Array.from(
(
await sessionAny.buildWorkspaceDescriptorMap({
includeGitData: false,
})
).values(),
);
expect(sessionAny.describeWorkspaceRecord).toHaveBeenCalledWith(workspace, project);
expect(sessionAny.describeWorkspaceRecordWithGitData).not.toHaveBeenCalled();
expect(descriptors).toEqual([baselineDescriptor]);
});
test("subscribed fetch_workspaces emits git enrichment updates after the baseline snapshot", async () => {
test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => {
const emitted: Array<{ type: string; payload: any }> = [];
const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", {
git: {
currentBranch: "runtime-branch",
isDirty: true,
aheadBehind: { ahead: 3, behind: 1 },
aheadOfOrigin: 3,
behindOfOrigin: 1,
},
github: {
pullRequest: {
url: "https://github.com/acme/repo/pull/456",
title: "Ship runtime payloads",
state: "open",
baseRefName: "main",
headRefName: "runtime-branch",
isMerged: false,
},
refreshedAt: "2026-04-12T00:05:00.000Z",
},
});
const workspaceGitService = createNoopWorkspaceGitService();
workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot);
workspaceGitService.getSnapshot = vi.fn(async () => {
throw new Error("fetch_workspaces should not trigger per-workspace refreshes");
});
workspaceGitService.subscribe = vi.fn(async () => ({
initial: runtimeSnapshot,
unsubscribe: () => {},
}));
const { session } = createSessionForWorkspaceTests({
workspaceGitService,
});
const sessionAny = session as any;
const project = createPersistedProjectRecord({
projectId: "/tmp/repo",
rootPath: "/tmp/repo",
kind: "git",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const workspace = createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo",
projectId: project.projectId,
cwd: "/tmp/repo",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
sessionAny.emit = (message: any) => emitted.push(message);
sessionAny.listAgentPayloads = async () => [];
sessionAny.projectRegistry.list = async () => [project];
sessionAny.workspaceRegistry.list = async () => [workspace];
sessionAny.buildProjectPlacement = async (cwd: string) => ({
projectKey: cwd,
projectName: "repo",
checkout: {
cwd,
isGit: true,
currentBranch: runtimeSnapshot.git.currentBranch,
remoteUrl: runtimeSnapshot.git.remoteUrl,
worktreeRoot: cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
await sessionAny.handleMessage({
type: "fetch_workspaces_request",
requestId: "req-fetch-workspaces-runtime",
});
const response = emitted.find((message) => message.type === "fetch_workspaces_response") as
| { type: "fetch_workspaces_response"; payload: any }
| undefined;
expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo");
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(response?.payload.entries).toEqual([
expect.objectContaining({
id: "/tmp/repo",
gitRuntime: {
currentBranch: "runtime-branch",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false,
isDirty: true,
aheadBehind: { ahead: 3, behind: 1 },
aheadOfOrigin: 3,
behindOfOrigin: 1,
},
githubRuntime: {
featuresEnabled: true,
pullRequest: {
url: "https://github.com/acme/repo/pull/456",
title: "Ship runtime payloads",
state: "open",
baseRefName: "main",
headRefName: "runtime-branch",
isMerged: false,
},
error: null,
refreshedAt: "2026-04-12T00:05:00.000Z",
},
}),
]);
});
test("workspace_update includes updated runtime fields", async () => {
const emitted: Array<{ type: string; payload: any }> = [];
const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", {
git: {
currentBranch: "feature/runtime-payloads",
isDirty: true,
},
github: {
pullRequest: {
url: "https://github.com/acme/repo/pull/789",
title: "Updated runtime payloads",
state: "merged",
baseRefName: "main",
headRefName: "feature/runtime-payloads",
isMerged: true,
},
refreshedAt: "2026-04-12T00:10:00.000Z",
},
});
const workspaceGitService = createNoopWorkspaceGitService();
workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot);
workspaceGitService.getSnapshot = vi.fn(async () => {
throw new Error("workspace updates should use passive workspace git snapshots");
});
const { session } = createSessionForWorkspaceTests({
workspaceGitService,
});
const sessionAny = session as any;
const project = createPersistedProjectRecord({
projectId: "/tmp/repo",
rootPath: "/tmp/repo",
kind: "git",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const workspace = createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo",
projectId: project.projectId,
cwd: "/tmp/repo",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
sessionAny.emit = (message: any) => emitted.push(message);
sessionAny.workspaceUpdatesSubscription = {
subscriptionId: "sub-runtime",
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
lastEmittedByWorkspaceId: new Map(),
};
sessionAny.reconcileActiveWorkspaceRecords = async () => new Set();
sessionAny.listAgentPayloads = async () => [];
sessionAny.projectRegistry.list = async () => [project];
sessionAny.workspaceRegistry.list = async () => [workspace];
sessionAny.buildProjectPlacement = async (cwd: string) => ({
projectKey: cwd,
projectName: "repo",
checkout: {
cwd,
isGit: true,
currentBranch: runtimeSnapshot.git.currentBranch,
remoteUrl: runtimeSnapshot.git.remoteUrl,
worktreeRoot: cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
await sessionAny.emitWorkspaceUpdateForCwd("/tmp/repo", {
skipReconcile: true,
});
expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo");
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(emitted).toContainEqual({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: expect.objectContaining({
id: "/tmp/repo",
gitRuntime: expect.objectContaining({
currentBranch: "feature/runtime-payloads",
isDirty: true,
}),
githubRuntime: expect.objectContaining({
featuresEnabled: true,
pullRequest: expect.objectContaining({
title: "Updated runtime payloads",
isMerged: true,
}),
refreshedAt: "2026-04-12T00:10:00.000Z",
}),
}),
},
});
});
test("subscribed fetch_workspaces includes git enrichment in the initial snapshot", async () => {
const emitted: Array<{ type: string; payload: any }> = [];
const { session } = createSessionForWorkspaceTests();
const sessionAny = session as any;
@@ -1940,31 +2360,25 @@ describe("backward compatibility", () => {
});
await new Promise((resolve) => setTimeout(resolve, 0));
const response = emitted.find(
(message) => message.type === "fetch_workspaces_response",
) as { type: "fetch_workspaces_response"; payload: any } | undefined;
const response = emitted.find((message) => message.type === "fetch_workspaces_response") as
| { type: "fetch_workspaces_response"; payload: any }
| undefined;
expect(
response?.payload.entries.map((entry: typeof baselineGitDescriptor | typeof directoryDescriptor) => [
entry.id,
entry.diffStat,
]),
response?.payload.entries.map(
(entry: typeof baselineGitDescriptor | typeof directoryDescriptor) => [
entry.id,
entry.diffStat,
],
),
).toEqual([
[directoryDescriptor.id, directoryDescriptor.diffStat],
[baselineGitDescriptor.id, baselineGitDescriptor.diffStat],
[enrichedGitDescriptor.id, enrichedGitDescriptor.diffStat],
]);
const workspaceUpdates = emitted.filter(
(message) => message.type === "workspace_update",
) as Array<{ type: "workspace_update"; payload: any }>;
expect(workspaceUpdates).toEqual([
{
type: "workspace_update",
payload: {
kind: "upsert",
workspace: enrichedGitDescriptor,
},
},
]);
expect(workspaceUpdates).toEqual([]);
expect(sessionAny.describeWorkspaceRecordWithGitData).toHaveBeenCalledWith(
gitWorkspace,
gitProject,
@@ -22,7 +22,10 @@ import { SherpaParakeetRealtimeTranscriptionSession } from "./sherpa/sherpa-para
import { SherpaRealtimeTranscriptionSession } from "./sherpa/sherpa-realtime-session.js";
import { SherpaOnnxSTT } from "./sherpa/sherpa-stt.js";
import { SherpaOnnxTTS } from "./sherpa/sherpa-tts.js";
import { ensureSileroVadModel, SherpaSileroTurnDetectionProvider } from "./sherpa/silero-vad-provider.js";
import {
ensureSileroVadModel,
SherpaSileroTurnDetectionProvider,
} from "./sherpa/silero-vad-provider.js";
type LocalSttEngine =
| { kind: "offline"; engine: SherpaOfflineRecognizerEngine }
@@ -244,7 +247,10 @@ export async function initializeLocalSpeechServices(params: {
logger.warn({ err }, "Failed to provision Silero VAD model, falling back to bundled");
}
}
turnDetectionService = new SherpaSileroTurnDetectionProvider({ modelPath: vadModelPath }, logger);
turnDetectionService = new SherpaSileroTurnDetectionProvider(
{ modelPath: vadModelPath },
logger,
);
}
if (providers.voiceStt.enabled !== false && providers.voiceStt.provider === "local") {
@@ -22,7 +22,10 @@ class TestSherpaOnnxParakeetStt extends SherpaOnnxParakeetSTT {
super({ engine: { sampleRate: 16000 } as any }, pino({ level: "silent" }));
}
override async transcribeAudio(audioBuffer: Buffer, format: string): Promise<TranscriptionResult> {
override async transcribeAudio(
audioBuffer: Buffer,
format: string,
): Promise<TranscriptionResult> {
this.calls.push({ audio: Buffer.from(audioBuffer), format });
const deferred = createDeferred<TranscriptionResult>();
this.pending.push(deferred);
@@ -162,10 +162,7 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
}
try {
this.logger.debug(
{ phase: this.phase.state },
"[VAD] Flushing remaining audio",
);
this.logger.debug({ phase: this.phase.state }, "[VAD] Flushing remaining audio");
this.vad.flush();
this.stepStateMachine();
if (this.phase.state === "speaking" || this.phase.state === "ending") {
@@ -205,10 +202,7 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
switch (this.phase.state) {
case "idle": {
if (detected) {
this.logger.debug(
{ now },
"[VAD] idle → confirming (detection started)",
);
this.logger.debug({ now }, "[VAD] idle → confirming (detection started)");
this.phase = { state: "confirming", startedAt: now };
}
break;
@@ -238,10 +232,7 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
case "speaking": {
if (!detected) {
this.logger.debug(
{ now },
"[VAD] speaking → ending (silence started)",
);
this.logger.debug({ now }, "[VAD] speaking → ending (silence started)");
this.phase = { state: "ending", startedAt: now };
}
break;
@@ -202,7 +202,7 @@ describe("speech models (download E2E)", () => {
provider: "codex",
cwd: voiceCwd,
modeId: "full-access",
model: "gpt-5.1-codex-mini",
model: "gpt-5.4-mini",
thinkingOptionId: "low",
},
});
@@ -243,7 +243,10 @@ class FakeAgentSession implements AgentSession {
private async resolveSlashCommandInput(
prompt: AgentPromptInput,
): Promise<{ commandName: string; args?: string } | null> {
if ((this.providerName !== "codex" && this.providerName !== "opencode") || typeof prompt !== "string") {
if (
(this.providerName !== "codex" && this.providerName !== "opencode") ||
typeof prompt !== "string"
) {
return null;
}
const parsed = this.parseSlashCommandInput(prompt);
@@ -914,8 +917,8 @@ class FakeAgentClient implements AgentClient {
return [
{
provider: this.provider,
id: "gpt-5.1-codex-mini",
label: "gpt-5.1-codex-mini",
id: "gpt-5.4-mini",
label: "gpt-5.4-mini",
isDefault: true,
},
];
@@ -54,7 +54,7 @@ function waitForSignal<T>(
},
voiceLlmProvider: "codex",
voiceLlmProviderExplicit: true,
voiceLlmModel: "gpt-5.1-codex-mini",
voiceLlmModel: "gpt-5.4-mini",
});
}, 120000);
@@ -1,51 +0,0 @@
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
import {
resolveVoiceMcpBridgeFromRuntime,
resolveVoiceMcpBridgeScriptPath,
} from "./voice-mcp-bridge-command.js";
describe("resolveVoiceMcpBridgeFromRuntime", () => {
const bootstrapModuleUrl = new URL("./bootstrap.ts", import.meta.url).toString();
test("resolves default JS bridge script with node execPath", () => {
const result = resolveVoiceMcpBridgeFromRuntime({
bootstrapModuleUrl,
execPath: "/usr/local/bin/node",
});
const expectedScriptPath = fileURLToPath(
new URL("../../scripts/mcp-stdio-socket-bridge-cli.mjs", bootstrapModuleUrl),
);
expect(result.source).toBe("default-js-script");
expect(result.resolved.command).toBe("/usr/local/bin/node");
expect(result.resolved.baseArgs).toEqual([expectedScriptPath]);
});
test("uses explicit script override when provided", () => {
const explicitScriptPath = fileURLToPath(
new URL("../../scripts/mcp-stdio-socket-bridge-cli.mjs", bootstrapModuleUrl),
);
const result = resolveVoiceMcpBridgeFromRuntime({
bootstrapModuleUrl,
execPath: "/usr/local/bin/node",
explicitScriptPath,
});
expect(result.source).toBe("explicit-js-script");
expect(result.resolved.command).toBe("/usr/local/bin/node");
expect(result.resolved.baseArgs).toEqual([explicitScriptPath]);
});
test("throws when explicit script path is missing", () => {
expect(() =>
resolveVoiceMcpBridgeScriptPath({
bootstrapModuleUrl,
explicitScriptPath: "/tmp/does-not-exist-voice-bridge-script.mjs",
}),
).toThrow("MCP stdio-socket bridge script not found");
});
});
@@ -1,50 +0,0 @@
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
export type VoiceMcpBridgeCommand = { command: string; baseArgs: string[] };
const DEFAULT_BRIDGE_SCRIPT_RELATIVE_URL = "../../scripts/mcp-stdio-socket-bridge-cli.mjs";
export function resolveVoiceMcpBridgeScriptPath(params: {
bootstrapModuleUrl: string;
explicitScriptPath?: string | null;
}): string {
const explicitScriptPath = params.explicitScriptPath?.trim();
if (explicitScriptPath) {
if (!existsSync(explicitScriptPath)) {
throw new Error(
`MCP stdio-socket bridge script not found at configured path: ${explicitScriptPath}`,
);
}
return explicitScriptPath;
}
const scriptPath = fileURLToPath(
new URL(DEFAULT_BRIDGE_SCRIPT_RELATIVE_URL, params.bootstrapModuleUrl),
);
if (!existsSync(scriptPath)) {
throw new Error(`MCP stdio-socket bridge script not found: ${scriptPath}`);
}
return scriptPath;
}
export function resolveVoiceMcpBridgeFromRuntime(params: {
bootstrapModuleUrl: string;
execPath: string;
explicitScriptPath?: string | null;
}): {
resolved: VoiceMcpBridgeCommand;
source: string;
} {
const scriptPath = resolveVoiceMcpBridgeScriptPath({
bootstrapModuleUrl: params.bootstrapModuleUrl,
explicitScriptPath: params.explicitScriptPath,
});
return {
source: params.explicitScriptPath?.trim() ? "explicit-js-script" : "default-js-script",
resolved: {
command: params.execPath,
baseArgs: [scriptPath],
},
};
}
@@ -1,88 +0,0 @@
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { describe, expect, test } from "vitest";
import { experimental_createMCPClient } from "ai";
import { z } from "zod";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import pino from "pino";
import { createVoiceMcpSocketBridgeManager } from "./voice-mcp-bridge.js";
import { resolveVoiceMcpBridgeScriptPath } from "./voice-mcp-bridge-command.js";
describe("voice MCP bridge", () => {
test("proxies stdio MCP bytes through per-agent unix socket bridge", async () => {
const tmpRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-voice-mcp-bridge-"));
const callerAgentId = "voice-agent-bridge-test";
const bridgeManager = createVoiceMcpSocketBridgeManager({
runtimeDir: tmpRoot,
logger: pino({ level: "silent" }),
createAgentMcpServerForCaller: async (callerId) => {
const server = new McpServer({
name: "bridge-test-server",
version: "1.0.0",
});
server.registerTool(
"echo_caller",
{
value: z.string().optional(),
},
async (args) => {
return {
content: [
{
type: "text",
text: JSON.stringify({
callerAgentId: callerId,
value: args.value ?? null,
}),
},
],
structuredContent: {
callerAgentId: callerId,
value: args.value ?? null,
},
};
},
);
return server;
},
});
const socketPath = await bridgeManager.ensureBridgeForCaller(callerAgentId);
const transport = new StdioClientTransport({
command: process.execPath,
args: [
resolveVoiceMcpBridgeScriptPath({
bootstrapModuleUrl: import.meta.url,
}),
"--socket",
socketPath,
],
});
const client = await experimental_createMCPClient({ transport });
try {
const result = await client.callTool({
name: "echo_caller",
args: { value: "ok" },
});
const payload =
(result as { structuredContent?: { callerAgentId?: string; value?: string | null } })
.structuredContent ?? null;
expect(payload?.callerAgentId).toBe(callerAgentId);
} finally {
await client.close();
await bridgeManager.stop();
await rm(tmpRoot, { recursive: true, force: true });
}
}, 30_000);
});

Some files were not shown because too many files have changed in this diff Show More