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:
@@ -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,
|
||||
];
|
||||
}),
|
||||
|
||||
+1
-1
@@ -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");
|
||||
|
||||
|
||||
+48
-1
@@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user