From 4cada20e3817f488f2274f1d5965b35c11a7e837 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 30 Apr 2026 18:43:27 +0800 Subject: [PATCH] fix(server): make Cursor CLI and other ACP custom providers reliable (#628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../src/server/agent/agent-manager.test.ts | 76 ++ .../server/src/server/agent/agent-manager.ts | 31 + .../src/server/agent/agent-sdk-types.ts | 12 + .../server/agent/providers/acp-agent.test.ts | 523 +++++++++++++- .../src/server/agent/providers/acp-agent.ts | 434 ++++++++--- .../agent/providers/acp-wrapper-smoke.test.ts | 678 ++++++++++++++++++ .../agent/providers/cursor-acp-smoke.test.ts | 330 +++++++++ packages/server/src/server/messages.test.ts | 23 + 8 files changed, 2017 insertions(+), 90 deletions(-) create mode 100644 packages/server/src/server/agent/providers/acp-wrapper-smoke.test.ts create mode 100644 packages/server/src/server/agent/providers/cursor-acp-smoke.test.ts diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 42d6867fc..a62c7fa54 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -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 { + 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"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index df91225e8..c09cf1791 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -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": diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 9d5c58242..fffe65a24 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -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; diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 13a31f707..cc2ba8c0d 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -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 }; 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; + }) => Promise; }; configOptions: SessionConfigOption[]; } +interface ACPConfiguredOverrideInternals { + sessionId: string | null; + connection: { + setSessionMode: (input: { sessionId: string; modeId: string }) => Promise; + setSessionConfigOption: (input: { + sessionId: string; + configId: string; + value: string; + }) => Promise; + unstable_setSessionModel?: (input: { sessionId: string; modelId: string }) => Promise; + }; + 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; +} + 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 = 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; + } = {}, +): { + internals: ACPConfiguredOverrideInternals; + setSessionMode: ReturnType; + unstableSetSessionModel: ReturnType; + setSessionConfigOption: ReturnType; +} { + 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( diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 9d9144c33..39144419b 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -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; +interface SelectConfigChoice { + value: string; + name: string; + description?: string | null; + group?: string; +} +type AvailableACPModel = NonNullable[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 { - 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 { @@ -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 { + 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 { @@ -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 { + 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 { @@ -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 { - 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 { - 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, ): void { @@ -1698,16 +1963,6 @@ export class ACPAgentSession implements AgentSession, ACPClient { return parts.length > 0 ? parts.join(" | ") : undefined; } - private getSelectConfigOption( - category: string, - ): Extract | null { - const option = this.configOptions.find( - (entry): entry is Extract => - 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["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 => - 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, diff --git a/packages/server/src/server/agent/providers/acp-wrapper-smoke.test.ts b/packages/server/src/server/agent/providers/acp-wrapper-smoke.test.ts new file mode 100644 index 000000000..0db55dd0f --- /dev/null +++ b/packages/server/src/server/agent/providers/acp-wrapper-smoke.test.ts @@ -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; +} + +interface WrapperSmokeConfig { + id: "claude-acp" | "codex-acp"; + packageName: string; + version: string; + command: [string, ...string[]]; + env?: Record; +} + +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; +} + +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 => + 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 { + 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 { + 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 | null { + const internals = session as unknown as SessionInternals; + return ( + internals.configOptions.find( + (option): option is Extract => + option.type === "select" && option.category === category, + ) ?? null + ); +} + +function flattenSelectValues(option: Extract): 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 { + if (!session?.id) { + return; + } + await captureFinalState(session, trace); +} diff --git a/packages/server/src/server/agent/providers/cursor-acp-smoke.test.ts b/packages/server/src/server/agent/providers/cursor-acp-smoke.test.ts new file mode 100644 index 000000000..20d1369fd --- /dev/null +++ b/packages/server/src/server/agent/providers/cursor-acp-smoke.test.ts @@ -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; +} + +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); +}); diff --git a/packages/server/src/server/messages.test.ts b/packages/server/src/server/messages.test.ts index 2e6184b20..cfcfb285c 100644 --- a/packages/server/src/server/messages.test.ts +++ b/packages/server/src/server/messages.test.ts @@ -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]); + }); });