diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 29ad4bea..78038ac4 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -1309,7 +1309,7 @@ export class ClaudeAcpAgent implements Agent { // effort changes and effort changes induced by a model switch go through // the same path. - await this.applyConfigOptionValue(session, params.configId, resolvedValue); + await this.applyConfigOptionValue(params.sessionId, session, params.configId, resolvedValue); return { configOptions: session.configOptions }; } @@ -1326,8 +1326,17 @@ export class ClaudeAcpAgent implements Agent { default: throw new Error("Invalid Mode"); } + + const session = this.sessions[sessionId]; + if (!session) { + throw new Error("Session not found"); + } + if (!session.modes.availableModes.some((mode) => mode.id === modeId)) { + throw new Error(`Mode ${modeId} is not available in this session`); + } + try { - await this.sessions[sessionId].query.setPermissionMode(modeId); + await session.query.setPermissionMode(modeId); } catch (error) { if (error instanceof Error) { if (!error.message) { @@ -1397,7 +1406,7 @@ export class ClaudeAcpAgent implements Agent { } if (toolName === "ExitPlanMode") { - const options = [ + const optionsAll = [ { kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" }, { kind: "allow_always", @@ -1408,12 +1417,20 @@ export class ClaudeAcpAgent implements Agent { { kind: "reject_once", name: "No, keep planning", optionId: "plan" }, ]; if (ALLOW_BYPASS) { - options.unshift({ + optionsAll.unshift({ kind: "allow_always", name: "Yes, and bypass permissions", optionId: "bypassPermissions", }); } + // Filter against the session's currently-advertised modes so we never + // present options the active model can't honor (e.g. `auto` on Haiku). + // `bypassPermissions` is already covered by `availableModes` via + // `buildAvailableModes`/`ALLOW_BYPASS`. The `plan` option is a + // "keep planning" reject path; it's always present in `availableModes`. + const options = optionsAll.filter((o) => + session.modes.availableModes.some((m) => m.id === o.optionId), + ); const response = await this.client.requestPermission({ options, @@ -1432,27 +1449,30 @@ export class ClaudeAcpAgent implements Agent { if (signal.aborted || response.outcome?.outcome === "cancelled") { throw new Error("Tool use aborted"); } + const selectedMode = + response.outcome?.outcome === "selected" ? response.outcome.optionId : undefined; + const selectedModeWasOffered = options.some((option) => option.optionId === selectedMode); if ( - response.outcome?.outcome === "selected" && - (response.outcome.optionId === "default" || - response.outcome.optionId === "acceptEdits" || - response.outcome.optionId === "auto" || - response.outcome.optionId === "bypassPermissions") + selectedModeWasOffered && + (selectedMode === "default" || + selectedMode === "acceptEdits" || + selectedMode === "auto" || + selectedMode === "bypassPermissions") ) { await this.client.sessionUpdate({ sessionId, update: { sessionUpdate: "current_mode_update", - currentModeId: response.outcome.optionId, + currentModeId: selectedMode, }, }); - await this.updateConfigOption(sessionId, "mode", response.outcome.optionId); + await this.updateConfigOption(sessionId, "mode", selectedMode); return { behavior: "allow", updatedInput: toolInput, updatedPermissions: suggestions ?? [ - { type: "setMode", mode: response.outcome.optionId, destination: "session" }, + { type: "setMode", mode: selectedMode, destination: "session" }, ], }; } else { @@ -1550,7 +1570,7 @@ export class ClaudeAcpAgent implements Agent { const session = this.sessions[sessionId]; if (!session) return; - await this.applyConfigOptionValue(session, configId, value); + await this.applyConfigOptionValue(sessionId, session, configId, value); await this.client.sessionUpdate({ sessionId, @@ -1562,13 +1582,16 @@ export class ClaudeAcpAgent implements Agent { } private async applyConfigOptionValue( + sessionId: string, session: Session, configId: string, value: string, ): Promise { - // Sync top-level session state if (configId === "mode") { session.modes = { ...session.modes, currentModeId: value }; + session.configOptions = session.configOptions.map((o) => + o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o, + ); } else if (configId === "model") { if (session.models.currentModelId !== value) { // The cached context window was learned for the previous model; reset @@ -1578,10 +1601,38 @@ export class ClaudeAcpAgent implements Agent { session.contextWindowSize = inferContextWindowFromModel(value) ?? DEFAULT_CONTEXT_WINDOW; } session.models = { ...session.models, currentModelId: value }; - } - // Update configOptions - if (configId === "model") { + // Recompute availableModes for the new model and clamp the current + // mode if the SDK no longer offers it (today: "auto" on Haiku). + // `ModelInfo.supportsAutoMode` is the canonical SDK signal. + const newModelInfo = session.modelInfos.find((m) => m.value === value); + const newAvailableModes = buildAvailableModes(newModelInfo); + // Capture BEFORE mutating session.modes so the log message reflects + // the invalidated mode rather than "default". + const previousModeId = session.modes.currentModeId; + let modeDowngraded = false; + if (!newAvailableModes.some((m) => m.id === previousModeId)) { + session.modes = { + availableModes: newAvailableModes, + currentModeId: "default", + }; + try { + await session.query.setPermissionMode("default"); + } catch (err) { + // Failing the entire model switch over a bookkeeping sync error is + // worse UX than logging and continuing; the user explicitly asked + // to change models. The next setPermissionMode from the user will + // either succeed or surface a fresh error. + this.logger.error( + `Failed to sync permissionMode to "default" after model switch invalidated "${previousModeId}":`, + err, + ); + } + modeDowngraded = true; + } else { + session.modes = { ...session.modes, availableModes: newAvailableModes }; + } + // Rebuild config options since effort levels depend on the selected model const effortOpt = session.configOptions.find((o) => o.id === "effort"); const currentEffort = @@ -1602,6 +1653,22 @@ export class ClaudeAcpAgent implements Agent { effortLevel: newEffort as Settings["effortLevel"], }); } + + // Emit current_mode_update only after session.modes AND + // session.configOptions have been fully reconciled. This way, a failure + // in the configOptions/effort rebuild above can't leave the client with + // a clamped currentModeId but stale configOptions, and the notification + // still precedes the caller's config_option_update so order-sensitive + // clients update currentModeId before re-rendering the option list. + if (modeDowngraded) { + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "current_mode_update", + currentModeId: "default", + }, + }); + } } else { session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o, @@ -1873,44 +1940,45 @@ export class ClaudeAcpAgent implements Agent { this.logger, ); - const availableModes = [ - { - id: "auto", - name: "Auto", - description: "Use a model classifier to approve/deny permission prompts", - }, - { - id: "default", - name: "Default", - description: "Standard behavior, prompts for dangerous operations", - }, - { - id: "acceptEdits", - name: "Accept Edits", - description: "Auto-accept file edit operations", - }, - { - id: "plan", - name: "Plan Mode", - description: "Planning mode, no actual tool execution", - }, - { - id: "dontAsk", - name: "Don't Ask", - description: "Don't prompt for permissions, deny if not pre-approved", - }, - ]; - // Only works in non-root mode - if (ALLOW_BYPASS) { - availableModes.push({ - id: "bypassPermissions", - name: "Bypass Permissions", - description: "Bypass all permission checks", - }); + // Gate `auto` (and future model-specific modes) on the resolved model's + // `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal. + const currentModelInfo = initializationResult.models.find( + (m) => m.value === models.currentModelId, + ); + const availableModes = buildAvailableModes(currentModelInfo); + + // Clamp `permissionMode` if the resolved session does not offer it. The + // common case is `permissions.defaultMode: "auto"` resolving to a model + // that does not support auto mode (e.g. Haiku); without this clamp the + // SDK would later throw `"auto mode unavailable for this model"` from + // `setPermissionMode`. Keep `permissionMode` as the resolved user intent + // (matches what was passed into `options.permissionMode` above) and use + // `effectiveMode` for the post-clamp value the session actually runs in. + let effectiveMode: PermissionMode = permissionMode; + if (!availableModes.some((m) => m.id === effectiveMode)) { + if (effectiveMode === "auto") { + this.logger.error( + `permissions.defaultMode "auto" is not available for model ` + + `"${models.currentModelId}"; falling back to "default".`, + ); + } else { + this.logger.error( + `permissions.defaultMode "${effectiveMode}" is not available in ` + + `this session; falling back to "default".`, + ); + } + effectiveMode = "default"; + // Sync the SDK so it doesn't keep "auto" cached internally. Wrapped in + // try/catch since failing here would abort session creation entirely. + try { + await q.setPermissionMode("default"); + } catch (err) { + this.logger.error("Failed to sync clamped permissionMode to SDK:", err); + } } const modes = { - currentModeId: permissionMode, + currentModeId: effectiveMode, availableModes, }; @@ -2053,6 +2121,58 @@ function createEnvForGateway(gatewayMeta?: GatewayAuthMeta) { }; } +/** + * Build the list of permission modes the agent will advertise for the given + * model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is + * the SDK's model-level availability signal. `undefined`/`false` both exclude + * `auto`. `bypassPermissions` is still gated by `ALLOW_BYPASS`. + */ +function buildAvailableModes(modelInfo: ModelInfo | undefined): SessionModeState["availableModes"] { + const modes: SessionModeState["availableModes"] = []; + + // Only advertise "auto" when the SDK reports the model supports it. + if (modelInfo?.supportsAutoMode === true) { + modes.push({ + id: "auto", + name: "Auto", + description: "Use a model classifier to approve/deny permission prompts", + }); + } + + modes.push( + { + id: "default", + name: "Default", + description: "Standard behavior, prompts for dangerous operations", + }, + { + id: "acceptEdits", + name: "Accept Edits", + description: "Auto-accept file edit operations", + }, + { + id: "plan", + name: "Plan Mode", + description: "Planning mode, no actual tool execution", + }, + { + id: "dontAsk", + name: "Don't Ask", + description: "Don't prompt for permissions, deny if not pre-approved", + }, + ); + + if (ALLOW_BYPASS) { + modes.push({ + id: "bypassPermissions", + name: "Bypass Permissions", + description: "Bypass all permission checks", + }); + } + + return modes; +} + function buildConfigOptions( modes: SessionModeState, models: SessionModelState, diff --git a/src/tests/acp-agent-settings.test.ts b/src/tests/acp-agent-settings.test.ts index d5c643a4..f310f9fb 100644 --- a/src/tests/acp-agent-settings.test.ts +++ b/src/tests/acp-agent-settings.test.ts @@ -201,6 +201,167 @@ describe("ClaudeAcpAgent settings", () => { expect(response.models.currentModelId).toBe("claude-sonnet-4-5"); }); + describe("auto mode availability per model", () => { + function mockQueryWithModels(models: any[]): { + getCapturedOptions: () => any; + setModelSpy: ReturnType; + setPermissionModeSpy: ReturnType; + } { + let capturedOptions: any; + const setModelSpy = vi.fn(); + const setPermissionModeSpy = vi.fn(); + querySpy.mockImplementation(({ options }: any) => { + capturedOptions = options; + return { + initializationResult: async () => ({ models }), + setModel: setModelSpy, + setPermissionMode: setPermissionModeSpy, + supportedCommands: async () => [], + } as any; + }); + return { + getCapturedOptions: () => capturedOptions, + setModelSpy, + setPermissionModeSpy, + }; + } + + it("omits `auto` from availableModes when the resolved model lacks supportsAutoMode", async () => { + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + mockQueryWithModels([ + { + value: "claude-haiku-4-5", + displayName: "Claude Haiku", + description: "Fast", + // supportsAutoMode intentionally omitted + }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + const modeIds: string[] = response.modes.availableModes.map((m: any) => m.id); + expect(modeIds).not.toContain("auto"); + expect(modeIds).toEqual( + expect.arrayContaining(["default", "acceptEdits", "plan", "dontAsk"]), + ); + }); + + it("includes `auto` when the resolved model has supportsAutoMode: true", async () => { + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + mockQueryWithModels([ + { + value: "claude-opus-4-5", + displayName: "Claude Opus", + description: "Most capable", + supportsAutoMode: true, + }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + const modeIds: string[] = response.modes.availableModes.map((m: any) => m.id); + expect(modeIds).toContain("auto"); + }); + + it("clamps permissions.defaultMode='auto' to 'default' on a model that lacks supportsAutoMode", async () => { + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ permissions: { defaultMode: "auto" } }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + const { getCapturedOptions, setPermissionModeSpy } = mockQueryWithModels([ + { + value: "claude-haiku-4-5", + displayName: "Claude Haiku", + description: "Fast", + // supportsAutoMode intentionally omitted + }, + ]); + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + // Options.permissionMode is built before init resolves, so it still + // carries the user-typed value; the SDK was synced via + // setPermissionMode after we discovered the model can't honor it. + expect(getCapturedOptions().permissionMode).toBe("auto"); + expect(setPermissionModeSpy).toHaveBeenCalledWith("default"); + expect(response.modes.currentModeId).toBe("default"); + expect(response.modes.availableModes.map((m: any) => m.id)).not.toContain("auto"); + + // A descriptive warning was logged so operators see the clamp. + const messages = errorSpy.mock.calls.map((c) => c.join(" ")); + expect(messages.some((m) => m.includes("auto") && m.includes("claude-haiku-4-5"))).toBe( + true, + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it("does not clamp permissions.defaultMode='auto' on a model that supports auto", async () => { + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ permissions: { defaultMode: "auto" } }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + const { getCapturedOptions, setPermissionModeSpy } = mockQueryWithModels([ + { + value: "claude-opus-4-5", + displayName: "Claude Opus", + description: "Most capable", + supportsAutoMode: true, + }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + expect(getCapturedOptions().permissionMode).toBe("auto"); + expect(setPermissionModeSpy).not.toHaveBeenCalled(); + expect(response.modes.currentModeId).toBe("auto"); + }); + }); + it("resolves model aliases like opus[1m] to the correct model", async () => { await fs.promises.writeFile( path.join(tempDir, "settings.json"), diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 1174b1ea..13dc808a 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -2502,7 +2502,12 @@ describe("usage_update computation", () => { const session = agent.sessions["test-session"]; expect(session.contextWindowSize).toBe(200000); - await (agent as any).applyConfigOptionValue(session, "model", "claude-opus-4-6-1m"); + await (agent as any).applyConfigOptionValue( + "test-session", + session, + "model", + "claude-opus-4-6-1m", + ); expect(session.contextWindowSize).toBe(1000000); await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); @@ -2587,7 +2592,12 @@ describe("usage_update computation", () => { session.models = { ...session.models, currentModelId: "claude-opus-4-6-1m" }; // User flips the selector to a 200k model. - await (agent as any).applyConfigOptionValue(session, "model", "claude-sonnet-4-6"); + await (agent as any).applyConfigOptionValue( + "test-session", + session, + "model", + "claude-sonnet-4-6", + ); await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); diff --git a/src/tests/session-config-options.test.ts b/src/tests/session-config-options.test.ts index 418e6c5b..29c11377 100644 --- a/src/tests/session-config-options.test.ts +++ b/src/tests/session-config-options.test.ts @@ -811,4 +811,391 @@ describe("session config options", () => { ); }); }); + + describe("auto mode availability per model", () => { + /** + * Augment the session populated by `populateSession()` with a Haiku entry + * (no `supportsAutoMode`), Opus + Sonnet entries with `supportsAutoMode: + * true`, and seed `availableModes` so it currently includes `auto`. This + * exercises the per-model recomputation done by `applyConfigOptionValue` + * on a model switch. + */ + function setupHaikuOpusSession(currentModeId: string = "default") { + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + session.modelInfos = [ + { + value: "claude-opus-4-5", + displayName: "Claude Opus", + description: "Most capable", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + supportsAutoMode: true, + }, + { + value: "claude-sonnet-4-5", + displayName: "Claude Sonnet", + description: "Balanced", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + supportsAutoMode: true, + }, + { + value: "claude-haiku-4-5", + displayName: "Claude Haiku", + description: "Fast", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + // supportsAutoMode intentionally omitted + }, + ]; + session.models = { + currentModelId: "claude-opus-4-5", + availableModels: [ + { modelId: "claude-opus-4-5", name: "Claude Opus", description: "Most capable" }, + { modelId: "claude-sonnet-4-5", name: "Claude Sonnet", description: "Balanced" }, + { modelId: "claude-haiku-4-5", name: "Claude Haiku", description: "Fast" }, + ], + }; + session.modes = { + currentModeId, + availableModes: [ + { + id: "auto", + name: "Auto", + description: "Use a model classifier to approve/deny permission prompts", + }, + { + id: "default", + name: "Default", + description: "Standard behavior, prompts for dangerous operations", + }, + { + id: "acceptEdits", + name: "Accept Edits", + description: "Auto-accept file edit operations", + }, + { id: "plan", name: "Plan Mode", description: "Planning mode" }, + { + id: "dontAsk", + name: "Don't Ask", + description: "Don't prompt for permissions, deny if not pre-approved", + }, + ], + }; + // Reflect the seeded availableModes/availableModels in configOptions so + // the pre-state matches what `createSession` would have produced for + // Opus, and `setSessionConfigOption` validation can accept the seeded + // model ids (notably the new Haiku entry). + session.configOptions = session.configOptions.map((o: any) => { + if (o.id === "mode") { + return { + ...o, + currentValue: currentModeId, + options: session.modes.availableModes.map((m: any) => ({ + value: m.id, + name: m.name, + description: m.description, + })), + }; + } + if (o.id === "model") { + return { + ...o, + currentValue: session.models.currentModelId, + options: session.models.availableModels.map((m: any) => ({ + value: m.modelId, + name: m.name, + description: m.description, + })), + }; + } + return o; + }); + return session; + } + + beforeEach(() => { + populateSession(); + }); + + it("drops `auto` from available modes when switching to Haiku", async () => { + setupHaikuOpusSession("default"); + + await agent.unstable_setSessionModel({ + sessionId: SESSION_ID, + modelId: "claude-haiku-4-5", + }); + + const configUpdate = sessionUpdates.find( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(configUpdate).toBeDefined(); + const modeOption = (configUpdate?.update as any).configOptions.find( + (o: any) => o.id === "mode", + ); + expect(modeOption).toBeDefined(); + const modeValues = modeOption.options.map((o: any) => o.value); + expect(modeValues).not.toContain("auto"); + expect(modeValues).toEqual( + expect.arrayContaining(["default", "acceptEdits", "plan", "dontAsk"]), + ); + }); + + it("clamps to `default` and emits current_mode_update when Opus(auto) → Haiku", async () => { + setupHaikuOpusSession("auto"); + + await agent.unstable_setSessionModel({ + sessionId: SESSION_ID, + modelId: "claude-haiku-4-5", + }); + + // SDK was synced to "default". + expect(setPermissionModeSpy).toHaveBeenCalledWith("default"); + + // current_mode_update was emitted before config_option_update so a + // client applying notifications in order observes the mode change + // before re-rendering the config-option list. + const modeUpdateIdx = sessionUpdates.findIndex( + (n) => n.update.sessionUpdate === "current_mode_update", + ); + const configUpdateIdx = sessionUpdates.findIndex( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(modeUpdateIdx).toBeGreaterThanOrEqual(0); + expect(configUpdateIdx).toBeGreaterThanOrEqual(0); + expect(modeUpdateIdx).toBeLessThan(configUpdateIdx); + expect((sessionUpdates[modeUpdateIdx].update as any).currentModeId).toBe("default"); + + // configOptions reflect the clamped mode. + const modeOption = (sessionUpdates[configUpdateIdx].update as any).configOptions.find( + (o: any) => o.id === "mode", + ); + expect(modeOption.currentValue).toBe("default"); + }); + + it("re-adds `auto` when switching from Haiku back to Opus", async () => { + const session = setupHaikuOpusSession("default"); + // Pretend Haiku is the current model with no `auto`. + session.models.currentModelId = "claude-haiku-4-5"; + session.modes.availableModes = session.modes.availableModes.filter( + (m: any) => m.id !== "auto", + ); + const modeOpt = session.configOptions.find((o: any) => o.id === "mode"); + modeOpt.options = session.modes.availableModes.map((m: any) => ({ + value: m.id, + name: m.name, + description: m.description, + })); + + await agent.unstable_setSessionModel({ + sessionId: SESSION_ID, + modelId: "claude-opus-4-5", + }); + + const configUpdate = sessionUpdates.find( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(configUpdate).toBeDefined(); + const modeOption = (configUpdate?.update as any).configOptions.find( + (o: any) => o.id === "mode", + ); + const modeValues = modeOption.options.map((o: any) => o.value); + expect(modeValues).toContain("auto"); + + // The current mode ("default") is still valid on Opus, so no + // current_mode_update should have been emitted by the model switch. + const modeUpdates = sessionUpdates.filter( + (n) => n.update.sessionUpdate === "current_mode_update", + ); + expect(modeUpdates).toHaveLength(0); + }); + + it("preserves the current mode when it remains valid after a model switch", async () => { + setupHaikuOpusSession("plan"); + + await agent.unstable_setSessionModel({ + sessionId: SESSION_ID, + modelId: "claude-haiku-4-5", + }); + + // `plan` is in availableModes for both Opus and Haiku, so no clamp. + expect(setPermissionModeSpy).not.toHaveBeenCalledWith("default"); + + const modeUpdates = sessionUpdates.filter( + (n) => n.update.sessionUpdate === "current_mode_update", + ); + expect(modeUpdates).toHaveLength(0); + + const configUpdate = sessionUpdates.find( + (n) => n.update.sessionUpdate === "config_option_update", + ); + const modeOption = (configUpdate?.update as any).configOptions.find( + (o: any) => o.id === "mode", + ); + expect(modeOption.currentValue).toBe("plan"); + }); + + it("clamps mode and emits current_mode_update via setSessionConfigOption(model)", async () => { + // Mirrors the unstable_setSessionModel(auto → Haiku) test, but goes + // through the request/response API. The `current_mode_update` side + // effect must still fire so clients learn about the clamp regardless of + // which entry point triggered the model switch. + setupHaikuOpusSession("auto"); + + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-haiku-4-5", + }); + + expect(setPermissionModeSpy).toHaveBeenCalledWith("default"); + + const modeUpdates = sessionUpdates.filter( + (n) => n.update.sessionUpdate === "current_mode_update", + ); + expect(modeUpdates).toHaveLength(1); + expect((modeUpdates[0].update as any).currentModeId).toBe("default"); + + // setSessionConfigOption is a request/response API: it returns the new + // configOptions in the response rather than emitting a + // config_option_update notification. + const configUpdates = sessionUpdates.filter( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(configUpdates).toHaveLength(0); + + const modeOption = response.configOptions.find((o: any) => o.id === "mode"); + expect(modeOption).toBeDefined(); + expect((modeOption as any).currentValue).toBe("default"); + expect((modeOption as any).options.map((o: any) => o.value)).not.toContain("auto"); + }); + + it("rejects direct setSessionMode to `auto` when the active model does not offer it", async () => { + const session = setupHaikuOpusSession("default"); + session.models.currentModelId = "claude-haiku-4-5"; + session.modes.availableModes = session.modes.availableModes.filter( + (mode: any) => mode.id !== "auto", + ); + + await expect(agent.setSessionMode({ sessionId: SESSION_ID, modeId: "auto" })).rejects.toThrow( + "Mode auto is not available in this session", + ); + + expect(setPermissionModeSpy).not.toHaveBeenCalledWith("auto"); + expect(sessionUpdates).toHaveLength(0); + }); + }); + + describe("ExitPlanMode permission options filtered by availableModes", () => { + let capturedPermissionRequest: any; + let permissionResponse: any; + + beforeEach(() => { + capturedPermissionRequest = null; + permissionResponse = { outcome: { outcome: "cancelled" } }; + // Replace the default mock client with one that captures the + // requestPermission call so we can assert on the offered options. + (agent as any).client = { + sessionUpdate: async (notification: SessionNotification) => { + sessionUpdates.push(notification); + }, + requestPermission: async (params: any) => { + capturedPermissionRequest = params; + return permissionResponse; + }, + readTextFile: async () => ({ content: "" }), + writeTextFile: async () => ({}), + }; + populateSession(); + }); + + it("omits the `auto` option on a model without supportsAutoMode", async () => { + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + // Haiku-shaped session: availableModes does NOT include `auto`. + session.modes = { + currentModeId: "plan", + availableModes: [ + { id: "default", name: "Default", description: "Standard" }, + { id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" }, + { id: "plan", name: "Plan Mode", description: "Planning mode" }, + { id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" }, + ], + }; + + const canUseTool = (agent as any).canUseTool(SESSION_ID); + const signal = new AbortController().signal; + try { + await canUseTool( + "ExitPlanMode", + { plan: "do stuff" }, + { signal, suggestions: undefined, toolUseID: "toolu_1" }, + ); + } catch { + // The mock client returns `cancelled`, which makes canUseTool throw. + // We only care about the captured requestPermission options. + } + + expect(capturedPermissionRequest).not.toBeNull(); + const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId); + expect(optionIds).not.toContain("auto"); + expect(optionIds).toEqual(expect.arrayContaining(["default", "acceptEdits", "plan"])); + }); + + it("denies a selected `auto` option if the client did not receive that option", async () => { + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + session.modes = { + currentModeId: "plan", + availableModes: [ + { id: "default", name: "Default", description: "Standard" }, + { id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" }, + { id: "plan", name: "Plan Mode", description: "Planning mode" }, + { id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" }, + ], + }; + permissionResponse = { outcome: { outcome: "selected", optionId: "auto" } }; + + const canUseTool = (agent as any).canUseTool(SESSION_ID); + const result = await canUseTool( + "ExitPlanMode", + { plan: "do stuff" }, + { signal: new AbortController().signal, suggestions: undefined, toolUseID: "toolu_2" }, + ); + + expect(capturedPermissionRequest).not.toBeNull(); + const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId); + expect(optionIds).not.toContain("auto"); + expect(result.behavior).toBe("deny"); + expect(sessionUpdates).toHaveLength(0); + }); + + it("includes the `auto` option on a model with supportsAutoMode", async () => { + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + session.modes = { + currentModeId: "plan", + availableModes: [ + { id: "auto", name: "Auto", description: "Use a model classifier" }, + { id: "default", name: "Default", description: "Standard" }, + { id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" }, + { id: "plan", name: "Plan Mode", description: "Planning mode" }, + { id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" }, + ], + }; + + const canUseTool = (agent as any).canUseTool(SESSION_ID); + const signal = new AbortController().signal; + try { + await canUseTool( + "ExitPlanMode", + { plan: "do stuff" }, + { signal, suggestions: undefined, toolUseID: "toolu_3" }, + ); + } catch { + // mock returns cancelled + } + + expect(capturedPermissionRequest).not.toBeNull(); + const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId); + expect(optionIds).toContain("auto"); + }); + }); });