Browse Source

Merge pull request #518 from Qesire/preset-safe-command-only

fix(preset): avoid runtime reload in preset command
Alvin 2 months ago
parent
commit
77ff67c716
2 changed files with 188 additions and 230 deletions
  1. 134 149
      src/tools/preset-manager.test.ts
  2. 54 81
      src/tools/preset-manager.ts

+ 134 - 149
src/tools/preset-manager.test.ts

@@ -12,11 +12,15 @@ import { createPresetManager } from './preset-manager';
 
 function createMockContext() {
   const configUpdate = mock(async () => ({}));
+  const instanceDispose = mock(async () => ({}));
   return {
     client: {
       config: {
         update: configUpdate,
       },
+      instance: {
+        dispose: instanceDispose,
+      },
     },
     directory: '/tmp/test',
   } as any;
@@ -34,12 +38,15 @@ function getOutputText(output: ReturnType<typeof createOutput>): string {
 }
 
 let previousXdgDataHome: string | undefined;
+let previousOpenCodeConfigDir: string | undefined;
 let tempDir: string;
 
 beforeEach(() => {
   previousXdgDataHome = process.env.XDG_DATA_HOME;
+  previousOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR;
   tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-preset-manager-'));
   process.env.XDG_DATA_HOME = tempDir;
+  delete process.env.OPENCODE_CONFIG_DIR;
   setActiveRuntimePreset(null);
 });
 
@@ -50,6 +57,12 @@ afterEach(() => {
     process.env.XDG_DATA_HOME = previousXdgDataHome;
   }
 
+  if (previousOpenCodeConfigDir === undefined) {
+    delete process.env.OPENCODE_CONFIG_DIR;
+  } else {
+    process.env.OPENCODE_CONFIG_DIR = previousOpenCodeConfigDir;
+  }
+
   fs.rmSync(tempDir, { recursive: true, force: true });
   setActiveRuntimePreset(null);
 });
@@ -133,7 +146,7 @@ describe('createPresetManager', () => {
       expect(text).toContain('No presets configured');
     });
 
