Merge branch 'main' into dev

Surgical merge of 74 commits from main into dev. Key features ported:

- Provider visibility gating (appVersion filtering for Pi/Copilot)
- Pi agent provider + Copilot re-enabled
- Provider-declared features system (Codex fast mode)
- Workspace dedup by worktree root (adapted to integer IDs)
- Bulk close archiving fix for stored agents
- Agent creation timeout increase to 60s
- Audio/voice crash fixes (external buffer copies)
- Multi-host setup fix
- Reload agent tab action

Dev architecture preserved: SQLite storage, service proxy,
workspace setup dialog, hover cards, removed launcher tabs.
This commit is contained in:
Mohamed Boudra
2026-04-05 12:59:07 +07:00
173 changed files with 8873 additions and 2316 deletions
+68 -2
View File
@@ -38,7 +38,9 @@ import type {
ArchiveWorkspaceResponseMessage,
WorkspaceSetupStatusResponseMessage,
ListCommandsResponse,
ListProviderFeaturesResponseMessage,
ListProviderModelsResponseMessage,
ListProviderModesResponseMessage,
ListAvailableProvidersResponse,
ListTerminalsResponse,
CreateTerminalResponse,
@@ -158,6 +160,7 @@ export type DaemonClientConfig = {
url: string;
clientId: string;
clientType?: "mobile" | "browser" | "cli" | "mcp";
appVersion?: string;
runtimeGeneration?: number | null;
authHeader?: string;
suppressSendErrors?: boolean;
@@ -221,12 +224,14 @@ type CreatePaseoWorktreePayload = Extract<
>["payload"];
type FileExplorerPayload = FileExplorerResponse["payload"];
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"];
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
type ListProviderModesPayload = ListProviderModesResponseMessage["payload"];
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
type ListCommandsPayload = ListCommandsResponse["payload"];
type ListCommandsDraftConfig = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId"
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues"
>;
type ListCommandsOptions = {
requestId?: string;
@@ -1423,7 +1428,7 @@ export class DaemonClient {
const status = await this.sendRequest({
requestId,
message,
timeout: 15000,
timeout: 60000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "status") {
@@ -1727,6 +1732,35 @@ export class DaemonClient {
}
}
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
type: "set_agent_feature_request",
agentId,
featureId,
value,
requestId,
});
const payload = await this.sendRequest({
requestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "set_agent_feature_response") {
return null;
}
if (msg.payload.requestId !== requestId) {
return null;
}
return msg.payload;
},
});
if (!payload.accepted) {
throw new Error(payload.error ?? "setAgentFeature rejected");
}
}
async setAgentThinkingOption(agentId: string, thinkingOptionId: string | null): Promise<void> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
@@ -2496,6 +2530,37 @@ export class DaemonClient {
});
}
async listProviderModes(
provider: AgentProvider,
options?: { cwd?: string; requestId?: string },
): Promise<ListProviderModesPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options?.requestId,
message: {
type: "list_provider_modes_request",
provider,
cwd: options?.cwd,
},
responseType: "list_provider_modes_response",
timeout: 45000,
});
}
async listProviderFeatures(
draftConfig: ListCommandsDraftConfig,
options?: { requestId?: string },
): Promise<ListProviderFeaturesPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options?.requestId,
message: {
type: "list_provider_features_request",
draftConfig,
},
responseType: "list_provider_features_response",
timeout: 45000,
});
}
async listAvailableProviders(options?: {
requestId?: string;
}): Promise<ListAvailableProvidersPayload> {
@@ -3247,6 +3312,7 @@ export class DaemonClient {
clientId: this.config.clientId,
clientType: this.config.clientType ?? "cli",
protocolVersion: 1,
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
}),
);
} catch (error) {
@@ -15,13 +15,16 @@
* This pattern is used in claude-agent.ts listModels().
*/
import { describe, it, expect } from "vitest";
import { describe, expect, test } from "vitest";
import {
query,
type Query,
type SlashCommand,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { isCommandAvailable } from "../utils/executable.js";
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials;
// Pattern from claude-agent.ts listModels():
// Use an empty async generator when you just need control methods
@@ -31,7 +34,7 @@ function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
describe("Claude Agent SDK Commands POC", () => {
describe("supportedCommands() API", () => {
it("should return an array of SlashCommand objects", async () => {
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();
@@ -72,7 +75,7 @@ describe("Claude Agent SDK Commands POC", () => {
}
}, 30000);
it("should have valid SlashCommand structure for all commands", async () => {
test.runIf(canRunClaudeIntegration)("should have valid SlashCommand structure for all commands", async () => {
const emptyPrompt = createEmptyPrompt();
const claudeQuery = query({
@@ -107,7 +110,7 @@ describe("Claude Agent SDK Commands POC", () => {
});
describe("Command Execution", () => {
it("should explain that commands are prompts with / prefix", () => {
test("should explain that commands are prompts with / prefix", () => {
// This is a documentation test - commands ARE just prompts with / prefix
// To execute a command:
// 1. Create a user message with content: "/{commandName}"
@@ -12,6 +12,7 @@ import type { TerminalManager } from "../../terminal/terminal-manager.js";
import type {
AgentCapabilityFlags,
AgentClient,
AgentFeature,
AgentLaunchContext,
AgentSlashCommand,
AgentMode,
@@ -146,6 +147,7 @@ type ManagedAgentBase = {
createdAt: Date;
updatedAt: Date;
availableModes: AgentMode[];
features?: AgentFeature[];
currentModeId: string | null;
pendingPermissions: Map<string, AgentPermissionRequest>;
pendingReplacement: boolean;
@@ -525,6 +527,31 @@ export class AgentManager {
}
}
async listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
const normalizedConfig = await this.normalizeConfig(config);
const client = this.requireClient(normalizedConfig.provider);
const available = await client.isAvailable();
if (!available) {
throw new Error(
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
);
}
const session = await client.createSession(normalizedConfig);
try {
return session.features ?? [];
} finally {
try {
await session.close();
} catch (error) {
this.logger.warn(
{ err: error, provider: normalizedConfig.provider },
"Failed to close draft feature listing session",
);
}
}
}
getAgent(id: string): ManagedAgent | null {
const agent = this.agents.get(id);
return agent ? { ...agent } : null;
@@ -781,6 +808,19 @@ export class AgentManager {
this.emitState(agent);
}
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
const agent = this.requireAgent(agentId);
if (!agent.session.setFeature) {
throw new Error("Agent session does not support setting features");
}
await agent.session.setFeature(featureId, value);
agent.config.featureValues = { ...agent.config.featureValues, [featureId]: value };
this.touchUpdatedAt(agent);
this.emitState(agent);
}
async setTitle(agentId: string, title: string): Promise<void> {
const agent = this.requireAgent(agentId);
const normalizedTitle = title.trim();
@@ -2474,6 +2514,10 @@ export class AgentManager {
this.enqueueBackgroundPersist(agent);
}
if (agent.session?.features) {
agent.features = agent.session.features;
}
this.dispatch({
type: "agent_state",
agent: { ...agent },
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus } from "./agent-manager.js";
import { toAgentPayload, toStoredAgentRecord, type ManagedAgent } from "./agent-projections.js";
import type {
AgentFeature,
AgentPermissionRequest,
AgentPersistenceHandle,
AgentSessionConfig,
@@ -111,6 +112,16 @@ function createPermission(overrides: Partial<AgentPermissionRequest> = {}): Agen
return { ...base, ...overrides };
}
function createFeature(overrides: Partial<AgentFeature> = {}): AgentFeature {
return {
type: "toggle",
id: "fast_mode",
label: "Fast mode",
value: true,
...overrides,
};
}
describe("toStoredAgentRecord", () => {
it("captures lifecycle metadata, config, and persistence", () => {
const agent = createManagedAgent({
@@ -291,4 +302,13 @@ describe("toAgentPayload", () => {
const payload = toAgentPayload(agent);
expect(payload).not.toHaveProperty("lastUsage");
});
it("includes features in the snapshot payload", () => {
const features = [createFeature()];
const agent = createManagedAgent({ features });
const payload = toAgentPayload(agent);
expect(payload.features).toEqual(features);
});
});
@@ -98,6 +98,7 @@ export function toAgentPayload(
capabilities: cloneCapabilities(agent.capabilities),
currentModeId: agent.currentModeId,
availableModes: cloneAvailableModes(agent.availableModes),
features: agent.features,
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
persistence: sanitizePersistenceHandle(agent.persistence),
title: options?.title ?? null,
@@ -140,6 +141,12 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
if (config.thinkingOptionId) {
serializable.thinkingOptionId = config.thinkingOptionId;
}
if (Object.prototype.hasOwnProperty.call(config, "featureValues")) {
const featureValues = sanitizeMetadata(config.featureValues);
if (featureValues !== undefined) {
serializable.featureValues = featureValues;
}
}
const extra = sanitizeMetadata(config.extra);
if (extra !== undefined) {
serializable.extra = extra;
@@ -64,6 +64,29 @@ export type AgentSelectOption = {
metadata?: AgentMetadata;
};
export type AgentFeatureToggle = {
type: "toggle";
id: string;
label: string;
description?: string;
tooltip?: string;
icon?: string;
value: boolean;
};
export type AgentFeatureSelect = {
type: "select";
id: string;
label: string;
description?: string;
tooltip?: string;
icon?: string;
value: string | null;
options: AgentSelectOption[];
};
export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect;
export type AgentCapabilityFlags = {
supportsStreaming: boolean;
supportsSessionPersistence: boolean;
@@ -202,6 +225,10 @@ export type ToolCallDetail =
text?: string;
icon?: ToolCallIconName;
}
| {
type: "plan";
text: string;
}
| {
type: "unknown";
input: unknown | null;
@@ -368,6 +395,7 @@ export type AgentSessionConfig = {
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
title?: string | null;
approvalPolicy?: string;
sandboxMode?: string;
@@ -393,6 +421,7 @@ export interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
readonly features?: AgentFeature[];
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
@@ -409,12 +438,17 @@ export interface AgentSession {
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
setFeature?(featureId: string, value: unknown): Promise<void>;
}
export interface ListModelsOptions {
cwd?: string;
}
export interface ListModesOptions {
cwd?: string;
}
export interface AgentClient {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
@@ -428,6 +462,7 @@ export interface AgentClient {
launchContext?: AgentLaunchContext,
): Promise<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
listModes?(options?: ListModesOptions): Promise<AgentMode[]>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
/**
* Check if this provider is available (CLI binary is installed).
@@ -6,6 +6,7 @@ import { promises as fs } from "node:fs";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentStorage } from "./agent-storage.js";
import { buildConfigOverrides, buildSessionConfig } from "../persistence-hooks.js";
import type { ManagedAgent } from "./agent-manager.js";
import type {
AgentPermissionRequest,
@@ -41,6 +42,9 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
systemPrompt: configOverrides.systemPrompt,
mcpServers: configOverrides.mcpServers,
};
if (Object.prototype.hasOwnProperty.call(configOverrides, "featureValues")) {
config.featureValues = configOverrides.featureValues;
}
const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession));
const activeForegroundTurnId =
overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null);
@@ -150,6 +154,62 @@ describe("AgentStorage", () => {
expect(persisted.config?.extra?.claude).toMatchObject({ maxThinkingTokens: 1024 });
});
test("applySnapshot stores and reloads featureValues when present", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "agent-feature-values",
config: {
featureValues: {
fast_mode: true,
},
},
}),
);
const record = await storage.get("agent-feature-values");
expect(record?.config?.featureValues).toEqual({ fast_mode: true });
const reloaded = new AgentStorage(storagePath, logger);
const persisted = await reloaded.get("agent-feature-values");
expect(persisted?.config?.featureValues).toEqual({ fast_mode: true });
expect(buildSessionConfig(persisted!).featureValues).toEqual({ fast_mode: true });
});
test("applySnapshot keeps featureValues absent when they were never set", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "agent-no-feature-values",
}),
);
const reloaded = new AgentStorage(storagePath, logger);
const persisted = await reloaded.get("agent-no-feature-values");
expect(persisted?.config?.featureValues).toBeUndefined();
expect(buildSessionConfig(persisted!).featureValues).toBeUndefined();
});
test("buildConfigOverrides includes featureValues when present in stored config", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "agent-resume-overrides",
config: {
featureValues: {
fast_mode: true,
},
},
}),
);
const record = await storage.get("agent-resume-overrides");
expect(record).not.toBeNull();
expect(buildConfigOverrides(record!)).toMatchObject({
cwd: "/tmp/project",
featureValues: {
fast_mode: true,
},
});
});
test("applySnapshot preserves original createdAt timestamp", async () => {
const agentId = "agent-created-at";
const firstTimestamp = new Date("2025-01-01T00:00:00.000Z");
@@ -16,6 +16,7 @@ const SERIALIZABLE_CONFIG_SCHEMA = z
modeId: z.string().nullable().optional(),
model: z.string().nullable().optional(),
thinkingOptionId: z.string().nullable().optional(),
featureValues: z.record(z.unknown()).nullable().optional(),
extra: z.record(z.any()).nullable().optional(),
systemPrompt: z.string().nullable().optional(),
mcpServers: z.record(z.any()).nullable().optional(),
@@ -71,6 +72,7 @@ export type SerializableAgentConfig = Pick<
| "modeId"
| "model"
| "thinkingOptionId"
| "featureValues"
| "extra"
| "systemPrompt"
| "mcpServers"
@@ -1,32 +1,11 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import {
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
resolveProviderCommandPrefix,
applyProviderEnv,
type ProviderRuntimeSettings,
} from "./provider-launch-config.js";
type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutable>[1]>;
function createFindExecutableDependencies(): FindExecutableDependencies {
return {
execFileSync: vi.fn(),
execSync: vi.fn(),
existsSync: vi.fn(),
platform: vi.fn(() => "darwin"),
shell: undefined,
};
}
let findExecutableDependencies: FindExecutableDependencies;
beforeEach(() => {
findExecutableDependencies = createFindExecutableDependencies();
});
describe("resolveProviderCommandPrefix", () => {
test("uses resolved default command in default mode", () => {
const resolveDefault = vi.fn(() => "/usr/local/bin/claude");
@@ -134,161 +113,3 @@ describe("applyProviderEnv", () => {
expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBeUndefined();
});
});
describe("findExecutable", () => {
test("on Windows, resolves executables using current machine and user PATH entries", () => {
findExecutableDependencies.platform = vi.fn(() => "win32");
process.env.Path = "C:\\Windows\\System32";
findExecutableDependencies.execFileSync.mockImplementation(
((command: string, args?: string[]) => {
if (command === "powershell") {
return "C:\\Windows\\System32\r\nC:\\Users\\boudr\\.local\\bin\r\n";
}
if (command === "where.exe") {
return "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n";
}
throw new Error(`unexpected command ${command}`);
}) as any,
);
expect(findExecutable("claude", findExecutableDependencies)).toBe(
"C:\\Users\\boudr\\.local\\bin\\claude.exe",
);
const powershellCall = findExecutableDependencies.execFileSync.mock.calls[0];
expect(powershellCall?.[0]).toBe("powershell");
expect(powershellCall?.[1]).toContain("-NoProfile");
expect(powershellCall?.[1]).toContain("-NonInteractive");
expect(powershellCall?.[1]).toContain(
'$machine = [Environment]::GetEnvironmentVariable("Path", "Machine"); $user = [Environment]::GetEnvironmentVariable("Path", "User"); if ($machine) { Write-Output $machine }; if ($user) { Write-Output $user }',
);
const whereCall = findExecutableDependencies.execFileSync.mock.calls[1];
expect(whereCall?.[0]).toBe("where.exe");
expect(whereCall?.[1]).toEqual(["claude"]);
expect(whereCall?.[2]?.encoding).toBe("utf8");
const env = whereCall?.[2]?.env as Record<string, string | undefined>;
expect(env.PATH).toContain("C:\\Users\\boudr\\.local\\bin");
expect(env.Path).toContain("C:\\Users\\boudr\\.local\\bin");
});
test("on Windows, preserves the first where.exe match", () => {
findExecutableDependencies.platform = vi.fn(() => "win32");
process.env.Path = "C:\\Windows\\System32";
findExecutableDependencies.execFileSync.mockImplementation(
((command: string) => {
if (command === "powershell") {
return "C:\\Windows\\System32\r\nC:\\nvm4w\\nodejs\r\n";
}
if (command === "where.exe") {
return "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n";
}
throw new Error(`unexpected command ${command}`);
}) as any,
);
expect(findExecutable("codex", findExecutableDependencies)).toBe("C:\\nvm4w\\nodejs\\codex");
});
test("uses the last line from login-shell which output", () => {
findExecutableDependencies.shell = "/bin/zsh";
findExecutableDependencies.execSync.mockReturnValue(
"echo from profile\n/usr/local/bin/codex\n",
);
expect(findExecutable("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex");
expect(findExecutableDependencies.execSync).toHaveBeenCalledOnce();
expect(findExecutableDependencies.execFileSync).not.toHaveBeenCalled();
});
test("warns and returns null when the final which line is not an absolute path", () => {
findExecutableDependencies.shell = "/bin/zsh";
findExecutableDependencies.execSync.mockReturnValue("profile noise\ncodex\n");
findExecutableDependencies.execFileSync.mockReturnValue("codex\n");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(findExecutable("codex", findExecutableDependencies)).toBeNull();
expect(warnSpy).toHaveBeenCalledTimes(2);
warnSpy.mockRestore();
});
test("returns direct paths when they exist", () => {
findExecutableDependencies.existsSync.mockReturnValue(true);
expect(findExecutable("/usr/local/bin/codex", findExecutableDependencies)).toBe(
"/usr/local/bin/codex",
);
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex");
});
});
describe("quoteWindowsCommand", () => {
const originalPlatform = process.platform;
function setPlatform(value: string) {
Object.defineProperty(process, "platform", { value, writable: true });
}
afterEach(() => {
setPlatform(originalPlatform);
});
test("quotes a Windows path with spaces", () => {
setPlatform("win32");
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
'"C:\\Program Files\\Anthropic\\claude.exe"',
);
});
test("does not double-quote an already-quoted path", () => {
setPlatform("win32");
expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe(
'"C:\\Program Files\\Anthropic\\claude.exe"',
);
});
test("returns the command unchanged when there are no spaces", () => {
setPlatform("win32");
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
});
test("returns the command unchanged on non-Windows platforms", () => {
setPlatform("darwin");
expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
});
});
describe("quoteWindowsArgument", () => {
const originalPlatform = process.platform;
function setPlatform(value: string) {
Object.defineProperty(process, "platform", { value, writable: true });
}
afterEach(() => {
setPlatform(originalPlatform);
});
test("quotes a Windows argument with spaces", () => {
setPlatform("win32");
expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe(
'"C:\\Program Files\\Anthropic\\cli.js"',
);
});
test("does not double-quote an already-quoted argument", () => {
setPlatform("win32");
expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe(
'"C:\\Program Files\\Anthropic\\cli.js"',
);
});
test("returns the argument unchanged when there are no spaces", () => {
setPlatform("win32");
expect(quoteWindowsArgument("--version")).toBe("--version");
});
test("returns the argument unchanged on non-Windows platforms", () => {
setPlatform("darwin");
expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
});
});
@@ -59,18 +59,10 @@ const CLAUDE_MODES: AgentProviderModeDefinition[] = [
];
const CODEX_MODES: AgentProviderModeDefinition[] = [
{
id: "read-only",
label: "Read Only",
description:
"Read files and answer questions. Manual approval required for edits, commands, or network ops.",
icon: "ShieldCheck",
colorTier: "safe",
},
{
id: "auto",
label: "Auto",
description: "Edit files and run commands but still request approval before escalating scope.",
label: "Default Permissions",
description: "Edit files and run commands with Codex's default approval flow.",
icon: "ShieldAlert",
colorTier: "moderate",
},
@@ -137,18 +129,6 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
defaultModel: "haiku",
},
},
{
id: "claude-acp",
label: "Claude ACP",
description: "Claude Code via Agent Client Protocol with streaming, permissions, and session resume",
defaultModeId: "default",
modes: CLAUDE_MODES,
voice: {
enabled: true,
defaultModeId: "default",
defaultModel: "haiku",
},
},
{
id: "codex",
label: "Codex",
@@ -157,7 +137,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
modes: CODEX_MODES,
voice: {
enabled: true,
defaultModeId: "read-only",
defaultModeId: "auto",
defaultModel: "gpt-5.1-codex-mini",
},
},
@@ -200,6 +180,13 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
defaultModeId: "build",
},
},
{
id: "pi",
label: "Pi",
description: "Minimal terminal-based coding agent with multi-provider LLM support",
defaultModeId: null,
modes: [],
},
];
export function getAgentProviderDefinition(provider: string): AgentProviderDefinition {
@@ -210,12 +197,9 @@ export function getAgentProviderDefinition(provider: string): AgentProviderDefin
return definition;
}
export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id) as [
string,
...string[],
];
export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id);
export const AgentProviderSchema = z.enum(AGENT_PROVIDER_IDS);
export const AgentProviderSchema = z.string();
export function isValidAgentProvider(value: string): boolean {
return AGENT_PROVIDER_IDS.includes(value);
@@ -1,20 +1,22 @@
import type {
AgentClient,
AgentMode,
AgentModelDefinition,
AgentProvider,
ListModelsOptions,
ListModesOptions,
} from "./agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./provider-launch-config.js";
import type { Logger } from "pino";
import { AiderAgentClient } from "./providers/aider-agent.js";
import { AmpAgentClient } from "./providers/amp-agent.js";
import { ClaudeACPAgentClient } from "./providers/claude-acp-agent.js";
import { ClaudeAgentClient } from "./providers/claude-agent.js";
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
import { GeminiAgentClient } from "./providers/gemini-agent.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
import { PiACPAgentClient } from "./providers/pi-acp-agent.js";
import {
AGENT_PROVIDER_DEFINITIONS,
@@ -29,6 +31,7 @@ export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition };
export interface ProviderDefinition extends AgentProviderDefinition {
createClient: (logger: Logger) => AgentClient;
fetchModels: (options?: ListModelsOptions) => Promise<AgentModelDefinition[]>;
fetchModes: (options?: ListModesOptions) => Promise<AgentMode[]>;
}
type BuildProviderRegistryOptions = {
@@ -46,11 +49,6 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
logger,
runtimeSettings: runtimeSettings?.claude,
}),
"claude-acp": (logger, runtimeSettings) =>
new ClaudeACPAgentClient({
logger,
runtimeSettings: runtimeSettings?.["claude-acp"],
}),
codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings?.codex),
gemini: (_logger, runtimeSettings) => new GeminiAgentClient(runtimeSettings?.gemini),
amp: (_logger, runtimeSettings) => new AmpAgentClient(runtimeSettings?.amp),
@@ -61,6 +59,7 @@ 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 }),
};
function getProviderClientFactory(provider: string): ProviderClientFactory {
@@ -86,6 +85,10 @@ export function buildProviderRegistry(
...definition,
createClient: (providerLogger: Logger) => createClient(providerLogger, runtimeSettings),
fetchModels: (listOptions?: ListModelsOptions) => modelClient.listModels(listOptions),
fetchModes: (listOptions?: ListModesOptions) =>
modelClient.listModes
? modelClient.listModes(listOptions)
: Promise.resolve(definition.modes),
} satisfies ProviderDefinition,
];
}),
@@ -17,7 +17,7 @@ import path from "node:path";
import pino from "pino";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import { isCommandAvailable } from "../../provider-launch-config.js";
import { isCommandAvailable } from "../../../../utils/executable.js";
import { ClaudeAgentClient } from "../claude-agent.js";
// ---------------------------------------------------------------------------
@@ -1,11 +1,14 @@
import { describe, expect, test, vi } from "vitest";
import {
ACPAgentClient,
ACPAgentSession,
type SessionStateResponse,
deriveModelDefinitionsFromACP,
deriveModesFromACP,
mapACPUsage,
} from "./acp-agent.js";
import { transformPiModels, transformPiSessionResponse, wrapPiSession } from "./pi-acp-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
function createSession(): ACPAgentSession {
@@ -98,6 +101,32 @@ describe("deriveModesFromACP", () => {
currentModeId: "acceptEdits",
});
});
test("returns an empty mode list when fallback modes are empty and config only exposes thought levels", () => {
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", () => {
@@ -154,7 +183,427 @@ describe("deriveModelDefinitionsFromACP", () => {
});
});
describe("ACPAgentClient modelTransformer", () => {
test("applies modelTransformer after deriving ACP models", async () => {
class TestACPAgentClient extends ACPAgentClient {
protected override async spawnProcess(): Promise<any> {
return {
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
connection: {
newSession: vi.fn().mockResolvedValue({
models: {
availableModels: [
{
modelId: "openrouter/openai/gpt-4.1-mini",
name: "openrouter/openai/gpt-4.1-mini",
description: null,
},
],
currentModelId: "openrouter/openai/gpt-4.1-mini",
},
configOptions: [],
}),
},
initialize: { agentCapabilities: {} },
};
}
protected override async closeProbe(): Promise<void> {}
}
const client = new TestACPAgentClient({
provider: "pi",
logger: createTestLogger(),
defaultCommand: ["pi-acp"],
modelTransformer: transformPiModels,
});
await expect(client.listModels()).resolves.toEqual([
{
provider: "pi",
id: "openrouter/openai/gpt-4.1-mini",
label: "gpt-4.1-mini",
description: "openrouter/openai/gpt-4.1-mini",
isDefault: true,
thinkingOptions: undefined,
defaultThinkingOptionId: undefined,
},
]);
});
});
describe("ACPAgentClient sessionResponseTransformer", () => {
class TestACPAgentClient extends ACPAgentClient {
protected override async spawnProcess(): Promise<any> {
const response: SessionStateResponse = {
sessionId: "session-1",
modes: {
availableModes: [
{ id: "off", name: "Thinking: Off", description: "No extra reasoning" },
{ id: "medium", name: "Thinking: Medium", description: "Balanced reasoning" },
{ id: "high", name: "Thinking: High", description: "Deeper reasoning" },
],
currentModeId: "medium",
},
models: {
availableModels: [
{
modelId: "openrouter/openai/gpt-4.1-mini",
name: "openrouter/openai/gpt-4.1-mini",
description: null,
},
],
currentModelId: "openrouter/openai/gpt-4.1-mini",
},
configOptions: [],
};
return {
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
connection: {
newSession: vi.fn().mockResolvedValue(response),
},
initialize: { agentCapabilities: {} },
};
}
protected override async closeProbe(): Promise<void> {}
}
test("remaps Pi thinking modes into thinking options for list probes", async () => {
const client = new TestACPAgentClient({
provider: "pi",
logger: createTestLogger(),
defaultCommand: ["pi-acp"],
defaultModes: [],
modelTransformer: transformPiModels,
sessionResponseTransformer: transformPiSessionResponse,
});
await expect(client.listModes()).resolves.toEqual([]);
await expect(client.listModels()).resolves.toEqual([
{
provider: "pi",
id: "openrouter/openai/gpt-4.1-mini",
label: "gpt-4.1-mini",
description: "openrouter/openai/gpt-4.1-mini",
isDefault: true,
thinkingOptions: [
{
id: "off",
label: "Off",
description: "No extra reasoning",
isDefault: false,
metadata: undefined,
},
{
id: "medium",
label: "Medium",
description: "Balanced reasoning",
isDefault: true,
metadata: undefined,
},
{
id: "high",
label: "High",
description: "Deeper reasoning",
isDefault: false,
metadata: undefined,
},
],
defaultThinkingOptionId: "medium",
},
]);
});
});
describe("ACPAgentClient listModes", () => {
test("returns an empty array when no ACP modes are reported and fallback modes are empty", async () => {
class TestACPAgentClient extends ACPAgentClient {
protected override async spawnProcess(): Promise<any> {
return {
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
connection: {
newSession: vi.fn().mockResolvedValue({
modes: null,
configOptions: [
{
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" },
],
},
],
}),
},
initialize: { agentCapabilities: {} },
};
}
protected override async closeProbe(): Promise<void> {}
}
const client = new TestACPAgentClient({
provider: "pi",
logger: createTestLogger(),
defaultCommand: ["pi-acp"],
defaultModes: [],
});
await expect(client.listModes()).resolves.toEqual([]);
});
});
describe("transformPiModels", () => {
test("keeps slash-free labels unchanged", () => {
expect(
transformPiModels([
{
provider: "pi",
id: "gpt-4.1-mini",
label: "GPT 4.1 Mini",
description: "Fast",
},
]),
).toEqual([
{
provider: "pi",
id: "gpt-4.1-mini",
label: "GPT 4.1 Mini",
description: "Fast",
},
]);
});
test("uses the last path segment as label and preserves existing descriptions", () => {
expect(
transformPiModels([
{
provider: "pi",
id: "openrouter/openai/gpt-4.1-mini",
label: "openrouter/openai/gpt-4.1-mini",
description: undefined,
},
{
provider: "pi",
id: "anthropic/claude-sonnet-4",
label: "anthropic/claude-sonnet-4",
description: "Balanced",
},
]),
).toEqual([
{
provider: "pi",
id: "openrouter/openai/gpt-4.1-mini",
label: "gpt-4.1-mini",
description: "openrouter/openai/gpt-4.1-mini",
},
{
provider: "pi",
id: "anthropic/claude-sonnet-4",
label: "claude-sonnet-4",
description: "Balanced",
},
]);
});
});
describe("ACPAgentSession slash commands", () => {
test("caches ACP available commands for listCommands", async () => {
const session = createSession();
expect(await session.listCommands()).toEqual([]);
(session as any).translateSessionUpdate({
sessionUpdate: "available_commands_update",
availableCommands: [
{
name: "research_codebase",
description: "Search the workspace for relevant files",
},
{
name: "create_plan",
description: "Draft a plan for the requested work",
},
],
});
expect(await session.listCommands()).toEqual([
{
name: "research_codebase",
description: "Search the workspace for relevant files",
argumentHint: "",
},
{
name: "create_plan",
description: "Draft a plan for the requested work",
argumentHint: "",
},
]);
});
});
describe("ACPAgentSession", () => {
test("applies sessionResponseTransformer before deriving modes and thinking state", () => {
const session = new ACPAgentSession(
{
provider: "pi",
cwd: "/tmp/paseo-acp-test",
},
{
provider: "pi",
logger: createTestLogger(),
defaultCommand: ["pi-acp"],
defaultModes: [],
modelTransformer: transformPiModels,
sessionResponseTransformer: transformPiSessionResponse,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
},
);
(session as any).applySessionState({
sessionId: "session-1",
modes: {
availableModes: [
{ id: "low", name: "Thinking: Low", description: "Faster" },
{ id: "medium", name: "Thinking: Medium", description: "Balanced" },
],
currentModeId: "medium",
},
models: {
availableModels: [
{
modelId: "openrouter/openai/gpt-4.1-mini",
name: "openrouter/openai/gpt-4.1-mini",
description: null,
},
],
currentModelId: "openrouter/openai/gpt-4.1-mini",
},
configOptions: [],
} satisfies SessionStateResponse);
expect((session as any).availableModes).toEqual([]);
expect((session as any).thinkingOptionId).toBe("medium");
expect((session as any).availableModels).toEqual([
{
provider: "pi",
id: "openrouter/openai/gpt-4.1-mini",
label: "gpt-4.1-mini",
description: "openrouter/openai/gpt-4.1-mini",
isDefault: true,
thinkingOptions: [
{
id: "low",
label: "Low",
description: "Faster",
isDefault: false,
metadata: undefined,
},
{
id: "medium",
label: "Medium",
description: "Balanced",
isDefault: true,
metadata: undefined,
},
],
defaultThinkingOptionId: "medium",
},
]);
});
test("Pi session wrapper hides synthetic modes and exposes them as thinking", async () => {
const wrapped = wrapPiSession(
{
provider: "pi",
id: "session-1",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
features: [
{
type: "select",
id: "thought_level",
label: "Thinking",
value: "medium",
options: [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
],
},
],
run: vi.fn(),
startTurn: vi.fn(),
subscribe: vi.fn(() => () => {}),
streamHistory: async function* () {},
getRuntimeInfo: vi.fn(async () => ({
provider: "pi",
sessionId: "session-1",
model: "gpt-4.1-mini",
thinkingOptionId: null,
modeId: "xhigh",
})),
getAvailableModes: vi.fn(async () => [
{ id: "xhigh", label: "xhigh" },
]),
getCurrentMode: vi.fn(async () => "xhigh"),
setMode: vi.fn(),
getPendingPermissions: vi.fn(() => []),
respondToPermission: vi.fn(),
describePersistence: vi.fn(() => null),
interrupt: vi.fn(),
close: vi.fn(),
setThinkingOption: vi.fn(),
},
{
provider: "pi",
cwd: "/tmp/paseo-acp-test",
thinkingOptionId: "medium",
},
);
await expect(wrapped.getAvailableModes()).resolves.toEqual([]);
await expect(wrapped.getCurrentMode()).resolves.toBeNull();
await expect(wrapped.getRuntimeInfo()).resolves.toEqual({
provider: "pi",
sessionId: "session-1",
model: "gpt-4.1-mini",
thinkingOptionId: "xhigh",
modeId: null,
});
expect(wrapped.features).toEqual([
{
type: "select",
id: "thought_level",
label: "Thinking",
value: "xhigh",
options: [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
],
},
]);
});
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
const session = createSession();
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];
@@ -71,9 +71,11 @@ import type {
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
ListModesOptions,
ListModelsOptions,
ListPersistedAgentsOptions,
McpServerConfig,
@@ -83,10 +85,10 @@ import type {
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
findExecutable,
resolveProviderCommandPrefix,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { findExecutable } from "../../../utils/executable.js";
const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
@@ -111,6 +113,14 @@ type ACPAgentClientOptions = {
runtimeSettings?: ProviderRuntimeSettings;
defaultCommand: [string, ...string[]];
defaultModes?: AgentMode[];
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
thinkingOptionWriter?: (
connection: ClientSideConnection,
sessionId: string,
thinkingOptionId: string,
) => Promise<void>;
capabilities?: AgentCapabilityFlags;
};
@@ -120,6 +130,14 @@ type ACPAgentSessionOptions = {
runtimeSettings?: ProviderRuntimeSettings;
defaultCommand: [string, ...string[]];
defaultModes: AgentMode[];
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
thinkingOptionWriter?: (
connection: ClientSideConnection,
sessionId: string,
thinkingOptionId: string,
) => Promise<void>;
capabilities: AgentCapabilityFlags;
handle?: AgentPersistenceHandle;
launchEnv?: Record<string, string>;
@@ -131,7 +149,7 @@ type SpawnedACPProcess = {
initialize: InitializeResponse;
};
type ACPToolSnapshot = {
export type ACPToolSnapshot = {
toolCallId: string;
title: string;
kind?: ToolKind | null;
@@ -154,7 +172,7 @@ type MessageAssemblyState = {
text: string;
};
type SessionStateResponse = NewSessionResponse | LoadSessionResponse | ResumeSessionResponse;
export type SessionStateResponse = NewSessionResponse | LoadSessionResponse | ResumeSessionResponse;
type TerminalExit = {
exitCode?: number | null;
@@ -271,6 +289,16 @@ export class ACPAgentClient implements AgentClient {
protected readonly runtimeSettings?: ProviderRuntimeSettings;
protected readonly defaultCommand: [string, ...string[]];
protected readonly defaultModes: AgentMode[];
private readonly modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
private readonly sessionResponseTransformer?: (
response: SessionStateResponse,
) => SessionStateResponse;
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
private readonly thinkingOptionWriter?: (
connection: ClientSideConnection,
sessionId: string,
thinkingOptionId: string,
) => Promise<void>;
constructor(options: ACPAgentClientOptions) {
this.provider = options.provider;
@@ -279,6 +307,10 @@ export class ACPAgentClient implements AgentClient {
this.runtimeSettings = options.runtimeSettings;
this.defaultCommand = options.defaultCommand;
this.defaultModes = options.defaultModes ?? [];
this.modelTransformer = options.modelTransformer;
this.sessionResponseTransformer = options.sessionResponseTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.thinkingOptionWriter = options.thinkingOptionWriter;
}
async createSession(
@@ -294,6 +326,10 @@ export class ACPAgentClient implements AgentClient {
runtimeSettings: this.runtimeSettings,
defaultCommand: this.defaultCommand,
defaultModes: this.defaultModes,
modelTransformer: this.modelTransformer,
sessionResponseTransformer: this.sessionResponseTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer,
thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities,
launchEnv: launchContext?.env,
},
@@ -329,6 +365,10 @@ export class ACPAgentClient implements AgentClient {
runtimeSettings: this.runtimeSettings,
defaultCommand: this.defaultCommand,
defaultModes: this.defaultModes,
modelTransformer: this.modelTransformer,
sessionResponseTransformer: this.sessionResponseTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer,
thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities,
handle,
launchEnv: launchContext?.env,
@@ -345,7 +385,33 @@ export class ACPAgentClient implements AgentClient {
cwd,
mcpServers: [],
});
return deriveModelDefinitionsFromACP(this.provider, response.models, response.configOptions);
const transformed = this.transformSessionResponse(response);
const models = deriveModelDefinitionsFromACP(
this.provider,
transformed.models,
transformed.configOptions,
);
return this.modelTransformer ? this.modelTransformer(models) : models;
} finally {
await this.closeProbe(probe);
}
}
async listModes(options?: ListModesOptions): Promise<AgentMode[]> {
const cwd = options?.cwd ?? process.cwd();
const probe = await this.spawnProcess(undefined);
try {
const response = await probe.connection.newSession({
cwd,
mcpServers: [],
});
const transformed = this.transformSessionResponse(response);
const modeInfo = deriveModesFromACP(
this.defaultModes,
transformed.modes,
transformed.configOptions,
);
return modeInfo.modes;
} finally {
await this.closeProbe(probe);
}
@@ -502,6 +568,10 @@ export class ACPAgentClient implements AgentClient {
throw new Error(`Expected ${this.provider} config, received ${config.provider}`);
}
}
protected transformSessionResponse(response: SessionStateResponse): SessionStateResponse {
return this.sessionResponseTransformer ? this.sessionResponseTransformer(response) : response;
}
}
export class ACPAgentSession implements AgentSession, ACPClient {
@@ -512,6 +582,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly defaultCommand: [string, ...string[]];
private readonly defaultModes: AgentMode[];
protected readonly modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
private readonly sessionResponseTransformer?: (
response: SessionStateResponse,
) => SessionStateResponse;
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
private readonly thinkingOptionWriter?: (
connection: ClientSideConnection,
sessionId: string,
thinkingOptionId: string,
) => Promise<void>;
private readonly launchEnv?: Record<string, string>;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private readonly pendingPermissions = new Map<string, PendingPermission>();
@@ -528,11 +608,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private sessionId: string | null = null;
private currentMode: string | null = null;
private availableModes: AgentMode[];
private availableModels: AgentModelDefinition[] = [];
private currentModel: string | null = null;
private thinkingOptionId: string | null = null;
private currentTitle: string | null = null;
private lastActivityAt: string | null = null;
private configOptions: SessionConfigOption[] = [];
private cachedCommands: AgentSlashCommand[] = [];
private currentTurnUsage: AgentUsage | undefined;
private activeForegroundTurnId: string | null = null;
private closed = false;
@@ -549,6 +631,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.runtimeSettings = options.runtimeSettings;
this.defaultCommand = options.defaultCommand;
this.defaultModes = options.defaultModes;
this.modelTransformer = options.modelTransformer;
this.sessionResponseTransformer = options.sessionResponseTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.thinkingOptionWriter = options.thinkingOptionWriter;
this.availableModes = options.defaultModes;
this.launchEnv = options.launchEnv;
this.initialHandle = options.handle;
@@ -788,6 +874,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return this.currentMode;
}
async listCommands(): Promise<AgentSlashCommand[]> {
return this.cachedCommands;
}
async setMode(modeId: string): Promise<void> {
if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized");
@@ -825,7 +915,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return;
}
if (this.agentCapabilities?.sessionCapabilities && "unstable_setSessionModel" in this.connection) {
const modelExists = this.availableModels.some((model) => model.id === modelId);
if (!modelExists && this.availableModels.length > 0) {
throw new Error(`Unknown ${this.provider} model '${modelId}'`);
}
if ("unstable_setSessionModel" in this.connection) {
try {
await this.connection.unstable_setSessionModel({
sessionId: this.sessionId,
@@ -859,6 +954,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return;
}
if (this.thinkingOptionWriter) {
await this.thinkingOptionWriter(this.connection, this.sessionId, thinkingOptionId);
this.thinkingOptionId = thinkingOptionId;
return;
}
const option = this.getSelectConfigOption("thought_level");
if (!option) {
throw new Error(`${this.provider} does not expose ACP thought-level selection`);
@@ -984,11 +1085,17 @@ export class ACPAgentSession implements AgentSession, ACPClient {
params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
const requestId = randomUUID();
let toolSnapshot =
this.toolCalls.get(params.toolCall.toolCallId) ??
mergeToolSnapshot(params.toolCall.toolCallId, params.toolCall);
if (this.toolSnapshotTransformer) {
toolSnapshot = this.toolSnapshotTransformer(toolSnapshot);
}
const request = mapPermissionRequest(
this.provider,
requestId,
params,
this.toolCalls.get(params.toolCall.toolCallId) ?? mergeToolSnapshot(params.toolCall.toolCallId, params.toolCall),
toolSnapshot,
);
const promise = new Promise<RequestPermissionResponse>((resolve, reject) => {
@@ -1182,13 +1289,19 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
private applySessionState(response: SessionStateResponse): void {
this.configOptions = response.configOptions ?? [];
const transformed = this.sessionResponseTransformer
? this.sessionResponseTransformer(response)
: response;
const modeInfo = deriveModesFromACP(this.defaultModes, response.modes, this.configOptions);
this.availableModes = modeInfo.modes.length > 0 ? modeInfo.modes : this.defaultModes;
this.configOptions = transformed.configOptions ?? [];
const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions);
this.availableModes = modeInfo.modes;
this.availableModels = this.deriveAvailableModels(transformed.models);
this.currentMode = modeInfo.currentModeId ?? this.currentMode;
this.currentModel = response.models?.currentModelId ?? deriveCurrentConfigValue(this.configOptions, "model");
this.currentModel =
transformed.models?.currentModelId ?? deriveCurrentConfigValue(this.configOptions, "model");
this.thinkingOptionId =
deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId;
}
@@ -1231,13 +1344,19 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return item ? [this.wrapTimeline(item)] : [];
}
case "tool_call": {
const snapshot = mergeToolSnapshot(update.toolCallId, update);
let snapshot = mergeToolSnapshot(update.toolCallId, update);
if (this.toolSnapshotTransformer) {
snapshot = this.toolSnapshotTransformer(snapshot);
}
this.toolCalls.set(update.toolCallId, snapshot);
return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))];
}
case "tool_call_update": {
const previous = this.toolCalls.get(update.toolCallId);
const snapshot = mergeToolSnapshot(update.toolCallId, update, previous);
let snapshot = mergeToolSnapshot(update.toolCallId, update, previous);
if (this.toolSnapshotTransformer) {
snapshot = this.toolSnapshotTransformer(snapshot);
}
this.toolCalls.set(update.toolCallId, snapshot);
return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))];
}
@@ -1256,6 +1375,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.handleUsageUpdate(update);
return [];
case "available_commands_update":
this.cachedCommands = update.availableCommands.map((command) => ({
name: command.name,
description: command.description,
argumentHint: "",
}));
return [];
default:
return [];
@@ -1298,15 +1422,21 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private handleConfigOptionUpdate(update: ConfigOptionUpdate): void {
this.configOptions = update.configOptions;
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
if (modeInfo.modes.length > 0) {
this.availableModes = modeInfo.modes;
}
this.availableModes = modeInfo.modes;
this.availableModels = this.deriveAvailableModels(null);
this.currentMode = modeInfo.currentModeId ?? this.currentMode;
this.currentModel = deriveCurrentConfigValue(this.configOptions, "model") ?? this.currentModel;
this.thinkingOptionId =
deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId;
}
private deriveAvailableModels(
models: SessionModelState | null | undefined,
): AgentModelDefinition[] {
const availableModels = deriveModelDefinitionsFromACP(this.provider, models, this.configOptions);
return this.modelTransformer ? this.modelTransformer(availableModels) : availableModels;
}
private handleSessionInfoUpdate(update: SessionInfoUpdate): void {
if ("title" in update) {
this.currentTitle = update.title ?? null;
@@ -1,62 +0,0 @@
import type { Logger } from "pino";
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { ACPAgentClient } from "./acp-agent.js";
const CLAUDE_ACP_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
const CLAUDE_ACP_MODES: AgentMode[] = [
{
id: "default",
label: "Always Ask",
description: "Prompts for permission the first time a tool is used",
},
{
id: "acceptEdits",
label: "Accept File Edits",
description: "Automatically approves edit-focused tools without prompting",
},
{
id: "plan",
label: "Plan Mode",
description: "Analyze the codebase without executing tools or edits",
},
{
id: "bypassPermissions",
label: "Bypass",
description: "Skip all permission prompts (use with caution)",
},
];
type ClaudeACPAgentClientOptions = {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
};
export class ClaudeACPAgentClient extends ACPAgentClient {
constructor(options: ClaudeACPAgentClientOptions) {
super({
provider: "claude-acp",
logger: options.logger,
runtimeSettings: options.runtimeSettings,
defaultCommand: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
defaultModes: CLAUDE_ACP_MODES,
capabilities: CLAUDE_ACP_CAPABILITIES,
});
}
override async isAvailable(): Promise<boolean> {
if (!(await super.isAvailable())) {
return false;
}
return Boolean(process.env["CLAUDE_CODE_OAUTH_TOKEN"] || process.env["ANTHROPIC_API_KEY"]);
}
}
@@ -1,8 +1,8 @@
import { describe, expect, test } from "vitest";
import pino from "pino";
import { isCommandAvailable } from "../provider-launch-config.js";
import type { AgentSlashCommand } from "../agent-sdk-types.js";
import { isCommandAvailable } from "../../../utils/executable.js";
import { ClaudeAgentClient } from "./claude-agent.js";
// Real-Claude contract coverage: validates slash command shape from a live Claude CLI session.
@@ -6,7 +6,7 @@ import pino from "pino";
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js";
import { isCommandAvailable } from "../provider-launch-config.js";
import { isCommandAvailable } from "../../../utils/executable.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import pino from "pino";
import type { AgentStreamEvent, AgentSession } from "../agent-sdk-types.js";
import { isCommandAvailable } from "../provider-launch-config.js";
import { isCommandAvailable } from "../../../utils/executable.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
@@ -69,11 +69,13 @@ import type {
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import {
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
} from "../../../utils/executable.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
const fsPromises = promises;
@@ -4,8 +4,9 @@
import { mkdtempSync, rmSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, it, expect } from "vitest";
import { beforeAll, describe, expect, test } from "vitest";
import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
class Pushable<T> implements AsyncIterable<T> {
private queue: T[] = [];
@@ -52,10 +53,22 @@ function tmpCwd(): string {
}
}
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
describe("Claude SDK direct behavior", () => {
it("shows what happens after interrupt()", async () => {
const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials;
beforeAll(() => {
if (canRunClaudeIntegration) {
expect(isCommandAvailable("claude")).toBe(true);
}
});
test.runIf(canRunClaudeIntegration)("shows what happens after interrupt()", async () => {
const cwd = tmpCwd();
const input = new Pushable<SDKUserMessage>();
const claudeBinary = findExecutable("claude");
// Use same options as claude-agent.ts
const q = query({
@@ -64,6 +77,7 @@ describe("Claude SDK direct behavior", () => {
cwd,
includePartialMessages: true,
permissionMode: "bypassPermissions",
...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
systemPrompt: {
type: "preset",
preset: "claude_code",
@@ -2,11 +2,14 @@ import { describe, expect, test } from "vitest";
import { execFileSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { mkdtempSync } from "node:fs";
import { createServer } from "node:http";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { once } from "node:events";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { agentConfigs } from "../../daemon-e2e/agent-configs.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
const CODEX_TEST_MODEL = agentConfigs.codex.model;
const CODEX_TEST_THINKING_OPTION_ID = agentConfigs.codex.thinkingOptionId;
@@ -20,6 +23,192 @@ function isCodexInstalled(): boolean {
}
}
function sse(events: unknown[]): string {
return events
.map((event) => {
const type =
typeof event === "object" &&
event !== null &&
"type" in event &&
typeof (event as { type?: unknown }).type === "string"
? (event as { type: string }).type
: "message";
return `event: ${type}\ndata: ${JSON.stringify(event)}\n\n`;
})
.join("");
}
function responseCreated(id: string): Record<string, unknown> {
return {
type: "response.created",
response: { id },
};
}
function responseCompleted(id: string): Record<string, unknown> {
return {
type: "response.completed",
response: {
id,
usage: {
input_tokens: 0,
input_tokens_details: null,
output_tokens: 0,
output_tokens_details: null,
total_tokens: 0,
},
},
};
}
function functionCallEvent(callId: string, name: string, argumentsJson: string): Record<string, unknown> {
return {
type: "response.output_item.done",
item: {
type: "function_call",
call_id: callId,
name,
arguments: argumentsJson,
},
};
}
function assistantMessageEvent(id: string, text: string): Record<string, unknown> {
return {
type: "response.output_item.done",
item: {
type: "message",
role: "assistant",
id,
content: [{ type: "output_text", text }],
},
};
}
function requestUserInputSse(callId: string): string {
return sse([
responseCreated("resp-1"),
functionCallEvent(
callId,
"request_user_input",
JSON.stringify({
questions: [
{
id: "confirm_path",
header: "Confirm",
question: "Proceed with the plan?",
options: [
{
label: "Yes (Recommended)",
description: "Continue the current plan.",
},
{
label: "No",
description: "Stop and revisit the approach.",
},
],
},
],
}),
),
responseCompleted("resp-1"),
]);
}
function assistantMessageSse(text: string): string {
return sse([responseCreated("resp-2"), assistantMessageEvent("msg-1", text), responseCompleted("resp-2")]);
}
async function startMockResponsesServer(sequence: string[]): Promise<{
url: string;
close: () => Promise<void>;
requestBodies: string[];
}> {
const requestBodies: string[] = [];
let index = 0;
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
req.on("end", () => {
requestBodies.push(Buffer.concat(chunks).toString("utf8"));
if (req.method !== "POST" || req.url !== "/v1/responses") {
res.statusCode = 404;
res.end("not found");
return;
}
const body = sequence[index] ?? assistantMessageSse("done");
index += 1;
res.statusCode = 200;
res.setHeader("content-type", "text/event-stream");
res.setHeader("cache-control", "no-cache");
res.end(body);
});
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Expected TCP address for mock responses server");
}
return {
url: `http://127.0.0.1:${address.port}`,
requestBodies,
close: () =>
new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
}),
};
}
function writeMockCodexConfig(codexHome: string, serverUrl: string): void {
writeFileSync(
path.join(codexHome, "config.toml"),
`
model = "mock-model"
approval_policy = "untrusted"
sandbox_mode = "read-only"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "${serverUrl}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
`,
);
}
function waitForEvent<TEvent extends AgentStreamEvent>(params: {
session: {
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
};
predicate: (event: AgentStreamEvent) => event is TEvent;
timeoutMs: number;
label: string;
}): Promise<TEvent> {
return new Promise<TEvent>((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error(`Timed out waiting for ${params.label}`));
}, params.timeoutMs);
const unsubscribe = params.session.subscribe((event) => {
if (!params.predicate(event)) {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(event);
});
});
}
describe("Codex app-server provider (e2e)", () => {
test.runIf(isCodexInstalled())(
"lists models and runs a simple prompt",
@@ -35,6 +224,7 @@ describe("Codex app-server provider (e2e)", () => {
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
expect(session.features?.some((feature) => feature.id === "plan_mode")).toBe(true);
const result = await session.run("Say hello in one sentence.");
expect(result.finalText.length).toBeGreaterThan(0);
@@ -42,4 +232,171 @@ describe("Codex app-server provider (e2e)", () => {
},
30000,
);
test.runIf(isCodexInstalled())(
"surfaces request_user_input from the app-server as question permissions and timeline tool calls",
async () => {
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-app-server-question-cwd-"));
const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-app-server-question-home-"));
const mockServer = await startMockResponsesServer([
requestUserInputSse("call1"),
assistantMessageSse("done"),
]);
try {
writeMockCodexConfig(codexHome, mockServer.url);
const client = new CodexAppServerAgentClient(createTestLogger());
const session = await client.createSession(
{
provider: "codex",
cwd,
modeId: "auto",
model: "mock-model",
thinkingOptionId: "medium",
},
{
env: {
CODEX_HOME: codexHome,
},
},
);
try {
await session.setFeature?.("plan_mode", true);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => {
events.push(event);
});
const questionRequested = waitForEvent({
session,
timeoutMs: 15_000,
label: "question permission request",
predicate: (
event,
): event is Extract<
AgentStreamEvent,
{ type: "permission_requested" }
> =>
event.type === "permission_requested" &&
event.request.provider === "codex" &&
event.request.kind === "question" &&
event.request.name === "request_user_input",
});
const turnFinished = waitForEvent({
session,
timeoutMs: 15_000,
label: "turn completion",
predicate: (
event,
): event is Extract<
AgentStreamEvent,
{ type: "turn_completed" | "turn_failed" | "turn_canceled" }
> =>
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled",
});
await session.startTurn("ask something");
const permissionEvent = await questionRequested;
expect(permissionEvent.request.input).toEqual({
questions: [
{
id: "confirm_path",
header: "Confirm",
question: "Proceed with the plan?",
isOther: true,
options: [
{
label: "Yes (Recommended)",
description: "Continue the current plan.",
},
{
label: "No",
description: "Stop and revisit the approach.",
},
],
},
],
});
const runningQuestionCall = events.find(
(event) =>
event.type === "timeline" &&
event.provider === "codex" &&
event.item.type === "tool_call" &&
event.item.name === "request_user_input" &&
event.item.status === "running",
);
expect(runningQuestionCall).toBeDefined();
await session.respondToPermission(permissionEvent.request.id, {
behavior: "allow",
updatedInput: {
answers: {
Confirm: "Yes (Recommended)",
},
},
});
const terminalEvent = await turnFinished;
expect(terminalEvent.type).toBe("turn_completed");
const completedQuestionCall = events.find(
(event) =>
event.type === "timeline" &&
event.provider === "codex" &&
event.item.type === "tool_call" &&
event.item.name === "request_user_input" &&
event.item.status === "completed",
);
expect(completedQuestionCall).toBeDefined();
if (
!completedQuestionCall ||
completedQuestionCall.type !== "timeline" ||
completedQuestionCall.item.type !== "tool_call"
) {
throw new Error("Expected completed request_user_input tool call");
}
expect(completedQuestionCall.item.metadata).toMatchObject({
answers: {
confirm_path: ["Yes (Recommended)"],
},
});
expect(
mockServer.requestBodies.some(
(body) =>
body.includes('"type":"function_call_output"') &&
body.includes('"call_id":"call1"'),
),
).toBe(true);
const finalAssistantMessage = [...events]
.reverse()
.find((event) => event.type === "timeline" && event.item.type === "assistant_message");
expect(finalAssistantMessage).toBeDefined();
if (
!finalAssistantMessage ||
finalAssistantMessage.type !== "timeline" ||
finalAssistantMessage.item.type !== "assistant_message"
) {
throw new Error("Expected final assistant message");
}
expect(finalAssistantMessage.item.text.trim()).toBe("done");
} finally {
await session.close();
}
} finally {
await mockServer.close();
rmSync(cwd, { recursive: true, force: true });
rmSync(codexHome, { recursive: true, force: true });
}
},
30_000,
);
});
@@ -0,0 +1,265 @@
import { describe, expect, test, vi } from "vitest";
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
import { __codexAppServerInternals } from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
const CODEX_PROVIDER = "codex";
const TEST_COLLABORATION_MODES = [
{
name: "Code",
mode: "code",
developer_instructions: "Built-in code mode",
},
{
name: "Plan",
mode: "plan",
developer_instructions: "Built-in plan mode",
},
];
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
return {
provider: CODEX_PROVIDER,
cwd: "/tmp/codex-fast-mode-test",
modeId: "auto",
model: "gpt-5.4",
...overrides,
};
}
function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
const config = createConfig(configOverrides);
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
{ ...config, provider: CODEX_PROVIDER },
null,
createTestLogger(),
() => {
throw new Error("Test session cannot spawn Codex app-server");
},
) as unknown as AgentSession & { [key: string]: unknown };
session.connected = true;
session.currentThreadId = "test-thread";
session.collaborationModes = TEST_COLLABORATION_MODES;
session.refreshResolvedCollaborationMode();
return session;
}
describe("Codex app-server provider features", () => {
test("features returns fast and plan toggles when supported", async () => {
const session = createSession();
expect(session.features).toEqual([
{
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at 2x usage",
tooltip: "Toggle fast mode",
icon: "zap",
value: false,
},
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
await session.setFeature?.("fast_mode", true);
await session.setFeature?.("plan_mode", true);
expect(session.features).toEqual([
{
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at 2x usage",
tooltip: "Toggle fast mode",
icon: "zap",
value: true,
},
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: true,
},
]);
});
test("features returns only plan toggle when model does not support fast mode", () => {
const session = createSession({ model: "gpt-3.5-turbo" });
expect(session.features).toEqual([
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
});
test("setFeature('fast_mode', true) sets serviceTier to fast", async () => {
const session = createSession();
await session.setFeature?.("fast_mode", true);
expect((session as any).serviceTier).toBe("fast");
});
test("setFeature('fast_mode', false) clears serviceTier to null", async () => {
const session = createSession({
featureValues: { fast_mode: true },
});
await session.setFeature?.("fast_mode", false);
expect((session as any).serviceTier).toBeNull();
});
test("setFeature invalidates cachedRuntimeInfo", async () => {
const session = createSession();
await session.getRuntimeInfo();
expect((session as any).cachedRuntimeInfo).not.toBeNull();
await session.setFeature?.("fast_mode", true);
expect((session as any).cachedRuntimeInfo).toBeNull();
});
test("setFeature throws for unknown feature ids", async () => {
const session = createSession();
await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow(
"Unknown Codex feature: unknown_feature",
);
});
test("constructor restores feature flags from config.featureValues", () => {
const session = createSession({
featureValues: { fast_mode: true, plan_mode: true },
});
expect((session as any).serviceTier).toBe("fast");
expect((session as any).planModeEnabled).toBe(true);
expect(session.features).toEqual([
{
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at 2x usage",
tooltip: "Toggle fast mode",
icon: "zap",
value: true,
},
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: true,
},
]);
});
test("startTurn includes serviceTier when fast mode is enabled", async () => {
const session = createSession();
const request = vi.fn().mockResolvedValue(undefined);
(session as any).client = { request };
(session as any).connected = true;
(session as any).currentThreadId = "thread-123";
(session as any).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
(session as any).ensureThread = vi.fn().mockResolvedValue(undefined);
(session as any).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
(session as any).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
await session.setFeature?.("fast_mode", true);
await session.startTurn("hello");
expect(request).toHaveBeenCalledWith(
"turn/start",
expect.objectContaining({
serviceTier: "fast",
}),
expect.any(Number),
);
});
test("setModel clears fast mode when switching to an unsupported model", async () => {
const session = createSession();
const request = vi.fn().mockResolvedValue(undefined);
(session as any).client = { request };
(session as any).connected = true;
(session as any).currentThreadId = "thread-123";
(session as any).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
(session as any).ensureThread = vi.fn().mockResolvedValue(undefined);
(session as any).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
(session as any).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
await session.setFeature?.("fast_mode", true);
await session.setModel("gpt-3.5-turbo");
expect(session.features).toEqual([
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
expect((session as any).serviceTier).toBeNull();
await session.startTurn("hello");
expect(request).toHaveBeenCalledWith(
"turn/start",
expect.not.objectContaining({
serviceTier: expect.anything(),
}),
expect.any(Number),
);
});
test("startTurn switches collaboration mode when plan mode is enabled", async () => {
const session = createSession();
const request = vi.fn().mockResolvedValue(undefined);
(session as any).client = { request };
(session as any).connected = true;
(session as any).currentThreadId = "thread-123";
(session as any).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
(session as any).ensureThread = vi.fn().mockResolvedValue(undefined);
(session as any).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
(session as any).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
await session.setFeature?.("plan_mode", true);
await session.startTurn("hello");
expect(request).toHaveBeenCalledWith(
"turn/start",
expect.objectContaining({
collaborationMode: expect.objectContaining({
mode: "plan",
}),
}),
expect.any(Number),
);
});
});
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import { existsSync, rmSync } from "node:fs";
import type { AgentLaunchContext } from "../agent-sdk-types.js";
import type { AgentLaunchContext, AgentSession, AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js";
import {
__codexAppServerInternals,
codexAppServerTurnInputFromPrompt,
@@ -10,6 +10,32 @@ import { createTestLogger } from "../../../test-utils/test-logger.js";
const ONE_BY_ONE_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII=";
const CODEX_PROVIDER = "codex";
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
return {
provider: CODEX_PROVIDER,
cwd: "/tmp/codex-question-test",
modeId: "auto",
model: "gpt-5.4",
...overrides,
};
}
function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
createConfig(configOverrides),
null,
createTestLogger(),
() => {
throw new Error("Test session cannot spawn Codex app-server");
},
) as unknown as AgentSession & { [key: string]: unknown };
session.connected = true;
session.currentThreadId = "test-thread";
session.activeForegroundTurnId = "test-turn";
return session;
}
describe("Codex app-server provider", () => {
const logger = createTestLogger();
@@ -55,6 +81,25 @@ describe("Codex app-server provider", () => {
}
});
test("maps Codex plan markdown to a synthetic plan tool call", () => {
const item = __codexAppServerInternals.mapCodexPlanToToolCall({
callId: "plan-turn-1",
text: "### Login Screen\n- Build layout\n- Add validation",
});
expect(item).toEqual({
type: "tool_call",
callId: "plan-turn-1",
name: "plan",
status: "completed",
error: null,
detail: {
type: "plan",
text: "### Login Screen\n- Build layout\n- Add validation",
},
});
});
test("maps patch notifications with object-style single change payloads", () => {
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
callId: "patch-object-single",
@@ -119,4 +164,185 @@ describe("Codex app-server provider", () => {
expect(env.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID);
expect(env.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG);
});
test("projects request_user_input into a question permission and running timeline tool call", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
void (session as any).handleToolApprovalRequest({
itemId: "call-question-1",
threadId: "thread-1",
turnId: "turn-1",
questions: [
{
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [
{ label: "Coffee", description: "Default" },
{ label: "Tea" },
],
},
],
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "tool_call",
callId: "call-question-1",
name: "request_user_input",
status: "running",
error: null,
detail: {
type: "plain_text",
text: "Drink: Which drink do you want?\nOptions: Coffee, Tea",
icon: "brain",
},
metadata: {
questions: [
{
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [
{ label: "Coffee", description: "Default" },
{ label: "Tea" },
],
},
],
},
},
},
{
type: "permission_requested",
provider: "codex",
turnId: "test-turn",
request: {
id: "permission-call-question-1",
provider: "codex",
name: "request_user_input",
kind: "question",
title: "Question",
detail: {
type: "plain_text",
text: "Drink: Which drink do you want?\nOptions: Coffee, Tea",
icon: "brain",
},
input: {
questions: [
{
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [
{ label: "Coffee", description: "Default" },
{ label: "Tea" },
],
},
],
},
metadata: {
itemId: "call-question-1",
threadId: "thread-1",
turnId: "turn-1",
questions: [
{
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [
{ label: "Coffee", description: "Default" },
{ label: "Tea" },
],
},
],
},
},
},
]);
});
test("maps question responses from headers back to question ids and completes the tool call", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const pendingResponse = (session as any).handleToolApprovalRequest({
itemId: "call-question-2",
threadId: "thread-1",
turnId: "turn-1",
questions: [
{
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [{ label: "Coffee" }, { label: "Tea" }],
},
],
});
await session.respondToPermission("permission-call-question-2", {
behavior: "allow",
updatedInput: {
answers: {
Drink: "Tea",
},
},
});
await expect(pendingResponse).resolves.toEqual({
answers: {
favorite_drink: { answers: ["Tea"] },
},
});
expect(events.at(-2)).toEqual({
type: "permission_resolved",
provider: "codex",
turnId: "test-turn",
requestId: "permission-call-question-2",
resolution: {
behavior: "allow",
updatedInput: {
answers: {
Drink: "Tea",
},
},
},
});
expect(events.at(-1)).toEqual({
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "tool_call",
callId: "call-question-2",
name: "request_user_input",
status: "completed",
error: null,
detail: {
type: "plain_text",
text: "Drink: Which drink do you want?\nOptions: Coffee, Tea\n\nAnswers:\n\nfavorite_drink: Tea",
icon: "brain",
},
metadata: {
questions: [
{
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [{ label: "Coffee" }, { label: "Tea" }],
},
],
answers: {
favorite_drink: ["Tea"],
},
},
},
});
});
});
@@ -1,6 +1,7 @@
import type {
AgentCapabilityFlags,
AgentClient,
AgentFeature,
AgentLaunchContext,
AgentMode,
AgentModelDefinition,
@@ -41,13 +42,16 @@ import {
} from "./codex/tool-call-mapper.js";
import {
applyProviderEnv,
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
resolveProviderCommandPrefix,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import {
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "../../../utils/executable.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
const TURN_START_TIMEOUT_MS = 90 * 1000;
@@ -64,16 +68,10 @@ const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
};
const CODEX_MODES: AgentMode[] = [
{
id: "read-only",
label: "Read Only",
description:
"Read files and answer questions. Manual approval required for edits, commands, or network ops.",
},
{
id: "auto",
label: "Auto",
description: "Edit files and run commands but still request approval before escalating scope.",
label: "Default Permissions",
description: "Edit files and run commands with Codex's default approval flow.",
},
{
id: "full-access",
@@ -575,6 +573,17 @@ class CodexAppServerClient {
this.child.stdin.write(`${JSON.stringify(payload)}\n`);
}
private writeJsonRpcResponse(response: JsonRpcResponse): void {
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
return;
}
try {
this.child.stdin.write(`${JSON.stringify(response)}\n`);
} catch (error) {
this.logger.debug({ error }, "Failed to write Codex app-server JSON-RPC response");
}
}
async dispose(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
@@ -619,14 +628,12 @@ class CodexAppServerClient {
const handler = this.requestHandlers.get(request.method);
try {
const result = handler ? await handler(request.params) : {};
const response: JsonRpcResponse = { id: request.id, result };
this.child.stdin.write(`${JSON.stringify(response)}\n`);
this.writeJsonRpcResponse({ id: request.id, result });
} catch (error) {
const response: JsonRpcResponse = {
this.writeJsonRpcResponse({
id: request.id,
error: { message: error instanceof Error ? error.message : String(error) },
};
this.child.stdin.write(`${JSON.stringify(response)}\n`);
});
}
return;
}
@@ -686,28 +693,224 @@ function extractUserText(content: unknown): string | null {
return parts.length > 0 ? parts.join("\n") : null;
}
function parsePlanTextToTodoItems(text: string): { text: string; completed: boolean }[] {
const lines = text
function normalizePlanMarkdown(text: string): string {
return text
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (lines.length === 0) {
return [{ text, completed: false }];
}
return lines.map((line) => ({
text: line.replace(/^[-*]\s+/, ""),
completed: false,
}));
.map((line) => line.replace(/\s+$/, ""))
.join("\n")
.trim();
}
function planStepsToTodoItems(steps: Array<{ step: string; status: string }>): {
text: string;
completed: boolean;
}[] {
return steps.map((entry) => ({
text: entry.step,
completed: entry.status === "completed",
}));
function planStepsToMarkdown(steps: Array<{ step: string; status: string }>): string {
const lines = steps
.map((entry) => entry.step.trim())
.filter((step) => step.length > 0)
.map((step) => {
if (/^(#{1,6}\s|[-*+]\s|\d+\.\s)/.test(step)) {
return step;
}
return `- ${step}`;
});
return normalizePlanMarkdown(lines.join("\n"));
}
function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolCallTimelineItem | null {
const text = normalizePlanMarkdown(params.text);
if (!text) {
return null;
}
return {
type: "tool_call",
callId: params.callId,
name: "plan",
status: "completed",
error: null,
detail: {
type: "plan",
text,
},
};
}
type CodexQuestionOption = {
label: string;
description?: string;
};
type CodexQuestionPrompt = {
id: string;
header: string;
question: string;
options: CodexQuestionOption[];
multiSelect?: boolean;
isOther?: boolean;
isSecret?: boolean;
};
function normalizeCodexQuestionPrompts(raw: unknown): CodexQuestionPrompt[] {
if (!Array.isArray(raw)) {
return [];
}
const questions: CodexQuestionPrompt[] = [];
for (const item of raw) {
if (!item || typeof item !== "object") {
continue;
}
const record = item as Record<string, unknown>;
const id = nonEmptyString(record.id);
const header = nonEmptyString(record.header);
const question = nonEmptyString(record.question);
if (!id || !header || !question) {
continue;
}
const options = Array.isArray(record.options)
? record.options.flatMap((option): CodexQuestionOption[] => {
if (!option || typeof option !== "object") {
return [];
}
const optionRecord = option as Record<string, unknown>;
const label = nonEmptyString(optionRecord.label);
if (!label) {
return [];
}
return [
{
label,
...(typeof optionRecord.description === "string" &&
optionRecord.description.trim().length > 0
? { description: optionRecord.description }
: {}),
},
];
})
: [];
questions.push({
id,
header,
question,
options,
...(record.multiSelect === true ? { multiSelect: true } : {}),
...(record.isOther === true ? { isOther: true } : {}),
...(record.isSecret === true ? { isSecret: true } : {}),
});
}
return questions;
}
function formatCodexQuestionPrompts(questions: CodexQuestionPrompt[]): string {
return questions
.map((question) => {
const lines = [`${question.header}: ${question.question}`];
if (question.options.length > 0) {
lines.push(`Options: ${question.options.map((option) => option.label).join(", ")}`);
}
return lines.join("\n");
})
.join("\n\n")
.trim();
}
function mapCodexQuestionRequestToToolCall(params: {
callId: string;
questions: CodexQuestionPrompt[];
status: ToolCallTimelineItem["status"];
answers?: Record<string, string[]>;
error?: unknown;
}): ToolCallTimelineItem {
const formattedQuestions = formatCodexQuestionPrompts(params.questions);
const formattedAnswers =
params.answers && Object.keys(params.answers).length > 0
? Object.entries(params.answers)
.map(([id, values]) => `${id}: ${values.join(", ")}`)
.join("\n")
: null;
const detailText =
params.status === "completed" && formattedAnswers
? [formattedQuestions, "Answers:", formattedAnswers].filter(Boolean).join("\n\n")
: formattedQuestions;
const base = {
type: "tool_call" as const,
callId: params.callId,
name: "request_user_input",
detail: {
type: "plain_text" as const,
text: detailText,
icon: "brain" as const,
},
metadata: {
questions: params.questions,
...(params.answers ? { answers: params.answers } : {}),
},
};
if (params.status === "failed") {
return {
...base,
status: "failed",
error: params.error ?? { message: "Question dismissed" },
};
}
if (params.status === "canceled") {
return {
...base,
status: "canceled",
error: null,
};
}
if (params.status === "running") {
return {
...base,
status: "running",
error: null,
};
}
return {
...base,
status: "completed",
error: null,
};
}
function mapCodexQuestionResponseByHeader(params: {
questions: CodexQuestionPrompt[];
response: AgentPermissionResponse;
}): Record<string, { answers: string[] }> | null {
if (params.response.behavior !== "allow") {
return null;
}
const answersRecord =
params.response.updatedInput && typeof params.response.updatedInput === "object"
? ((params.response.updatedInput as Record<string, unknown>).answers as
| Record<string, unknown>
| undefined)
: undefined;
if (!answersRecord || typeof answersRecord !== "object") {
return null;
}
const answers: Record<string, { answers: string[] }> = {};
for (const question of params.questions) {
const rawAnswer = answersRecord[question.header];
if (typeof rawAnswer !== "string") {
continue;
}
const normalizedAnswer = rawAnswer.trim();
if (!normalizedAnswer) {
continue;
}
const values = question.multiSelect
? normalizedAnswer
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
: [normalizedAnswer];
if (values.length > 0) {
answers[question.id] = { answers: values };
}
}
return Object.keys(answers).length > 0 ? answers : null;
}
type CodexPatchFileChange = {
@@ -1092,9 +1295,12 @@ function threadItemToTimeline(
return { type: "assistant_message", text: normalizedItem.text ?? "" };
}
case "plan": {
const text = normalizedItem.text ?? "";
const items = parsePlanTextToTodoItems(text);
return { type: "todo", items };
return mapCodexPlanToToolCall({
callId:
nonEmptyString(normalizedItem.id ?? normalizedItem.itemId ?? undefined) ??
`plan:${normalizePlanMarkdown(normalizedItem.text ?? "")}`,
text: normalizedItem.text ?? "",
});
}
case "reasoning": {
const summary = Array.isArray(normalizedItem.summary)
@@ -2017,10 +2223,21 @@ function buildCodexAppServerEnv(
};
}
export const __codexAppServerInternals = {
buildCodexAppServerEnv,
mapCodexPatchNotificationToToolCall,
};
function buildCodexAppServerInitializeParams(): {
clientInfo: { name: string; title: string; version: string };
capabilities: { experimentalApi: true };
} {
return {
clientInfo: {
name: "paseo",
title: "Paseo",
version: "0.0.0",
},
capabilities: {
experimentalApi: true,
},
};
}
class CodexAppServerAgentSession implements AgentSession {
readonly provider = CODEX_PROVIDER;
@@ -2036,6 +2253,8 @@ class CodexAppServerAgentSession implements AgentSession {
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
private serviceTier: "fast" | null = null;
private planModeEnabled = false;
private historyPending = false;
private persistedHistory: AgentTimelineItem[] = [];
private pendingPermissions = new Map<string, AgentPermissionRequest>();
@@ -2043,8 +2262,8 @@ class CodexAppServerAgentSession implements AgentSession {
string,
{
resolve: (value: unknown) => void;
kind: "command" | "file" | "tool";
questions?: Array<{ id: string; options?: Array<{ label?: string; value?: string }> }>;
kind: "command" | "file" | "question";
questions?: CodexQuestionPrompt[];
}
>();
private resolvedPermissionRequests = new Set<string>();
@@ -2092,6 +2311,12 @@ class CodexAppServerAgentSession implements AgentSession {
this.currentMode = config.modeId;
this.config = config;
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
if (this.config.featureValues?.fast_mode) {
this.serviceTier = "fast";
}
if (this.config.featureValues?.plan_mode) {
this.planModeEnabled = true;
}
if (this.resumeHandle?.sessionId) {
this.currentThreadId = this.resumeHandle.sessionId;
@@ -2103,6 +2328,15 @@ class CodexAppServerAgentSession implements AgentSession {
return this.currentThreadId;
}
get features(): AgentFeature[] {
return buildCodexFeatures({
modelId: this.config.model,
fastModeEnabled: this.serviceTier === "fast",
planModeEnabled: this.planModeEnabled,
planModeAvailable: this.hasPlanCollaborationMode(),
});
}
async connect(): Promise<void> {
if (this.connected) return;
const child = this.spawnAppServer();
@@ -2110,13 +2344,7 @@ class CodexAppServerAgentSession implements AgentSession {
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
this.registerRequestHandlers();
await this.client.request("initialize", {
clientInfo: {
name: "paseo",
title: "Paseo",
version: "0.0.0",
},
});
await this.client.request("initialize", buildCodexAppServerInitializeParams());
this.client.notify("initialized", {});
await this.loadCollaborationModes();
@@ -2148,7 +2376,7 @@ class CodexAppServerAgentSession implements AgentSession {
this.logger.trace({ error }, "Failed to load collaboration modes");
this.collaborationModes = [];
}
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode);
this.refreshResolvedCollaborationMode();
}
private async loadSkills(): Promise<void> {
@@ -2177,23 +2405,46 @@ class CodexAppServerAgentSession implements AgentSession {
}
}
private resolveCollaborationMode(
modeId: string,
): { mode: string; settings: Record<string, unknown>; name: string } | null {
private findCollaborationMode(
target: "code" | "plan",
): {
name: string;
mode?: string | null;
model?: string | null;
reasoning_effort?: string | null;
developer_instructions?: string | null;
} | null {
if (this.collaborationModes.length === 0) return null;
const normalized = modeId.toLowerCase();
const findByName = (predicate: (name: string) => boolean) =>
this.collaborationModes.find((entry) => predicate(entry.name.toLowerCase()));
let match =
normalized === "read-only"
? findByName((name) => name.includes("read") || name.includes("plan"))
: normalized === "full-access"
? findByName((name) => name.includes("full") || name.includes("exec"))
: findByName((name) => name.includes("auto") || name.includes("code"));
if (!match) {
match = this.collaborationModes[0] ?? null;
if (target === "plan") {
return findByName((name) => name.includes("plan") || name.includes("read")) ?? null;
}
return (
findByName((name) => name.includes("auto") || name.includes("code")) ??
this.collaborationModes.find((entry) => {
const name = entry.name.toLowerCase();
return !name.includes("plan") && !name.includes("read");
}) ??
this.collaborationModes[0] ??
null
);
}
private hasPlanCollaborationMode(): boolean {
return this.findCollaborationMode("plan") !== null;
}
private resolveCollaborationMode(): {
mode: string;
settings: Record<string, unknown>;
name: string;
} | null {
const match = this.findCollaborationMode(this.planModeEnabled ? "plan" : "code");
if (!match) return null;
const settings: Record<string, unknown> = {};
if (match.model) settings.model = match.model;
if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort;
@@ -2210,6 +2461,10 @@ class CodexAppServerAgentSession implements AgentSession {
return { mode: match.mode ?? "code", settings, name: match.name };
}
private refreshResolvedCollaborationMode(): void {
this.resolvedCollaborationMode = this.resolveCollaborationMode();
}
private registerRequestHandlers(): void {
if (!this.client) return;
@@ -2219,6 +2474,10 @@ class CodexAppServerAgentSession implements AgentSession {
this.client.setRequestHandler("item/fileChange/requestApproval", (params) =>
this.handleFileChangeApprovalRequest(params),
);
this.client.setRequestHandler("item/tool/requestUserInput", (params) =>
this.handleToolApprovalRequest(params),
);
// Keep the legacy method name for older Codex builds.
this.client.setRequestHandler("tool/requestUserInput", (params) =>
this.handleToolApprovalRequest(params),
);
@@ -2391,6 +2650,8 @@ class CodexAppServerAgentSession implements AgentSession {
timeline.push(event.item);
if (event.item.type === "assistant_message") {
finalText = event.item.text;
} else if (event.item.type === "tool_call" && event.item.detail.type === "plan") {
finalText = event.item.detail.text;
}
return;
}
@@ -2493,6 +2754,9 @@ class CodexAppServerAgentSession implements AgentSession {
if (thinkingOptionId) {
params.effort = thinkingOptionId;
}
if (this.serviceTier) {
params.serviceTier = this.serviceTier;
}
if (this.resolvedCollaborationMode) {
params.collaborationMode = {
mode: this.resolvedCollaborationMode.mode,
@@ -2578,22 +2842,39 @@ class CodexAppServerAgentSession implements AgentSession {
async setMode(modeId: string): Promise<void> {
validateCodexMode(modeId);
this.currentMode = modeId;
this.resolvedCollaborationMode = this.resolveCollaborationMode(modeId);
this.cachedRuntimeInfo = null;
}
async setModel(modelId: string | null): Promise<void> {
this.config.model = modelId ?? undefined;
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode);
if (!codexModelSupportsFastMode(this.config.model)) {
this.serviceTier = null;
}
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(thinkingOptionId);
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode);
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
}
async setFeature(featureId: string, value: unknown): Promise<void> {
if (featureId === "fast_mode") {
this.serviceTier = value ? "fast" : null;
this.cachedRuntimeInfo = null;
return;
}
if (featureId === "plan_mode") {
this.planModeEnabled = Boolean(value);
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
return;
}
throw new Error(`Unknown Codex feature: ${featureId}`);
}
getPendingPermissions(): AgentPermissionRequest[] {
return Array.from(this.pendingPermissions.values());
}
@@ -2658,26 +2939,53 @@ class CodexAppServerAgentSession implements AgentSession {
return;
}
// tool/requestUserInput
const answers: Record<string, { answers: string[] }> = {};
const questions = pending.questions ?? [];
const decision =
response.behavior === "allow" ? "accept" : response.interrupt ? "cancel" : "decline";
for (const question of questions) {
let picked = decision;
const options = question.options ?? [];
if (options.length > 0) {
const byLabel = options.find((opt) => (opt.label ?? "").toLowerCase().includes(decision));
const byValue = options.find((opt) => (opt.value ?? "").toLowerCase().includes(decision));
const option = byLabel ?? byValue ?? options[0]!;
picked = option.value ?? option.label ?? decision;
}
answers[question.id] = { answers: [picked] };
const itemId =
typeof pendingRequest?.metadata?.itemId === "string" ? pendingRequest.metadata.itemId : requestId;
if (response.behavior === "allow") {
const mappedAnswers = mapCodexQuestionResponseByHeader({
questions,
response,
});
const answers =
mappedAnswers ??
Object.fromEntries(
questions
.map((question) => {
const fallback = question.options[0]?.label?.trim();
return fallback
? [question.id, { answers: [fallback] }]
: null;
})
.filter((entry): entry is [string, { answers: string[] }] => entry !== null),
);
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: mapCodexQuestionRequestToToolCall({
callId: itemId,
questions,
status: "completed",
answers: Object.fromEntries(
Object.entries(answers).map(([id, value]) => [id, value.answers]),
),
}),
});
pending.resolve({ answers });
return;
}
if (questions.length === 0) {
answers["default"] = { answers: [decision] };
}
pending.resolve({ answers });
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: mapCodexQuestionRequestToToolCall({
callId: itemId,
questions,
status: response.interrupt ? "canceled" : "failed",
error: { message: response.message ?? "Question dismissed" },
}),
});
pending.resolve({ answers: {} });
}
describePersistence(): {
@@ -2917,17 +3225,22 @@ class CodexAppServerAgentSession implements AgentSession {
}
if (parsed.kind === "plan_updated") {
const items = planStepsToTodoItems(
parsed.plan.map((entry) => ({
step: entry.step ?? "",
status: entry.status ?? "pending",
})),
);
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: { type: "todo", items },
const timelineItem = mapCodexPlanToToolCall({
callId: `plan:${this.currentTurnId ?? this.currentThreadId ?? "current"}`,
text: planStepsToMarkdown(
parsed.plan.map((entry) => ({
step: entry.step ?? "",
status: entry.status ?? "pending",
})),
),
});
if (timelineItem) {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: timelineItem,
});
}
return;
}
@@ -3371,34 +3684,43 @@ class CodexAppServerAgentSession implements AgentSession {
private handleToolApprovalRequest(params: unknown): Promise<unknown> {
const parsed = params as { itemId: string; threadId: string; turnId: string; questions: any[] };
const requestId = `permission-${parsed.itemId}`;
const questions = normalizeCodexQuestionPrompts(parsed.questions);
const request: AgentPermissionRequest = {
id: requestId,
provider: CODEX_PROVIDER,
name: "CodexTool",
kind: "tool",
title: "Tool action requires approval",
name: "request_user_input",
kind: "question",
title: "Question",
description: undefined,
detail: {
type: "unknown",
input: {
questions: Array.isArray(parsed.questions) ? parsed.questions : [],
},
output: null,
type: "plain_text",
text: formatCodexQuestionPrompts(questions),
icon: "brain",
},
input: { questions },
metadata: {
itemId: parsed.itemId,
threadId: parsed.threadId,
turnId: parsed.turnId,
questions: parsed.questions,
questions,
},
};
this.pendingPermissions.set(requestId, request);
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: mapCodexQuestionRequestToToolCall({
callId: parsed.itemId,
questions,
status: "running",
}),
});
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
return new Promise((resolve) => {
this.pendingPermissionHandlers.set(requestId, {
resolve,
kind: "tool",
questions: Array.isArray(parsed.questions) ? parsed.questions : [],
kind: "question",
questions,
});
});
}
@@ -3471,9 +3793,7 @@ export class CodexAppServerAgentClient implements AgentClient {
const client = new CodexAppServerClient(child, this.logger);
try {
await client.request("initialize", {
clientInfo: { name: "paseo", title: "Paseo", version: "0.0.0" },
});
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
const limit = options?.limit ?? 20;
@@ -3543,13 +3863,7 @@ export class CodexAppServerAgentClient implements AgentClient {
const client = new CodexAppServerClient(child, this.logger);
try {
await client.request("initialize", {
clientInfo: {
name: "paseo",
title: "Paseo",
version: "0.0.0",
},
});
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
const response = (await client.request("model/list", {})) as { data?: Array<any> };
@@ -3634,3 +3948,16 @@ export class CodexAppServerAgentClient implements AgentClient {
return true;
}
}
export const __codexAppServerInternals = {
buildCodexAppServerEnv,
codexModelSupportsFastMode,
CodexAppServerAgentSession,
formatCodexQuestionPrompts,
mapCodexQuestionRequestToToolCall,
mapCodexPatchNotificationToToolCall,
planStepsToMarkdown,
mapCodexPlanToToolCall,
normalizeCodexQuestionPrompts,
threadItemToTimeline,
};
@@ -0,0 +1,66 @@
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;
export const CODEX_FAST_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at 2x usage",
tooltip: "Toggle fast mode",
icon: "zap",
};
export const CODEX_PLAN_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
};
function normalizeCodexModelId(modelId: string | null | undefined): string | null {
const normalized = typeof modelId === "string" ? modelId.trim() : "";
return normalized.length > 0 ? normalized : null;
}
export function codexModelSupportsFastMode(modelId: string | null | undefined): boolean {
const normalizedModelId = normalizeCodexModelId(modelId);
if (!normalizedModelId) {
return false;
}
return CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES.some(
(prefix) => normalizedModelId === prefix || normalizedModelId.startsWith(prefix),
);
}
export function buildCodexFeatures(input: {
modelId: string | null | undefined;
fastModeEnabled: boolean;
planModeEnabled: boolean;
planModeAvailable?: boolean;
}): AgentFeature[] {
const features: AgentFeature[] = [];
if (codexModelSupportsFastMode(input.modelId)) {
features.push({
...CODEX_FAST_MODE_FEATURE,
value: input.fastModeEnabled,
});
}
if (input.planModeAvailable !== false) {
features.push({
...CODEX_PLAN_MODE_FEATURE,
value: input.planModeEnabled,
});
}
return features;
}
@@ -0,0 +1,64 @@
import { describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { isProviderAvailable } from "../../daemon-e2e/agent-configs.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "codex-plan-mode-real-"));
}
describe("Codex app-server provider (real) plan mode", () => {
test.runIf(isProviderAvailable("codex"))(
"maps gpt-5.4 markdown plans to a plan tool call instead of todo items",
async () => {
const cwd = tmpCwd();
const client = new CodexAppServerAgentClient(createTestLogger());
try {
const session = await client.createSession({
provider: "codex",
cwd,
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "medium",
});
try {
await session.setFeature?.("plan_mode", true);
const result = await session.run(
"You are in plan mode. Produce a markdown plan with a short heading and exactly 3 bullets for implementing a login screen. Do not ask questions.",
);
expect(result.timeline).not.toContainEqual(
expect.objectContaining({
type: "todo",
}),
);
const planCall = result.timeline.find(
(item) => item.type === "tool_call" && item.detail.type === "plan",
);
expect(planCall).toBeDefined();
if (!planCall || planCall.type !== "tool_call" || planCall.detail.type !== "plan") {
throw new Error("Expected a plan tool call");
}
expect(planCall.detail.text).toContain("Login");
expect(planCall.detail.text).toContain("- ");
expect(result.finalText).toBe(planCall.detail.text);
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
240_000,
);
});
@@ -37,6 +37,58 @@ describe("opencode agent commands E2E", () => {
}
}, 60_000);
test("sendMessage executes a slash command without arguments", async () => {
const agent = await ctx.client.createAgent({
...getFullAccessConfig("opencode"),
cwd: "/tmp",
title: "OpenCode Slash Command No Args",
});
expect(agent.id).toBeTruthy();
expect(agent.provider).toBe("opencode");
await ctx.client.sendMessage(agent.id, "/help");
const state = await ctx.client.waitForFinish(agent.id, 30_000);
expect(state.status).toBe("idle");
expect(state.lastMessage).toContain("PASEO_SKILL_OK");
}, 30_000);
test("sendMessage executes a slash command with arguments", async () => {
const agent = await ctx.client.createAgent({
...getFullAccessConfig("opencode"),
cwd: "/tmp",
title: "OpenCode Slash Command With Args",
});
expect(agent.id).toBeTruthy();
expect(agent.provider).toBe("opencode");
await ctx.client.sendMessage(agent.id, "/help some-topic");
const state = await ctx.client.waitForFinish(agent.id, 30_000);
expect(state.status).toBe("idle");
expect(state.lastMessage).toContain("PASEO_SKILL_OK");
}, 30_000);
test("sendMessage keeps unknown slash input as plain prompt text", async () => {
const agent = await ctx.client.createAgent({
...getFullAccessConfig("opencode"),
cwd: "/tmp",
title: "OpenCode Slash Fallback",
});
const token = `RAW_PROMPT_TOKEN_${Date.now()}`;
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");
expect(state.lastMessage).toContain(token);
}, 30_000);
test("returns error for non-existent agent", async () => {
const result = await ctx.client.listCommands("non-existent-agent-id");
@@ -1,8 +1,8 @@
import { describe, expect, test } from "vitest";
import pino from "pino";
import { isCommandAvailable } from "../provider-launch-config.js";
import type { AgentSlashCommand } from "../agent-sdk-types.js";
import { isCommandAvailable } from "../../../utils/executable.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
describe("opencode agent commands contract (real)", () => {
@@ -35,4 +35,51 @@ describe("opencode agent commands contract (real)", () => {
await session.close();
}
}, 60_000);
test("executes a slash command without arguments", async () => {
expect(isCommandAvailable("opencode")).toBe(true);
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "default",
});
try {
const commands = await session.listCommands!();
expect(commands.length).toBeGreaterThan(0);
// Pick the first available command and send it without arguments.
const command = commands[0]!;
const events: Array<{ type: string }> = [];
session.subscribe((event) => events.push(event));
const { turnId } = await session.startTurn(`/${command.name}`);
expect(turnId).toBeTruthy();
// Wait for the turn to complete or fail.
await new Promise<void>((resolve) => {
const unsub = session.subscribe((event) => {
if (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
) {
unsub();
resolve();
}
});
});
// The turn should not have failed with an "invalid_type" error on arguments.
const failEvent = events.find((e) => e.type === "turn_failed");
if (failEvent && "error" in failEvent) {
expect(String(failEvent.error)).not.toContain("invalid_type");
expect(String(failEvent.error)).not.toContain("expected string, received undefined");
}
} finally {
await session.close();
}
}, 60_000);
});
@@ -219,6 +219,42 @@ const hasOpenCode = isBinaryInstalled("opencode");
rmSync(cwd, { recursive: true, force: true });
}, 60_000);
test("custom agents defined in opencode.json appear in available modes", async () => {
const cwd = tmpCwd();
writeFileSync(
path.join(cwd, "opencode.json"),
JSON.stringify({
agent: {
"paseo-test-custom": {
description: "Custom agent defined for Paseo integration test",
mode: "primary",
},
},
}),
);
const client = new OpenCodeAgentClient(logger);
const session = await client.createSession(buildConfig(cwd));
const modes = await session.getAvailableModes();
expect(modes.some((mode) => mode.id === "build")).toBe(true);
expect(modes.some((mode) => mode.id === "plan")).toBe(true);
const custom = modes.find((mode) => mode.id === "paseo-test-custom");
expect(custom).toBeDefined();
expect(custom!.label).toBe("Paseo-test-custom");
expect(custom!.description).toBe("Custom agent defined for Paseo integration test");
// System agents should not appear as selectable modes
expect(modes.some((mode) => mode.id === "compaction")).toBe(false);
expect(modes.some((mode) => mode.id === "summary")).toBe(false);
expect(modes.some((mode) => mode.id === "title")).toBe(false);
await session.close();
rmSync(cwd, { recursive: true, force: true });
}, 60_000);
test("plan mode blocks edits while build mode can write files", async () => {
const cwd = tmpCwd();
const planFile = path.join(cwd, "plan-mode-output.txt");
@@ -26,18 +26,22 @@ import type {
AgentTimelineItem,
AgentUsage,
ListModelsOptions,
ListModesOptions,
ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor,
ToolCallTimelineItem,
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
resolveProviderCommandPrefix,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import {
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "../../../utils/executable.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
@@ -62,8 +66,6 @@ const DEFAULT_MODES: AgentMode[] = [
},
];
const OPENCODE_MODE_IDS = new Set(DEFAULT_MODES.map((mode) => mode.id));
type OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" };
type OpenCodeMessageRole = "user" | "assistant";
@@ -201,6 +203,11 @@ function stringifyUnknownError(error: unknown): string {
}
}
function normalizeTurnFailureError(error: unknown): string {
const normalized = stringifyUnknownError(error).trim();
return normalized.length > 0 ? normalized : "Unknown error";
}
function isAlreadyPresentMcpError(error: unknown): boolean {
const normalized = stringifyUnknownError(error).toLowerCase();
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
@@ -527,18 +534,19 @@ export class OpenCodeAgentClient implements AgentClient {
for (const [modelId, model] of Object.entries(provider.models)) {
const rawVariants = model.variants ? Object.keys(model.variants) : [];
const thinkingOptions = [
{ id: "default", label: "Model default", isDefault: true },
...rawVariants.map((id) => ({ id, label: id })),
];
const thinkingOptions = rawVariants.map((id, index) => ({
id,
label: id,
isDefault: index === 0,
}));
models.push({
provider: "opencode",
id: `${provider.id}/${modelId}`,
label: model.name,
description: `${provider.name} - ${model.family ?? ""}`.trim(),
thinkingOptions: thinkingOptions.length > 1 ? thinkingOptions : undefined,
defaultThinkingOptionId: "default",
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
defaultThinkingOptionId: thinkingOptions[0]?.id,
metadata: {
providerId: provider.id,
providerName: provider.name,
@@ -557,6 +565,41 @@ export class OpenCodeAgentClient implements AgentClient {
return models;
}
async listModes(options?: ListModesOptions): Promise<AgentMode[]> {
const { url } = await this.serverManager.ensureRunning();
const directory = options?.cwd ?? process.cwd();
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,
);
});
const response = await Promise.race([
client.app.agents({ directory }),
timeoutPromise,
]);
if (response.error || !response.data) {
return DEFAULT_MODES;
}
const discovered = response.data
.filter((agent) => agent.mode === "primary" && agent.hidden !== true)
.map((agent) => ({
id: agent.name,
label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1),
description:
typeof agent.description === "string" && agent.description.trim().length > 0
? agent.description.trim()
: DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description,
}));
return discovered.length > 0 ? sortOpenCodeModes(discovered) : DEFAULT_MODES;
}
async listPersistedAgents(
_options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
@@ -955,11 +998,10 @@ export function translateOpenCodeEvent(
if (sessionId === state.sessionId) {
state.streamedPartKeys.clear();
state.partTypes.clear();
const error = props.error as string | undefined;
events.push({
type: "turn_failed",
provider: "opencode",
error: error ?? "Unknown error",
error: normalizeTurnFailureError(props.error),
});
}
break;
@@ -995,6 +1037,7 @@ class OpenCodeAgentSession implements AgentSession {
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
constructor(
config: OpenCodeAgentConfig,
@@ -1112,11 +1155,19 @@ class OpenCodeAgentSession implements AgentSession {
}
async interrupt(): Promise<void> {
this.abortController?.abort();
const turnId = this.activeForegroundTurnId;
const turnAbortController = this.abortController;
turnAbortController?.abort();
await this.client.session.abort({
sessionID: this.sessionId,
directory: this.config.cwd,
});
if (turnId) {
this.finishForegroundTurn(
{ type: "turn_canceled", provider: "opencode", reason: "interrupted" },
turnId,
);
}
}
async startTurn(
@@ -1127,30 +1178,44 @@ class OpenCodeAgentSession implements AgentSession {
throw new Error("A foreground turn is already active");
}
this.abortController = new AbortController();
this.runningToolCalls.clear();
const turnAbortController = new AbortController();
this.abortController = turnAbortController;
await this.ensureMcpServersConfigured();
const parts = this.buildPromptParts(prompt);
const model = this.parseModel(this.config.model);
const thinkingOptionId = this.config.thinkingOptionId;
const effectiveVariant =
thinkingOptionId && thinkingOptionId !== "default" ? thinkingOptionId : undefined;
const effectiveVariant = thinkingOptionId ?? undefined;
const effectiveMode = normalizeOpenCodeModeId(this.currentMode);
let promptResponse;
const turnId = this.createTurnId();
this.activeForegroundTurnId = turnId;
void this.consumeEventStream(turnId, turnAbortController);
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
if (slashCommand) {
promptResponse = await this.client.session.command({
// command() blocks until completion, but events stream via SSE in the
// background — fire without awaiting so the event stream isn't starved.
void this.client.session.command({
sessionID: this.sessionId,
directory: this.config.cwd,
command: slashCommand.commandName,
arguments: slashCommand.args,
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 {
promptResponse = await this.client.session.promptAsync({
const promptResponse = await this.client.session.promptAsync({
sessionID: this.sessionId,
directory: this.config.cwd,
parts,
@@ -1167,22 +1232,18 @@ class OpenCodeAgentSession implements AgentSession {
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
});
}
if (promptResponse.error) {
const errorMsg = JSON.stringify(promptResponse.error);
this.notifySubscribers({
type: "turn_failed",
provider: "opencode",
error: errorMsg,
});
throw new Error(errorMsg);
if (promptResponse.error) {
const errorMsg = normalizeTurnFailureError(promptResponse.error);
this.notifySubscribers({
type: "turn_failed",
provider: "opencode",
error: errorMsg,
});
throw new Error(errorMsg);
}
}
const turnId = this.createTurnId();
this.activeForegroundTurnId = turnId;
void this.consumeEventStream();
return { turnId };
}
@@ -1193,40 +1254,118 @@ class OpenCodeAgentSession implements AgentSession {
};
}
private async consumeEventStream(): Promise<void> {
private async consumeEventStream(
turnId: string,
turnAbortController: AbortController,
): Promise<void> {
const eventsResult = await this.client.event.subscribe({
directory: this.config.cwd,
});
try {
for await (const event of eventsResult.stream) {
if (this.abortController?.signal.aborted) {
if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
break;
}
const translated = this.translateEvent(event);
for (const e of translated) {
this.notifySubscribers(e);
if (e.type === "turn_completed" || e.type === "turn_failed") {
this.activeForegroundTurnId = null;
if (this.activeForegroundTurnId !== turnId) {
return;
}
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_failed") {
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: normalizeTurnFailureError(e.error),
},
turnId,
);
} else {
this.finishForegroundTurn(e, turnId);
}
return;
}
this.notifySubscribers(e, turnId);
}
}
} catch (error) {
if (!this.abortController?.signal.aborted) {
this.notifySubscribers({
type: "turn_failed",
provider: "opencode",
error: error instanceof Error ? error.message : "Stream error",
});
this.activeForegroundTurnId = null;
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: normalizeTurnFailureError(error),
},
turnId,
);
}
} finally {
if (turnAbortController.signal.aborted) {
this.finishForegroundTurn(
{
type: "turn_canceled",
provider: "opencode",
reason: "interrupted",
},
turnId,
);
}
if (this.abortController === turnAbortController && this.activeForegroundTurnId !== turnId) {
this.abortController = null;
}
}
}
private notifySubscribers(event: AgentStreamEvent): void {
const turnId = this.activeForegroundTurnId;
private finishForegroundTurn(
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
turnId: string,
): void {
if (this.activeForegroundTurnId !== turnId) {
return;
}
if (event.type === "turn_canceled" || event.type === "turn_failed") {
this.synthesizeInterruptedToolCalls(turnId);
} else {
this.runningToolCalls.clear();
}
this.activeForegroundTurnId = null;
this.notifySubscribers(event, turnId);
}
private trackToolCall(item: ToolCallTimelineItem): void {
if (item.status === "running") {
this.runningToolCalls.set(item.callId, item);
return;
}
this.runningToolCalls.delete(item.callId);
}
private synthesizeInterruptedToolCalls(turnId: string): void {
for (const item of this.runningToolCalls.values()) {
this.notifySubscribers(
{
type: "timeline",
provider: "opencode",
item: {
...item,
status: "failed",
error: { message: "Tool execution aborted" },
},
},
turnId,
);
}
this.runningToolCalls.clear();
}
private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string): void {
const turnId = turnIdOverride ?? this.activeForegroundTurnId;
const tagged = turnId ? { ...event, turnId } : event;
for (const callback of this.subscribers) {
try {
@@ -1338,7 +1477,6 @@ class OpenCodeAgentSession implements AgentSession {
? []
: response.data
.filter((agent) => agent.mode === "primary" && agent.hidden !== true)
.filter((agent) => OPENCODE_MODE_IDS.has(agent.name))
.map((agent) => ({
id: agent.name,
label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1),
@@ -0,0 +1,332 @@
import { createRequire } from "node:module";
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 {
AgentLaunchContext,
AgentCapabilityFlags,
AgentFeature,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentFeatureSelect,
} from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { isCommandAvailable } from "../../../utils/executable.js";
import {
ACPAgentClient,
type ACPToolSnapshot,
type SessionStateResponse,
} from "./acp-agent.js";
const require = createRequire(import.meta.url);
const resolvedPiAcpPath = require.resolve("pi-acp");
const PI_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
// Pi tool kind corrections: pi-acp maps 'bash' to kind 'other' instead of 'execute'.
const PI_TOOL_KIND_MAP: Record<string, ToolKind> = {
bash: "execute",
};
type PiACPAgentClientOptions = {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
};
function normalizePiModelLabel(label: string): string {
return label.trim().replace(/[_\s]+/g, " ");
}
export function transformPiModels(models: AgentModelDefinition[]): AgentModelDefinition[] {
return models.map((model) => {
if (!model.label.includes("/")) {
return model;
}
const segments = model.label.split("/").filter((segment) => segment.length > 0);
const rawLabel = segments.at(-1);
if (!rawLabel) {
return model;
}
return {
...model,
label: normalizePiModelLabel(rawLabel),
description: model.description ?? model.label,
};
});
}
function transformPiToolSnapshot(snapshot: ACPToolSnapshot): ACPToolSnapshot {
if (snapshot.kind === "other" && snapshot.title && PI_TOOL_KIND_MAP[snapshot.title]) {
return { ...snapshot, kind: PI_TOOL_KIND_MAP[snapshot.title] };
}
return snapshot;
}
/**
* Pi-acp reports thinking levels (off/minimal/low/medium/high/xhigh) as ACP
* session modes rather than as configOptions with category 'thought_level'.
* This transformer remaps them so the base ACP class treats them as thinking
* options instead of permission modes.
*/
export function transformPiSessionResponse(
response: SessionStateResponse,
): SessionStateResponse {
const modes = response.modes;
if (!modes?.availableModes?.length) {
return response;
}
const thinkingOption: SessionConfigOption = {
id: "thought_level",
name: "Thinking",
type: "select",
category: "thought_level",
currentValue: modes.currentModeId ?? "medium",
options: modes.availableModes.map((mode) => ({
value: mode.id,
name: mode.name.replace(/^Thinking:\s*/i, ""),
description: mode.description,
})),
};
return {
...response,
modes: undefined,
configOptions: [
thinkingOption,
...(response.configOptions ?? []),
],
};
}
function isThoughtLevelFeature(feature: AgentFeature): feature is AgentFeatureSelect {
return feature.type === "select" && feature.id === "thought_level";
}
function normalizePiFeatures(
features: AgentFeature[] | undefined,
thinkingOptionId: string | null | undefined,
): AgentFeature[] | undefined {
if (!features) {
return features;
}
return features.map((feature) => {
if (!isThoughtLevelFeature(feature)) {
return feature;
}
return {
...feature,
value: thinkingOptionId ?? feature.value,
};
});
}
class PiACPAgentSession implements AgentSession {
readonly provider: AgentSession["provider"];
readonly capabilities: AgentSession["capabilities"];
get id(): string | null {
return this.inner.id;
}
get features(): AgentFeature[] | undefined {
return normalizePiFeatures(this.inner.features, this.thinkingOptionId);
}
private thinkingOptionId: string | null;
constructor(
private readonly inner: AgentSession,
config: AgentSessionConfig,
) {
this.provider = inner.provider;
this.capabilities = inner.capabilities;
this.thinkingOptionId = config.thinkingOptionId ?? null;
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
return this.inner.run(prompt, options);
}
async startTurn(
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<{ turnId: string }> {
return this.inner.startTurn(prompt, options);
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
return this.inner.subscribe(callback);
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield* this.inner.streamHistory();
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
const runtimeInfo = await this.inner.getRuntimeInfo();
const thinkingOptionId =
runtimeInfo.modeId ?? runtimeInfo.thinkingOptionId ?? this.thinkingOptionId;
this.thinkingOptionId = thinkingOptionId ?? null;
return {
...runtimeInfo,
modeId: null,
thinkingOptionId: thinkingOptionId ?? null,
};
}
async getAvailableModes(): Promise<AgentMode[]> {
return [];
}
async getCurrentMode(): Promise<string | null> {
return null;
}
async setMode(modeId: string): Promise<void> {
void modeId;
throw new Error("Pi does not expose selectable modes");
}
getPendingPermissions(): AgentPermissionRequest[] {
return this.inner.getPendingPermissions();
}
async respondToPermission(
requestId: string,
response: AgentPermissionResponse,
): Promise<void> {
await this.inner.respondToPermission(requestId, response);
}
describePersistence(): AgentPersistenceHandle | null {
return this.inner.describePersistence();
}
async interrupt(): Promise<void> {
await this.inner.interrupt();
}
async close(): Promise<void> {
await this.inner.close();
}
async listCommands(): Promise<AgentSlashCommand[]> {
return this.inner.listCommands ? this.inner.listCommands() : [];
}
async setModel(modelId: string | null): Promise<void> {
if (this.inner.setModel) {
await this.inner.setModel(modelId);
}
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
this.thinkingOptionId = thinkingOptionId ?? null;
if (this.inner.setThinkingOption) {
await this.inner.setThinkingOption(thinkingOptionId);
}
}
async setFeature(featureId: string, value: unknown): Promise<void> {
if (!this.inner.setFeature) {
throw new Error("Agent session does not support setting features");
}
await this.inner.setFeature(featureId, value);
}
}
export function wrapPiSession(
session: AgentSession,
config: Pick<AgentSessionConfig, "provider" | "cwd" | "thinkingOptionId">,
): AgentSession {
return new PiACPAgentSession(session, config);
}
export class PiACPAgentClient extends ACPAgentClient {
constructor(options: PiACPAgentClientOptions) {
super({
provider: "pi",
logger: options.logger,
runtimeSettings: options.runtimeSettings,
defaultCommand: [process.execPath, resolvedPiAcpPath],
defaultModes: [],
modelTransformer: transformPiModels,
sessionResponseTransformer: transformPiSessionResponse,
toolSnapshotTransformer: transformPiToolSnapshot,
thinkingOptionWriter: async (
connection: ClientSideConnection,
sessionId: string,
thinkingOptionId: string,
) => {
await connection.setSessionMode({ sessionId, modeId: thinkingOptionId });
},
capabilities: PI_CAPABILITIES,
});
}
override async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const session = await super.createSession(config, launchContext);
return wrapPiSession(session, config);
}
override async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const session = await super.resumeSession(handle, overrides, launchContext);
return wrapPiSession(session, {
provider: "pi",
cwd: overrides?.cwd ?? process.cwd(),
thinkingOptionId: overrides?.thinkingOptionId,
});
}
override async isAvailable(): Promise<boolean> {
if (!existsSync(resolvedPiAcpPath)) {
return false;
}
if (!isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi")) {
return false;
}
return (
Boolean(process.env.OPENAI_API_KEY) ||
Boolean(process.env.ANTHROPIC_API_KEY) ||
Boolean(process.env.OPENROUTER_API_KEY) ||
existsSync(join(homedir(), ".pi", "agent", "auth.json"))
);
}
}
+1
View File
@@ -650,6 +650,7 @@ export async function createPaseoDaemon(
command: voiceMcpBridgeCommand.command,
baseArgs: [...voiceMcpBridgeCommand.baseArgs],
env: {
ELECTRON_RUN_AS_NODE: "1",
PASEO_HOME: config.paseoHome,
},
},
@@ -7,7 +7,7 @@ import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
import dotenv from "dotenv";
import { isCommandAvailable } from "../agent/provider-launch-config.js";
import { isCommandAvailable } from "../../utils/executable.js";
// Load .env.test eagerly so isProviderAvailable() has credentials at collection time.
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
@@ -15,9 +15,9 @@ dotenv.config({ path: resolve(serverRoot, ".env.test"), override: true });
export interface AgentTestConfig {
provider: string;
model: string;
model?: string;
thinkingOptionId?: string;
modes: {
modes?: {
full: string; // No permissions required
ask: string; // Requires permission approval
};
@@ -32,14 +32,6 @@ export const agentConfigs = {
ask: "default",
},
},
"claude-acp": {
provider: "claude-acp",
model: "haiku",
modes: {
full: "bypassPermissions",
ask: "default",
},
},
codex: {
provider: "codex",
model: "gpt-5.1-codex-mini",
@@ -65,6 +57,10 @@ export const agentConfigs = {
ask: "default",
},
},
pi: {
provider: "pi",
thinkingOptionId: "medium",
},
} as const satisfies Record<string, AgentTestConfig>;
export type AgentProvider = keyof typeof agentConfigs;
@@ -77,9 +73,9 @@ export function getFullAccessConfig(provider: AgentProvider) {
const thinkingOptionId = "thinkingOptionId" in config ? config.thinkingOptionId : undefined;
return {
provider: config.provider,
model: config.model,
...(config.model ? { model: config.model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
modeId: config.modes.full,
...(config.modes?.full ? { modeId: config.modes.full } : {}),
};
}
@@ -91,9 +87,9 @@ export function getAskModeConfig(provider: AgentProvider) {
const thinkingOptionId = "thinkingOptionId" in config ? config.thinkingOptionId : undefined;
return {
provider: config.provider,
model: config.model,
...(config.model ? { model: config.model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
modeId: config.modes.ask,
...(config.modes?.ask ? { modeId: config.modes.ask } : {}),
};
}
@@ -112,8 +108,6 @@ export function isProviderAvailable(provider: AgentProvider): boolean {
isCommandAvailable("claude") &&
(Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY))
);
case "claude-acp":
return Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY);
case "codex":
return (
isCommandAvailable("codex") &&
@@ -123,6 +117,14 @@ export function isProviderAvailable(provider: AgentProvider): boolean {
return isCommandAvailable("copilot");
case "opencode":
return isCommandAvailable("opencode");
case "pi":
return (
isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi") &&
(Boolean(process.env.OPENAI_API_KEY) ||
Boolean(process.env.ANTHROPIC_API_KEY) ||
Boolean(process.env.OPENROUTER_API_KEY) ||
existsSync(join(homedir(), ".pi", "agent", "auth.json")))
);
}
}
@@ -131,8 +133,8 @@ export function isProviderAvailable(provider: AgentProvider): boolean {
*/
export const allProviders: AgentProvider[] = [
"claude",
"claude-acp",
"codex",
"copilot",
"opencode",
"pi",
];
@@ -1,156 +0,0 @@
import { describe, expect, test } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { ClaudeACPAgentClient } from "../agent/providers/claude-acp-agent.js";
import type { SessionOutboundMessage } from "../messages.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { getAskModeConfig, getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-real-claude-acp-"));
}
describe("daemon E2E (real claude-acp)", () => {
test.runIf(isProviderAvailable("claude-acp"))(
"smoke test in full-access mode",
async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { "claude-acp": new ClaudeACPAgentClient({ logger }) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-acp-real-smoke" },
});
const agent = await client.createAgent({
cwd,
title: "claude-acp-real-smoke",
...getFullAccessConfig("claude-acp"),
});
await client.sendMessage(
agent.id,
"Reply with exactly: PINEAPPLE",
);
const finish = await client.waitForFinish(agent.id, 240_000);
expect(finish.status).toBe("idle");
expect(finish.final?.persistence).toBeTruthy();
expect(finish.final?.persistence?.provider).toBe("claude-acp");
expect(finish.final?.persistence?.sessionId).toBeTruthy();
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const assistantText = timeline.entries
.filter(
(
entry,
): entry is typeof entry & {
item: { type: "assistant_message"; text: string };
} => entry.item.type === "assistant_message",
)
.map((entry) => entry.item.text)
.join("\n");
expect(assistantText).toContain("PINEAPPLE");
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
}
},
420_000,
);
test.runIf(isProviderAvailable("claude-acp"))(
"permission flow in ask mode",
async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { "claude-acp": new ClaudeACPAgentClient({ logger }) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const messages: SessionOutboundMessage[] = [];
const targetFile = path.join(cwd, "permission-target.txt");
try {
writeFileSync(targetFile, "ACP_PERMISSION_CONTENT\n", "utf8");
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-acp-real-permission" },
});
const unsubscribe = client.subscribeRawMessages((message) => {
messages.push(message);
});
try {
const agent = await client.createAgent({
cwd,
title: "claude-acp-real-permission",
...getAskModeConfig("claude-acp"),
});
await client.sendMessage(
agent.id,
[
`Use the Bash tool to run exactly: cat ${JSON.stringify(targetFile)}.`,
"If approval is required, wait for approval.",
"After the command succeeds, reply with exactly: ACP_PERMISSION_DONE",
].join(" "),
);
const permissionState = await client.waitForFinish(agent.id, 30_000);
expect(permissionState.status).toBe("permission");
expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0);
const permission = permissionState.final!.pendingPermissions[0]!;
await client.respondToPermission(agent.id, permission.id, {
behavior: "allow",
});
const finalState = await client.waitForFinish(agent.id, 60_000);
expect(finalState.status).toBe("idle");
const hasPermissionResolved = messages.some((message) => {
if (message.type !== "agent_stream") {
return false;
}
if (message.payload.agentId !== agent.id) {
return false;
}
return (
message.payload.event.type === "permission_resolved" &&
message.payload.event.requestId === permission.id &&
message.payload.event.resolution.behavior === "allow"
);
});
expect(hasPermissionResolved).toBe(true);
} finally {
unsubscribe();
}
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
}
},
420_000,
);
});
@@ -0,0 +1,83 @@
import { describe, test, expect } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { isProviderAvailable } from "./agent-configs.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-custom-"));
}
async function createHarness(): Promise<{
client: DaemonClient;
daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>>;
}> {
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { opencode: new OpenCodeAgentClient(logger) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-custom-agents" } });
return { client, daemon };
}
describe("daemon E2E (real opencode) - custom agent discovery", () => {
test.runIf(isProviderAvailable("opencode"))(
"custom agents defined in opencode.json appear in availableModes",
async () => {
const cwd = tmpCwd();
writeFileSync(
path.join(cwd, "opencode.json"),
JSON.stringify({
agent: {
"paseo-e2e-custom": {
description: "Custom agent for Paseo daemon E2E test",
mode: "primary",
},
},
}),
);
const { client, daemon } = await createHarness();
try {
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode custom agent discovery",
});
// Wait for modes to be populated (they arrive after session init)
const snapshot = await client.waitForAgentUpsert(
agent.id,
(s) => s.availableModes.length > 0,
30_000,
);
expect(snapshot.availableModes.some((m) => m.id === "build")).toBe(true);
expect(snapshot.availableModes.some((m) => m.id === "plan")).toBe(true);
const custom = snapshot.availableModes.find((m) => m.id === "paseo-e2e-custom");
expect(custom).toBeDefined();
expect(custom!.description).toBe("Custom agent for Paseo daemon E2E test");
// System agents should not leak through
expect(snapshot.availableModes.some((m) => m.id === "compaction")).toBe(false);
expect(snapshot.availableModes.some((m) => m.id === "summary")).toBe(false);
expect(snapshot.availableModes.some((m) => m.id === "title")).toBe(false);
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
60_000,
);
});
@@ -0,0 +1,424 @@
import { describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient, type WaitForFinishResult } from "../test-utils/daemon-client.js";
import { createMessageCollector } from "../test-utils/message-collector.js";
import { isProviderAvailable } from "./agent-configs.js";
import type { AgentPermissionRequest } from "../agent/agent-sdk-types.js";
import type { SessionOutboundMessage } from "../messages.js";
const SYSTEM_ERROR_SNIPPET = "A foreground turn is already active";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-send-interrupt-"));
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function pickOpenCodeModel(
models: Array<{ id: string }>,
preferences: string[] = [
"minimax-m2.5-free",
"kimi-k2.5-free",
"glm-5-free",
"free",
"mini",
"gpt-5-nano",
],
): string {
const preferred = models.find((model) =>
preferences.some((fragment) => model.id.includes(fragment)),
);
return preferred?.id ?? models[0]!.id;
}
function hasRunningBashToolCall(messages: SessionOutboundMessage[], agentId: string): boolean {
return messages.some(
(message) =>
message.type === "agent_stream" &&
message.payload.agentId === agentId &&
message.payload.event.type === "timeline" &&
message.payload.event.item.type === "tool_call" &&
message.payload.event.item.status === "running" &&
["bash", "shell"].includes(message.payload.event.item.name.toLowerCase()),
);
}
function getAssistantTexts(messages: SessionOutboundMessage[], agentId: string): string[] {
return messages
.filter(
(message) =>
message.type === "agent_stream" &&
message.payload.agentId === agentId &&
message.payload.event.type === "timeline" &&
message.payload.event.item.type === "assistant_message",
)
.map((message) => message.payload.event.item.text);
}
function findSystemErrorText(texts: string[]): string | null {
return texts.find((text) => text.includes("[System Error]")) ?? null;
}
function getTimelineAssistantTexts(
timeline: Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>,
): string[] {
return timeline.entries
.filter((entry) => entry.item.type === "assistant_message")
.map((entry) => entry.item.text);
}
function findSleepToolCall(
timeline: Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>,
): { status: "running" | "completed" | "failed" | "canceled"; callId: string } | null {
for (let idx = timeline.entries.length - 1; idx >= 0; idx -= 1) {
const entry = timeline.entries[idx];
if (entry?.item.type !== "tool_call") {
continue;
}
if (entry.item.detail.type !== "shell") {
continue;
}
if (!entry.item.detail.command.includes("sleep 60")) {
continue;
}
return {
status: entry.item.status,
callId: entry.item.callId,
};
}
return null;
}
async function allowPermission(
client: DaemonClient,
agentId: string,
permission: AgentPermissionRequest,
): Promise<void> {
if (permission.kind === "question") {
throw new Error(
`Unexpected question permission while waiting for tool call: ${permission.id} ${permission.title}`,
);
}
await client.respondToPermission(agentId, permission.id, {
behavior: "allow",
message: "Approved by integration test",
});
}
async function approvePendingPermissions(
client: DaemonClient,
agentId: string,
handledPermissionIds: Set<string>,
): Promise<void> {
const snapshot = await client.fetchAgent(agentId).catch(() => null);
const pending = snapshot?.agent.pendingPermissions ?? [];
for (const permission of pending) {
if (handledPermissionIds.has(permission.id)) {
continue;
}
handledPermissionIds.add(permission.id);
await allowPermission(client, agentId, permission);
}
}
async function waitForRunningBashToolCall(
client: DaemonClient,
collector: ReturnType<typeof createMessageCollector>,
agentId: string,
timeoutMs = 120_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
const handledPermissionIds = new Set<string>();
while (Date.now() < deadline) {
await approvePendingPermissions(client, agentId, handledPermissionIds);
const streamSystemError = findSystemErrorText(getAssistantTexts(collector.messages, agentId));
if (streamSystemError) {
throw new Error(`OpenCode failed before tool call started: ${streamSystemError}`);
}
if (hasRunningBashToolCall(collector.messages, agentId)) {
return;
}
const timeline = await client.fetchAgentTimeline(agentId, { limit: 120 }).catch(() => null);
const timelineSystemError = timeline
? findSystemErrorText(getTimelineAssistantTexts(timeline).slice(-8))
: null;
if (timelineSystemError) {
throw new Error(`OpenCode failed before tool call started: ${timelineSystemError}`);
}
if (
timeline?.entries.some(
(entry) =>
entry.item.type === "tool_call" &&
entry.item.status === "running" &&
["bash", "shell"].includes(entry.item.name.toLowerCase()),
)
) {
return;
}
await sleep(500);
}
const timeline = await client.fetchAgentTimeline(agentId, { limit: 120 }).catch(() => null);
const recentToolCalls =
timeline?.entries
.filter((entry) => entry.item.type === "tool_call")
.slice(-10)
.map((entry) => ({
name: entry.item.name,
status: entry.item.status,
callId: entry.item.callId,
})) ?? [];
const recentAssistantTexts = timeline ? getTimelineAssistantTexts(timeline).slice(-6) : [];
throw new Error(
`Timed out waiting for running bash/shell tool call. recentToolCalls=${JSON.stringify(recentToolCalls)} recentAssistantTexts=${JSON.stringify(recentAssistantTexts)}`,
);
}
async function waitForSleepToolCallTerminal(
client: DaemonClient,
agentId: string,
timeoutMs = 30_000,
): Promise<{ status: "completed" | "failed" | "canceled"; callId: string }> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const timeline = await client.fetchAgentTimeline(agentId, { limit: 200 });
const sleepToolCall = findSleepToolCall(timeline);
if (sleepToolCall && sleepToolCall.status !== "running") {
return {
status: sleepToolCall.status,
callId: sleepToolCall.callId,
};
}
await sleep(300);
}
const timeline = await client.fetchAgentTimeline(agentId, { limit: 200 }).catch(() => null);
const recentToolCalls =
timeline?.entries
.filter((entry) => entry.item.type === "tool_call")
.slice(-10)
.map((entry) => ({
callId: entry.item.callId,
name: entry.item.name,
status: entry.item.status,
})) ?? [];
throw new Error(
`Timed out waiting for interrupted sleep tool call to become terminal. recentToolCalls=${JSON.stringify(recentToolCalls)}`,
);
}
async function waitForIdleResolvingPermissions(
client: DaemonClient,
agentId: string,
timeoutMs: number,
): Promise<WaitForFinishResult> {
const deadline = Date.now() + timeoutMs;
const handledPermissionIds = new Set<string>();
while (Date.now() < deadline) {
const remaining = Math.max(1, deadline - Date.now());
const result = await client.waitForFinish(agentId, Math.min(remaining, 45_000));
if (result.status !== "permission") {
return result;
}
const pendingPermissions = result.final?.pendingPermissions ?? [];
if (pendingPermissions.length === 0) {
throw new Error("waitForFinish reported permission but no pending permissions were present");
}
let resolvedAny = false;
for (const permission of pendingPermissions) {
if (handledPermissionIds.has(permission.id)) {
continue;
}
handledPermissionIds.add(permission.id);
await allowPermission(client, agentId, permission);
resolvedAny = true;
}
if (!resolvedAny) {
throw new Error(
"Permission wait loop made no progress; all permissions were already handled",
);
}
}
return {
status: "timeout",
final: null,
error: `Timed out waiting for idle after ${timeoutMs}ms`,
lastMessage: null,
};
}
async function createHarness(): Promise<{
client: DaemonClient;
daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>>;
}> {
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { opencode: new OpenCodeAgentClient(logger) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-send-interrupt-real" } });
return { client, daemon };
}
describe("daemon E2E (real opencode) - send while working and interrupt", () => {
test.runIf(isProviderAvailable("opencode"))(
"send_message while sleep tool call is running starts a clean replacement turn",
async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
const collector = createMessageCollector(client);
const followUpToken = "OPENCODE_SEND_WHILE_WORKING_OK";
try {
const modelList = await client.listProviderModels("opencode");
expect(modelList.models.length).toBeGreaterThan(0);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode send while working",
model: pickOpenCodeModel(modelList.models),
modeId: "default",
});
await client.sendMessage(
agent.id,
[
"Use the Bash tool.",
"Run exactly: sleep 60",
"Do not run it in the background.",
"Do not do anything after starting the command.",
].join(" "),
);
await client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
90_000,
);
await waitForRunningBashToolCall(client, collector, agent.id);
collector.clear();
await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`);
const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000);
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);
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 160 });
const assistantTexts = getTimelineAssistantTexts(timeline);
expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true);
expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false);
} finally {
collector.unsubscribe();
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
360_000,
);
test.runIf(isProviderAvailable("opencode"))(
"explicit interrupt during sleep tool call still allows the next turn to complete",
async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
const collector = createMessageCollector(client);
const followUpToken = "OPENCODE_INTERRUPT_FOLLOWUP_OK";
try {
const modelList = await client.listProviderModels("opencode");
expect(modelList.models.length).toBeGreaterThan(0);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode explicit interrupt",
model: pickOpenCodeModel(modelList.models),
modeId: "default",
});
await client.sendMessage(
agent.id,
[
"Use the Bash tool.",
"Run exactly: sleep 60",
"Do not run it in the background.",
"Do not do anything after starting the command.",
].join(" "),
);
await client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
90_000,
);
await waitForRunningBashToolCall(client, collector, agent.id);
await client.cancelAgent(agent.id);
await client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "idle" || snapshot.status === "error",
90_000,
);
const interruptedToolCall = await waitForSleepToolCallTerminal(client, agent.id, 45_000);
expect(interruptedToolCall.status).toBe("failed");
collector.clear();
await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`);
const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000);
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_SNIPPET)),
).toBe(false);
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 200 });
const assistantTexts = getTimelineAssistantTexts(timeline);
expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true);
expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false);
} finally {
collector.unsubscribe();
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
360_000,
);
});
@@ -0,0 +1,76 @@
import { describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { PiACPAgentClient } from "../agent/providers/pi-acp-agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { isProviderAvailable } from "./agent-configs.js";
process.env.PASEO_SUPERVISED = "0";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-real-pi-"));
}
describe("daemon E2E (real pi)", () => {
test.runIf(isProviderAvailable("pi"))(
"smoke test with thinking option configured separately from modes",
async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { pi: new PiACPAgentClient({ logger }) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "pi-real-smoke" },
});
const agent = await client.createAgent({
cwd,
title: "pi-real-smoke",
provider: "pi",
thinkingOptionId: "medium",
});
await client.sendMessage(agent.id, "Reply with exactly: PINEAPPLE");
const finish = await client.waitForFinish(agent.id, 240_000);
expect(finish.status).toBe("idle");
expect(finish.final?.persistence).toBeTruthy();
expect(finish.final?.persistence?.provider).toBe("pi");
expect(finish.final?.persistence?.sessionId).toBeTruthy();
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const assistantText = timeline.entries
.filter(
(
entry,
): entry is typeof entry & {
item: { type: "assistant_message"; text: string };
} => entry.item.type === "assistant_message",
)
.map((entry) => entry.item.text)
.join("\n");
expect(assistantText.replace(/\s+/g, "")).toContain("PINEAPPLE");
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
}
},
420_000,
);
});
+1 -3
View File
@@ -31,11 +31,9 @@ export {
// Provider binary resolution
export {
findExecutable,
applyProviderEnv,
quoteWindowsArgument,
quoteWindowsCommand,
} from "./agent/provider-launch-config.js";
export { findExecutable, quoteWindowsArgument, quoteWindowsCommand } from "../utils/executable.js";
// Provider manifest (source of truth for provider definitions)
export {
@@ -106,9 +106,9 @@ class ScriptedAgentSession implements AgentSession {
const promptText = typeof prompt === "string" ? prompt : JSON.stringify(prompt);
const turnId = `turn-${++this.turnCount}`;
this.interrupted = false;
setTimeout(() => {
queueMicrotask(() => {
void this.runScript(promptText, turnId);
}, 0);
});
return { turnId };
}
+1 -1
View File
@@ -256,7 +256,7 @@ async function runVerifyCheck(options: {
}): Promise<LoopVerifyCheckResult> {
const startedAt = nowIso();
try {
const result = await execFileAsync("/bin/zsh", ["-lc", options.command], {
const result = await execFileAsync("/bin/sh", ["-lc", options.command], {
cwd: options.cwd,
maxBuffer: MAX_VERIFY_OUTPUT_BYTES,
});
@@ -1,7 +1,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { z } from "zod";
import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
import { AgentProviderRuntimeSettingsMapSchema } from "./agent/provider-launch-config.js";
const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
@@ -82,7 +82,7 @@ const FeatureVoiceModeSchema = z
enabled: z.boolean().optional(),
llm: z
.object({
provider: z.enum(AGENT_PROVIDER_IDS as [string, ...string[]]).optional(),
provider: z.string().optional(),
model: z.string().min(1).optional(),
})
.strict()
@@ -45,6 +45,7 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
model: record.config?.model ?? undefined,
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
featureValues: record.config?.featureValues ?? undefined,
title: record.config?.title ?? undefined,
extra: record.config?.extra ?? undefined,
systemPrompt: record.config?.systemPrompt ?? undefined,
@@ -63,6 +64,7 @@ export function buildSessionConfig(record: StoredAgentRecord): AgentSessionConfi
modeId: overrides.modeId,
model: overrides.model,
thinkingOptionId: overrides.thinkingOptionId,
featureValues: overrides.featureValues,
title: overrides.title,
extra: overrides.extra,
systemPrompt: overrides.systemPrompt,
+2
View File
@@ -9,6 +9,7 @@ export interface PidLockInfo {
hostname: string;
uid: number;
listen: string | null;
desktopManaged?: boolean;
}
export class PidLockError extends Error {
@@ -86,6 +87,7 @@ export async function acquirePidLock(
hostname: hostname(),
uid: process.getuid?.() ?? 0,
listen,
...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}),
};
let fd;
+301 -28
View File
@@ -130,6 +130,7 @@ import {
getCheckoutDiff,
getCheckoutShortstat,
getCheckoutStatus,
getCheckoutStatusLite,
listBranchSuggestions,
commitChanges,
mergeToBase,
@@ -175,6 +176,8 @@ const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]);
const MIN_VERSION_ALL_PROVIDERS = "0.1.45";
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024;
@@ -210,6 +213,20 @@ function deriveInitialAgentTitle(prompt: string): string | null {
return clamped.length > 0 ? clamped : null;
}
function clientSupportsAllProviders(appVersion: string | null): boolean {
if (!appVersion) return false;
const base = appVersion.replace(/-.*$/, "");
const parts = base.split(".").map(Number);
const minParts = MIN_VERSION_ALL_PROVIDERS.split(".").map(Number);
for (let i = 0; i < minParts.length; i++) {
const current = parts[i] ?? 0;
const minimum = minParts[i] ?? 0;
if (current > minimum) return true;
if (current < minimum) return false;
}
return true;
}
export function resolveCreateAgentTitles(options: {
configTitle?: string | null;
initialPrompt?: string | null;
@@ -353,6 +370,7 @@ type VoiceTranscriptionResultPayload = {
export type SessionOptions = {
clientId: string;
appVersion?: string | null;
onMessage: (msg: SessionOutboundMessage) => void;
onBinaryMessage?: (frame: Uint8Array) => void;
getBinaryBufferedAmount?: () => number;
@@ -484,6 +502,7 @@ function coerceAgentProvider(logger: pino.Logger, value: string, agentId?: strin
*/
export class Session {
private readonly clientId: string;
private appVersion: string | null;
private readonly sessionId: string;
private readonly onMessage: (msg: SessionOutboundMessage) => void;
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
@@ -584,6 +603,7 @@ export class Session {
constructor(options: SessionOptions) {
const {
clientId,
appVersion,
onMessage,
onBinaryMessage,
getBinaryBufferedAmount,
@@ -614,6 +634,7 @@ export class Session {
agentProviderRuntimeSettings,
} = options;
this.clientId = clientId;
this.appVersion = appVersion ?? null;
this.sessionId = uuidv4();
this.onMessage = onMessage;
this.onBinaryMessage = onBinaryMessage ?? null;
@@ -685,6 +706,12 @@ export class Session {
this.sessionLogger.trace("Session created");
}
updateAppVersion(appVersion: string | null): void {
if (appVersion && appVersion !== this.appVersion) {
this.appVersion = appVersion;
}
}
/**
* Get the client's current activity state
*/
@@ -1111,6 +1138,13 @@ export class Session {
return record;
}
private isProviderVisibleToClient(provider: string): boolean {
if (clientSupportsAllProviders(this.appVersion)) {
return true;
}
return LEGACY_PROVIDER_IDS.has(provider);
}
private matchesAgentFilter(options: {
agent: AgentSnapshotPayload;
project: ProjectPlacementPayload;
@@ -1179,6 +1213,9 @@ export class Session {
subscription: AgentUpdatesSubscriptionState,
payload: AgentUpdatePayload,
): void {
if (payload.kind === "upsert" && !this.isProviderVisibleToClient(payload.agent.provider)) {
return;
}
if (subscription.isBootstrapping) {
subscription.pendingUpdatesByAgentId.set(this.getAgentUpdateTargetId(payload), payload);
return;
@@ -1221,11 +1258,23 @@ export class Session {
}
private async findWorkspaceByDirectory(cwd: string): Promise<PersistedWorkspaceRecord | null> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
const workspaces = await this.workspaceRegistry.list();
return workspaces.find((workspace) => workspace.directory === normalizedCwd) ?? null;
}
private async resolveWorkspaceDirectory(cwd: string): Promise<string> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
try {
const checkout = await getCheckoutStatusLite(normalizedCwd, {
paseoHome: this.paseoHome,
});
return normalizePersistedWorkspaceId(checkout.worktreeRoot ?? normalizedCwd);
} catch {
return normalizedCwd;
}
}
private async buildProjectPlacementForWorkspace(
workspace: PersistedWorkspaceRecord,
projectRecord?: PersistedProjectRecord | null,
@@ -1451,6 +1500,15 @@ export class Session {
await this.handleSetAgentModelRequest(msg.agentId, msg.modelId, msg.requestId);
break;
case "set_agent_feature_request":
await this.handleSetAgentFeatureRequest(
msg.agentId,
msg.featureId,
msg.value,
msg.requestId,
);
break;
case "set_agent_thinking_request":
await this.handleSetAgentThinkingRequest(
msg.agentId,
@@ -1551,6 +1609,14 @@ export class Session {
await this.handleListProviderModelsRequest(msg);
break;
case "list_provider_modes_request":
await this.handleListProviderModesRequest(msg);
break;
case "list_provider_features_request":
await this.handleListProviderFeaturesRequest(msg);
break;
case "list_available_providers_request":
await this.handleListAvailableProvidersRequest(msg);
break;
@@ -1881,7 +1947,7 @@ export class Session {
private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise<void> {
this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`);
const { archivedAt } = await this.archiveAgentState(agentId);
const { archivedAt } = await this.archiveAgentForClose(agentId);
this.emit({
type: "agent_archived",
@@ -1893,38 +1959,100 @@ export class Session {
});
}
private async archiveAgentState(agentId: string): Promise<{
archivedAt: string;
archivedRecord: StoredAgentRecord;
}> {
if (this.agentManager.getAgent(agentId)) {
await this.interruptAgentIfRunning(agentId);
await this.agentManager.clearAgentAttention(agentId).catch(() => undefined);
private async archiveStoredAgentForClose(
agentId: string,
): Promise<{ agentId: string; archivedAt: string }> {
const existing = await this.agentStorage.get(agentId);
if (!existing) {
throw new Error(`Agent not found: ${agentId}`);
}
if (existing.archivedAt) {
return {
agentId,
archivedAt: existing.archivedAt,
};
}
const archivedAt = new Date().toISOString();
const nextRecord = await this.agentManager.archiveSnapshot(agentId, archivedAt);
const normalizedStatus =
existing.lastStatus === "running" || existing.lastStatus === "initializing"
? "idle"
: existing.lastStatus;
// Unload the agent from memory — the storage record is the source of truth now.
// This tears down the provider session and drops the hydrated timeline,
// freeing memory. ensureAgentLoaded will re-initialize if needed later.
if (this.agentManager.getAgent(agentId)) {
try {
await this.agentManager.closeAgent(agentId);
} catch (error) {
this.sessionLogger.warn({ err: error, agentId }, "Failed to close agent during archive");
}
await this.agentStorage.upsert({
...existing,
archivedAt,
updatedAt: archivedAt,
lastStatus: normalizedStatus,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
});
return { agentId, archivedAt };
}
private async archiveAgentForClose(
agentId: string,
): Promise<{ agentId: string; archivedAt: string }> {
const liveAgent = this.agentManager.getAgent(agentId);
if (liveAgent) {
await this.interruptAgentIfRunning(agentId);
await this.agentManager.clearAgentAttention(agentId).catch(() => undefined);
await this.agentManager.archiveAgent(agentId);
} else {
await this.archiveStoredAgentForClose(agentId);
}
return { archivedAt, archivedRecord: nextRecord };
const archivedRecord = await this.agentStorage.get(agentId);
if (!archivedRecord) {
throw new Error(`Agent not found in storage after archive: ${agentId}`);
}
if (this.agentUpdatesSubscription) {
const payload = this.buildStoredAgentPayload(archivedRecord);
const project = await this.buildProjectPlacementForCwd(payload.cwd);
if (project) {
const matches = this.matchesAgentFilter({
agent: payload,
project,
filter: this.agentUpdatesSubscription.filter,
});
this.bufferOrEmitAgentUpdate(
this.agentUpdatesSubscription,
matches
? {
kind: "upsert",
agent: payload,
project,
}
: {
kind: "remove",
agentId,
},
);
} else {
this.bufferOrEmitAgentUpdate(this.agentUpdatesSubscription, {
kind: "remove",
agentId,
});
}
await this.emitWorkspaceUpdateForCwd(payload.cwd);
}
if (!archivedRecord.archivedAt) {
throw new Error(`Agent missing archivedAt after archive: ${agentId}`);
}
return { agentId, archivedAt: archivedRecord.archivedAt };
}
private async handleCloseItemsRequest(msg: CloseItemsRequest): Promise<void> {
const agents = [];
for (const agentId of msg.agentIds) {
try {
const { archivedAt } = await this.archiveAgentState(agentId);
agents.push({ agentId, archivedAt });
agents.push(await this.archiveAgentForClose(agentId));
} catch (error: any) {
this.sessionLogger.warn(
{ err: error, agentId, requestId: msg.requestId },
@@ -2848,12 +2976,103 @@ export class Session {
}
}
private async handleListProviderModesRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_modes_request" }>,
): Promise<void> {
const fetchedAt = new Date().toISOString();
try {
const modes = await this.providerRegistry[msg.provider].fetchModes({
cwd: msg.cwd ? expandTilde(msg.cwd) : undefined,
});
this.emit({
type: "list_provider_modes_response",
payload: {
provider: msg.provider,
modes,
error: null,
fetchedAt,
requestId: msg.requestId,
},
});
} catch (error) {
this.sessionLogger.error(
{ err: error, provider: msg.provider },
`Failed to list modes for ${msg.provider}`,
);
this.emit({
type: "list_provider_modes_response",
payload: {
provider: msg.provider,
error: (error as Error)?.message ?? String(error),
fetchedAt,
requestId: msg.requestId,
},
});
}
}
private buildDraftAgentSessionConfig(draftConfig: {
provider: AgentProvider;
cwd: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
}): AgentSessionConfig {
return {
provider: draftConfig.provider,
cwd: expandTilde(draftConfig.cwd),
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
...(draftConfig.model ? { model: draftConfig.model } : {}),
...(draftConfig.thinkingOptionId
? { thinkingOptionId: draftConfig.thinkingOptionId }
: {}),
...(draftConfig.featureValues ? { featureValues: draftConfig.featureValues } : {}),
};
}
private async handleListProviderFeaturesRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_features_request" }>,
): Promise<void> {
const fetchedAt = new Date().toISOString();
try {
const sessionConfig = this.buildDraftAgentSessionConfig(msg.draftConfig);
const features = await this.agentManager.listDraftFeatures(sessionConfig);
this.emit({
type: "list_provider_features_response",
payload: {
provider: msg.draftConfig.provider,
features,
error: null,
fetchedAt,
requestId: msg.requestId,
},
});
} catch (error) {
this.sessionLogger.error(
{ err: error, provider: msg.draftConfig.provider, draftConfig: msg.draftConfig },
`Failed to list features for ${msg.draftConfig.provider}`,
);
this.emit({
type: "list_provider_features_response",
payload: {
provider: msg.draftConfig.provider,
error: (error as Error)?.message ?? String(error),
fetchedAt,
requestId: msg.requestId,
},
});
}
}
private async handleListAvailableProvidersRequest(
msg: Extract<SessionInboundMessage, { type: "list_available_providers_request" }>,
): Promise<void> {
const fetchedAt = new Date().toISOString();
try {
const providers = await this.agentManager.listProviderAvailability();
const providers = (await this.agentManager.listProviderAvailability()).filter((provider) =>
this.isProviderVisibleToClient(provider.provider),
);
this.emit({
type: "list_available_providers_response",
payload: {
@@ -3196,6 +3415,53 @@ export class Session {
}
}
private async handleSetAgentFeatureRequest(
agentId: string,
featureId: string,
value: unknown,
requestId: string,
): Promise<void> {
this.sessionLogger.info(
{ agentId, featureId, value, requestId },
"session: set_agent_feature_request",
);
try {
await this.agentManager.setAgentFeature(agentId, featureId, value);
this.sessionLogger.info(
{ agentId, featureId, value, requestId },
"session: set_agent_feature_request success",
);
this.emit({
type: "set_agent_feature_response",
payload: { requestId, agentId, accepted: true, error: null },
});
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId, featureId, value, requestId },
"session: set_agent_feature_request error",
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to set agent feature: ${error.message}`,
},
});
this.emit({
type: "set_agent_feature_response",
payload: {
requestId,
agentId,
accepted: false,
error: error?.message ? String(error.message) : "Failed to set agent feature",
},
});
}
}
private async handleSetAgentThinkingRequest(
agentId: string,
thinkingOptionId: string | null,
@@ -4369,6 +4635,8 @@ export class Session {
let agents = [...liveAgents, ...persistedAgents];
agents = agents.filter((agent) => this.isProviderVisibleToClient(agent.provider));
// Filter by labels if filter provided
if (filter?.labels) {
const filterLabels = filter.labels;
@@ -4436,14 +4704,16 @@ export class Session {
private async getAgentPayloadById(agentId: string): Promise<AgentSnapshotPayload | null> {
const live = this.agentManager.getAgent(agentId);
if (live) {
return await this.buildAgentPayload(live);
const payload = await this.buildAgentPayload(live);
return this.isProviderVisibleToClient(payload.provider) ? payload : null;
}
const record = await this.agentStorage.get(agentId);
if (!record || record.internal) {
return null;
}
return this.buildStoredAgentPayload(record);
const payload = this.buildStoredAgentPayload(record);
return this.isProviderVisibleToClient(payload.provider) ? payload : null;
}
private normalizeFetchAgentsSort(
@@ -4856,6 +5126,9 @@ export class Session {
if (agent.archivedAt) {
continue;
}
if (!this.isProviderVisibleToClient(agent.provider)) {
continue;
}
const workspaceId = workspaceIdsByDirectory.get(normalizePersistedWorkspaceId(agent.cwd));
if (workspaceId === undefined) {
@@ -5163,7 +5436,7 @@ export class Session {
}
private async findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
const existingWorkspace = await this.findWorkspaceByDirectory(normalizedCwd);
if (existingWorkspace) {
return existingWorkspace;
@@ -5299,7 +5572,7 @@ export class Session {
return;
}
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
const persistedWorkspace = await this.findWorkspaceByDirectory(normalizedCwd);
const all = await this.listWorkspaceDescriptorsSnapshot();
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const));
@@ -5341,7 +5614,7 @@ export class Session {
const uniqueWorkspaceCwds = new Set<string>();
for (const cwd of cwds) {
const normalized = normalizePersistedWorkspaceId(cwd);
const normalized = await this.resolveWorkspaceDirectory(cwd);
if (!normalized) {
continue;
}
@@ -13,6 +13,7 @@ describe("voice MCP stdio config", () => {
baseArgs: ["/tmp/mcp-stdio-socket-bridge-cli.mjs"],
socketPath: "/tmp/paseo-voice.sock",
env: {
ELECTRON_RUN_AS_NODE: "1",
PASEO_HOME: "/tmp/paseo-home",
},
});
@@ -25,6 +26,7 @@ describe("voice MCP stdio config", () => {
"/tmp/paseo-voice.sock",
]);
expect(config.env).toEqual({
ELECTRON_RUN_AS_NODE: "1",
PASEO_HOME: "/tmp/paseo-home",
});
});
@@ -51,6 +51,11 @@ vi.mock("./checkout-git-utils.js", () => ({
resolveCheckoutGitDir: resolveCheckoutGitDirMock,
}));
vi.mock("@getpaseo/highlight", () => ({
highlightCode: vi.fn(async () => ""),
isLanguageSupported: vi.fn(() => false),
}));
function createSessionForWorkspaceGitWatchTests(): {
session: Session;
emitted: Array<{ type: string; payload: unknown }>;
@@ -10,6 +10,11 @@ import {
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
vi.mock("@getpaseo/highlight", () => ({
highlightCode: vi.fn(async () => ""),
isLanguageSupported: vi.fn(() => false),
}));
function makeAgent(input: {
id: string;
cwd: string;
@@ -243,34 +248,46 @@ function createTempGitRepo(options?: {
}
describe("workspace aggregation", () => {
test("archive request emits agent_archived using the snapshot archive flow", async () => {
test("archive request emits agent_archived and an authoritative agent_update", async () => {
const emitted: Array<{ type: string; payload: any }> = [];
const archiveSnapshot = vi.fn(async (_agentId: string, archivedAt: string) => {
return {
id: "agent-1",
const archivedRecord = {
id: "agent-1",
provider: "codex",
cwd: "/tmp/repo",
createdAt: "2026-03-30T15:00:00.000Z",
updatedAt: "2026-03-30T15:00:00.000Z",
lastActivityAt: "2026-03-30T15:00:00.000Z",
lastUserMessageAt: null,
lastStatus: "idle" as const,
lastModeId: null,
runtimeInfo: null,
config: {
provider: "codex",
cwd: "/tmp/repo",
createdAt: "2026-03-30T15:00:00.000Z",
updatedAt: archivedAt,
lastActivityAt: "2026-03-30T15:00:00.000Z",
lastUserMessageAt: null,
lastStatus: "idle" as const,
lastModeId: null,
runtimeInfo: null,
config: {
provider: "codex",
cwd: "/tmp/repo",
},
persistence: null,
title: "Archive me",
labels: {},
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt,
};
},
persistence: null,
title: "Archive me",
labels: {},
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null as string | null,
};
const projects = new Map<number, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>();
seedProject({
projects,
id: 1,
directory: "/tmp/repo",
displayName: "repo",
});
seedWorkspace({
workspaces,
id: 10,
projectId: 1,
directory: "/tmp/repo",
displayName: "repo",
});
const logger = {
child: () => logger,
trace: vi.fn(),
@@ -292,19 +309,23 @@ describe("workspace aggregation", () => {
subscribe: () => () => {},
listAgents: () => [],
getAgent: (agentId: string) => (agentId === "agent-1" ? { id: agentId } : null),
archiveSnapshot,
closeAgent,
archiveAgent: async () => {
const archivedAt = new Date().toISOString();
archivedRecord.archivedAt = archivedAt;
archivedRecord.updatedAt = archivedAt;
return { archivedAt };
},
clearAgentAttention: async () => {},
} as any,
agentStorage: {
list: async () => [],
get: async () => null,
get: async (agentId: string) => (agentId === archivedRecord.id ? archivedRecord : null),
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
list: async () => Array.from(projects.values()),
get: async (id: number) => projects.get(id) ?? null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
@@ -312,8 +333,8 @@ describe("workspace aggregation", () => {
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
list: async () => Array.from(workspaces.values()),
get: async (id: number) => workspaces.get(id) ?? null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
@@ -340,29 +361,43 @@ describe("workspace aggregation", () => {
terminalManager: null,
}) as any;
session.agentUpdatesSubscription = {
subscriptionId: "sub-agents",
filter: { includeArchived: true },
isBootstrapping: false,
pendingUpdatesByAgentId: new Map(),
};
session.interruptAgentIfRunning = vi.fn();
await session.handleArchiveAgentRequest("agent-1", "req-archive");
expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-1");
expect(closeAgent).toHaveBeenCalledWith("agent-1");
const update = emitted.find((message) => message.type === "agent_update");
expect(update?.payload).toMatchObject({
kind: "upsert",
agent: {
id: "agent-1",
archivedAt: expect.any(String),
},
});
const archivedPayload = emitted.find((message) => message.type === "agent_archived")?.payload;
expect(archivedPayload).toMatchObject({
agentId: "agent-1",
archivedAt: expect.any(String),
requestId: "req-archive",
});
expect(archiveSnapshot).toHaveBeenCalledWith("agent-1", archivedPayload.archivedAt);
expect(archivedRecord.archivedAt).toBe(archivedPayload.archivedAt);
});
test("close_items_request archives agents and kills terminals in one batch", async () => {
const emitted: Array<{ type: string; payload: any }> = [];
const archiveSnapshot = vi.fn(async (_agentId: string, archivedAt: string) => ({
const archivedAt = "2026-04-01T00:00:00.000Z";
const archivedRecord = {
id: "agent-1",
provider: "codex",
cwd: "/tmp/repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: archivedAt,
updatedAt: "2026-03-01T12:00:00.000Z",
lastActivityAt: "2026-03-01T12:00:00.000Z",
lastUserMessageAt: null,
lastStatus: "idle" as const,
@@ -375,8 +410,23 @@ describe("workspace aggregation", () => {
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt,
}));
archivedAt: null as string | null,
};
const projects = new Map<number, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>();
seedProject({
projects,
id: 2,
directory: "/tmp/repo",
displayName: "repo",
});
seedWorkspace({
workspaces,
id: 20,
projectId: 2,
directory: "/tmp/repo",
displayName: "repo",
});
const sessionLogger = {
child: () => sessionLogger,
trace: vi.fn(),
@@ -397,19 +447,22 @@ describe("workspace aggregation", () => {
subscribe: () => () => {},
listAgents: () => [],
getAgent: (agentId: string) => (agentId === "agent-1" ? { id: agentId } : null),
archiveSnapshot,
closeAgent: async () => undefined,
archiveAgent: async () => {
archivedRecord.archivedAt = archivedAt;
archivedRecord.updatedAt = archivedAt;
return { archivedAt };
},
clearAgentAttention: async () => {},
} as any,
agentStorage: {
list: async () => [],
get: async () => null,
get: async (agentId: string) => (agentId === archivedRecord.id ? archivedRecord : null),
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
list: async () => Array.from(projects.values()),
get: async (id: number) => projects.get(id) ?? null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
@@ -417,8 +470,8 @@ describe("workspace aggregation", () => {
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
list: async () => Array.from(workspaces.values()),
get: async (id: number) => workspaces.get(id) ?? null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
@@ -448,6 +501,12 @@ describe("workspace aggregation", () => {
} as any,
}) as any;
session.agentUpdatesSubscription = {
subscriptionId: "sub-agents",
filter: { includeArchived: true },
isBootstrapping: false,
pendingUpdatesByAgentId: new Map(),
};
session.interruptAgentIfRunning = vi.fn();
await session.handleMessage({
@@ -465,10 +524,16 @@ describe("workspace aggregation", () => {
terminals: [{ terminalId: "term-1", success: true }],
requestId: "req-close-items",
});
expect(archiveSnapshot).toHaveBeenCalledWith("agent-1", closePayload.agents[0].archivedAt);
expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({
kind: "upsert",
agent: {
id: "agent-1",
archivedAt,
},
});
});
test("close_items_request continues after an archive failure", async () => {
test("close_items_request archives stored agents that are not currently loaded", async () => {
const emitted: Array<{ type: string; payload: any }> = [];
const sessionLogger = {
child: () => sessionLogger,
@@ -478,30 +543,73 @@ describe("workspace aggregation", () => {
warn: vi.fn(),
error: vi.fn(),
};
const archiveSnapshot = vi.fn(async (agentId: string, archivedAt: string) => {
if (agentId === "agent-bad") {
throw new Error("archive failed");
const liveArchivedAt = "2026-04-01T00:00:00.000Z";
const storedAgentId = "agent-stored";
const liveRecord = {
id: "agent-live",
provider: "codex",
cwd: "/tmp/repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
lastActivityAt: "2026-03-01T12:00:00.000Z",
lastUserMessageAt: null,
lastStatus: "idle" as const,
lastModeId: null,
runtimeInfo: null,
config: null,
persistence: null,
title: null,
labels: {},
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null as string | null,
};
const storedRecord = {
id: storedAgentId,
provider: "codex",
cwd: "/tmp/repo",
createdAt: "2026-03-01T12:05:00.000Z",
updatedAt: "2026-03-01T12:05:00.000Z",
lastActivityAt: "2026-03-01T12:05:00.000Z",
lastUserMessageAt: null,
lastStatus: "idle" as const,
lastModeId: null,
runtimeInfo: null,
config: null,
persistence: null,
title: null,
labels: {},
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null as string | null,
};
const upsertStoredRecord = vi.fn(async (record: typeof storedRecord) => {
if (record.id !== storedAgentId) {
return;
}
return {
id: "agent-good",
provider: "codex",
cwd: "/tmp/repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: archivedAt,
lastActivityAt: "2026-03-01T12:00:00.000Z",
lastUserMessageAt: null,
lastStatus: "idle" as const,
lastModeId: null,
runtimeInfo: null,
config: null,
persistence: null,
title: null,
labels: {},
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt,
};
storedRecord.archivedAt = record.archivedAt;
storedRecord.updatedAt = record.updatedAt;
storedRecord.lastStatus = record.lastStatus;
storedRecord.requiresAttention = record.requiresAttention;
storedRecord.attentionReason = record.attentionReason;
storedRecord.attentionTimestamp = record.attentionTimestamp;
});
const projects = new Map<number, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>();
seedProject({
projects,
id: 3,
directory: "/tmp/repo",
displayName: "repo",
});
seedWorkspace({
workspaces,
id: 30,
projectId: 3,
directory: "/tmp/repo",
displayName: "repo",
});
const session = new Session({
@@ -514,21 +622,35 @@ describe("workspace aggregation", () => {
agentManager: {
subscribe: () => () => {},
listAgents: () => [],
getAgent: (agentId: string) =>
agentId === "agent-bad" || agentId === "agent-good" ? { id: agentId } : null,
archiveSnapshot,
closeAgent: async () => undefined,
getAgent: (agentId: string) => (agentId === "agent-live" ? { id: agentId } : null),
archiveAgent: async (agentId: string) => {
if (agentId !== "agent-live") {
throw new Error(`Unexpected live archive: ${agentId}`);
}
liveRecord.archivedAt = liveArchivedAt;
liveRecord.updatedAt = liveArchivedAt;
return { archivedAt: liveArchivedAt };
},
clearAgentAttention: async () => {},
} as any,
agentStorage: {
list: async () => [],
get: async () => null,
get: async (agentId: string) => {
if (agentId === "agent-live") {
return liveRecord;
}
if (agentId === storedAgentId) {
return storedRecord;
}
return null;
},
upsert: upsertStoredRecord,
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
list: async () => Array.from(projects.values()),
get: async (id: number) => projects.get(id) ?? null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
@@ -536,8 +658,8 @@ describe("workspace aggregation", () => {
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
list: async () => Array.from(workspaces.values()),
get: async (id: number) => workspaces.get(id) ?? null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
@@ -567,6 +689,156 @@ describe("workspace aggregation", () => {
} as any,
}) as any;
session.agentUpdatesSubscription = {
subscriptionId: "sub-agents",
filter: { includeArchived: true },
isBootstrapping: false,
pendingUpdatesByAgentId: new Map(),
};
session.interruptAgentIfRunning = vi.fn();
await session.handleMessage({
type: "close_items_request",
agentIds: ["agent-live", storedAgentId],
terminalIds: [],
requestId: "req-close-stored",
});
expect(upsertStoredRecord).toHaveBeenCalledTimes(1);
expect(storedRecord.archivedAt).toEqual(expect.any(String));
expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({
agents: [
{ agentId: "agent-live", archivedAt: liveArchivedAt },
{ agentId: storedAgentId, archivedAt: storedRecord.archivedAt },
],
terminals: [],
requestId: "req-close-stored",
});
expect(sessionLogger.warn).not.toHaveBeenCalled();
});
test("close_items_request continues after an archive failure", async () => {
const emitted: Array<{ type: string; payload: any }> = [];
const sessionLogger = {
child: () => sessionLogger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const archivedAt = "2026-04-01T00:00:00.000Z";
const goodRecord = {
id: "agent-good",
provider: "codex",
cwd: "/tmp/repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
lastActivityAt: "2026-03-01T12:00:00.000Z",
lastUserMessageAt: null,
lastStatus: "idle" as const,
lastModeId: null,
runtimeInfo: null,
config: null,
persistence: null,
title: null,
labels: {},
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null as string | null,
};
const projects = new Map<number, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>();
seedProject({
projects,
id: 4,
directory: "/tmp/repo",
displayName: "repo",
});
seedWorkspace({
workspaces,
id: 40,
projectId: 4,
directory: "/tmp/repo",
displayName: "repo",
});
const session = new Session({
clientId: "test-client",
onMessage: (message) => emitted.push(message as any),
logger: sessionLogger as any,
downloadTokenStore: {} as any,
pushTokenStore: {} as any,
paseoHome: "/tmp/paseo-test",
agentManager: {
subscribe: () => () => {},
listAgents: () => [],
getAgent: (agentId: string) =>
agentId === "agent-bad" || agentId === "agent-good" ? { id: agentId } : null,
archiveAgent: async (agentId: string) => {
if (agentId === "agent-bad") {
throw new Error("archive failed");
}
goodRecord.archivedAt = archivedAt;
goodRecord.updatedAt = archivedAt;
return { archivedAt };
},
clearAgentAttention: async () => {},
} as any,
agentStorage: {
list: async () => [],
get: async (agentId: string) => (agentId === "agent-good" ? goodRecord : null),
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(projects.values()),
get: async (id: number) => projects.get(id) ?? null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()),
get: async (id: number) => workspaces.get(id) ?? null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
checkoutDiffManager: {
subscribe: async () => ({
initial: { cwd: "/tmp", files: [], error: null },
unsubscribe: () => {},
}),
scheduleRefreshForCwd: () => {},
getMetrics: () => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
}),
dispose: () => {},
} as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
stt: null,
tts: null,
terminalManager: {
killTerminal: vi.fn(),
subscribeTerminalsChanged: () => () => {},
} as any,
}) as any;
session.agentUpdatesSubscription = {
subscriptionId: "sub-agents",
filter: { includeArchived: true },
isBootstrapping: false,
pendingUpdatesByAgentId: new Map(),
};
session.interruptAgentIfRunning = vi.fn();
await session.handleMessage({
@@ -585,7 +857,13 @@ describe("workspace aggregation", () => {
terminals: [{ terminalId: "term-1", success: true }],
requestId: "req-close-best-effort",
});
expect(archiveSnapshot).toHaveBeenCalledWith("agent-good", closePayload.agents[0].archivedAt);
expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({
kind: "upsert",
agent: {
id: "agent-good",
archivedAt,
},
});
expect(sessionLogger.warn).toHaveBeenCalled();
});
@@ -22,7 +22,7 @@ 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 { SherpaSileroTurnDetectionProvider } from "./sherpa/silero-vad-provider.js";
import { ensureSileroVadModel, SherpaSileroTurnDetectionProvider } from "./sherpa/silero-vad-provider.js";
type LocalSttEngine =
| { kind: "offline"; engine: SherpaOfflineRecognizerEngine }
@@ -236,7 +236,15 @@ export async function initializeLocalSpeechServices(params: {
providers.voiceTurnDetection.enabled !== false &&
providers.voiceTurnDetection.provider === "local"
) {
turnDetectionService = new SherpaSileroTurnDetectionProvider({}, logger);
let vadModelPath: string | undefined;
if (localConfig) {
try {
vadModelPath = await ensureSileroVadModel(localConfig.modelsDir, logger);
} catch (err) {
logger.warn({ err }, "Failed to provision Silero VAD model, falling back to bundled");
}
}
turnDetectionService = new SherpaSileroTurnDetectionProvider({ modelPath: vadModelPath }, logger);
}
if (providers.voiceStt.enabled !== false && providers.voiceStt.provider === "local") {
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import pino from "pino";
import { SherpaOnnxParakeetSTT } from "./sherpa-parakeet-stt.js";
import type { TranscriptionResult } from "../../../speech-provider.js";
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
class TestSherpaOnnxParakeetStt extends SherpaOnnxParakeetSTT {
public readonly calls: Array<{ audio: Buffer; format: string }> = [];
public readonly pending: Array<ReturnType<typeof createDeferred<TranscriptionResult>>> = [];
constructor() {
super({ engine: { sampleRate: 16000 } as any }, pino({ level: "silent" }));
}
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);
return deferred.promise;
}
}
describe("SherpaOnnxParakeetSTT session", () => {
it("snapshots segment ids and buffers before async transcription starts", async () => {
const provider = new TestSherpaOnnxParakeetStt();
const session = provider.createSession({
logger: pino({ level: "silent" }),
language: "en",
});
const committed: Array<{ segmentId: string; previousSegmentId: string | null }> = [];
const transcripts: Array<{ segmentId: string; transcript: string; isFinal: boolean }> = [];
session.on("committed", (payload) => {
committed.push(payload);
});
session.on("transcript", (payload) => {
transcripts.push(payload);
});
await session.connect();
session.appendPcm16(Buffer.from([1, 2, 3, 4]));
session.commit();
session.appendPcm16(Buffer.from([5, 6, 7, 8]));
session.commit();
expect(committed).toHaveLength(2);
expect(committed[1]?.segmentId).not.toBe(committed[0]?.segmentId);
expect(committed[0]?.previousSegmentId).toBeNull();
expect(committed[1]?.previousSegmentId).toBe(committed[0]?.segmentId);
expect(provider.calls).toEqual([
{ audio: Buffer.from([1, 2, 3, 4]), format: "audio/pcm;rate=16000" },
{ audio: Buffer.from([5, 6, 7, 8]), format: "audio/pcm;rate=16000" },
]);
provider.pending[0]?.resolve({ text: "first", duration: 1 });
provider.pending[1]?.resolve({ text: "second", duration: 1 });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(transcripts).toHaveLength(2);
expect(transcripts).toEqual([
expect.objectContaining({
segmentId: committed[0]!.segmentId,
transcript: "first",
isFinal: true,
}),
expect.objectContaining({
segmentId: committed[1]!.segmentId,
transcript: "second",
isFinal: true,
}),
]);
});
});
@@ -66,11 +66,18 @@ export class SherpaOnnxParakeetSTT implements SpeechToTextProvider {
const committedId = segmentId;
const prev = previousSegmentId;
const committedPcm16 = pcm16;
previousSegmentId = committedId;
segmentId = uuidv4();
pcm16 = Buffer.alloc(0);
(emitter as any).emit("committed", { segmentId: committedId, previousSegmentId: prev });
void (async () => {
try {
const rt = await this.transcribeAudio(pcm16, `audio/pcm;rate=${requiredSampleRate}`);
const rt = await this.transcribeAudio(
committedPcm16,
`audio/pcm;rate=${requiredSampleRate}`,
);
(emitter as any).emit("transcript", {
segmentId: committedId,
transcript: rt.text,
@@ -83,10 +90,7 @@ export class SherpaOnnxParakeetSTT implements SpeechToTextProvider {
} catch (err) {
(emitter as any).emit("error", err);
} finally {
previousSegmentId = committedId;
segmentId = uuidv4();
pcm16 = Buffer.alloc(0);
logger.debug({ bytes: pcm16.length }, "Parakeet session reset");
logger.debug({ bytes: committedPcm16.length }, "Parakeet session reset");
}
})();
},
@@ -0,0 +1,55 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import pino from "pino";
const generate = vi.fn();
const free = vi.fn();
vi.mock("node:fs", () => ({
existsSync: vi.fn(() => true),
}));
vi.mock("./sherpa-onnx-node-loader.js", () => ({
loadSherpaOnnxNode: () => ({
OfflineTts: class {
public readonly sampleRate = 24000;
constructor(_config: unknown) {}
generate = generate;
free = free;
},
}),
}));
describe("SherpaOnnxTTS", () => {
beforeEach(() => {
generate.mockReset();
free.mockReset();
});
it("disables external buffers when calling sherpa generate", async () => {
generate.mockReturnValue({
samples: Float32Array.from([0, 0.5, -0.5, 0.25]),
sampleRate: 24000,
});
const { SherpaOnnxTTS } = await import("./sherpa-tts.js");
const tts = new SherpaOnnxTTS(
{
preset: "kokoro-en-v0_19",
modelDir: "/tmp/fake-model",
},
pino({ level: "silent" }),
);
const result = await tts.synthesizeSpeech("hello");
expect(generate).toHaveBeenCalledWith({
text: "hello",
sid: 0,
speed: 1,
enableExternalBuffer: false,
});
expect(result.format).toBe("pcm;rate=24000");
});
});
@@ -94,13 +94,23 @@ export class SherpaOnnxTTS implements TextToSpeechProvider {
throw new Error("Cannot synthesize empty text");
}
const audio = this.tts.generate({ text: trimmed, sid: this.speakerId, speed: this.speed });
const samples: Float32Array | null =
const audio = this.tts.generate({
text: trimmed,
sid: this.speakerId,
speed: this.speed,
// Electron rejects native external-backed typed arrays. Request a copied buffer
// from sherpa itself instead of trying to clone after generate() returns.
enableExternalBuffer: false,
});
const rawSamples: Float32Array | null =
audio && audio.samples instanceof Float32Array
? audio.samples
: audio && Array.isArray(audio.samples)
? Float32Array.from(audio.samples as number[])
: null;
// Copy to avoid "External buffers are not allowed" when sherpa-onnx
// returns a Float32Array backed by native memory.
const samples = rawSamples ? Float32Array.from(rawSamples) : null;
const sampleRate: number =
audio &&
typeof audio.sampleRate === "number" &&
@@ -1,10 +1,43 @@
import { copyFile, mkdir, stat } from "node:fs/promises";
import path from "node:path";
import type { Logger } from "pino";
import type {
TurnDetectionProvider,
TurnDetectionSession,
} from "../../../turn-detection-provider.js";
import { SherpaSileroVadSession, type SherpaSileroVadSessionConfig } from "./silero-vad-session.js";
import {
resolveBundledSileroVadModelPath,
SherpaSileroVadSession,
type SherpaSileroVadSessionConfig,
} from "./silero-vad-session.js";
const SILERO_VAD_DIR = "silero-vad";
const SILERO_VAD_FILE = "silero_vad.onnx";
/**
* Ensure the Silero VAD ONNX model exists in modelsDir where native code can
* read it. The bundled asset lives inside Electron's app.asar which native C++
* cannot open, so we copy it out on first run using Node.js fs (asar-aware).
*/
export async function ensureSileroVadModel(modelsDir: string, logger: Logger): Promise<string> {
const destDir = path.join(modelsDir, SILERO_VAD_DIR);
const destPath = path.join(destDir, SILERO_VAD_FILE);
try {
const s = await stat(destPath);
if (s.isFile() && s.size > 0) return destPath;
} catch {
// not present yet
}
const bundledPath = resolveBundledSileroVadModelPath();
await mkdir(destDir, { recursive: true });
await copyFile(bundledPath, destPath);
logger.info({ destPath }, "Copied Silero VAD model to models directory");
return destPath;
}
export class SherpaSileroTurnDetectionProvider implements TurnDetectionProvider {
public readonly id = "local" as const;
@@ -34,7 +34,7 @@ type SherpaVadModule = {
CircularBuffer: new (capacity: number) => SherpaCircularBufferHandle;
};
function resolveBundledSileroVadModelPath(): string {
export function resolveBundledSileroVadModelPath(): string {
return fileURLToPath(new URL("./assets/silero_vad.onnx", import.meta.url));
}
@@ -107,7 +107,7 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
const samples = pcm16leToFloat32(pcm16le, 1);
this.inputBuffer.push(samples);
while (this.inputBuffer.size() > this.windowSize) {
const window = this.inputBuffer.get(this.inputBuffer.head(), this.windowSize);
const window = this.inputBuffer.get(this.inputBuffer.head(), this.windowSize, false);
this.inputBuffer.pop(this.windowSize);
this.vad.acceptWaveform(window);
this.syncDetectionState();
@@ -243,7 +243,7 @@ class FakeAgentSession implements AgentSession {
private async resolveSlashCommandInput(
prompt: AgentPromptInput,
): Promise<{ commandName: string; args?: string } | null> {
if (this.providerName !== "codex" || typeof prompt !== "string") {
if ((this.providerName !== "codex" && this.providerName !== "opencode") || typeof prompt !== "string") {
return null;
}
const parsed = this.parseSlashCommandInput(prompt);
+11 -1
View File
@@ -178,6 +178,7 @@ type WebSocketLike = {
type SessionConnection = {
session: Session;
clientId: string;
appVersion: string | null;
connectionLogger: pino.Logger;
sockets: Set<WebSocketLike>;
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null;
@@ -618,13 +619,15 @@ export class VoiceAssistantWebSocketServer {
private createSessionConnection(params: {
ws: WebSocketLike;
clientId: string;
appVersion: string | null;
connectionLogger: pino.Logger;
}): SessionConnection {
const { ws, clientId, connectionLogger } = params;
const { ws, clientId, appVersion, connectionLogger } = params;
let connection: SessionConnection | null = null;
const session = new Session({
clientId,
appVersion,
onMessage: (msg) => {
if (!connection) {
return;
@@ -703,6 +706,7 @@ export class VoiceAssistantWebSocketServer {
connection = {
session,
clientId,
appVersion,
connectionLogger,
sockets: new Set([ws]),
externalDisconnectCleanupTimeout: null,
@@ -767,6 +771,11 @@ export class VoiceAssistantWebSocketServer {
clearTimeout(existing.externalDisconnectCleanupTimeout);
existing.externalDisconnectCleanupTimeout = null;
}
const newAppVersion = message.appVersion ?? null;
if (newAppVersion && newAppVersion !== existing.appVersion) {
existing.appVersion = newAppVersion;
existing.session.updateAppVersion(newAppVersion);
}
existing.sockets.add(ws);
this.sessions.set(ws, existing);
this.sendToClient(ws, this.createServerInfoMessage());
@@ -786,6 +795,7 @@ export class VoiceAssistantWebSocketServer {
const connection = this.createSessionConnection({
ws,
clientId,
appVersion: message.appVersion ?? null,
connectionLogger,
});
this.sessions.set(ws, connection);
@@ -528,7 +528,6 @@ export async function registerPendingWorktreeWorkspace(
if (!Number.isInteger(projectId)) {
throw new Error(`Invalid project id for repo root ${options.repoRoot}`);
}
const now = new Date().toISOString();
const existingWorkspace = await dependencies.findWorkspaceByDirectory(workspaceDirectory);
if (!existingWorkspace) {
@@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import {
AgentFeatureSchema,
AgentSnapshotPayloadSchema,
SetAgentFeatureRequestMessageSchema,
SetAgentFeatureResponseMessageSchema,
} from "./messages.js";
describe("agent feature schemas", () => {
it("parses valid toggle features", () => {
const parsed = AgentFeatureSchema.parse({
type: "toggle",
id: "fast_mode",
label: "Fast mode",
description: "Uses lower latency service tier",
tooltip: "Toggle fast mode",
icon: "bolt",
value: true,
});
expect(parsed.type).toBe("toggle");
if (parsed.type !== "toggle") {
throw new Error("Expected toggle feature");
}
expect(parsed.value).toBe(true);
});
it("parses valid select features", () => {
const parsed = AgentFeatureSchema.parse({
type: "select",
id: "service_tier",
label: "Service tier",
description: "Choose a processing tier",
tooltip: "Select service tier",
icon: "gauge",
value: "flex",
options: [
{ id: "default", label: "Default", isDefault: true },
{ id: "flex", label: "Flex" },
],
});
expect(parsed.type).toBe("select");
if (parsed.type !== "select") {
throw new Error("Expected select feature");
}
expect(parsed.options).toHaveLength(2);
expect(parsed.value).toBe("flex");
});
it("rejects invalid features", () => {
const invalidDiscriminator = AgentFeatureSchema.safeParse({
type: "slider",
id: "fast_mode",
label: "Fast mode",
value: true,
});
const missingToggleValue = AgentFeatureSchema.safeParse({
type: "toggle",
id: "fast_mode",
label: "Fast mode",
});
const missingSelectOptions = AgentFeatureSchema.safeParse({
type: "select",
id: "service_tier",
label: "Service tier",
value: null,
});
expect(invalidDiscriminator.success).toBe(false);
expect(missingToggleValue.success).toBe(false);
expect(missingSelectOptions.success).toBe(false);
});
it("parses valid requests", () => {
const parsed = SetAgentFeatureRequestMessageSchema.parse({
type: "set_agent_feature_request",
agentId: "agent-123",
featureId: "fast_mode",
value: true,
requestId: "req-123",
});
expect(parsed.featureId).toBe("fast_mode");
expect(parsed.value).toBe(true);
});
it("parses valid responses", () => {
const parsed = SetAgentFeatureResponseMessageSchema.parse({
type: "set_agent_feature_response",
payload: {
requestId: "req-123",
agentId: "agent-123",
accepted: true,
error: null,
},
});
expect(parsed.payload.accepted).toBe(true);
expect(parsed.payload.error).toBeNull();
});
it("accepts features on agent snapshot payloads", () => {
const parsed = AgentSnapshotPayloadSchema.parse({
id: "agent-123",
provider: "codex",
cwd: "/tmp/project",
model: "gpt-5",
features: [
{
type: "toggle",
id: "fast_mode",
label: "Fast mode",
tooltip: "Toggle fast mode",
value: false,
},
],
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: "2026-04-03T12:00:00.000Z",
updatedAt: "2026-04-03T12:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
labels: {},
});
expect(parsed.features).toHaveLength(1);
expect(parsed.features?.[0]?.id).toBe("fast_mode");
});
});
@@ -27,6 +27,9 @@ describe("list_commands_request schema", () => {
modeId: "bypassPermissions",
model: "gpt-5",
thinkingOptionId: "off",
featureValues: {
plan_mode: true,
},
},
requestId: "req-456",
});
@@ -41,6 +44,9 @@ describe("list_commands_request schema", () => {
modeId: "bypassPermissions",
model: "gpt-5",
thinkingOptionId: "off",
featureValues: {
plan_mode: true,
},
});
});
});
+152 -24
View File
@@ -77,6 +77,32 @@ const AgentSelectOptionSchema = z.object({
metadata: z.record(z.unknown()).optional(),
});
export const AgentFeatureToggleSchema = z.object({
type: z.literal("toggle"),
id: z.string(),
label: z.string(),
description: z.string().optional(),
tooltip: z.string().optional(),
icon: z.string().optional(),
value: z.boolean(),
});
export const AgentFeatureSelectSchema = z.object({
type: z.literal("select"),
id: z.string(),
label: z.string(),
description: z.string().optional(),
tooltip: z.string().optional(),
icon: z.string().optional(),
value: z.string().nullable(),
options: z.array(AgentSelectOptionSchema),
});
export const AgentFeatureSchema = z.discriminatedUnion("type", [
AgentFeatureToggleSchema,
AgentFeatureSelectSchema,
]);
const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
provider: AgentProviderSchema,
id: z.string(),
@@ -135,6 +161,7 @@ const AgentSessionConfigSchema = z.object({
modeId: z.string().optional(),
model: z.string().optional(),
thinkingOptionId: z.string().optional(),
featureValues: z.record(z.unknown()).optional(),
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
approvalPolicy: z.string().optional(),
sandboxMode: z.string().optional(),
@@ -293,6 +320,10 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUn
text: z.string().optional(),
icon: z.enum(TOOL_CALL_ICON_NAMES).optional(),
}),
z.object({
type: z.literal("plan"),
text: z.string(),
}),
z.object({
type: z.literal("unknown"),
input: UnknownValueSchema,
@@ -461,6 +492,7 @@ export const AgentSnapshotPayloadSchema = z.object({
provider: AgentProviderSchema,
cwd: z.string(),
model: z.string().nullable(),
features: z.array(AgentFeatureSchema).optional(),
thinkingOptionId: z.string().nullable().optional(),
effectiveThinkingOptionId: z.string().nullable().optional(),
createdAt: z.string(),
@@ -732,6 +764,13 @@ export const ListProviderModelsRequestMessageSchema = z.object({
requestId: z.string(),
});
export const ListProviderModesRequestMessageSchema = z.object({
type: z.literal("list_provider_modes_request"),
provider: AgentProviderSchema,
cwd: z.string().optional(),
requestId: z.string(),
});
export const ListAvailableProvidersRequestMessageSchema = z.object({
type: z.literal("list_available_providers_request"),
requestId: z.string(),
@@ -835,6 +874,24 @@ export const SetAgentThinkingResponseMessageSchema = z.object({
}),
});
export const SetAgentFeatureRequestMessageSchema = z.object({
type: z.literal("set_agent_feature_request"),
agentId: z.string(),
featureId: z.string(),
value: z.unknown(),
requestId: z.string(),
});
export const SetAgentFeatureResponseMessageSchema = z.object({
type: z.literal("set_agent_feature_response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
accepted: z.boolean(),
error: z.string().nullable(),
}),
});
export const UpdateAgentResponseMessageSchema = z.object({
type: z.literal("update_agent_response"),
payload: z.object({
@@ -1114,6 +1171,13 @@ const ListCommandsDraftConfigSchema = z.object({
modeId: z.string().optional(),
model: z.string().optional(),
thinkingOptionId: z.string().optional(),
featureValues: z.record(z.unknown()).optional(),
});
export const ListProviderFeaturesRequestMessageSchema = z.object({
type: z.literal("list_provider_features_request"),
draftConfig: ListCommandsDraftConfigSchema,
requestId: z.string(),
});
export const ListCommandsRequestSchema = z.object({
@@ -1222,6 +1286,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
DictationStreamCancelMessageSchema,
CreateAgentRequestMessageSchema,
ListProviderModelsRequestMessageSchema,
ListProviderModesRequestMessageSchema,
ListProviderFeaturesRequestMessageSchema,
ListAvailableProvidersRequestMessageSchema,
ResumeAgentRequestMessageSchema,
RefreshAgentRequestMessageSchema,
@@ -1232,6 +1298,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SetAgentModeRequestMessageSchema,
SetAgentModelRequestMessageSchema,
SetAgentThinkingRequestMessageSchema,
SetAgentFeatureRequestMessageSchema,
AgentPermissionResponseMessageSchema,
CheckoutStatusRequestSchema,
SubscribeCheckoutDiffRequestSchema,
@@ -1555,32 +1622,50 @@ export const ArtifactMessageSchema = z.object({
}),
});
export const ProjectCheckoutLiteNotGitPayloadSchema = z.object({
cwd: z.string(),
isGit: z.literal(false),
currentBranch: z.null(),
remoteUrl: z.null(),
isPaseoOwnedWorktree: z.literal(false),
mainRepoRoot: z.null(),
});
export const ProjectCheckoutLiteNotGitPayloadSchema = z
.object({
cwd: z.string(),
isGit: z.literal(false),
currentBranch: z.null(),
remoteUrl: z.null(),
worktreeRoot: z.null().optional(),
isPaseoOwnedWorktree: z.literal(false),
mainRepoRoot: z.null(),
})
.transform((value) => ({
...value,
worktreeRoot: null,
}));
export const ProjectCheckoutLiteGitNonPaseoPayloadSchema = z.object({
cwd: z.string(),
isGit: z.literal(true),
currentBranch: z.string().nullable(),
remoteUrl: z.string().nullable(),
isPaseoOwnedWorktree: z.literal(false),
mainRepoRoot: z.null(),
});
export const ProjectCheckoutLiteGitNonPaseoPayloadSchema = z
.object({
cwd: z.string(),
isGit: z.literal(true),
currentBranch: z.string().nullable(),
remoteUrl: z.string().nullable(),
worktreeRoot: z.string().optional(),
isPaseoOwnedWorktree: z.literal(false),
mainRepoRoot: z.null(),
})
.transform((value) => ({
...value,
worktreeRoot: value.worktreeRoot ?? value.cwd,
}));
export const ProjectCheckoutLiteGitPaseoPayloadSchema = z.object({
cwd: z.string(),
isGit: z.literal(true),
currentBranch: z.string().nullable(),
remoteUrl: z.string().nullable(),
isPaseoOwnedWorktree: z.literal(true),
mainRepoRoot: z.string(),
});
export const ProjectCheckoutLiteGitPaseoPayloadSchema = z
.object({
cwd: z.string(),
isGit: z.literal(true),
currentBranch: z.string().nullable(),
remoteUrl: z.string().nullable(),
worktreeRoot: z.string().optional(),
isPaseoOwnedWorktree: z.literal(true),
mainRepoRoot: z.string(),
})
.transform((value) => ({
...value,
worktreeRoot: value.worktreeRoot ?? value.cwd,
}));
export const ProjectCheckoutLitePayloadSchema = z.union([
ProjectCheckoutLiteNotGitPayloadSchema,
@@ -2161,6 +2246,28 @@ export const ListProviderModelsResponseMessageSchema = z.object({
}),
});
export const ListProviderModesResponseMessageSchema = z.object({
type: z.literal("list_provider_modes_response"),
payload: z.object({
provider: AgentProviderSchema,
modes: z.array(AgentModeSchema).optional(),
error: z.string().nullable().optional(),
fetchedAt: z.string(),
requestId: z.string(),
}),
});
export const ListProviderFeaturesResponseMessageSchema = z.object({
type: z.literal("list_provider_features_response"),
payload: z.object({
provider: AgentProviderSchema,
features: z.array(AgentFeatureSchema).optional(),
error: z.string().nullable().optional(),
fetchedAt: z.string(),
requestId: z.string(),
}),
});
const ProviderAvailabilitySchema = z.object({
provider: AgentProviderSchema,
available: z.boolean(),
@@ -2345,6 +2452,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
SetAgentModeResponseMessageSchema,
SetAgentModelResponseMessageSchema,
SetAgentThinkingResponseMessageSchema,
SetAgentFeatureResponseMessageSchema,
UpdateAgentResponseMessageSchema,
WaitForFinishResponseMessageSchema,
AgentPermissionRequestMessageSchema,
@@ -2371,6 +2479,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ProjectIconResponseSchema,
FileDownloadTokenResponseSchema,
ListProviderModelsResponseMessageSchema,
ListProviderModesResponseMessageSchema,
ListProviderFeaturesResponseMessageSchema,
ListAvailableProvidersResponseSchema,
ListCommandsResponseSchema,
ListTerminalsResponseSchema,
@@ -2439,6 +2549,10 @@ export type FetchAgentTimelineResponseMessage = z.infer<
>;
export type SendAgentMessageResponseMessage = z.infer<typeof SendAgentMessageResponseMessageSchema>;
export type SetVoiceModeResponseMessage = z.infer<typeof SetVoiceModeResponseMessageSchema>;
export type SetAgentModeResponseMessage = z.infer<typeof SetAgentModeResponseMessageSchema>;
export type SetAgentModelResponseMessage = z.infer<typeof SetAgentModelResponseMessageSchema>;
export type SetAgentThinkingResponseMessage = z.infer<typeof SetAgentThinkingResponseMessageSchema>;
export type SetAgentFeatureResponseMessage = z.infer<typeof SetAgentFeatureResponseMessageSchema>;
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
export type WaitForFinishResponseMessage = z.infer<typeof WaitForFinishResponseMessageSchema>;
export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionRequestMessageSchema>;
@@ -2447,6 +2561,12 @@ export type AgentDeletedMessage = z.infer<typeof AgentDeletedMessageSchema>;
export type ListProviderModelsResponseMessage = z.infer<
typeof ListProviderModelsResponseMessageSchema
>;
export type ListProviderModesResponseMessage = z.infer<
typeof ListProviderModesResponseMessageSchema
>;
export type ListProviderFeaturesResponseMessage = z.infer<
typeof ListProviderFeaturesResponseMessageSchema
>;
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
@@ -2486,6 +2606,12 @@ export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessage
export type ListProviderModelsRequestMessage = z.infer<
typeof ListProviderModelsRequestMessageSchema
>;
export type ListProviderModesRequestMessage = z.infer<
typeof ListProviderModesRequestMessageSchema
>;
export type ListProviderFeaturesRequestMessage = z.infer<
typeof ListProviderFeaturesRequestMessageSchema
>;
export type ListAvailableProvidersRequestMessage = z.infer<
typeof ListAvailableProvidersRequestMessageSchema
>;
@@ -2514,6 +2640,7 @@ export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessage
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
export type SetAgentFeatureRequestMessage = z.infer<typeof SetAgentFeatureRequestMessageSchema>;
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
@@ -2603,6 +2730,7 @@ export const WSHelloMessageSchema = z.object({
clientId: z.string().min(1),
clientType: z.enum(["mobile", "browser", "cli", "mcp"]),
protocolVersion: z.number().int(),
appVersion: z.string().optional(),
capabilities: z
.object({
voice: z.boolean().optional(),
@@ -179,4 +179,71 @@ describe("workspace message schemas", () => {
expect(parsed.type).toBe("workspace_setup_status_response");
});
test("parses legacy fetch_agents_response checkout payloads without worktreeRoot", () => {
const result = SessionOutboundMessageSchema.safeParse({
type: "fetch_agents_response",
payload: {
requestId: "req-1",
entries: [
{
agent: {
id: "agent-1",
provider: "codex",
cwd: "C:\\repo",
model: null,
features: [],
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: "2026-04-04T00:00:00.000Z",
updatedAt: "2026-04-04T00:00:00.000Z",
lastUserMessageAt: null,
status: "running",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Agent 1",
labels: {},
requiresAttention: false,
attentionReason: null,
},
project: {
projectKey: "remote:github.com/acme/repo",
projectName: "acme/repo",
checkout: {
cwd: "C:\\repo",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
},
],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
},
});
expect(result.success).toBe(true);
if (!result.success) {
return;
}
const checkout = result.data.payload.entries[0]?.project.checkout;
expect(checkout?.worktreeRoot).toBe("C:\\repo");
});
});
@@ -141,4 +141,20 @@ describe("shared tool-call display mapping", () => {
summary: "npm run test",
});
});
it("labels plan detail rows as Plan", () => {
const display = buildToolCallDisplayModel({
name: "plan",
status: "completed",
error: null,
detail: {
type: "plan",
text: "### Login Screen\n- Build layout",
},
});
expect(display).toEqual({
displayName: "Plan",
});
});
});
@@ -109,6 +109,10 @@ function buildCanonicalDetailDisplay(input: ToolCallDisplayInput): DetailDisplay
return {
summary: input.detail.label,
};
case "plan":
return {
displayName: "Plan",
};
case "unknown":
return {};
}
+24 -42
View File
@@ -12,7 +12,9 @@ import {
import { join } from "path";
import { tmpdir } from "os";
import {
__resetGhPathCacheForTests,
__resetPullRequestStatusCacheForTests,
__setGhPathForTests,
__setPullRequestStatusCacheTtlForTests,
commitAll,
getCheckoutDiff,
@@ -62,10 +64,12 @@ describe("checkout git utilities", () => {
tempDir = setup.tempDir;
repoDir = setup.repoDir;
paseoHome = join(tempDir, "paseo-home");
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
});
afterEach(() => {
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
rmSync(tempDir, { recursive: true, force: true });
});
@@ -147,6 +151,7 @@ const x = 1;
const status = await getCheckoutStatusLite(repoDir);
expect(status.isGit).toBe(true);
expect(status.currentBranch).toBe("main");
expect(status.worktreeRoot).toBe(repoDir);
expect(status.isPaseoOwnedWorktree).toBe(false);
expect(status.mainRepoRoot).toBeNull();
});
@@ -369,6 +374,7 @@ const x = 1;
const status = await getCheckoutStatusLite(result.worktreePath, { paseoHome });
expect(status.isGit).toBe(true);
expect(status.worktreeRoot).toBe(result.worktreePath);
expect(status.isPaseoOwnedWorktree).toBe(true);
expect(status.mainRepoRoot).toBe(repoDir);
});
@@ -582,18 +588,14 @@ const x = 1;
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
symlinkSync(gitPath, join(fakeBinDir, "git"));
const originalPath = process.env.PATH;
process.env.PATH = fakeBinDir;
try {
__setGhPathForTests(null);
const status = await getPullRequestStatus(repoDir);
expect(status.githubFeaturesEnabled).toBe(false);
expect(status.status).toBeNull();
} finally {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
}
});
@@ -627,9 +629,8 @@ const x = 1;
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
const originalPath = process.env.PATH;
process.env.PATH = `${fakeBinDir}:${originalPath ?? ""}`;
try {
__setGhPathForTests(join(fakeBinDir, "gh"));
const status = await getPullRequestStatus(repoDir);
expect(status.githubFeaturesEnabled).toBe(true);
expect(status.status).not.toBeNull();
@@ -639,11 +640,8 @@ const x = 1;
expect(status.status?.isMerged).toBe(true);
expect(status.status?.state).toBe("merged");
} finally {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
}
});
@@ -677,9 +675,8 @@ const x = 1;
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
const originalPath = process.env.PATH;
process.env.PATH = `${fakeBinDir}:${originalPath ?? ""}`;
try {
__setGhPathForTests(join(fakeBinDir, "gh"));
const status = await getPullRequestStatus(repoDir);
expect(status.githubFeaturesEnabled).toBe(true);
expect(status.status).not.toBeNull();
@@ -689,11 +686,8 @@ const x = 1;
expect(status.status?.isMerged).toBe(false);
expect(status.status?.state).toBe("closed");
} finally {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
}
});
@@ -732,20 +726,16 @@ const x = 1;
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
const originalPath = process.env.PATH;
process.env.PATH = `${fakeBinDir}:${originalPath ?? ""}`;
try {
__setGhPathForTests(join(fakeBinDir, "gh"));
const first = await getPullRequestStatus(repoDir);
const second = await getPullRequestStatus(repoDir);
expect(first).toEqual(second);
expect(first.status?.url).toContain("/pull/123");
expect(readFileSync(callCountPath, "utf8").trim()).toBe("1");
} finally {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
}
});
@@ -785,10 +775,9 @@ const x = 1;
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
const originalPath = process.env.PATH;
process.env.PATH = `${fakeBinDir}:${originalPath ?? ""}`;
__setPullRequestStatusCacheTtlForTests(50);
try {
__setGhPathForTests(join(fakeBinDir, "gh"));
const first = await getPullRequestStatus(repoDir);
await sleep(80);
const second = await getPullRequestStatus(repoDir);
@@ -796,11 +785,8 @@ const x = 1;
expect(second.status?.url).toContain("/pull/2");
expect(readFileSync(callCountPath, "utf8").trim()).toBe("2");
} finally {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
}
});
@@ -840,9 +826,8 @@ const x = 1;
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
const originalPath = process.env.PATH;
process.env.PATH = `${fakeBinDir}:${originalPath ?? ""}`;
try {
__setGhPathForTests(join(fakeBinDir, "gh"));
const [first, second] = await Promise.all([
getPullRequestStatus(repoDir),
getPullRequestStatus(repoDir),
@@ -850,11 +835,8 @@ const x = 1;
expect(first).toEqual(second);
expect(readFileSync(callCountPath, "utf8").trim()).toBe("1");
} finally {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
}
});
+30 -10
View File
@@ -6,6 +6,7 @@ import { open as openFile, stat as statFile } from "fs/promises";
import { TTLCache } from "@isaacs/ttlcache";
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
import { findExecutable, resolveShellEnv } from "./executable.js";
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js";
@@ -23,6 +24,7 @@ const PULL_REQUEST_STATUS_CACHE_MAX = 1_000;
let pullRequestStatusCacheTtlMs = DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS;
let pullRequestStatusCache = createPullRequestStatusCache(pullRequestStatusCacheTtlMs);
const pullRequestStatusInFlight = new Map<string, Promise<PullRequestStatusResult>>();
let cachedGhPath: string | null | undefined = undefined;
function createPullRequestStatusCache(ttlMs: number) {
return new TTLCache<string, PullRequestStatusResult>({
@@ -52,6 +54,14 @@ export function __setPullRequestStatusCacheTtlForTests(ttlMs: number): void {
pullRequestStatusInFlight.clear();
}
export function __resetGhPathCacheForTests(): void {
cachedGhPath = undefined;
}
export function __setGhPathForTests(path: string | null): void {
cachedGhPath = path;
}
async function execGit(
command: string,
options: { cwd: string; env?: NodeJS.ProcessEnv },
@@ -532,6 +542,7 @@ export type CheckoutStatusLiteNotGit = {
isGit: false;
currentBranch: null;
remoteUrl: null;
worktreeRoot: null;
isPaseoOwnedWorktree: false;
mainRepoRoot: null;
};
@@ -540,6 +551,7 @@ export type CheckoutStatusLiteGitNonPaseo = {
isGit: true;
currentBranch: string | null;
remoteUrl: string | null;
worktreeRoot: string;
isPaseoOwnedWorktree: false;
mainRepoRoot: null;
};
@@ -548,6 +560,7 @@ export type CheckoutStatusLiteGitPaseo = {
isGit: true;
currentBranch: string | null;
remoteUrl: string | null;
worktreeRoot: string;
isPaseoOwnedWorktree: true;
mainRepoRoot: string;
};
@@ -1149,6 +1162,7 @@ export async function getCheckoutStatusLite(
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
@@ -1159,6 +1173,7 @@ export async function getCheckoutStatusLite(
isGit: true,
currentBranch: inspected.currentBranch,
remoteUrl: inspected.remoteUrl,
worktreeRoot: inspected.worktreeRoot,
isPaseoOwnedWorktree: true,
mainRepoRoot: await getMainRepoRoot(cwd),
};
@@ -1168,6 +1183,7 @@ export async function getCheckoutStatusLite(
isGit: true,
currentBranch: inspected.currentBranch,
remoteUrl: inspected.remoteUrl,
worktreeRoot: inspected.worktreeRoot,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
@@ -1731,12 +1747,14 @@ export interface PullRequestStatusResult {
githubFeaturesEnabled: boolean;
}
async function ensureGhAvailable(cwd: string): Promise<void> {
try {
await execAsync("gh --version", { cwd });
} catch {
throw new Error("GitHub CLI (gh) is not available or not authenticated");
function resolveGhPath(): string {
if (cachedGhPath === undefined) {
cachedGhPath = findExecutable("gh");
}
if (cachedGhPath === null) {
throw new Error("GitHub CLI (gh) is not installed or not in PATH");
}
return cachedGhPath;
}
function getCommandErrorText(error: unknown): string {
@@ -1805,7 +1823,7 @@ export async function createPullRequest(
options: CreatePullRequestOptions,
): Promise<{ url: string; number: number }> {
await requireGitRepo(cwd);
await ensureGhAvailable(cwd);
const ghPath = resolveGhPath();
const repo = await resolveGitHubRepo(cwd);
if (!repo) {
throw new Error("Unable to determine GitHub repo from git remote");
@@ -1827,13 +1845,14 @@ export async function createPullRequest(
await execAsync(`git push -u origin ${head}`, { cwd });
const ghEnv = { ...resolveShellEnv(), GIT_TERMINAL_PROMPT: "0" };
const args = ["api", "-X", "POST", `repos/${repo}/pulls`, "-f", `title=${options.title}`];
args.push("-f", `head=${head}`);
args.push("-f", `base=${normalizedBase}`);
if (options.body) {
args.push("-f", `body=${options.body}`);
}
const { stdout } = await execFileAsync("gh", args, { cwd });
const { stdout } = await execFileAsync(ghPath, args, { cwd, env: ghEnv });
const parsed = JSON.parse(stdout.trim());
if (!parsed?.url || !parsed?.number) {
throw new Error("GitHub CLI did not return PR url/number");
@@ -1875,8 +1894,9 @@ async function getPullRequestStatusUncached(cwd: string): Promise<PullRequestSta
githubFeaturesEnabled: false,
};
}
let ghPath: string;
try {
await ensureGhAvailable(cwd);
ghPath = resolveGhPath();
} catch {
return {
status: null,
@@ -1885,14 +1905,14 @@ async function getPullRequestStatusUncached(cwd: string): Promise<PullRequestSta
}
try {
const { stdout } = await execFileAsync(
"gh",
ghPath,
[
"pr",
"view",
"--json",
"url,title,state,baseRefName,headRefName,mergedAt",
],
{ cwd },
{ cwd, env: { ...resolveShellEnv(), GIT_TERMINAL_PROMPT: "0" } },
);
const pr = JSON.parse(stdout.trim());
if (!pr || typeof pr !== "object" || !pr.url || !pr.title) {
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "./executable.js";
type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutable>[1]>;
function createFindExecutableDependencies(): FindExecutableDependencies {
return {
execFileSync: vi.fn(),
execSync: vi.fn(),
existsSync: vi.fn(),
platform: vi.fn(() => "darwin"),
shell: undefined,
};
}
let findExecutableDependencies: FindExecutableDependencies;
beforeEach(() => {
findExecutableDependencies = createFindExecutableDependencies();
});
describe("findExecutable", () => {
test("on Windows, resolves executables using current machine and user PATH entries", () => {
findExecutableDependencies.platform = vi.fn(() => "win32");
process.env.Path = "C:\\Windows\\System32";
findExecutableDependencies.execFileSync.mockImplementation(
((command: string, args?: string[]) => {
if (command === "powershell") {
return "C:\\Windows\\System32\r\nC:\\Users\\boudr\\.local\\bin\r\n";
}
if (command === "where.exe") {
return "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n";
}
throw new Error(`unexpected command ${command}`);
}) as any,
);
expect(findExecutable("claude", findExecutableDependencies)).toBe(
"C:\\Users\\boudr\\.local\\bin\\claude.exe",
);
const powershellCall = findExecutableDependencies.execFileSync.mock.calls[0];
expect(powershellCall?.[0]).toBe("powershell");
expect(powershellCall?.[1]).toContain("-NoProfile");
expect(powershellCall?.[1]).toContain("-NonInteractive");
expect(powershellCall?.[1]).toContain(
'$machine = [Environment]::GetEnvironmentVariable("Path", "Machine"); $user = [Environment]::GetEnvironmentVariable("Path", "User"); if ($machine) { Write-Output $machine }; if ($user) { Write-Output $user }',
);
const whereCall = findExecutableDependencies.execFileSync.mock.calls[1];
expect(whereCall?.[0]).toBe("where.exe");
expect(whereCall?.[1]).toEqual(["claude"]);
expect(whereCall?.[2]?.encoding).toBe("utf8");
const env = whereCall?.[2]?.env as Record<string, string | undefined>;
expect(env.PATH).toContain("C:\\Users\\boudr\\.local\\bin");
expect(env.Path).toContain("C:\\Users\\boudr\\.local\\bin");
});
test("on Windows, preserves the first where.exe match", () => {
findExecutableDependencies.platform = vi.fn(() => "win32");
process.env.Path = "C:\\Windows\\System32";
findExecutableDependencies.execFileSync.mockImplementation(
((command: string) => {
if (command === "powershell") {
return "C:\\Windows\\System32\r\nC:\\nvm4w\\nodejs\r\n";
}
if (command === "where.exe") {
return "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n";
}
throw new Error(`unexpected command ${command}`);
}) as any,
);
expect(findExecutable("codex", findExecutableDependencies)).toBe("C:\\nvm4w\\nodejs\\codex");
});
test("uses the last line from login-shell which output", () => {
findExecutableDependencies.shell = "/bin/zsh";
findExecutableDependencies.execSync.mockReturnValue(
"echo from profile\n/usr/local/bin/codex\n",
);
expect(findExecutable("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex");
expect(findExecutableDependencies.execSync).toHaveBeenCalledOnce();
expect(findExecutableDependencies.execFileSync).not.toHaveBeenCalled();
});
test("warns and returns null when the final which line is not an absolute path", () => {
findExecutableDependencies.shell = "/bin/zsh";
findExecutableDependencies.execSync.mockReturnValue("profile noise\ncodex\n");
findExecutableDependencies.execFileSync.mockReturnValue("codex\n");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(findExecutable("codex", findExecutableDependencies)).toBeNull();
expect(warnSpy).toHaveBeenCalledTimes(2);
warnSpy.mockRestore();
});
test("returns direct paths when they exist", () => {
findExecutableDependencies.existsSync.mockReturnValue(true);
expect(findExecutable("/usr/local/bin/codex", findExecutableDependencies)).toBe(
"/usr/local/bin/codex",
);
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex");
});
});
describe("quoteWindowsCommand", () => {
const originalPlatform = process.platform;
function setPlatform(value: string) {
Object.defineProperty(process, "platform", { value, writable: true });
}
afterEach(() => {
setPlatform(originalPlatform);
});
test("quotes a Windows path with spaces", () => {
setPlatform("win32");
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
'"C:\\Program Files\\Anthropic\\claude.exe"',
);
});
test("does not double-quote an already-quoted path", () => {
setPlatform("win32");
expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe(
'"C:\\Program Files\\Anthropic\\claude.exe"',
);
});
test("returns the command unchanged when there are no spaces", () => {
setPlatform("win32");
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
});
test("returns the command unchanged on non-Windows platforms", () => {
setPlatform("darwin");
expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
});
});
describe("quoteWindowsArgument", () => {
const originalPlatform = process.platform;
function setPlatform(value: string) {
Object.defineProperty(process, "platform", { value, writable: true });
}
afterEach(() => {
setPlatform(originalPlatform);
});
test("quotes a Windows argument with spaces", () => {
setPlatform("win32");
expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe(
'"C:\\Program Files\\Anthropic\\cli.js"',
);
});
test("does not double-quote an already-quoted argument", () => {
setPlatform("win32");
expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe(
'"C:\\Program Files\\Anthropic\\cli.js"',
);
});
test("returns the argument unchanged when there are no spaces", () => {
setPlatform("win32");
expect(quoteWindowsArgument("--version")).toBe("--version");
});
test("returns the argument unchanged on non-Windows platforms", () => {
setPlatform("darwin");
expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
});
});
+197
View File
@@ -0,0 +1,197 @@
import { execFileSync, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { platform } from "node:os";
import path from "node:path";
import { shellEnvSync } from "shell-env";
export interface FindExecutableDependencies {
execSync: typeof execSync;
execFileSync: typeof execFileSync;
existsSync: typeof existsSync;
platform: typeof platform;
shell: string | undefined;
}
function resolveWindowsPathEntries(deps: FindExecutableDependencies): string[] {
try {
const output = deps.execFileSync(
"powershell",
[
"-NoProfile",
"-NonInteractive",
"-Command",
[
'$machine = [Environment]::GetEnvironmentVariable("Path", "Machine")',
'$user = [Environment]::GetEnvironmentVariable("Path", "User")',
"if ($machine) { Write-Output $machine }",
"if ($user) { Write-Output $user }",
].join("; "),
],
{ encoding: "utf8" },
);
return output
.split(/\r?\n/)
.flatMap((line) => line.split(";"))
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
} catch {
return [];
}
}
function resolveExecutableFromWhichOutput(
name: string,
output: string,
source: "login-shell" | "which",
): string | null {
const lines = output
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
const candidate = lines.at(-1);
if (!candidate) {
return null;
}
if (!path.isAbsolute(candidate)) {
console.warn(
`[findExecutable] Ignoring non-absolute ${source} output for '${name}': ${JSON.stringify(candidate)}`,
);
return null;
}
return candidate;
}
/**
* On Unix we first try `$SHELL -lic "which <name>"` so that rc-file PATH
* additions (asdf, nvm, homebrew, nix, etc.) are visible — exactly as if the
* user opened a terminal and typed the command. If that fails (e.g. the login
* shell itself errors) we fall back to a plain `which`.
*
* On Windows we augment the daemon PATH with machine/user registry PATH values
* and return the first `where.exe` match. Launch-time execution decides whether
* the resolved path needs `cmd.exe` semantics (for example npm shims under
* nvm4w such as `C:\nvm4w\nodejs\codex`).
*/
export function findExecutable(
name: string,
dependencies?: FindExecutableDependencies,
): string | null {
const trimmed = name.trim();
if (!trimmed) {
return null;
}
const deps: FindExecutableDependencies = {
execSync,
execFileSync,
existsSync,
platform,
shell: process.env["SHELL"],
...dependencies,
};
if (trimmed.includes("/") || trimmed.includes("\\")) {
return deps.existsSync(trimmed) ? trimmed : null;
}
if (deps.platform() === "win32") {
try {
const inheritedPath = process.env["Path"] ?? process.env["PATH"] ?? "";
const resolvedPath = [
...inheritedPath.split(";"),
...resolveWindowsPathEntries(deps),
]
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.filter((entry, index, entries) => entries.indexOf(entry) === index)
.join(";");
const env = {
...process.env,
PATH: resolvedPath,
Path: resolvedPath,
};
const out = deps.execFileSync("where.exe", [trimmed], { encoding: "utf8", env }).trim();
return (
out
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0) ?? null
);
} catch {
return null;
}
}
// Unix: try the user's login shell so rc-file PATH entries are visible.
const shell = deps.shell;
if (shell) {
try {
const out = deps
.execSync(`${shell} -lic "which ${trimmed}"`, {
encoding: "utf8",
timeout: 5000,
})
.trim();
const resolved = resolveExecutableFromWhichOutput(trimmed, out, "login-shell");
if (resolved) {
return resolved;
}
} catch {
// Login shell failed (broken rc, etc.) — fall through to plain which.
}
}
try {
return resolveExecutableFromWhichOutput(
trimmed,
deps.execFileSync("which", [trimmed], { encoding: "utf8" }).trim(),
"which",
);
} catch {
return null;
}
}
export function isCommandAvailable(command: string): boolean {
return findExecutable(command) !== null;
}
/**
* When spawning with `shell: true` on Windows, the command is passed to
* `cmd.exe /d /s /c "command args"`. The `/s` strips outer quotes, so a
* command path with spaces (e.g. `C:\Program Files\...`) is split at the
* space. Wrapping it in quotes produces the correct `"C:\Program Files\..." args`.
*/
export function quoteWindowsCommand(command: string): string {
if (process.platform !== "win32") return command;
if (!command.includes(" ")) return command;
if (command.startsWith('"') && command.endsWith('"')) return command;
return `"${command}"`;
}
/**
* `spawn(..., { shell: true })` on Windows also passes argv through `cmd.exe`.
* Any argument containing spaces must be quoted or it will be split before the
* child process sees it.
*/
export function quoteWindowsArgument(argument: string): string {
if (process.platform !== "win32") return argument;
if (!argument.includes(" ")) return argument;
if (argument.startsWith('"') && argument.endsWith('"')) return argument;
return `"${argument}"`;
}
let cachedShellEnv: Record<string, string> | null = null;
export function resolveShellEnv(): Record<string, string> {
if (cachedShellEnv) return cachedShellEnv;
try {
cachedShellEnv = shellEnvSync();
} catch {
cachedShellEnv = { ...process.env } as Record<string, string>;
}
return cachedShellEnv;
}