fix(server): make Cursor CLI and other ACP custom providers reliable (#628)

* refactor(server): validate ACP mode/model selection and subscribe to live state

Match Zed's selection validity model: resolve the requested mode/model
against availableModes/configOptions before issuing the ACP RPC, log a
warning instead of throwing on unknown values, and adopt the
currentValue echoed back in setSessionConfigOption responses. Subscribe
to current_mode_update / config_option_update so AgentManager re-emits
state when the agent changes selection unprompted.

Also drops the Copilot Autopilot auto-approve shim to mirror Zed's pure
pass-through requestPermission path.

* refactor(server): emit ACP session config drift via namespaced events

Replace the parallel subscribeToSessionState callback channel with three
payload-bearing variants on the existing AgentStreamEvent union
(mode_changed, model_changed, thinking_option_changed). Manager updates
its record from the event payload and emits state — no callbacks back
into the session. Variants suppressed before websocket dispatch so the
wire schema is unchanged.
This commit is contained in:
Mohamed Boudra
2026-04-30 18:43:27 +08:00
committed by GitHub
parent f9e6a1c5ea
commit 4cada20e38
8 changed files with 2017 additions and 90 deletions
@@ -1343,6 +1343,82 @@ test("persists live mode, model, and thinking changes without an external snapsh
expect(persisted?.runtimeInfo?.model).toBe("gpt-5.4");
});
test("session config drift events update state through the stream channel", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-session-config-events-"));
let capturedSession: TestAgentSession | null = null;
class ConfigEventClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
capturedSession = new TestAgentSession(config);
return capturedSession;
}
}
const manager = new AgentManager({
clients: {
codex: new ConfigEventClient(),
},
logger,
idFactory: () => "00000000-0000-4000-8000-000000000133",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
modeId: "plan",
model: "gpt-5.2-codex",
thinkingOptionId: "low",
});
const streams: AgentStreamEvent[] = [];
manager.subscribe(
(event) => {
if (event.type === "agent_stream") {
streams.push(event.event);
}
},
{ agentId: snapshot.id, replayState: false },
);
capturedSession?.pushEvent({
type: "mode_changed",
provider: "codex",
currentModeId: "build",
availableModes: [
{ id: "plan", label: "Plan" },
{ id: "build", label: "Build" },
],
});
capturedSession?.pushEvent({
type: "model_changed",
provider: "codex",
runtimeInfo: {
provider: "codex",
sessionId: capturedSession.id,
model: "gpt-5.4",
modeId: "build",
thinkingOptionId: "low",
},
});
capturedSession?.pushEvent({
type: "thinking_option_changed",
provider: "codex",
thinkingOptionId: "high",
});
await manager.flush();
const agent = manager.getAgent(snapshot.id);
expect(agent?.currentModeId).toBe("build");
expect(agent?.availableModes).toEqual([
{ id: "plan", label: "Plan" },
{ id: "build", label: "Build" },
]);
expect(agent?.runtimeInfo).toMatchObject({
model: "gpt-5.4",
modeId: "build",
thinkingOptionId: "high",
});
expect(streams.map((event) => event.type)).toEqual([]);
});
test("setLabels merges and persists labels", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-labels-"));
const storagePath = join(workdir, "agents");
@@ -2519,6 +2519,37 @@ export class AgentManager {
agent.lastUsage = event.usage;
this.emitState(agent);
return undefined;
case "mode_changed":
agent.currentModeId = event.currentModeId;
agent.availableModes = event.availableModes;
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, modeId: event.currentModeId };
}
flags.shouldDispatchEvent = false;
this.emitState(agent);
return undefined;
case "model_changed":
agent.runtimeInfo = event.runtimeInfo;
if (!agent.persistence && event.runtimeInfo.sessionId) {
agent.persistence = attachPersistenceCwd(
{ provider: agent.provider, sessionId: event.runtimeInfo.sessionId },
agent.cwd,
);
}
agent.currentModeId = event.runtimeInfo.modeId ?? agent.currentModeId;
flags.shouldDispatchEvent = false;
this.emitState(agent);
return undefined;
case "thinking_option_changed":
if (agent.runtimeInfo) {
agent.runtimeInfo = {
...agent.runtimeInfo,
thinkingOptionId: event.thinkingOptionId,
};
}
flags.shouldDispatchEvent = false;
this.emitState(agent);
return undefined;
case "timeline":
return this.onStreamTimelineEvent({ agent, event, options, isForegroundEvent, flags });
case "turn_completed":
@@ -315,6 +315,18 @@ export type AgentStreamEvent =
| { type: "turn_started"; provider: AgentProvider; turnId?: string }
| { type: "turn_completed"; provider: AgentProvider; usage?: AgentUsage; turnId?: string }
| { type: "usage_updated"; provider: AgentProvider; usage: AgentUsage; turnId?: string }
| {
type: "mode_changed";
provider: AgentProvider;
currentModeId: string | null;
availableModes: AgentMode[];
}
| { type: "model_changed"; provider: AgentProvider; runtimeInfo: AgentRuntimeInfo }
| {
type: "thinking_option_changed";
provider: AgentProvider;
thinkingOptionId: string | null;
}
| {
type: "turn_failed";
provider: AgentProvider;
@@ -1,5 +1,11 @@
import { describe, expect, test, vi } from "vitest";
import type { PromptResponse, SessionConfigOption, SessionUpdate } from "@agentclientprotocol/sdk";
import type {
PermissionOption,
PromptResponse,
RequestPermissionRequest,
SessionConfigOption,
SessionUpdate,
} from "@agentclientprotocol/sdk";
import {
ACPAgentClient,
@@ -10,15 +16,19 @@ import {
deriveModelDefinitionsFromACP,
deriveModesFromACP,
mapACPUsage,
resolveACPModeSelection,
resolveACPModelSelection,
} from "./acp-agent.js";
import { transformPiModels } from "./pi-direct-agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
interface ACPSessionInternals {
sessionId: string | null;
connection: { prompt: (...args: unknown[]) => Promise<PromptResponse> };
activeForegroundTurnId: string | null;
translateSessionUpdate(update: SessionUpdate): unknown;
configOptions: SessionConfigOption[];
translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[];
}
interface ACPModelSelectionInternals {
@@ -28,11 +38,30 @@ interface ACPModelSelectionInternals {
sessionId: string;
configId: string;
value: string;
}) => Promise<void>;
}) => Promise<unknown>;
};
configOptions: SessionConfigOption[];
}
interface ACPConfiguredOverrideInternals {
sessionId: string | null;
connection: {
setSessionMode: (input: { sessionId: string; modeId: string }) => Promise<void>;
setSessionConfigOption: (input: {
sessionId: string;
configId: string;
value: string;
}) => Promise<unknown>;
unstable_setSessionModel?: (input: { sessionId: string; modelId: string }) => Promise<void>;
};
configOptions: SessionConfigOption[];
availableModes: Array<{ id: string; label: string; description?: string }>;
availableModels: Array<{ modelId: string; name: string; description?: string | null }> | null;
currentMode: string | null;
currentModel: string | null;
applyConfiguredOverrides(): Promise<void>;
}
function createSession(): ACPAgentSession {
return new ACPAgentSession(
{
@@ -56,9 +85,114 @@ function createSession(): ACPAgentSession {
);
}
test("ACP setModel forwards model ids that are absent from the advertised catalog", async () => {
const session = createSession();
const setSessionConfigOption = vi.fn(async () => undefined);
function createSessionWithConfig(
config: { provider?: string; modeId?: string | null; model?: string | null } = {},
logger: ReturnType<typeof createTestLogger> = createTestLogger(),
): ACPAgentSession {
return new ACPAgentSession(
{
provider: config.provider ?? "claude-acp",
cwd: "/tmp/paseo-acp-test",
modeId: config.modeId ?? undefined,
model: config.model ?? undefined,
},
{
provider: config.provider ?? "claude-acp",
logger,
defaultCommand: ["claude", "--acp"],
defaultModes: [],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
},
);
}
function selectConfigOption(
category: "mode" | "model" | "thought_level",
values: string[],
currentValue = values[0] ?? "",
): SessionConfigOption {
return {
id: `${category}-option`,
name: selectConfigOptionName(category),
category,
type: "select",
currentValue,
options: values.map((value) => ({ value, name: value })),
};
}
function selectConfigOptionName(category: "mode" | "model" | "thought_level"): string {
if (category === "mode") {
return "Mode";
}
if (category === "model") {
return "Model";
}
return "Thinking";
}
function prepareConfiguredOverrideSession(
session: ACPAgentSession,
options: {
currentMode?: string | null;
availableModes?: Array<{ id: string; label: string; description?: string }>;
currentModel?: string | null;
availableModels?: Array<{ modelId: string; name: string; description?: string | null }> | null;
configOptions?: SessionConfigOption[];
connection?: Partial<ACPConfiguredOverrideInternals["connection"]>;
} = {},
): {
internals: ACPConfiguredOverrideInternals;
setSessionMode: ReturnType<typeof vi.fn>;
unstableSetSessionModel: ReturnType<typeof vi.fn>;
setSessionConfigOption: ReturnType<typeof vi.fn>;
} {
const setSessionMode = vi.fn(async () => undefined);
const unstableSetSessionModel = vi.fn(async () => undefined);
const setSessionConfigOption = vi.fn(async () => ({
configOptions: options.configOptions ?? [],
}));
const internals = session as unknown as ACPConfiguredOverrideInternals;
internals.sessionId = "session-1";
internals.connection = {
setSessionMode,
setSessionConfigOption,
unstable_setSessionModel: unstableSetSessionModel,
...options.connection,
};
internals.availableModes = options.availableModes ?? [];
internals.availableModels = options.availableModels ?? null;
internals.configOptions = options.configOptions ?? [];
internals.currentMode = options.currentMode ?? null;
internals.currentModel = options.currentModel ?? null;
return { internals, setSessionMode, unstableSetSessionModel, setSessionConfigOption };
}
test("ACP setModel only uses config-option fallback when the matching select choice contains the model", async () => {
const logger = createTestLogger();
const childLogger = { warn: vi.fn() };
vi.spyOn(logger, "child").mockReturnValue(childLogger as unknown as typeof logger);
const session = createSessionWithConfig({}, logger);
const setSessionConfigOption = vi.fn(async () => ({
configOptions: [
{
id: "model-option",
name: "Model",
category: "model",
type: "select",
currentValue: "sonnet",
options: [{ value: "sonnet", name: "Sonnet" }],
},
],
}));
const internals = session as unknown as ACPModelSelectionInternals;
internals.sessionId = "session-1";
internals.connection = { setSessionConfigOption };
@@ -73,13 +207,22 @@ test("ACP setModel forwards model ids that are absent from the advertised catalo
},
];
await session.setModel("new-provider-model");
await session.setModel("sonnet");
expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: "session-1",
configId: "model-option",
value: "new-provider-model",
value: "sonnet",
});
setSessionConfigOption.mockClear();
await expect(session.setModel("new-provider-model")).resolves.toBeUndefined();
expect(childLogger.warn).toHaveBeenCalledWith(
"new-provider-model",
expect.stringContaining("is not a valid claude-acp model config option"),
);
expect(setSessionConfigOption).not.toHaveBeenCalled();
});
describe("createLoggedNdJsonStream", () => {
@@ -246,6 +389,370 @@ describe("deriveModesFromACP", () => {
});
});
describe("ACP selection validity helpers", () => {
test("classifies advertised ACP modes and select config option choices", () => {
const result = resolveACPModeSelection({
modeId: "plan",
availableModes: [
{ id: "default", label: "Always Ask" },
{ id: "plan", label: "Plan" },
],
configOptions: [
{
id: "mode",
name: "Mode",
category: "mode",
type: "select",
currentValue: "default",
options: [{ value: "default", name: "Always Ask" }],
},
],
});
expect(result).toMatchObject({
availableMode: { id: "plan", label: "Plan" },
configChoice: null,
hasAvailableModes: true,
});
expect(result.configOption?.id).toBe("mode");
});
test("classifies model select config option choices separately from advertised models", () => {
const result = resolveACPModelSelection({
modelId: "opus",
availableModels: [{ modelId: "sonnet", name: "Sonnet", description: null }],
configOptions: [
{
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: "sonnet",
options: [
{
group: "Anthropic",
options: [{ value: "opus", name: "Opus", description: "Deep" }],
},
],
},
],
});
expect(result).toMatchObject({
availableModel: null,
configChoice: {
value: "opus",
name: "Opus",
description: "Deep",
group: "Anthropic",
},
hasAvailableModels: true,
});
expect(result.configOption?.id).toBe("model");
});
});
describe("ACPAgentSession Zed parity", () => {
test("applies valid stored mode/model values, routes current_mode_update, and skips invalid Cursor-style stored values with warnings", async () => {
const validSession = createSessionWithConfig({ modeId: "plan", model: "sonnet" });
const valid = prepareConfiguredOverrideSession(validSession, {
currentMode: "default",
availableModes: [
{ id: "default", label: "Always Ask" },
{ id: "plan", label: "Plan" },
],
currentModel: "haiku",
availableModels: [
{ modelId: "haiku", name: "Haiku", description: null },
{ modelId: "sonnet", name: "Sonnet", description: null },
],
});
await valid.internals.applyConfiguredOverrides();
expect(valid.setSessionMode).toHaveBeenCalledWith({ sessionId: "session-1", modeId: "plan" });
expect(valid.unstableSetSessionModel).toHaveBeenCalledWith({
sessionId: "session-1",
modelId: "sonnet",
});
const modeEvents = (validSession as unknown as ACPSessionInternals).translateSessionUpdate({
sessionUpdate: "current_mode_update",
currentModeId: "default",
});
expect(modeEvents).toEqual([
{
type: "mode_changed",
provider: "claude-acp",
currentModeId: "default",
availableModes: [
{ id: "default", label: "Always Ask" },
{ id: "plan", label: "Plan" },
],
},
]);
expect(await validSession.getCurrentMode()).toBe("default");
const logger = createTestLogger();
const childLogger = { warn: vi.fn() };
vi.spyOn(logger, "child").mockReturnValue(childLogger as unknown as typeof logger);
const invalidSession = createSessionWithConfig(
{ modeId: "acceptEdits", model: "opus" },
logger,
);
const invalid = prepareConfiguredOverrideSession(invalidSession, {
currentMode: "default",
availableModes: [
{ id: "default", label: "Always Ask" },
{ id: "plan", label: "Plan" },
],
currentModel: "sonnet",
availableModels: [{ modelId: "sonnet", name: "Sonnet", description: null }],
});
await expect(invalid.internals.applyConfiguredOverrides()).resolves.toBeUndefined();
expect(invalid.setSessionMode).not.toHaveBeenCalled();
expect(invalid.unstableSetSessionModel).not.toHaveBeenCalled();
expect(childLogger.warn).toHaveBeenCalledWith(
expect.stringContaining("acceptEdits"),
expect.stringContaining("not valid"),
);
expect(childLogger.warn).toHaveBeenCalledWith(
expect.stringContaining("opus"),
expect.stringContaining("not a valid"),
);
});
test("does not use config-option fallback when Cursor-style availableModes omit the stored mode", async () => {
const session = createSessionWithConfig({ modeId: "acceptEdits" });
const { internals, setSessionConfigOption } = prepareConfiguredOverrideSession(session, {
currentMode: "default",
availableModes: [
{ id: "default", label: "Always Ask" },
{ id: "plan", label: "Plan" },
],
configOptions: [selectConfigOption("mode", ["default", "acceptEdits"], "default")],
connection: { unstable_setSessionModel: undefined },
});
await expect(internals.applyConfiguredOverrides()).resolves.toBeUndefined();
expect(setSessionConfigOption).not.toHaveBeenCalled();
});
test("routes config_option_update and refreshes derived mode, model, and thinking state", async () => {
const session = createSession();
const internals = session as unknown as ACPSessionInternals;
const events = internals.translateSessionUpdate({
sessionUpdate: "config_option_update",
configOptions: [
selectConfigOption("mode", ["default", "plan"], "plan"),
selectConfigOption("model", ["sonnet", "opus"], "opus"),
selectConfigOption("thought_level", ["low", "high"], "high"),
],
});
expect(events).toMatchObject([
{
type: "mode_changed",
provider: "claude-acp",
currentModeId: "plan",
availableModes: [
{ id: "default", label: "default" },
{ id: "plan", label: "plan" },
],
},
{
type: "model_changed",
provider: "claude-acp",
runtimeInfo: expect.objectContaining({
model: "opus",
thinkingOptionId: "high",
modeId: "plan",
}),
},
{
type: "thinking_option_changed",
provider: "claude-acp",
thinkingOptionId: "high",
},
]);
expect(internals.configOptions).toEqual([
selectConfigOption("mode", ["default", "plan"], "plan"),
selectConfigOption("model", ["sonnet", "opus"], "opus"),
selectConfigOption("thought_level", ["low", "high"], "high"),
]);
expect(await session.getAvailableModes()).toEqual([
{ id: "default", label: "default" },
{ id: "plan", label: "plan" },
]);
expect(await session.getCurrentMode()).toBe("plan");
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
model: "opus",
thinkingOptionId: "high",
modeId: "plan",
});
});
test("keeps pushed mode when a later config_option_update has no mode payload", async () => {
const session = createSession();
const internals = session as unknown as ACPSessionInternals;
internals.translateSessionUpdate({
sessionUpdate: "current_mode_update",
currentModeId: "plan",
});
const events = internals.translateSessionUpdate({
sessionUpdate: "config_option_update",
configOptions: [selectConfigOption("model", ["sonnet"], "sonnet")],
});
expect(events.map((event) => event.type)).toEqual(["model_changed"]);
expect(await session.getCurrentMode()).toBe("plan");
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
model: "sonnet",
modeId: "plan",
});
});
test("uses last writer when current_mode_update and config_option_update both include a mode", async () => {
const session = createSession();
const internals = session as unknown as ACPSessionInternals;
const configEvents = internals.translateSessionUpdate({
sessionUpdate: "config_option_update",
configOptions: [selectConfigOption("mode", ["default", "plan"], "plan")],
});
const modeEvents = internals.translateSessionUpdate({
sessionUpdate: "current_mode_update",
currentModeId: "default",
});
expect(configEvents).toMatchObject([{ type: "mode_changed", currentModeId: "plan" }]);
expect(modeEvents).toMatchObject([{ type: "mode_changed", currentModeId: "default" }]);
expect(await session.getCurrentMode()).toBe("default");
});
test("uses canonical mode returned by setSessionConfigOption response", async () => {
const session = createSession();
const internals = session as unknown as ACPModelSelectionInternals;
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
internals.sessionId = "session-1";
internals.configOptions = [selectConfigOption("mode", ["ask", "default"], "ask")];
internals.connection = {
setSessionConfigOption: vi.fn(async () => ({
configOptions: [selectConfigOption("mode", ["ask", "default"], "default")],
})),
};
await session.setMode("ask");
unsubscribe();
expect(await session.getCurrentMode()).toBe("default");
expect(events).toMatchObject([
{
type: "mode_changed",
provider: "claude-acp",
currentModeId: "default",
availableModes: [
{ id: "ask", label: "ask" },
{ id: "default", label: "default" },
],
},
]);
});
test("uses canonical model returned by setSessionConfigOption response", async () => {
const session = createSession();
const internals = session as unknown as ACPModelSelectionInternals;
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
internals.sessionId = "session-1";
internals.configOptions = [selectConfigOption("model", ["claude-sonnet", "sonnet"], "sonnet")];
internals.connection = {
setSessionConfigOption: vi.fn(async () => ({
configOptions: [selectConfigOption("model", ["claude-sonnet", "sonnet"], "sonnet")],
})),
};
await session.setModel("claude-sonnet");
unsubscribe();
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ model: "sonnet" });
expect(events).toContainEqual({
type: "model_changed",
provider: "claude-acp",
runtimeInfo: expect.objectContaining({ model: "sonnet" }),
});
});
test("uses canonical thinking option returned by setSessionConfigOption response", async () => {
const session = createSession();
const internals = session as unknown as ACPModelSelectionInternals;
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
internals.sessionId = "session-1";
internals.configOptions = [
selectConfigOption("thought_level", ["think-hard", "high"], "think-hard"),
];
internals.connection = {
setSessionConfigOption: vi.fn(async () => ({
configOptions: [selectConfigOption("thought_level", ["think-hard", "high"], "high")],
})),
};
await session.setThinkingOption("think-hard");
unsubscribe();
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: "high" });
expect(events).toContainEqual({
type: "thinking_option_changed",
provider: "claude-acp",
thinkingOptionId: "high",
});
});
test("passes Copilot Autopilot ACP permission requests through to the user", async () => {
// Zed parity: Copilot Autopilot no longer auto-approves ACP permission requests,
// so the accepted UX regression is that every tool request reaches the user UI.
const session = createSessionWithConfig({
provider: "copilot",
modeId: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
});
const events: Array<{ type: string; request?: { id: string } }> = [];
const permissionOptions: PermissionOption[] = [
{ optionId: "allow-once", name: "Allow", kind: "allow_once" },
{ optionId: "reject-once", name: "Reject", kind: "reject_once" },
];
(session as unknown as ACPSessionInternals).sessionId = "session-1";
session.subscribe((event) => {
events.push(event as { type: string; request?: { id: string } });
});
const permission = session.requestPermission({
sessionId: "session-1",
toolCall: {
toolCallId: "tool-1",
title: "Edit file",
kind: "edit",
status: "pending",
},
options: permissionOptions,
} satisfies RequestPermissionRequest);
await Promise.resolve();
const requested = events.find((event) => event.type === "permission_requested");
expect(requested?.request?.id).toEqual(expect.any(String));
await session.respondToPermission(requested!.request!.id, { behavior: "allow" });
await expect(permission).resolves.toEqual({
outcome: { outcome: "selected", optionId: "allow-once" },
});
});
});
describe("deriveModelDefinitionsFromACP", () => {
test("attaches shared thinking options to ACP model state", () => {
const result = deriveModelDefinitionsFromACP(
@@ -106,8 +106,6 @@ const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
terminal: true,
};
const COPILOT_AUTOPILOT_MODE = "https://agentclientprotocol.com/protocol/session-modes#autopilot";
// Suppress interactive auth side-effects (e.g. Gemini CLI opening a Google
// sign-in URL in the browser) when probing an ACP agent for models/modes.
// NO_BROWSER is honored by Gemini CLI; other ACP agents ignore it.
@@ -283,6 +281,29 @@ interface ConfigOptionSelector {
metadata?: AgentMetadata;
}
type SelectConfigOption = Extract<SessionConfigOption, { type: "select" }>;
interface SelectConfigChoice {
value: string;
name: string;
description?: string | null;
group?: string;
}
type AvailableACPModel = NonNullable<SessionModelState["availableModels"]>[number];
interface ACPModeSelection {
availableMode: AgentMode | null;
configOption: SelectConfigOption | null;
configChoice: SelectConfigChoice | null;
hasAvailableModes: boolean;
}
interface ACPModelSelection {
availableModel: AvailableACPModel | null;
configOption: SelectConfigOption | null;
configChoice: SelectConfigChoice | null;
hasAvailableModels: boolean;
}
export function mapACPUsage(usage: Usage | null | undefined): AgentUsage | undefined {
if (!usage) {
return undefined;
@@ -295,6 +316,42 @@ export function mapACPUsage(usage: Usage | null | undefined): AgentUsage | undef
};
}
export function resolveACPModeSelection({
modeId,
availableModes,
configOptions,
}: {
modeId: string;
availableModes: AgentMode[];
configOptions: SessionConfigOption[] | null | undefined;
}): ACPModeSelection {
const configOption = findSelectConfigOption({ configOptions, category: "mode" });
return {
availableMode: availableModes.find((mode) => mode.id === modeId) ?? null,
configOption,
configChoice: findSelectConfigChoice({ option: configOption, value: modeId }),
hasAvailableModes: availableModes.length > 0,
};
}
export function resolveACPModelSelection({
modelId,
availableModels,
configOptions,
}: {
modelId: string;
availableModels: AvailableACPModel[] | null | undefined;
configOptions: SessionConfigOption[] | null | undefined;
}): ACPModelSelection {
const configOption = findSelectConfigOption({ configOptions, category: "model" });
return {
availableModel: availableModels?.find((model) => model.modelId === modelId) ?? null,
configOption,
configChoice: findSelectConfigChoice({ option: configOption, value: modelId }),
hasAvailableModels: Boolean(availableModels?.length),
};
}
export function deriveModesFromACP(
fallbackModes: AgentMode[],
modeState?: { availableModes?: SessionMode[] | null; currentModeId?: string | null } | null,
@@ -311,10 +368,8 @@ export function deriveModesFromACP(
};
}
const modeOption = configOptions?.find(
(option) => option.type === "select" && option.category === "mode",
);
if (modeOption?.type === "select") {
const modeOption = findSelectConfigOption({ configOptions, category: "mode" });
if (modeOption) {
const flatOptions = flattenSelectOptions(modeOption.options);
return {
modes: flatOptions.map((option) => ({
@@ -701,6 +756,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private currentMode: string | null = null;
private availableModes: AgentMode[];
private currentModel: string | null = null;
private availableModels: AvailableACPModel[] | null = null;
private thinkingOptionId: string | null = null;
private currentTitle: string | null = null;
private lastActivityAt: string | null = null;
@@ -954,17 +1010,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
return {
provider: this.provider,
sessionId: this.sessionId,
model: this.currentModel,
thinkingOptionId: this.thinkingOptionId,
modeId: this.currentMode,
extra: {
title: this.currentTitle,
updatedAt: this.lastActivityAt,
},
};
return this.runtimeInfo();
}
async getAvailableModes(): Promise<AgentMode[]> {
@@ -1036,27 +1082,83 @@ export class ACPAgentSession implements AgentSession, ACPClient {
throw new Error("ACP session not initialized");
}
const modeExists = this.availableModes.some((mode) => mode.id === modeId);
if (!modeExists && this.availableModes.length > 0) {
throw new Error(`Unknown ${this.provider} mode '${modeId}'`);
const selection = resolveACPModeSelection({
modeId,
availableModes: this.availableModes,
configOptions: this.configOptions,
});
await this.setModeWithSelection({ modeId, selection });
}
// Mode/model selection updates stay after ACP RPC success; this intentionally diverges from Zed's optimistic rollback path (acp.rs:3080-3104).
private async setModeWithSelection({
modeId,
selection,
}: {
modeId: string;
selection: ACPModeSelection;
}): Promise<void> {
if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized");
}
if (this.availableModes.length > 0) {
if (selection.hasAvailableModes) {
if (!selection.availableMode) {
this.warnInvalidSelection(
modeId,
`is not valid ${this.provider} mode. Available options: ${this.availableModes
.map((mode) => mode.id)
.join(", ")}`,
);
return;
}
await this.connection.setSessionMode({ sessionId: this.sessionId, modeId });
this.currentMode = modeId;
this.pushEvent({
type: "mode_changed",
provider: this.provider,
currentModeId: this.currentMode,
availableModes: [...this.availableModes],
});
return;
}
const modeOption = this.getSelectConfigOption("mode");
const modeOption = selection.configOption;
if (!modeOption) {
throw new Error(`${this.provider} does not expose ACP mode switching`);
}
await this.connection.setSessionConfigOption({
if (!selection.configChoice) {
this.warnInvalidSelection(
modeId,
`is not valid ${this.provider} mode config option. Available options: ${flattenSelectOptions(
modeOption.options,
)
.map((option) => option.value)
.join(", ")}`,
);
return;
}
const response = await this.connection.setSessionConfigOption({
sessionId: this.sessionId,
configId: modeOption.id,
value: modeId,
});
this.currentMode = modeId;
this.currentMode = this.applyConfigOptionResponse({
response,
configId: modeOption.id,
category: "mode",
requestedValue: modeId,
label: "mode",
});
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
this.pushEvent({
type: "mode_changed",
provider: this.provider,
currentModeId: this.currentMode,
availableModes: [...this.availableModes],
});
}
async setModel(modelId: string | null): Promise<void> {
@@ -1068,29 +1170,90 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return;
}
if ("unstable_setSessionModel" in this.connection) {
const selection = resolveACPModelSelection({
modelId,
availableModels: this.availableModels,
configOptions: this.configOptions,
});
await this.setModelWithSelection({ modelId, selection });
}
private async setModelWithSelection({
modelId,
selection,
}: {
modelId: string;
selection: ACPModelSelection;
}): Promise<void> {
if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized");
}
if (selection.hasAvailableModels) {
if (!selection.availableModel) {
this.warnInvalidSelection(
modelId,
`is not a valid ${this.provider} model. Available options: ${this.availableModels
?.map((model) => model.modelId)
.join(", ")}`,
);
return;
}
if (typeof this.connection.unstable_setSessionModel !== "function") {
throw new Error(`${this.provider} does not expose ACP model selection`);
}
try {
await this.connection.unstable_setSessionModel({
sessionId: this.sessionId,
modelId,
});
this.currentModel = modelId;
this.pushEvent({
type: "model_changed",
provider: this.provider,
runtimeInfo: this.runtimeInfo(),
});
return;
} catch {
// Fall through to config option path.
}
}
const modelOption = this.getSelectConfigOption("model");
const modelOption = selection.configOption;
if (!modelOption) {
throw new Error(`${this.provider} does not expose ACP model selection`);
}
await this.connection.setSessionConfigOption({
if (!selection.configChoice) {
this.warnInvalidSelection(
modelId,
`is not a valid ${this.provider} model config option. Available options: ${flattenSelectOptions(
modelOption.options,
)
.map((option) => option.value)
.join(", ")}`,
);
return;
}
const response = await this.connection.setSessionConfigOption({
sessionId: this.sessionId,
configId: modelOption.id,
value: modelId,
});
this.currentModel = modelId;
this.currentModel = this.applyConfigOptionResponse({
response,
configId: modelOption.id,
category: "model",
requestedValue: modelId,
label: "model",
});
this.pushEvent({
type: "model_changed",
provider: this.provider,
runtimeInfo: this.runtimeInfo(),
});
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
@@ -1105,19 +1268,67 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (this.thinkingOptionWriter) {
await this.thinkingOptionWriter(this.connection, this.sessionId, thinkingOptionId);
this.thinkingOptionId = thinkingOptionId;
this.pushEvent({
type: "thinking_option_changed",
provider: this.provider,
thinkingOptionId: this.thinkingOptionId,
});
return;
}
const option = this.getSelectConfigOption("thought_level");
const option = findSelectConfigOption({
configOptions: this.configOptions,
category: "thought_level",
});
if (!option) {
throw new Error(`${this.provider} does not expose ACP thought-level selection`);
}
await this.connection.setSessionConfigOption({
const response = await this.connection.setSessionConfigOption({
sessionId: this.sessionId,
configId: option.id,
value: thinkingOptionId,
});
this.thinkingOptionId = thinkingOptionId;
this.thinkingOptionId = this.applyConfigOptionResponse({
response,
configId: option.id,
category: "thought_level",
requestedValue: thinkingOptionId,
label: "thought-level",
});
this.pushEvent({
type: "thinking_option_changed",
provider: this.provider,
thinkingOptionId: this.thinkingOptionId,
});
}
private applyConfigOptionResponse({
response,
configId,
category,
requestedValue,
label,
}: {
response: { configOptions: SessionConfigOption[] };
configId: string;
category: string;
requestedValue: string;
label: string;
}): string {
this.configOptions = response.configOptions;
const responseOption = findSelectConfigOption({
configOptions: response.configOptions,
category,
id: configId,
});
if (responseOption?.currentValue != null) {
return responseOption.currentValue;
}
this.logger.warn(
{ configId, value: requestedValue },
`ACP setSessionConfigOption response did not include the requested ${label} option currentValue; using requested value`,
);
return requestedValue;
}
getPendingPermissions(): AgentPermissionRequest[] {
@@ -1232,18 +1443,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
if (shouldAutoApprovePermissionRequest(this.provider, this.currentMode)) {
const selectedOption = selectPermissionOption(params.options, { behavior: "allow" });
return selectedOption
? {
outcome: {
outcome: "selected",
optionId: selectedOption.optionId,
},
}
: { outcome: { outcome: "cancelled" } };
}
// Match Zed acp.rs:3189-3220 — pure pass-through. Accepted UX regression: Copilot Autopilot will now prompt the user for every tool request.
const requestId = randomUUID();
let toolSnapshot =
this.toolCalls.get(params.toolCall.toolCallId) ??
@@ -1461,6 +1661,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.availableModes = modeInfo.modes;
this.currentMode = modeInfo.currentModeId ?? this.currentMode;
this.availableModels = transformed.models?.availableModels ?? null;
this.currentModel =
transformed.models?.currentModelId ?? deriveCurrentConfigValue(this.configOptions, "model");
this.thinkingOptionId =
@@ -1468,17 +1669,33 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
private async applyConfiguredOverrides(): Promise<void> {
if (this.config.modeId && this.config.modeId !== this.currentMode) {
await this.setMode(this.config.modeId);
const configuredModeId = this.config.modeId;
if (configuredModeId && configuredModeId !== this.currentMode) {
const selection = resolveACPModeSelection({
modeId: configuredModeId,
availableModes: this.availableModes,
configOptions: this.configOptions,
});
await this.setModeWithSelection({ modeId: configuredModeId, selection });
}
if (this.config.model && this.config.model !== this.currentModel) {
await this.setModel(this.config.model);
const configuredModelId = this.config.model;
if (configuredModelId && configuredModelId !== this.currentModel) {
const selection = resolveACPModelSelection({
modelId: configuredModelId,
availableModels: this.availableModels,
configOptions: this.configOptions,
});
await this.setModelWithSelection({ modelId: configuredModelId, selection });
}
if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) {
await this.setThinkingOption(this.config.thinkingOptionId);
}
}
private warnInvalidSelection(value: string, message: string): void {
(this.logger.warn as unknown as (value: string, message: string) => void)(value, message);
}
private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] {
switch (update.sessionUpdate) {
case "user_message_chunk": {
@@ -1516,10 +1733,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return [this.wrapTimeline(mapPlanToTimeline(update))];
case "current_mode_update":
this.handleCurrentModeUpdate(update);
return [];
return [
{
type: "mode_changed",
provider: this.provider,
currentModeId: this.currentMode,
availableModes: [...this.availableModes],
},
];
case "config_option_update":
this.handleConfigOptionUpdate(update);
return [];
return this.handleConfigOptionUpdate(update);
case "session_info_update":
this.handleSessionInfoUpdate(update);
return [];
@@ -1585,14 +1808,42 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.currentMode = update.currentModeId;
}
private handleConfigOptionUpdate(update: ConfigOptionUpdate): void {
private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] {
this.configOptions = update.configOptions;
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
const nextMode = modeInfo.currentModeId;
const nextModel = deriveCurrentConfigValue(this.configOptions, "model");
const nextThinkingOptionId = deriveCurrentConfigValue(this.configOptions, "thought_level");
this.availableModes = modeInfo.modes;
this.currentMode = modeInfo.currentModeId ?? this.currentMode;
this.currentModel = deriveCurrentConfigValue(this.configOptions, "model") ?? this.currentModel;
this.thinkingOptionId =
deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId;
this.currentMode = nextMode ?? this.currentMode;
this.currentModel = nextModel ?? this.currentModel;
this.thinkingOptionId = nextThinkingOptionId ?? this.thinkingOptionId;
const events: AgentStreamEvent[] = [];
if (nextMode !== null) {
events.push({
type: "mode_changed",
provider: this.provider,
currentModeId: this.currentMode,
availableModes: [...this.availableModes],
});
}
if (nextModel !== null) {
events.push({
type: "model_changed",
provider: this.provider,
runtimeInfo: this.runtimeInfo(),
});
}
if (nextThinkingOptionId !== null) {
events.push({
type: "thinking_option_changed",
provider: this.provider,
thinkingOptionId: this.thinkingOptionId,
});
}
return events;
}
private handleSessionInfoUpdate(update: SessionInfoUpdate): void {
@@ -1651,6 +1902,20 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
}
private runtimeInfo(): AgentRuntimeInfo {
return {
provider: this.provider,
sessionId: this.sessionId,
model: this.currentModel,
thinkingOptionId: this.thinkingOptionId,
modeId: this.currentMode,
extra: {
title: this.currentTitle,
updatedAt: this.lastActivityAt,
},
};
}
private finishTurn(
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
): void {
@@ -1698,16 +1963,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return parts.length > 0 ? parts.join(" | ") : undefined;
}
private getSelectConfigOption(
category: string,
): Extract<SessionConfigOption, { type: "select" }> | null {
const option = this.configOptions.find(
(entry): entry is Extract<SessionConfigOption, { type: "select" }> =>
entry.type === "select" && entry.category === category,
);
return option ?? null;
}
private getTerminalEntry(terminalId: string): TerminalEntry {
const entry = this.terminalEntries.get(terminalId);
if (!entry) {
@@ -1717,15 +1972,37 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
}
function flattenSelectOptions(
options: Extract<SessionConfigOption, { type: "select" }>["options"],
): Array<{ value: string; name: string; description?: string | null; group?: string }> {
const flattened: Array<{
value: string;
name: string;
description?: string | null;
group?: string;
}> = [];
function findSelectConfigOption({
configOptions,
category,
id,
}: {
configOptions: SessionConfigOption[] | null | undefined;
category: string;
id?: string;
}): SelectConfigOption | null {
const option = configOptions?.find(
(entry): entry is SelectConfigOption =>
entry.type === "select" && entry.category === category && (!id || entry.id === id),
);
return option ?? null;
}
function findSelectConfigChoice({
option,
value,
}: {
option: SelectConfigOption | null;
value: string;
}): SelectConfigChoice | null {
if (!option) {
return null;
}
return flattenSelectOptions(option.options).find((choice) => choice.value === value) ?? null;
}
function flattenSelectOptions(options: SelectConfigOption["options"]): SelectConfigChoice[] {
const flattened: SelectConfigChoice[] = [];
for (const option of options) {
if ("value" in option) {
flattened.push(option);
@@ -1742,10 +2019,7 @@ function deriveSelectorOptions(
configOptions: SessionConfigOption[] | null | undefined,
category: string,
): ConfigOptionSelector[] {
const option = configOptions?.find(
(entry): entry is Extract<SessionConfigOption, { type: "select" }> =>
entry.type === "select" && entry.category === category,
);
const option = findSelectConfigOption({ configOptions, category });
if (!option) {
return [];
}
@@ -2167,10 +2441,6 @@ function mapPermissionRequest(
};
}
function shouldAutoApprovePermissionRequest(provider: string, currentMode: string | null): boolean {
return provider === "copilot" && currentMode === COPILOT_AUTOPILOT_MODE;
}
function selectPermissionOption(
options: PermissionOption[],
response: AgentPermissionResponse,
@@ -0,0 +1,678 @@
import { ClientSideConnection } from "@agentclientprotocol/sdk";
import type {
PromptResponse,
SessionConfigOption,
SessionNotification,
SessionUpdate,
} from "@agentclientprotocol/sdk";
import type { Logger } from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import type {
AgentCapabilityFlags,
AgentStreamEvent,
AgentTimelineItem,
} from "../agent-sdk-types.js";
import { ACPAgentSession } from "./acp-agent.js";
const smokeSelection = new Set(
(process.env.ACP_SMOKE ?? "")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
);
const capabilities: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
interface SmokeLogger extends Logger {
warn: ReturnType<typeof vi.fn>;
}
interface WrapperSmokeConfig {
id: "claude-acp" | "codex-acp";
packageName: string;
version: string;
command: [string, ...string[]];
env?: Record<string, string>;
}
interface CapturedRpc {
direction: "out" | "in" | "error";
method: string;
payload: unknown;
}
interface CanonicalValueMapping {
configId: string;
category: string | null;
requested: string;
responseCurrentValue: string | null;
behavior: "echoed" | "canonicalized" | "missing-response-option";
}
interface SmokeCriterionReport {
outcome: "PASS" | "PRECONDITION-NOT-MET";
detail: string;
}
interface SmokeTrace {
wrapper: string;
package: string;
command: string[];
rpc: CapturedRpc[];
notifications: unknown[];
events: AgentStreamEvent[];
canonicalValueMappings: CanonicalValueMapping[];
criteria: {
toolSnapshot: SmokeCriterionReport | null;
permission: SmokeCriterionReport | null;
};
finalState: unknown;
notes: string[];
}
interface SessionInternals {
availableModels: Array<{ modelId: string; name?: string }> | null;
configOptions: SessionConfigOption[];
toolCalls: Map<string, unknown>;
}
const wrappers: WrapperSmokeConfig[] = [
{
id: "claude-acp",
packageName: "@agentclientprotocol/claude-agent-acp",
version: "0.31.4",
command: ["npx", "--yes", "@agentclientprotocol/claude-agent-acp@0.31.4"],
env: {
ANTHROPIC_API_KEY: "",
CLAUDE_CODE_EXECUTABLE: "/opt/homebrew/bin/claude",
},
},
{
id: "codex-acp",
packageName: "@zed-industries/codex-acp",
version: "0.12.0",
command: ["npx", "--yes", "@zed-industries/codex-acp@0.12.0"],
},
];
function shouldRunWrapper(id: string): boolean {
return smokeSelection.has(id) || smokeSelection.has("both");
}
function createSmokeLogger(): SmokeLogger {
const logger = {
child: vi.fn(() => logger),
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
};
return logger as unknown as SmokeLogger;
}
function createTrace(config: WrapperSmokeConfig): SmokeTrace {
return {
wrapper: config.id,
package: `${config.packageName}@${config.version}`,
command: config.command,
rpc: [],
notifications: [],
events: [],
canonicalValueMappings: [],
criteria: {
toolSnapshot: null,
permission: null,
},
finalState: null,
notes: [],
};
}
function formatError(error: unknown): string {
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
}
function summarizeSessionResponse(response: unknown): unknown {
const value = response as {
sessionId?: string;
modes?: unknown;
models?: unknown;
configOptions?: unknown;
};
return {
sessionId: value.sessionId,
modes: value.modes,
models: value.models,
configOptions: value.configOptions,
};
}
function summarizeUpdate(update: SessionUpdate): unknown {
if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") {
return {
sessionUpdate: update.sessionUpdate,
toolCallId: update.toolCallId,
status: "status" in update ? update.status : undefined,
title: "title" in update ? update.title : undefined,
kind: "kind" in update ? update.kind : undefined,
};
}
return update;
}
function captureCanonicalMapping({
trace,
configId,
requested,
response,
}: {
trace: SmokeTrace;
configId: string;
requested: string;
response: { configOptions: SessionConfigOption[] };
}): void {
const responseOption = response.configOptions.find(
(option): option is Extract<SessionConfigOption, { type: "select" }> =>
option.type === "select" && option.id === configId,
);
const responseCurrentValue = responseOption?.currentValue ?? null;
let behavior: CanonicalValueMapping["behavior"] = "missing-response-option";
if (responseCurrentValue) {
behavior = responseCurrentValue === requested ? "echoed" : "canonicalized";
}
trace.canonicalValueMappings.push({
configId,
category: responseOption?.category ?? null,
requested,
responseCurrentValue,
behavior,
});
}
function summarizeEvents(events: AgentStreamEvent[]): unknown[] {
return events.map((event) => {
if (event.type !== "timeline") {
return event;
}
const item = event.item;
if (item.type === "tool_call") {
return {
...event,
item: {
type: item.type,
id: item.id,
title: item.title,
status: item.status,
kind: item.kind,
},
};
}
if (item.type === "assistant_message" || item.type === "reasoning") {
return {
...event,
item: { type: item.type, text: item.text.slice(0, 400) },
};
}
return event;
});
}
function installWireCapture(trace: SmokeTrace): void {
const originalNewSession = ClientSideConnection.prototype.newSession;
vi.spyOn(ClientSideConnection.prototype, "newSession").mockImplementation(
async function (params) {
trace.rpc.push({ direction: "out", method: "session/new", payload: params });
try {
const response = await originalNewSession.call(this, params);
trace.rpc.push({
direction: "in",
method: "session/new",
payload: summarizeSessionResponse(response),
});
return response;
} catch (error) {
trace.rpc.push({ direction: "error", method: "session/new", payload: formatError(error) });
throw error;
}
},
);
const originalSetMode = ClientSideConnection.prototype.setSessionMode;
vi.spyOn(ClientSideConnection.prototype, "setSessionMode").mockImplementation(
async function (params) {
trace.rpc.push({ direction: "out", method: "session/setMode", payload: params });
try {
const response = await originalSetMode.call(this, params);
trace.rpc.push({ direction: "in", method: "session/setMode", payload: response });
return response;
} catch (error) {
trace.rpc.push({
direction: "error",
method: "session/setMode",
payload: formatError(error),
});
throw error;
}
},
);
const originalSetModel = ClientSideConnection.prototype.unstable_setSessionModel;
vi.spyOn(ClientSideConnection.prototype, "unstable_setSessionModel").mockImplementation(
async function (params) {
trace.rpc.push({ direction: "out", method: "session/setModel", payload: params });
try {
const response = await originalSetModel.call(this, params);
trace.rpc.push({ direction: "in", method: "session/setModel", payload: response });
return response;
} catch (error) {
trace.rpc.push({
direction: "error",
method: "session/setModel",
payload: formatError(error),
});
throw error;
}
},
);
const originalSetConfig = ClientSideConnection.prototype.setSessionConfigOption;
vi.spyOn(ClientSideConnection.prototype, "setSessionConfigOption").mockImplementation(
async function (params) {
trace.rpc.push({ direction: "out", method: "session/setConfigOption", payload: params });
try {
const response = await originalSetConfig.call(this, params);
trace.rpc.push({ direction: "in", method: "session/setConfigOption", payload: response });
captureCanonicalMapping({
trace,
configId: params.configId,
requested: params.value,
response,
});
return response;
} catch (error) {
trace.rpc.push({
direction: "error",
method: "session/setConfigOption",
payload: formatError(error),
});
throw error;
}
},
);
const originalPrompt = ClientSideConnection.prototype.prompt;
vi.spyOn(ClientSideConnection.prototype, "prompt").mockImplementation(async function (params) {
trace.rpc.push({ direction: "out", method: "session/prompt", payload: params });
try {
const response: PromptResponse = await originalPrompt.call(this, params);
trace.rpc.push({ direction: "in", method: "session/prompt", payload: response });
return response;
} catch (error) {
trace.rpc.push({ direction: "error", method: "session/prompt", payload: formatError(error) });
throw error;
}
});
const originalSessionUpdate = ACPAgentSession.prototype.sessionUpdate;
vi.spyOn(ACPAgentSession.prototype, "sessionUpdate").mockImplementation(async function (
params: SessionNotification,
) {
trace.notifications.push({
sessionId: params.sessionId,
update: summarizeUpdate(params.update),
});
return originalSessionUpdate.call(this, params);
});
}
function createSession(config: WrapperSmokeConfig, logger: Logger): ACPAgentSession {
return new ACPAgentSession(
{
provider: config.id,
cwd: process.cwd(),
},
{
provider: config.id,
logger,
defaultCommand: config.command,
defaultModes: [],
capabilities,
launchEnv: config.env,
},
);
}
async function bootSession(
config: WrapperSmokeConfig,
trace: SmokeTrace,
): Promise<ACPAgentSession> {
const logger = createSmokeLogger();
const session = createSession(config, logger);
session.subscribe((event) => trace.events.push(event));
await session.initializeNewSession();
await captureFinalState(session, trace);
return session;
}
async function captureFinalState(session: ACPAgentSession, trace: SmokeTrace): Promise<void> {
const internals = session as unknown as SessionInternals;
trace.finalState = {
runtime: await session.getRuntimeInfo(),
availableModes: await session.getAvailableModes(),
availableModels: internals.availableModels,
configOptions: internals.configOptions,
toolSnapshotCount: internals.toolCalls.size,
};
}
function getModelIds(session: ACPAgentSession): string[] {
const internals = session as unknown as SessionInternals;
return internals.availableModels?.map((model) => model.modelId) ?? [];
}
function getSelectOption(
session: ACPAgentSession,
category: string,
): Extract<SessionConfigOption, { type: "select" }> | null {
const internals = session as unknown as SessionInternals;
return (
internals.configOptions.find(
(option): option is Extract<SessionConfigOption, { type: "select" }> =>
option.type === "select" && option.category === category,
) ?? null
);
}
function flattenSelectValues(option: Extract<SessionConfigOption, { type: "select" }>): string[] {
const values: string[] = [];
for (const choice of option.options) {
if ("value" in choice) {
values.push(choice.value);
continue;
}
for (const nested of choice.options) {
values.push(nested.value);
}
}
return values;
}
function firstToolTimelineItem(events: AgentStreamEvent[]): AgentTimelineItem | null {
const match = events.find(
(event) => event.type === "timeline" && event.item.type === "tool_call",
);
return match?.type === "timeline" ? match.item : null;
}
function emitEvidence(label: string, trace: SmokeTrace): void {
console.log(
`ACP_WRAPPER_SMOKE_EVIDENCE ${label} ${JSON.stringify(
{
...trace,
events: summarizeEvents(trace.events),
},
null,
2,
)}`,
);
}
async function runToolPrompt(
session: ACPAgentSession,
trace: SmokeTrace,
): Promise<{ permissionCount: number; resultText: string }> {
const permissionRequests: string[] = [];
const unsubscribe = session.subscribe((event) => {
if (event.type !== "permission_requested") {
return;
}
permissionRequests.push(event.request.id);
void session.respondToPermission(event.request.id, { behavior: "allow" });
});
try {
const result = await session.run(
"Use your file-reading tool to read package.json in the current working directory, then reply with only the JSON package name.",
);
await captureFinalState(session, trace);
return { permissionCount: permissionRequests.length, resultText: result.finalText };
} finally {
unsubscribe();
}
}
afterEach(() => {
vi.restoreAllMocks();
});
for (const config of wrappers) {
const wrapperDescribe = shouldRunWrapper(config.id) ? describe.sequential : describe.skip;
wrapperDescribe(`real ${config.id} ACP wrapper smoke`, () => {
let toolPromptTraceForEvidence: SmokeTrace | null = null;
test("(a) session/new exposes modes/models and applySessionState derives runtime state", async () => {
const trace = createTrace(config);
installWireCapture(trace);
let session: ACPAgentSession | null = null;
try {
session = await bootSession(config, trace);
const modes = await session.getAvailableModes();
const modelIds = getModelIds(session);
const modelOption = getSelectOption(session, "model");
expect(modes.length).toBeGreaterThan(0);
expect(
modelIds.length + (modelOption ? flattenSelectValues(modelOption).length : 0),
).toBeGreaterThan(0);
expect((await session.getRuntimeInfo()).sessionId).toBeTruthy();
expect(
trace.rpc.some((entry) => entry.method === "session/new" && entry.direction === "in"),
).toBe(true);
} finally {
await captureFinalStateIfOpen(session, trace);
emitEvidence(`${config.id}:a`, trace);
await session?.close();
}
}, 120_000);
test("(b) valid setMode round-trip applies via RPC success and stays unchanged on RPC error", async () => {
const trace = createTrace(config);
installWireCapture(trace);
let session: ACPAgentSession | null = null;
let originalSessionId: string | null = null;
try {
session = await bootSession(config, trace);
const currentMode = await session.getCurrentMode();
const mode =
(await session.getAvailableModes()).find((entry) => entry.id !== currentMode) ??
(await session.getAvailableModes())[0];
expect(mode).toBeDefined();
await session.setMode(mode.id);
await captureFinalState(session, trace);
expect(await session.getCurrentMode()).toBe(mode.id);
expect(trace.rpc).toContainEqual(
expect.objectContaining({
direction: "out",
method: expect.stringMatching(/session\/(setMode|setConfigOption)/),
payload: expect.objectContaining({ sessionId: session.id }),
}),
);
const modeAfterSuccess = await session.getCurrentMode();
originalSessionId = session.id;
(session as unknown as { sessionId: string }).sessionId = `${originalSessionId}-missing`;
await expect(session.setMode(mode.id)).rejects.toThrow();
expect(await session.getCurrentMode()).toBe(modeAfterSuccess);
trace.notes.push(
"RPC-error check used the real wrapper with a deliberately unknown sessionId; local currentMode stayed unchanged after rejection.",
);
} finally {
if (session && originalSessionId) {
(session as unknown as { sessionId: string }).sessionId = originalSessionId;
}
await captureFinalStateIfOpen(session, trace);
emitEvidence(`${config.id}:b`, trace);
await session?.close();
}
}, 120_000);
test("(c) valid setModel round-trip applies", async () => {
const trace = createTrace(config);
installWireCapture(trace);
let session: ACPAgentSession | null = null;
try {
session = await bootSession(config, trace);
const modelId = getModelIds(session)[0] ?? firstSelectValue(session, "model");
expect(modelId).toBeTruthy();
await session.setModel(modelId);
await captureFinalState(session, trace);
const modelMapping = trace.canonicalValueMappings.find(
(mapping) => mapping.category === "model" && mapping.requested === modelId,
);
expect((await session.getRuntimeInfo()).model).toBe(
modelMapping?.responseCurrentValue ?? modelId,
);
expect(
trace.rpc.some(
(entry) =>
entry.direction === "out" &&
(entry.method === "session/setModel" || entry.method === "session/setConfigOption") &&
JSON.stringify(entry.payload).includes(modelId),
),
).toBe(true);
} finally {
await captureFinalStateIfOpen(session, trace);
emitEvidence(`${config.id}:c`, trace);
await session?.close();
}
}, 120_000);
test("(d) thinking-mode setSessionConfigOption round-trip updates thinkingOptionId when supported", async () => {
const trace = createTrace(config);
installWireCapture(trace);
let session: ACPAgentSession | null = null;
try {
session = await bootSession(config, trace);
const thinkingOptionId = firstSelectValue(session, "thought_level");
if (!thinkingOptionId) {
trace.notes.push(
"PRECONDITION-NOT-MET: wrapper did not expose thought_level select config option.",
);
return;
}
await session.setThinkingOption(thinkingOptionId);
await captureFinalState(session, trace);
const thinkingMapping = trace.canonicalValueMappings.find(
(mapping) =>
mapping.category === "thought_level" && mapping.requested === thinkingOptionId,
);
expect((await session.getRuntimeInfo()).thinkingOptionId).toBe(
thinkingMapping?.responseCurrentValue ?? thinkingOptionId,
);
expect(
trace.rpc.some(
(entry) =>
entry.direction === "out" &&
entry.method === "session/setConfigOption" &&
JSON.stringify(entry.payload).includes(thinkingOptionId),
),
).toBe(true);
} finally {
await captureFinalStateIfOpen(session, trace);
emitEvidence(`${config.id}:d`, trace);
await session?.close();
}
}, 120_000);
test("(e) real assistant tool call becomes a Paseo tool snapshot and observes permission flow when emitted", async () => {
const trace = createTrace(config);
installWireCapture(trace);
let session: ACPAgentSession | null = null;
try {
session = await bootSession(config, trace);
const result = await runToolPrompt(session, trace);
const toolItem = firstToolTimelineItem(trace.events);
expect(toolItem).toBeTruthy();
expect(
trace.notifications.some((entry) => JSON.stringify(entry).includes("tool_call")),
).toBe(true);
expect(
trace.notifications.some((entry) => JSON.stringify(entry).includes("tool_call_update")),
).toBe(true);
expect((session as unknown as SessionInternals).toolCalls.size).toBeGreaterThan(0);
trace.criteria.toolSnapshot = {
outcome: "PASS",
detail:
"Read-file prompt produced ACP tool_call/tool_call_update notifications and Paseo tool snapshots.",
};
if (result.permissionCount === 0) {
trace.criteria.permission = {
outcome: "PRECONDITION-NOT-MET",
detail: "Wrapper emitted no requestPermission during read-file smoke.",
};
trace.notes.push(
"PRECONDITION-NOT-MET: wrapper emitted no requestPermission during read-file smoke.",
);
} else {
expect(trace.events.some((event) => event.type === "permission_requested")).toBe(true);
expect(trace.events.some((event) => event.type === "permission_resolved")).toBe(true);
expect(session.getPendingPermissions()).toHaveLength(0);
trace.criteria.permission = {
outcome: "PASS",
detail:
"Permission requests surfaced as AgentStreamEvents and the user response resolved them.",
};
}
} finally {
await captureFinalStateIfOpen(session, trace);
toolPromptTraceForEvidence = trace;
emitEvidence(`${config.id}:e`, trace);
await session?.close();
}
}, 180_000);
test("(f) permission criterion reports the outcome observed during the tool-call smoke", () => {
expect(toolPromptTraceForEvidence?.criteria.permission).toBeTruthy();
emitEvidence(`${config.id}:f`, toolPromptTraceForEvidence ?? createTrace(config));
});
});
}
function firstSelectValue(session: ACPAgentSession, category: string): string | null {
const option = getSelectOption(session, category);
return option ? (flattenSelectValues(option)[0] ?? null) : null;
}
async function captureFinalStateIfOpen(
session: ACPAgentSession | null,
trace: SmokeTrace,
): Promise<void> {
if (!session?.id) {
return;
}
await captureFinalState(session, trace);
}
@@ -0,0 +1,330 @@
import { ClientSideConnection } from "@agentclientprotocol/sdk";
import type { Logger } from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { AgentCapabilityFlags, AgentStreamEvent } from "../agent-sdk-types.js";
import { ACPAgentSession } from "./acp-agent.js";
const runCursorACPSmoke = process.env.CURSOR_ACP_SMOKE === "1" ? describe : describe.skip;
const cursorClaudeModel = "claude-opus-4-7[thinking=true,thinking_budget=20000]";
const issue560ClaudeModel = "claude-opus-4-7[thinking=true,reasoning_effort=xhigh]";
const provider = "cursor-acp";
const capabilities: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
interface SmokeLogger extends Logger {
warn: ReturnType<typeof vi.fn>;
}
interface SmokeEvidence {
availableModes: unknown;
warnings: unknown[];
initialCurrentMode: string | null;
acceptEditsRpcSent: boolean;
reconnectCurrentMode: string | null;
reconnectWarnings: unknown[];
requestPermissionCalls: number;
error: string | null;
permissionScope: string;
}
interface Issue560Evidence {
sessionNewSucceeded: boolean;
availableModes: unknown;
warnings: unknown[];
setModeRpc: Array<{ modeId: string; direction: "out" | "in" | "error"; payload: unknown }>;
setConfigRpc: Array<{ configId: string; value: string; direction: "out" | "in" | "error" }>;
setModelRpc: Array<{ modelId: string; direction: "out" | "in" | "error" }>;
acceptEditsConfigRpcSent: boolean;
bracketedModelConfigRpcSent: boolean;
planCurrentMode: string | null;
defaultCurrentMode: string | null;
userFacingErrors: unknown[];
finalVerdict: "PASS" | "FAIL";
failure: string | null;
}
function createSmokeLogger(): SmokeLogger {
const logger = {
child: vi.fn(() => logger),
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
};
return logger as unknown as SmokeLogger;
}
function createCursorSessionWithPersistedClaudeValues({
logger,
model,
}: {
logger: Logger;
model: string;
}): ACPAgentSession {
return new ACPAgentSession(
{
provider,
cwd: process.cwd(),
modeId: "acceptEdits",
model,
},
{
provider,
logger,
defaultCommand: ["npx", "--yes", "cursor-acp@0.1.0"],
defaultModes: [],
capabilities,
},
);
}
function formatError(error: unknown): string {
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
}
afterEach(() => {
vi.restoreAllMocks();
});
runCursorACPSmoke("real cursor-acp@0.1.0 smoke", () => {
test("reconnect skips invalid persisted values again on a fresh session", async () => {
const setModeSpy = vi.spyOn(ClientSideConnection.prototype, "setSessionMode");
const setModelSpy = vi.spyOn(ClientSideConnection.prototype, "unstable_setSessionModel");
const requestPermissionSpy = vi.spyOn(ACPAgentSession.prototype, "requestPermission");
const logger = createSmokeLogger();
const reconnectLogger = createSmokeLogger();
let session: ACPAgentSession | null = null;
let reconnectedSession: ACPAgentSession | null = null;
const evidence: SmokeEvidence = {
availableModes: null,
warnings: [],
initialCurrentMode: null,
acceptEditsRpcSent: false,
reconnectCurrentMode: null,
reconnectWarnings: [],
requestPermissionCalls: 0,
error: null,
permissionScope:
"cursor-acp@0.1.0 emits no requestPermission calls; Cursor's hidden TUI permission UI is out of scope for this wrapper smoke.",
};
try {
session = createCursorSessionWithPersistedClaudeValues({ logger, model: cursorClaudeModel });
await session.initializeNewSession();
evidence.availableModes = await session.getAvailableModes();
evidence.warnings = logger.warn.mock.calls;
evidence.initialCurrentMode = await session.getCurrentMode();
evidence.acceptEditsRpcSent = setModeSpy.mock.calls.some(
([params]) => params.modeId === "acceptEdits",
);
await session.close();
session = null;
reconnectedSession = createCursorSessionWithPersistedClaudeValues({
logger: reconnectLogger,
model: cursorClaudeModel,
});
await reconnectedSession.initializeNewSession();
evidence.reconnectCurrentMode = await reconnectedSession.getCurrentMode();
evidence.reconnectWarnings = reconnectLogger.warn.mock.calls;
evidence.requestPermissionCalls = requestPermissionSpy.mock.calls.length;
expect((evidence.availableModes as Array<{ id: string }>).map((mode) => mode.id)).toEqual([
"default",
"plan",
]);
expect(evidence.acceptEditsRpcSent).toBe(false);
expect(evidence.initialCurrentMode).toBe("default");
expect(logger.warn).toHaveBeenCalledWith(
"acceptEdits",
expect.stringContaining("is not valid cursor-acp mode"),
);
expect(reconnectLogger.warn).toHaveBeenCalledWith(
"acceptEdits",
expect.stringContaining("is not valid cursor-acp mode"),
);
expect(evidence.reconnectCurrentMode).toBe("default");
expect(evidence.requestPermissionCalls).toBe(0);
expect(setModelSpy).not.toHaveBeenCalledWith(
expect.objectContaining({ modelId: cursorClaudeModel }),
);
} catch (error) {
evidence.error = formatError(error);
throw error;
} finally {
console.log(`CURSOR_ACP_SMOKE_EVIDENCE ${JSON.stringify(evidence, null, 2)}`);
await reconnectedSession?.close();
await session?.close();
}
}, 120_000);
test("issue #560 replay skips invalid Claude selections and allows valid Cursor modes after resume", async () => {
const logger = createSmokeLogger();
const evidence: Issue560Evidence = {
sessionNewSucceeded: false,
availableModes: null,
warnings: [],
setModeRpc: [],
setConfigRpc: [],
setModelRpc: [],
acceptEditsConfigRpcSent: false,
bracketedModelConfigRpcSent: false,
planCurrentMode: null,
defaultCurrentMode: null,
userFacingErrors: [],
finalVerdict: "FAIL",
failure: null,
};
let session: ACPAgentSession | null = null;
const originalSetMode = ClientSideConnection.prototype.setSessionMode;
vi.spyOn(ClientSideConnection.prototype, "setSessionMode").mockImplementation(
async function (params) {
evidence.setModeRpc.push({ modeId: params.modeId, direction: "out", payload: params });
try {
const response = await originalSetMode.call(this, params);
evidence.setModeRpc.push({ modeId: params.modeId, direction: "in", payload: response });
return response;
} catch (error) {
evidence.setModeRpc.push({
modeId: params.modeId,
direction: "error",
payload: formatError(error),
});
throw error;
}
},
);
const originalSetConfig = ClientSideConnection.prototype.setSessionConfigOption;
vi.spyOn(ClientSideConnection.prototype, "setSessionConfigOption").mockImplementation(
async function (params) {
evidence.setConfigRpc.push({
configId: params.configId,
value: params.value,
direction: "out",
});
try {
const response = await originalSetConfig.call(this, params);
evidence.setConfigRpc.push({
configId: params.configId,
value: params.value,
direction: "in",
});
return response;
} catch (error) {
evidence.setConfigRpc.push({
configId: params.configId,
value: params.value,
direction: "error",
});
throw error;
}
},
);
const originalSetModel = ClientSideConnection.prototype.unstable_setSessionModel;
vi.spyOn(ClientSideConnection.prototype, "unstable_setSessionModel").mockImplementation(
async function (params) {
evidence.setModelRpc.push({ modelId: params.modelId, direction: "out" });
try {
const response = await originalSetModel.call(this, params);
evidence.setModelRpc.push({ modelId: params.modelId, direction: "in" });
return response;
} catch (error) {
evidence.setModelRpc.push({ modelId: params.modelId, direction: "error" });
throw error;
}
},
);
try {
session = createCursorSessionWithPersistedClaudeValues({
logger,
model: issue560ClaudeModel,
});
session.subscribe((event: AgentStreamEvent) => {
if (
event.type === "turn_failed" ||
(event.type === "timeline" &&
event.item.type === "error" &&
/Unknown acp model|unknown.*model/i.test(event.item.message))
) {
evidence.userFacingErrors.push(event);
}
});
await session.initializeNewSession();
evidence.sessionNewSucceeded = true;
evidence.availableModes = await session.getAvailableModes();
evidence.warnings = logger.warn.mock.calls;
await session.setMode("plan");
evidence.planCurrentMode = await session.getCurrentMode();
await session.setMode("default");
evidence.defaultCurrentMode = await session.getCurrentMode();
evidence.acceptEditsConfigRpcSent = evidence.setConfigRpc.some(
(entry) => entry.value === "acceptEdits",
);
evidence.bracketedModelConfigRpcSent = evidence.setConfigRpc.some(
(entry) => entry.value === issue560ClaudeModel,
);
expect(evidence.sessionNewSucceeded).toBe(true);
expect((evidence.availableModes as Array<{ id: string }>).map((mode) => mode.id)).toEqual([
"default",
"plan",
]);
expect(evidence.setModeRpc.some((entry) => entry.modeId === "acceptEdits")).toBe(false);
expect(evidence.acceptEditsConfigRpcSent).toBe(false);
expect(evidence.setModelRpc.some((entry) => entry.modelId === issue560ClaudeModel)).toBe(
false,
);
expect(evidence.bracketedModelConfigRpcSent).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
"acceptEdits",
expect.stringContaining("is not valid cursor-acp mode"),
);
expect(logger.warn).toHaveBeenCalledWith(
issue560ClaudeModel,
expect.stringContaining("is not a valid cursor-acp model"),
);
expect(evidence.setModeRpc).toEqual(
expect.arrayContaining([
expect.objectContaining({ modeId: "plan", direction: "out" }),
expect.objectContaining({ modeId: "plan", direction: "in" }),
expect.objectContaining({ modeId: "default", direction: "out" }),
expect.objectContaining({ modeId: "default", direction: "in" }),
]),
);
expect(evidence.planCurrentMode).toBe("plan");
expect(evidence.defaultCurrentMode).toBe("default");
expect(
evidence.userFacingErrors.some((event) =>
/Unknown acp model|unknown.*model/i.test(JSON.stringify(event)),
),
).toBe(false);
evidence.finalVerdict = "PASS";
} catch (error) {
evidence.failure = formatError(error);
throw error;
} finally {
console.log(`CURSOR_ACP_ISSUE_560_EVIDENCE ${JSON.stringify(evidence, null, 2)}`);
await session?.close();
}
}, 120_000);
});
@@ -100,4 +100,27 @@ describe("serializeAgentStreamEvent", () => {
const serialized = serializeAgentStreamEvent(event as AgentStreamEvent);
expect(serialized).toBeNull();
});
test("drops internal session config drift events from websocket payloads", () => {
const events: AgentStreamEvent[] = [
{
type: "mode_changed",
provider: "codex",
currentModeId: "build",
availableModes: [{ id: "build", label: "Build" }],
},
{
type: "model_changed",
provider: "codex",
runtimeInfo: { provider: "codex", sessionId: "session-1", model: "gpt-5.4" },
},
{
type: "thinking_option_changed",
provider: "codex",
thinkingOptionId: "high",
},
];
expect(events.map((event) => serializeAgentStreamEvent(event))).toEqual([null, null, null]);
});
});