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:
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+297
-272
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
+6
-4
@@ -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");
|
||||
|
||||
+4
-6
@@ -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 } : {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-4
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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" }>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"}';
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
+4
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user