-    test('switches preset and calls config.update', async () => {
+    test('switches preset state without config.update or instance.dispose', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
@@ -152,19 +165,14 @@ describe('createPresetManager', () => {
       );
 
       const text = getOutputText(output);
-      expect(text).toContain('Switched to preset "cheap"');
+      expect(text).toContain('Saved preset "cheap"');
       expect(text).toContain('orchestrator');
       expect(text).toContain('anthropic/claude-3.5-haiku');
       expect(text).toContain('explorer');
-      expect(ctx.client.config.update).toHaveBeenCalledTimes(1);
-      expect(ctx.client.config.update).toHaveBeenCalledWith({
-        body: {
-          agent: {
-            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
-            explorer: { model: 'openai/gpt-5.4-mini' },
-          },
-        },
-      });
+      expect(text).toContain('Restart or reload OpenCode');
+      expect(getActiveRuntimePreset()).toBe('cheap');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
     });
 
     test('updates the TUI snapshot after a successful preset switch', async () => {
@@ -199,7 +207,52 @@ describe('createPresetManager', () => {
       });
     });
 
-    test('passes temperature in config update', async () => {
+    test('persists preset changes from JSONC user config', async () => {
+      const configDir = path.join(tempDir, 'opencode-config');
+      fs.mkdirSync(configDir, { recursive: true });
+      process.env.OPENCODE_CONFIG_DIR = configDir;
+
+      const configPath = path.join(configDir, 'oh-my-opencode-slim.jsonc');
+      fs.writeFileSync(
+        configPath,
+        `{
+          // User-selected preset should be updated even in JSONC files.
+          "preset": "old",
+          "agents": {
+            "orchestrator": { "model": "old-model" },
+          },
+        }`,
+      );
+
+      const ctx = { ...createMockContext(), directory: tempDir };
+      const config: PluginConfig = {
+        presets: {
+          cheap: {
+            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
+          },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output = createOutput();
+
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
+        output,
+      );
+
+      const persisted = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as {
+        preset?: string;
+        agents?: Record<string, unknown>;
+      };
+      expect(persisted.preset).toBe('cheap');
+      expect(persisted.agents).toEqual({
+        orchestrator: { model: 'old-model' },
+      });
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
+    });
+
+    test('shows temperature in preset summary without runtime config update', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
@@ -216,16 +269,15 @@ describe('createPresetManager', () => {
         output,
       );
 
-      expect(ctx.client.config.update).toHaveBeenCalledWith({
-        body: {
-          agent: {
-            orchestrator: { model: 'openai/o3', temperature: 0.1 },
-          },
-        },
-      });
+      const text = getOutputText(output);
+      expect(text).toContain('orchestrator');
+      expect(text).toContain('model: openai/o3');
+      expect(text).toContain('temp: 0.1');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
     });
 
-    test('passes variant in config update', async () => {
+    test('shows variant in preset summary without runtime config update', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
@@ -245,16 +297,12 @@ describe('createPresetManager', () => {
         output,
       );
 
-      expect(ctx.client.config.update).toHaveBeenCalledWith({
-        body: {
-          agent: {
-            oracle: {
-              model: 'anthropic/claude-sonnet-4-6',
-              variant: 'thinking',
-            },
-          },
-        },
-      });
+      const text = getOutputText(output);
+      expect(text).toContain('oracle');
+      expect(text).toContain('model: anthropic/claude-sonnet-4-6');
+      expect(text).toContain('variant: thinking');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
     });
 
     test('shows error for unknown preset name', async () => {
@@ -276,6 +324,7 @@ describe('createPresetManager', () => {
       expect(text).toContain('not found');
       expect(text).toContain('cheap');
       expect(ctx.client.config.update).not.toHaveBeenCalled();
+      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
     });
 
     test('shows error when no presets configured but argument given', async () => {
@@ -294,7 +343,8 @@ describe('createPresetManager', () => {
       expect(text).toContain('No presets configured');
     });
 
-    test('handles config.update error gracefully', async () => {
+    test('unknown preset does not change active state or dispose instance', async () => {
+      setActiveRuntimePreset('cheap');
       recordTuiAgentModels({
         agentModels: {
           explorer: 'openai/gpt-5.4-mini',
@@ -302,9 +352,6 @@ describe('createPresetManager', () => {
       });
 
       const ctx = createMockContext();
-      ctx.client.config.update = mock(async () => {
-        throw new Error('Server unavailable');
-      });
       const config: PluginConfig = {
         presets: {
           cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
@@ -314,16 +361,15 @@ describe('createPresetManager', () => {
       const output = createOutput();
 
       await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
+        { command: 'preset', sessionID: 's1', arguments: 'nonexistent' },
         output,
       );
 
       const text = getOutputText(output);
-      expect(text).toContain('Failed to switch preset');
-      expect(text).toContain('Server unavailable');
-      expect(readTuiSnapshot().agentModels).toEqual({
-        explorer: 'openai/gpt-5.4-mini',
-      });
+      expect(text).toContain('not found');
+      expect(getActiveRuntimePreset()).toBe('cheap');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
     });
 
     test('shows empty preset message when preset has no valid overrides', async () => {
@@ -348,7 +394,7 @@ describe('createPresetManager', () => {
       expect(ctx.client.config.update).not.toHaveBeenCalled();
     });
 
-    test('forwards options field in config update', async () => {
+    test('shows options in preset summary without runtime config update', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
@@ -370,18 +416,11 @@ describe('createPresetManager', () => {
         output,
       );
 
-      expect(ctx.client.config.update).toHaveBeenCalledWith({
-        body: {
-          agent: {
-            oracle: {
-              model: 'anthropic/claude-sonnet-4-6',
-              options: {
-                thinking: { type: 'enabled', budgetTokens: 10000 },
-              },
-            },
-          },
-        },
-      });
+      const text = getOutputText(output);
+      expect(text).toContain('oracle');
+      expect(text).toContain('options: yes');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
     });
 
     test('trims whitespace from preset name argument', async () => {
@@ -400,8 +439,9 @@ describe('createPresetManager', () => {
       );
 
       const text = getOutputText(output);
-      expect(text).toContain('Switched to preset "cheap"');
-      expect(ctx.client.config.update).toHaveBeenCalledTimes(1);
+      expect(text).toContain('Saved preset "cheap"');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
     });
 
     test('shows suggestion for multi-word arguments', async () => {
@@ -465,16 +505,11 @@ describe('createPresetManager', () => {
       );
 
       const text = getOutputText(output);
-      expect(text).toContain('Switched to preset "mixed"');
-      // Only orchestrator and oracle should be forwarded
-      expect(ctx.client.config.update).toHaveBeenCalledWith({
-        body: {
-          agent: {
-            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
-            oracle: { temperature: 0.3 },
-          },
-        },
-      });
+      expect(text).toContain('Saved preset "mixed"');
+      expect(text).toContain('orchestrator');
+      expect(text).toContain('oracle');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
     });
 
     test('resolves array-form model to first entry', async () => {
@@ -497,14 +532,11 @@ describe('createPresetManager', () => {
       );
 
       const text = getOutputText(output);
-      expect(text).toContain('Switched to preset "fallback"');
-      expect(ctx.client.config.update).toHaveBeenCalledWith({
-        body: {
-          agent: {
-            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
-          },
-        },
-      });
+      expect(text).toContain('Saved preset "fallback"');
+      expect(text).toContain('orchestrator');
+      expect(text).toContain('anthropic/claude-3.5-haiku');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
     });
 
     test('resolves array-form model with object entries', async () => {
@@ -529,16 +561,12 @@ describe('createPresetManager', () => {
         output,
       );
 
-      expect(ctx.client.config.update).toHaveBeenCalledWith({
-        body: {
-          agent: {
-            oracle: {
-              model: 'anthropic/claude-sonnet-4-6',
-              variant: 'thinking',
-            },
-          },
-        },
-      });
+      const text = getOutputText(output);
+      expect(text).toContain('Saved preset "thinker"');
+      expect(text).toContain('oracle');
+      expect(text).toContain('variant: thinking');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
     });
 
     test('shows variant and options in switch summary', async () => {
@@ -583,7 +611,7 @@ describe('createPresetManager', () => {
         { command: 'preset', sessionID: 's1', arguments: 'cheap' },
         output1,
       );
-      expect(getOutputText(output1)).toContain('Switched');
+      expect(getOutputText(output1)).toContain('Saved preset');
 
       // List presets should now show cheap as active
       const output2 = createOutput();
@@ -599,7 +627,7 @@ describe('createPresetManager', () => {
         { command: 'preset', sessionID: 's1', arguments: 'powerful' },
         output3,
       );
-      expect(getOutputText(output3)).toContain('Switched to preset "powerful"');
+      expect(getOutputText(output3)).toContain('Saved preset "powerful"');
 
       // List should now show powerful as active
       const output4 = createOutput();
@@ -648,7 +676,7 @@ describe('createPresetManager', () => {
   });
 
   describe('preset switching stale state', () => {
-    test('reset updates for agents removed when switching presets', async () => {
+    test('switching presets updates active preset without runtime config update', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
@@ -671,16 +699,9 @@ describe('createPresetManager', () => {
         { command: 'preset', sessionID: 's1', arguments: 'cheap' },
         output1,
       );
-      expect(ctx.client.config.update).toHaveBeenCalledWith({
-        body: {
-          agent: {
-            oracle: { model: 'cheap-model', temperature: 0.3 },
-          },
-        },
-      });
-
-      // Reset mock for next call
-      ctx.client.config.update.mockClear();
+      expect(getOutputText(output1)).toContain('Saved preset "cheap"');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
 
       const output2 = createOutput();
       await manager.handleCommandExecuteBefore(
@@ -688,21 +709,13 @@ describe('createPresetManager', () => {
         output2,
       );
 
-      // Second update should reset oracle to baseline and set orchestrator
-      expect(ctx.client.config.update).toHaveBeenCalledWith({
-        body: {
-          agent: {
-            oracle: { model: 'baseline-model' },
-            orchestrator: { model: 'powerful-model' },
-          },
-        },
-      });
-
-      // Cleanup
-      setActiveRuntimePreset(null);
+      expect(getOutputText(output2)).toContain('Saved preset "powerful"');
+      expect(getActiveRuntimePreset()).toBe('powerful');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
     });
 
-    test('no reset updates when new preset covers same agents', async () => {
+    test('new preset with same agents still avoids runtime config update', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
@@ -722,16 +735,9 @@ describe('createPresetManager', () => {
         { command: 'preset', sessionID: 's1', arguments: 'cheap' },
         output1,
       );
-      expect(ctx.client.config.update).toHaveBeenCalledWith({
-        body: {
-          agent: {
-            oracle: { model: 'a' },
-          },
-        },
-      });
-
-      // Reset mock for next call
-      ctx.client.config.update.mockClear();
+      expect(getOutputText(output1)).toContain('Saved preset "cheap"');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
 
       const output2 = createOutput();
       await manager.handleCommandExecuteBefore(
@@ -739,24 +745,13 @@ describe('createPresetManager', () => {
         output2,
       );
 
-      // Second update should only have oracle, no reset updates
-      expect(ctx.client.config.update).toHaveBeenCalledWith({
-        body: {
-          agent: {
-            oracle: { model: 'b' },
-          },
-        },
-      });
-
-      // Cleanup
-      setActiveRuntimePreset(null);
+      expect(getOutputText(output2)).toContain('Saved preset "cheaper"');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
     });
 
-    test('preset state rolled back on config.update error', async () => {
+    test('preset state persists across successive switches without runtime update', async () => {
       const ctx = createMockContext();
-      ctx.client.config.update = mock(async () => {
-        throw new Error('Server unavailable');
-      });
       const config: PluginConfig = {
         presets: {
           cheap: {
@@ -769,9 +764,6 @@ describe('createPresetManager', () => {
       };
       const manager = createPresetManager(ctx, config);
 
-      // Reset mock for successful switch
-      ctx.client.config.update = mock(async () => ({}));
-
       // Switch to cheap successfully
       const output1 = createOutput();
       await manager.handleCommandExecuteBefore(
@@ -780,24 +772,17 @@ describe('createPresetManager', () => {
       );
       expect(getActiveRuntimePreset()).toBe('cheap');
 
-      // Reset mock to throw error
-      ctx.client.config.update = mock(async () => {
-        throw new Error('Server unavailable');
-      });
-
-      // Try to switch to expensive but it fails
+      // Try to switch to expensive
       const output2 = createOutput();
       await manager.handleCommandExecuteBefore(
         { command: 'preset', sessionID: 's1', arguments: 'expensive' },
         output2,
       );
 
-      // Active preset should still be "cheap" after error
-      expect(getActiveRuntimePreset()).toBe('cheap');
-      expect(getOutputText(output2)).toContain('Failed to switch preset');
-
-      // Cleanup
-      setActiveRuntimePreset(null);
+      expect(getActiveRuntimePreset()).toBe('expensive');
+      expect(getOutputText(output2)).toContain('Saved preset "expensive"');
+      expect(ctx.client.config.update).not.toHaveBeenCalled();
+      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
     });
 
     test('activePreset syncs from runtime-preset state on factory creation', async () => {

+ 54 - 81
src/tools/preset-manager.ts

@@ -1,4 +1,6 @@
+import * as fs from 'node:fs';
 import type { PluginInput } from '@opencode-ai/plugin';
+import { stripJsonComments } from '../cli/config-io';
 import type {
   AgentOverrideConfig,
   ModelEntry,
@@ -6,9 +8,9 @@ import type {
   Preset,
 } from '../config';
 import { AGENT_ALIASES } from '../config/constants';
+import { findPluginConfigPaths } from '../config/loader';
 import {
   getActiveRuntimePreset,
-  rollbackRuntimePreset,
   setActiveRuntimePresetWithPrevious,
 } from '../config/runtime-preset';
 import { readTuiSnapshot, recordTuiAgentModels } from '../tui-state';
@@ -19,13 +21,12 @@ const COMMAND_NAME = 'preset';
 /**
  * Creates a preset manager for the /preset slash command.
  *
- * Uses the OpenCode SDK's client.config.update() to change agent models
- * and temperatures without restarting. The server invalidates its agent
- * cache and re-reads config on the next prompt.
- *
- * Note: activePreset is tracked in-memory only and resets on plugin reload.
- * If the user manually edits config or another mechanism changes agents,
- * this tracker may become stale until the next /preset call.
+ * Stores the requested runtime preset in plugin memory and updates the
+ * plugin-facing TUI snapshot. It deliberately does not call OpenCode's
+ * client.config.update() or instance.dispose() from command hooks because
+ * OpenCode continues the same command into the prompt loop after
+ * command.execute.before, which can break the active conversation while
+ * the agent registry is changing.
  */
 export function createPresetManager(ctx: PluginInput, config: PluginConfig) {
   // Sync from module-level state in case of plugin re-init — the runtime
@@ -98,7 +99,9 @@ export function createPresetManager(ctx: PluginInput, config: PluginConfig) {
   }
 
   /**
-   * Switch to the given preset name by calling client.config.update().
+   * Save the given preset name for the plugin runtime without mutating the
+   * active OpenCode instance. The preset is applied by the plugin config path
+   * when OpenCode reloads the plugin in a safe lifecycle boundary.
    */
   async function switchPreset(
     presetName: string,
@@ -141,39 +144,7 @@ export function createPresetManager(ctx: PluginInput, config: PluginConfig) {
       }
     }
 
-    // Build reset updates for agents in the old preset but not the new one.
-    // The SDK accumulates client.config.update() calls, so switching from
-    // Preset A to Preset B leaks A's variant/temperature/options on agents
-    // that aren't in B. Reset them to the config-file baseline values.
-    const currentRuntimePreset = getActiveRuntimePreset();
-    const resetUpdates: Record<
-      string,
-      {
-        model?: string;
-        temperature?: number;
-        variant?: string;
-        options?: Record<string, unknown>;
-      }
-    > = {};
-    if (currentRuntimePreset && config.presets?.[currentRuntimePreset]) {
-      const oldPreset = config.presets[currentRuntimePreset];
-      for (const rawName of Object.keys(oldPreset)) {
-        const resolvedOld = AGENT_ALIASES[rawName] ?? rawName;
-        if (resolvedOld in agentUpdates) continue; // new preset handles this agent
-        const baseline = config.agents?.[resolvedOld];
-        if (baseline) {
-          // Note: mapOverrideToAgentConfig(baseline) only emits fields
-          // the baseline defines. Scalar fields (variant/temperature/options)
-          // not in baseline are NOT cleared here. The config() hook in
-          // src/index.ts handles complete cleanup using the previous
-          // preset's override keys to drive deletion.
-          resetUpdates[resolvedOld] = mapOverrideToAgentConfig(baseline);
-        }
-      }
-    }
-
     const hasAgentUpdates = Object.keys(agentUpdates).length > 0;
-    const allUpdates = { ...resetUpdates, ...agentUpdates };
     if (!hasAgentUpdates) {
       output.parts.push(
         createInternalAgentTextPart(
@@ -183,59 +154,61 @@ export function createPresetManager(ctx: PluginInput, config: PluginConfig) {
       return;
     }
 
-    const previousPreset = activePreset;
     setActiveRuntimePresetWithPrevious(presetName);
 
+    // Persist preset name to user config file so it survives
+    // process restarts. The file write is best-effort and must not
+    // fail the command.
     try {
-      await ctx.client.config.update({
-        body: { agent: allUpdates },
-      });
+      const { userConfigPath } = findPluginConfigPaths(ctx.directory);
+      if (userConfigPath) {
+        const raw = fs.readFileSync(userConfigPath, 'utf-8');
+        const persisted = JSON.parse(stripJsonComments(raw)) as Record<
+          string,
+          unknown
+        >;
+        persisted.preset = presetName;
+        fs.writeFileSync(
+          userConfigPath,
+          `${JSON.stringify(persisted, null, 2)}\n`,
+        );
+      }
+    } catch {
+      // Non-critical: runtime state is set regardless
+    }
 
-      const snapshot = readTuiSnapshot();
-      const agentModels = { ...snapshot.agentModels };
-      for (const [agentName, agentConfig] of Object.entries(allUpdates)) {
-        if (typeof agentConfig.model === 'string') {
-          agentModels[agentName] = agentConfig.model;
-        }
+    const snapshot = readTuiSnapshot();
+    const agentModels = { ...snapshot.agentModels };
+    for (const [agentName, agentConfig] of Object.entries(agentUpdates)) {
+      if (typeof agentConfig.model === 'string') {
+        agentModels[agentName] = agentConfig.model;
       }
-      recordTuiAgentModels({ agentModels });
+    }
 
-      activePreset = presetName;
+    recordTuiAgentModels({ agentModels });
 
-      const summaryParts: string[] = [];
-      for (const [name, cfg] of Object.entries(agentUpdates)) {
-        const parts: string[] = [name];
-        if (cfg.model) parts.push(`model: ${cfg.model}`);
-        if (cfg.variant) parts.push(`variant: ${cfg.variant}`);
-        if (cfg.temperature !== undefined)
-          parts.push(`temp: ${cfg.temperature}`);
-        if (cfg.options) parts.push('options: yes');
-        summaryParts.push(parts.join(' → '));
-      }
-      if (Object.keys(resetUpdates).length > 0) {
-        summaryParts.push(
-          `Reset to baseline: ${Object.keys(resetUpdates).join(', ')}`,
-        );
-      }
+    activePreset = presetName;
 
-      output.parts.push(
-        createInternalAgentTextPart(
-          `Switched to preset "${presetName}":\n${summaryParts.join('\n')}`,
-        ),
-      );
-    } catch (err) {
-      rollbackRuntimePreset(previousPreset);
-      output.parts.push(
-        createInternalAgentTextPart(
-          `Failed to switch preset "${presetName}": ${String(err)}`,
-        ),
-      );
+    const summaryParts: string[] = [];
+    for (const [name, cfg] of Object.entries(agentUpdates)) {
+      const parts: string[] = [name];
+      if (cfg.model) parts.push(`model: ${cfg.model}`);
+      if (cfg.variant) parts.push(`variant: ${cfg.variant}`);
+      if (cfg.temperature !== undefined) parts.push(`temp: ${cfg.temperature}`);
+      if (cfg.options) parts.push('options: yes');
+      summaryParts.push(parts.join(' → '));
     }
+
+    output.parts.push(
+      createInternalAgentTextPart(
+        `Saved preset "${presetName}". Restart or reload OpenCode to apply it to agent configuration. The current session was not reloaded to avoid interrupting the active conversation.\n${summaryParts.join('\n')}`,
+      ),
+    );
   }
 
   /**
    * Map an AgentOverrideConfig (from plugin config) to the subset of
-   * SDK AgentConfig fields that client.config.update() can apply at runtime.
+   * Agent config fields shown in the saved preset summary.
    *
    * Excluded fields and why:
    * - prompt, orchestratorPrompt: require restart (resolved at init by config() hook)