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:
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user