Browse Source

fix(preset): persist runtime preset across plugin re-inits

The /preset command switches agent models at runtime via
client.config.update(), which triggers Instance.dispose() and
causes the config() hook to re-run with stale modelArrayMap,
reverting model changes back to config-file defaults.

Fix: module-level singleton (config/runtime-preset.ts) persists
the active runtime preset name across dispose()/re-init cycles.

Three layers of defense:
1. preset-manager: sets state before await, builds reset diffs for
   agents removed from new preset, rolls back on error
2. config() hook: overrides stale model resolution from runtime
   preset data, resets previous preset agents to config-file baseline
3. init block: defensive re-merge for potential full plugin re-run

Additional fixes from council review:
- Extract inline variant from array-form model entries
- Resolve alias keys (AGENT_ALIASES) to canonical agent names
- Clear stale variant/temperature/options on preset switch
- Swap deepMerge args so runtime preset wins over config-file
- Sync activePreset from module state on factory construction

Includes 10 new tests for runtime preset state management,
stale state handling, rollback on error, and factory sync.

Updates docs/preset-switching.md with reset behavior and
persistence semantics.
ReqX 3 months ago
parent
commit
56f12e66f0

+ 10 - 3
docs/preset-switching.md

@@ -13,7 +13,9 @@ Switch agent model presets at runtime without restarting OpenCode using the `/pr
 
 1. Define named presets in `oh-my-opencode-slim.jsonc` under the `presets` field
 2. Run `/preset <name>` to switch. The plugin calls the OpenCode SDK's `config.update()` method, which triggers a server-side cache invalidation
-3. The next LLM call uses the new models and settings
+3. Agents covered by the new preset get the preset's values
+4. Agents that were in the *previous* preset but are *not* in the new one are reset to their config-file baseline values
+5. The next LLM call uses the new models and settings
 
 ## Example Configuration
 
@@ -61,9 +63,9 @@ There are two ways to activate a preset:
 | Method | How | Persists? |
 |--------|-----|-----------|
 | Config file | Set `"preset": "cheap"` in `oh-my-opencode-slim.jsonc` | Yes, across restarts |
-| `/preset` command | Run `/preset cheap` during a session | No, reverts on restart |
+| `/preset` command | Run `/preset cheap` during a session | Across re-inits, not restarts |
 
-On restart, the plugin's `config()` hook re-applies the preset from the config file, overwriting any runtime switch. To make a runtime switch permanent, update the `"preset"` field in your config file.
+Runtime preset switches persist across plugin re-inits (triggered by config changes, etc.) within the same process, but revert on process restart. On restart, the plugin applies the preset from the config file. To make a runtime switch permanent, update the `"preset"` field in your config file.
 
 ## Example Output
 
@@ -92,6 +94,11 @@ Usage: /preset <name> to switch.
 Switched to preset "powerful":
 orchestrator → model: openai/gpt-5.5
 oracle → model: anthropic/claude-opus-4-6
+Reset to baseline: explorer
 ```
 
+The "Reset to baseline" line appears when agents from the previous preset
+are not present in the new one. Those agents are reverted to their
+config-file defaults.
+
 > See [Configuration](configuration.md) for the full preset option reference.

+ 1 - 1
src/config/index.ts

@@ -1,5 +1,5 @@
 export * from './constants';
 export * from './council-schema';
-export { loadAgentPrompt, loadPluginConfig } from './loader';
+export { deepMerge, loadAgentPrompt, loadPluginConfig } from './loader';
 export * from './schema';
 export { getAgentOverride, getCustomAgentNames } from './utils';

+ 1 - 1
src/config/loader.ts

@@ -88,7 +88,7 @@ function findConfigPathInDirs(
  * @param override - Override object whose values take precedence
  * @returns Merged object, or undefined if both inputs are undefined
  */
-function deepMerge<T extends Record<string, unknown>>(
+export function deepMerge<T extends Record<string, unknown>>(
   base?: T,
   override?: T,
 ): T | undefined {

+ 61 - 0
src/config/runtime-preset.test.ts

@@ -0,0 +1,61 @@
+import { describe, expect, test } from 'bun:test';
+import {
+  getActiveRuntimePreset,
+  getPreviousRuntimePreset,
+  rollbackRuntimePreset,
+  setActiveRuntimePreset,
+  setActiveRuntimePresetWithPrevious,
+} from './runtime-preset';
+
+describe('runtime-preset', () => {
+  // Cleanup after each test to avoid state leakage
+  test('getActiveRuntimePreset returns null initially', () => {
+    setActiveRuntimePreset(null);
+    expect(getActiveRuntimePreset()).toBeNull();
+    setActiveRuntimePreset(null);
+  });
+
+  test('setActiveRuntimePreset sets the active preset', () => {
+    setActiveRuntimePreset(null);
+    setActiveRuntimePreset('foo');
+    expect(getActiveRuntimePreset()).toBe('foo');
+    setActiveRuntimePreset(null);
+  });
+
+  test('setActiveRuntimePresetWithPrevious sets active and previous', () => {
+    setActiveRuntimePreset(null);
+    setActiveRuntimePreset('old');
+    setActiveRuntimePresetWithPrevious('new');
+    expect(getActiveRuntimePreset()).toBe('new');
+    expect(getPreviousRuntimePreset()).toBe('old');
+    setActiveRuntimePreset(null);
+  });
+
+  test('setActiveRuntimePresetWithPrevious with null sets previous to old', () => {
+    setActiveRuntimePreset(null);
+    setActiveRuntimePreset('old');
+    setActiveRuntimePresetWithPrevious(null);
+    expect(getActiveRuntimePreset()).toBeNull();
+    expect(getPreviousRuntimePreset()).toBe('old');
+    setActiveRuntimePreset(null);
+  });
+
+  test('rollbackRuntimePreset restores active and clears previous', () => {
+    setActiveRuntimePreset(null);
+    setActiveRuntimePreset('old');
+    setActiveRuntimePresetWithPrevious('new');
+    rollbackRuntimePreset('old');
+    expect(getActiveRuntimePreset()).toBe('old');
+    expect(getPreviousRuntimePreset()).toBeNull();
+    setActiveRuntimePreset(null);
+  });
+
+  test('rollbackRuntimePreset with null clears active and previous', () => {
+    setActiveRuntimePreset(null);
+    setActiveRuntimePresetWithPrevious('new');
+    rollbackRuntimePreset(null);
+    expect(getActiveRuntimePreset()).toBeNull();
+    expect(getPreviousRuntimePreset()).toBeNull();
+    setActiveRuntimePreset(null);
+  });
+});

+ 37 - 0
src/config/runtime-preset.ts

@@ -0,0 +1,37 @@
+/**
+ * Module-level runtime preset state.
+ *
+ * Survives plugin re-inits triggered by client.config.update() →
+ * Instance.dispose(). The plugin function re-runs but this module-level
+ * variable persists within the same Node.js process.
+ */
+
+let activeRuntimePreset: string | null = null;
+
+export function setActiveRuntimePreset(name: string | null): void {
+  activeRuntimePreset = name;
+}
+
+export function getActiveRuntimePreset(): string | null {
+  return activeRuntimePreset;
+}
+
+/**
+ * Returns the name of the previously active runtime preset (before the
+ * current one), used to compute reset diffs when switching presets.
+ */
+let previousRuntimePreset: string | null = null;
+
+export function getPreviousRuntimePreset(): string | null {
+  return previousRuntimePreset;
+}
+
+export function setActiveRuntimePresetWithPrevious(name: string | null): void {
+  previousRuntimePreset = activeRuntimePreset;
+  activeRuntimePreset = name;
+}
+
+export function rollbackRuntimePreset(previous: string | null): void {
+  activeRuntimePreset = previous;
+  previousRuntimePreset = null;
+}

+ 151 - 1
src/index.ts

@@ -1,8 +1,18 @@
 import type { Plugin } from '@opencode-ai/plugin';
 import { createAgents, getAgentConfigs, getDisabledAgents } from './agents';
 import { buildOrchestratorPrompt } from './agents/orchestrator';
-import { loadPluginConfig, type MultiplexerConfig } from './config';
+import {
+  deepMerge,
+  loadPluginConfig,
+  type MultiplexerConfig,
+} from './config';
+import { AGENT_ALIASES } from './config/constants';
 import { parseList } from './config/agent-mcps';
+import {
+  getActiveRuntimePreset,
+  getPreviousRuntimePreset,
+  setActiveRuntimePreset,
+} from './config/runtime-preset';
 import { CouncilManager } from './council';
 import {
   createApplyPatchHook,
@@ -85,6 +95,11 @@ async function probeJSDOM(): Promise<string | null> {
   }
 }
 
+// Module-level runtime preset tracking. Survives plugin re-inits triggered
+// by client.config.update() → Instance.dispose(). When the plugin function
+// re-runs, it checks this variable and applies the runtime preset instead
+// of the config file's preset. State lives in config/runtime-preset.ts.
+
 const OhMyOpenCodeLite: Plugin = async (ctx) => {
   const sessionId = new Date().toISOString().replace(/[-:]/g, '').slice(0, 15);
   initLogger(sessionId);
@@ -129,6 +144,25 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
   try {
     config = loadPluginConfig(ctx.directory);
+
+    // Safety net: if a runtime preset was set via /preset command and
+    // OpenCode ever fully re-runs the plugin function (not just the
+    // config() hook), override config.preset so agents are created with
+    // the correct models. Currently only the config() hook re-runs after
+    // Instance.dispose(), so this is a defensive guard.
+    const runtimePreset = getActiveRuntimePreset();
+    if (runtimePreset && config.presets?.[runtimePreset]) {
+      config.preset = runtimePreset;
+      // Re-merge runtime preset into config.agents (loadPluginConfig
+      // already merged the config-file preset, not the runtime one).
+      // Runtime preset is override so it wins over config-file preset.
+      const presetAgents = config.presets[runtimePreset];
+      config.agents = deepMerge(config.agents, presetAgents);
+    } else if (runtimePreset) {
+      // Preset was deleted from config since last switch — clear stale state
+      setActiveRuntimePreset(null);
+    }
+
     disabledAgents = getDisabledAgents(config);
     rewriteDisplayNameMentions = createDisplayNameMentionRewriter(config);
     agentDefs = createAgents(config);
@@ -472,6 +506,122 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         }
       }
 
+      // Runtime preset override: if /preset switched to a runtime preset,
+      // override the model/variant/temperature from the preset's agent
+      // config. This runs after the normal model resolution because the
+      // config() hook re-runs with stale modelArrayMap after dispose(),
+      // but the runtime preset data is in the captured `config` closure.
+      const runtimePresetName = getActiveRuntimePreset();
+      if (runtimePresetName && config.presets?.[runtimePresetName]) {
+        const runtimePreset = config.presets[runtimePresetName];
+        for (const [agentName, override] of Object.entries(runtimePreset)) {
+          // Resolve legacy alias keys (e.g. "explore" → "explorer")
+          // so presets using aliases work in this path.
+          const resolvedName = AGENT_ALIASES[agentName] ?? agentName;
+          const entry = configAgent[resolvedName] as
+            | Record<string, unknown>
+            | undefined;
+          if (!entry) continue;
+
+          if (typeof override.model === 'string') {
+            entry.model = override.model;
+          } else if (
+            Array.isArray(override.model) &&
+            override.model.length > 0
+          ) {
+            const first = override.model[0];
+            entry.model = typeof first === 'string' ? first : first.id;
+            // Extract inline variant from array-form model entry
+            if (typeof first !== 'string' && first.variant) {
+              entry.variant = first.variant;
+            }
+          }
+          // Explicitly set or clear scalar fields so switching from
+          // Preset A (which sets a field) to Preset B (which doesn't)
+          // doesn't leave stale values behind.
+          if (typeof override.variant === 'string') {
+            entry.variant = override.variant;
+          } else if ('variant' in override) {
+            delete entry.variant;
+          }
+          if (typeof override.temperature === 'number') {
+            entry.temperature = override.temperature;
+          } else if ('temperature' in override) {
+            delete entry.temperature;
+          }
+          if (
+            override.options &&
+            typeof override.options === 'object' &&
+            !Array.isArray(override.options)
+          ) {
+            entry.options = override.options;
+          } else if ('options' in override) {
+            delete entry.options;
+          }
+          log('[plugin] runtime preset override', {
+            preset: runtimePresetName,
+            agent: agentName,
+            model: entry.model as string,
+          });
+        }
+
+        // Reset agents from the previous preset that aren't in the new one.
+        // The stale model resolution above overwrites the reset values sent
+        // by preset-manager, so we re-apply them here from config-file
+        // baseline.
+        const prevPresetName = getPreviousRuntimePreset();
+        if (prevPresetName && config.presets?.[prevPresetName]) {
+          const prevPreset = config.presets[prevPresetName];
+          // Build resolved key set from new preset for correct comparison
+          // (handles alias keys like "explore" → "explorer")
+          const newPresetResolved = new Set(
+            Object.keys(runtimePreset).map(
+              (k) => AGENT_ALIASES[k] ?? k,
+            ),
+          );
+          for (const agentName of Object.keys(prevPreset)) {
+            const resolvedName =
+              AGENT_ALIASES[agentName] ?? agentName;
+            if (newPresetResolved.has(resolvedName))
+              continue; // new preset handles it
+            const entry = configAgent[resolvedName] as
+              | Record<string, unknown>
+              | undefined;
+            if (!entry) continue;
+            // Reset to config-file baseline
+            const baseline =
+              config.agents?.[resolvedName];
+            if (typeof baseline?.model === 'string') {
+              entry.model = baseline.model;
+            }
+            if (typeof baseline?.variant === 'string') {
+              entry.variant = baseline.variant;
+            } else if (baseline && 'variant' in baseline) {
+              delete entry.variant;
+            }
+            if (typeof baseline?.temperature === 'number') {
+              entry.temperature = baseline.temperature;
+            } else if (baseline && 'temperature' in baseline) {
+              delete entry.temperature;
+            }
+            if (
+              baseline?.options &&
+              typeof baseline.options === 'object' &&
+              !Array.isArray(baseline.options)
+            ) {
+              entry.options = baseline.options;
+            } else if (baseline && 'options' in baseline) {
+              delete entry.options;
+            }
+            log('[plugin] runtime preset reset from previous', {
+              previousPreset: prevPresetName,
+              agent: resolvedName,
+              model: entry.model as string,
+            });
+          }
+        }
+      }
+
       // Merge MCP configs
       const configMcp = opencodeConfig.mcp as
         | Record<string, unknown>

+ 334 - 142
src/tools/preset-manager.test.ts

@@ -1,6 +1,10 @@
-import { describe, expect, mock, test } from "bun:test";
-import type { PluginConfig } from "../config";
-import { createPresetManager } from "./preset-manager";
+import { describe, expect, mock, test } from 'bun:test';
+import type { PluginConfig } from '../config';
+import {
+  getActiveRuntimePreset,
+  setActiveRuntimePreset,
+} from '../config/runtime-preset';
+import { createPresetManager } from './preset-manager';
 
 function createMockContext() {
   const configUpdate = mock(async () => ({}));
@@ -10,7 +14,7 @@ function createMockContext() {
         update: configUpdate,
       },
     },
-    directory: "/tmp/test",
+    directory: '/tmp/test',
   } as any;
 }
 
@@ -20,37 +24,37 @@ function createOutput() {
 
 function getOutputText(output: ReturnType<typeof createOutput>): string {
   return output.parts
-    .filter((p) => p.type === "text")
-    .map((p) => p.text ?? "")
-    .join("\n");
+    .filter((p) => p.type === 'text')
+    .map((p) => p.text ?? '')
+    .join('\n');
 }
 
-describe("createPresetManager", () => {
-  describe("handleCommandExecuteBefore", () => {
-    test("ignores non-preset commands", async () => {
+describe('createPresetManager', () => {
+  describe('handleCommandExecuteBefore', () => {
+    test('ignores non-preset commands', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {};
       const manager = createPresetManager(ctx, config);
       const output = createOutput();
 
       await manager.handleCommandExecuteBefore(
-        { command: "auto-continue", sessionID: "s1", arguments: "on" },
-        output
+        { command: 'auto-continue', sessionID: 's1', arguments: 'on' },
+        output,
       );
 
       expect(output.parts).toHaveLength(0);
       expect(ctx.client.config.update).not.toHaveBeenCalled();
     });
 
-    test("lists available presets when no argument given", async () => {
+    test('lists available presets when no argument given', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
           cheap: {
-            orchestrator: { model: "anthropic/claude-3.5-haiku" },
+            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
           },
           powerful: {
-            orchestrator: { model: "openai/gpt-5.5" },
+            orchestrator: { model: 'openai/gpt-5.5' },
           },
         },
       };
@@ -58,59 +62,59 @@ describe("createPresetManager", () => {
       const output = createOutput();
 
       await manager.handleCommandExecuteBefore(
-        { command: "preset", sessionID: "s1", arguments: "" },
-        output
+        { command: 'preset', sessionID: 's1', arguments: '' },
+        output,
       );
 
       const text = getOutputText(output);
-      expect(text).toContain("cheap");
-      expect(text).toContain("powerful");
+      expect(text).toContain('cheap');
+      expect(text).toContain('powerful');
       expect(ctx.client.config.update).not.toHaveBeenCalled();
     });
 
-    test("lists presets with active marker when preset is set", async () => {
+    test('lists presets with active marker when preset is set', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
-        preset: "cheap",
+        preset: 'cheap',
         presets: {
-          cheap: { orchestrator: { model: "anthropic/claude-3.5-haiku" } },
-          powerful: { orchestrator: { model: "openai/gpt-5.5" } },
+          cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+          powerful: { orchestrator: { model: 'openai/gpt-5.5' } },
         },
       };
       const manager = createPresetManager(ctx, config);
       const output = createOutput();
 
       await manager.handleCommandExecuteBefore(
-        { command: "preset", sessionID: "s1", arguments: "" },
-        output
+        { command: 'preset', sessionID: 's1', arguments: '' },
+        output,
       );
 
       const text = getOutputText(output);
-      expect(text).toContain("← active");
+      expect(text).toContain('← active');
     });
 
-    test("shows no-presets message when none configured", async () => {
+    test('shows no-presets message when none configured', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {};
       const manager = createPresetManager(ctx, config);
       const output = createOutput();
 
       await manager.handleCommandExecuteBefore(
-        { command: "preset", sessionID: "s1", arguments: "" },
-        output
+        { command: 'preset', sessionID: 's1', arguments: '' },
+        output,
       );
 
       const text = getOutputText(output);
-      expect(text).toContain("No presets configured");
+      expect(text).toContain('No presets configured');
     });
 
-    test("switches preset and calls config.update", async () => {
+    test('switches preset and calls config.update', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
           cheap: {
-            orchestrator: { model: "anthropic/claude-3.5-haiku" },
-            explorer: { model: "openai/gpt-5.4-mini" },
+            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
+            explorer: { model: 'openai/gpt-5.4-mini' },
           },
         },
       };
@@ -118,32 +122,32 @@ describe("createPresetManager", () => {
       const output = createOutput();
 
       await manager.handleCommandExecuteBefore(
-        { command: "preset", sessionID: "s1", arguments: "cheap" },
-        output
+        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
+        output,
       );
 
       const text = getOutputText(output);
       expect(text).toContain('Switched to preset "cheap"');
-      expect(text).toContain("orchestrator");
-      expect(text).toContain("anthropic/claude-3.5-haiku");
-      expect(text).toContain("explorer");
+      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" },
+            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
+            explorer: { model: 'openai/gpt-5.4-mini' },
           },
         },
       });
     });
 
-    test("passes temperature in config update", async () => {
+    test('passes temperature in config update', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
           precise: {
-            orchestrator: { model: "openai/o3", temperature: 0.1 },
+            orchestrator: { model: 'openai/o3', temperature: 0.1 },
           },
         },
       };
@@ -151,27 +155,27 @@ describe("createPresetManager", () => {
       const output = createOutput();
 
       await manager.handleCommandExecuteBefore(
-        { command: "preset", sessionID: "s1", arguments: "precise" },
-        output
+        { command: 'preset', sessionID: 's1', arguments: 'precise' },
+        output,
       );
 
       expect(ctx.client.config.update).toHaveBeenCalledWith({
         body: {
           agent: {
-            orchestrator: { model: "openai/o3", temperature: 0.1 },
+            orchestrator: { model: 'openai/o3', temperature: 0.1 },
           },
         },
       });
     });
 
-    test("passes variant in config update", async () => {
+    test('passes variant in config update', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
           thinker: {
             oracle: {
-              model: "anthropic/claude-sonnet-4-6",
-              variant: "thinking",
+              model: 'anthropic/claude-sonnet-4-6',
+              variant: 'thinking',
             },
           },
         },
@@ -180,83 +184,83 @@ describe("createPresetManager", () => {
       const output = createOutput();
 
       await manager.handleCommandExecuteBefore(
-        { command: "preset", sessionID: "s1", arguments: "thinker" },
-        output
+        { command: 'preset', sessionID: 's1', arguments: 'thinker' },
+        output,
       );
 
       expect(ctx.client.config.update).toHaveBeenCalledWith({
         body: {
           agent: {
             oracle: {
-              model: "anthropic/claude-sonnet-4-6",
-              variant: "thinking",
+              model: 'anthropic/claude-sonnet-4-6',
+              variant: 'thinking',
             },
           },
         },
       });
     });
 
-    test("shows error for unknown preset name", async () => {
+    test('shows error for unknown preset name', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
-          cheap: { orchestrator: { model: "anthropic/claude-3.5-haiku" } },
+          cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
         },
       };
       const manager = createPresetManager(ctx, config);
       const output = createOutput();
 
       await manager.handleCommandExecuteBefore(
-        { command: "preset", sessionID: "s1", arguments: "nonexistent" },
-        output
+        { command: 'preset', sessionID: 's1', arguments: 'nonexistent' },
+        output,
       );
 
       const text = getOutputText(output);
-      expect(text).toContain("not found");
-      expect(text).toContain("cheap");
+      expect(text).toContain('not found');
+      expect(text).toContain('cheap');
       expect(ctx.client.config.update).not.toHaveBeenCalled();
     });
 
-    test("shows error when no presets configured but argument given", async () => {
+    test('shows error when no presets configured but argument given', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {};
       const manager = createPresetManager(ctx, config);
       const output = createOutput();
 
       await manager.handleCommandExecuteBefore(
-        { command: "preset", sessionID: "s1", arguments: "cheap" },
-        output
+        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
+        output,
       );
 
       const text = getOutputText(output);
-      expect(text).toContain("not found");
-      expect(text).toContain("No presets configured");
+      expect(text).toContain('not found');
+      expect(text).toContain('No presets configured');
     });
 
-    test("handles config.update error gracefully", async () => {
+    test('handles config.update error gracefully', async () => {
       const ctx = createMockContext();
       ctx.client.config.update = mock(async () => {
-        throw new Error("Server unavailable");
+        throw new Error('Server unavailable');
       });
       const config: PluginConfig = {
         presets: {
-          cheap: { orchestrator: { model: "anthropic/claude-3.5-haiku" } },
+          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
+        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
+        output,
       );
 
       const text = getOutputText(output);
-      expect(text).toContain("Failed to switch preset");
-      expect(text).toContain("Server unavailable");
+      expect(text).toContain('Failed to switch preset');
+      expect(text).toContain('Server unavailable');
     });
 
-    test("shows empty preset message when preset has no valid overrides", async () => {
+    test('shows empty preset message when preset has no valid overrides', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
@@ -269,24 +273,24 @@ describe("createPresetManager", () => {
       const output = createOutput();
 
       await manager.handleCommandExecuteBefore(
-        { command: "preset", sessionID: "s1", arguments: "empty" },
-        output
+        { command: 'preset', sessionID: 's1', arguments: 'empty' },
+        output,
       );
 
       const text = getOutputText(output);
-      expect(text).toContain("empty");
+      expect(text).toContain('empty');
       expect(ctx.client.config.update).not.toHaveBeenCalled();
     });
 
-    test("forwards options field in config update", async () => {
+    test('forwards options field in config update', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
           thinker: {
             oracle: {
-              model: "anthropic/claude-sonnet-4-6",
+              model: 'anthropic/claude-sonnet-4-6',
               options: {
-                thinking: { type: "enabled", budgetTokens: 10000 },
+                thinking: { type: 'enabled', budgetTokens: 10000 },
               },
             },
           },
@@ -296,17 +300,17 @@ describe("createPresetManager", () => {
       const output = createOutput();
 
       await manager.handleCommandExecuteBefore(
-        { command: "preset", sessionID: "s1", arguments: "thinker" },
-        output
+        { command: 'preset', sessionID: 's1', arguments: 'thinker' },
+        output,
       );
 
       expect(ctx.client.config.update).toHaveBeenCalledWith({
         body: {
           agent: {
             oracle: {
-              model: "anthropic/claude-sonnet-4-6",
+              model: 'anthropic/claude-sonnet-4-6',
               options: {
-                thinking: { type: "enabled", budgetTokens: 10000 },
+                thinking: { type: 'enabled', budgetTokens: 10000 },
               },
             },
           },
@@ -314,19 +318,19 @@ describe("createPresetManager", () => {
       });
     });
 
-    test("trims whitespace from preset name argument", async () => {
+    test('trims whitespace from preset name argument', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
-          cheap: { orchestrator: { model: "anthropic/claude-3.5-haiku" } },
+          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
+        { command: 'preset', sessionID: 's1', arguments: '  cheap  ' },
+        output,
       );
 
       const text = getOutputText(output);
@@ -334,53 +338,53 @@ describe("createPresetManager", () => {
       expect(ctx.client.config.update).toHaveBeenCalledTimes(1);
     });
 
-    test("shows suggestion for multi-word arguments", async () => {
+    test('shows suggestion for multi-word arguments', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
-          cheap: { orchestrator: { model: "anthropic/claude-3.5-haiku" } },
+          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 powerful" },
-        output
+        { command: 'preset', sessionID: 's1', arguments: 'cheap powerful' },
+        output,
       );
 
       const text = getOutputText(output);
-      expect(text).toContain("cannot contain spaces");
-      expect(text).toContain("/preset cheap");
+      expect(text).toContain('cannot contain spaces');
+      expect(text).toContain('/preset cheap');
       expect(ctx.client.config.update).not.toHaveBeenCalled();
     });
 
-    test("catches tab-separated arguments", async () => {
+    test('catches tab-separated arguments', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
-          cheap: { orchestrator: { model: "anthropic/claude-3.5-haiku" } },
+          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\tpowerful" },
-        output
+        { command: 'preset', sessionID: 's1', arguments: 'cheap\tpowerful' },
+        output,
       );
 
       const text = getOutputText(output);
-      expect(text).toContain("cannot contain spaces");
+      expect(text).toContain('cannot contain spaces');
       expect(ctx.client.config.update).not.toHaveBeenCalled();
     });
 
-    test("skips agents with empty overrides in mixed preset", async () => {
+    test('skips agents with empty overrides in mixed preset', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
           mixed: {
-            orchestrator: { model: "anthropic/claude-3.5-haiku" },
+            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
             explorer: {},
             oracle: { temperature: 0.3 },
           },
@@ -390,8 +394,8 @@ describe("createPresetManager", () => {
       const output = createOutput();
 
       await manager.handleCommandExecuteBefore(
-        { command: "preset", sessionID: "s1", arguments: "mixed" },
-        output
+        { command: 'preset', sessionID: 's1', arguments: 'mixed' },
+        output,
       );
 
       const text = getOutputText(output);
@@ -400,20 +404,20 @@ describe("createPresetManager", () => {
       expect(ctx.client.config.update).toHaveBeenCalledWith({
         body: {
           agent: {
-            orchestrator: { model: "anthropic/claude-3.5-haiku" },
+            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
             oracle: { temperature: 0.3 },
           },
         },
       });
     });
 
-    test("resolves array-form model to first entry", async () => {
+    test('resolves array-form model to first entry', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
           fallback: {
             orchestrator: {
-              model: ["anthropic/claude-3.5-haiku", "openai/gpt-5.5"],
+              model: ['anthropic/claude-3.5-haiku', 'openai/gpt-5.5'],
             },
           },
         },
@@ -422,8 +426,8 @@ describe("createPresetManager", () => {
       const output = createOutput();
 
       await manager.handleCommandExecuteBefore(
-        { command: "preset", sessionID: "s1", arguments: "fallback" },
-        output
+        { command: 'preset', sessionID: 's1', arguments: 'fallback' },
+        output,
       );
 
       const text = getOutputText(output);
@@ -431,21 +435,21 @@ describe("createPresetManager", () => {
       expect(ctx.client.config.update).toHaveBeenCalledWith({
         body: {
           agent: {
-            orchestrator: { model: "anthropic/claude-3.5-haiku" },
+            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
           },
         },
       });
     });
 
-    test("resolves array-form model with object entries", async () => {
+    test('resolves array-form model with object entries', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
           thinker: {
             oracle: {
               model: [
-                { id: "anthropic/claude-sonnet-4-6", variant: "thinking" },
-                { id: "openai/o3" },
+                { id: 'anthropic/claude-sonnet-4-6', variant: 'thinking' },
+                { id: 'openai/o3' },
               ],
             },
           },
@@ -455,31 +459,31 @@ describe("createPresetManager", () => {
       const output = createOutput();
 
       await manager.handleCommandExecuteBefore(
-        { command: "preset", sessionID: "s1", arguments: "thinker" },
-        output
+        { command: 'preset', sessionID: 's1', arguments: 'thinker' },
+        output,
       );
 
       expect(ctx.client.config.update).toHaveBeenCalledWith({
         body: {
           agent: {
             oracle: {
-              model: "anthropic/claude-sonnet-4-6",
-              variant: "thinking",
+              model: 'anthropic/claude-sonnet-4-6',
+              variant: 'thinking',
             },
           },
         },
       });
     });
 
-    test("shows variant and options in switch summary", async () => {
+    test('shows variant and options in switch summary', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
           thinker: {
             oracle: {
-              model: "anthropic/claude-sonnet-4-6",
-              variant: "thinking",
-              options: { thinking: { type: "enabled", budgetTokens: 10000 } },
+              model: 'anthropic/claude-sonnet-4-6',
+              variant: 'thinking',
+              options: { thinking: { type: 'enabled', budgetTokens: 10000 } },
             },
           },
         },
@@ -488,21 +492,21 @@ describe("createPresetManager", () => {
       const output = createOutput();
 
       await manager.handleCommandExecuteBefore(
-        { command: "preset", sessionID: "s1", arguments: "thinker" },
-        output
+        { command: 'preset', sessionID: 's1', arguments: 'thinker' },
+        output,
       );
 
       const text = getOutputText(output);
-      expect(text).toContain("variant: thinking");
-      expect(text).toContain("options: yes");
+      expect(text).toContain('variant: thinking');
+      expect(text).toContain('options: yes');
     });
 
-    test("tracks active preset after switch", async () => {
+    test('tracks active preset after switch', async () => {
       const ctx = createMockContext();
       const config: PluginConfig = {
         presets: {
-          cheap: { orchestrator: { model: "anthropic/claude-3.5-haiku" } },
-          powerful: { orchestrator: { model: "openai/gpt-5.5" } },
+          cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+          powerful: { orchestrator: { model: 'openai/gpt-5.5' } },
         },
       };
       const manager = createPresetManager(ctx, config);
@@ -510,39 +514,39 @@ describe("createPresetManager", () => {
       // Switch to cheap
       const output1 = createOutput();
       await manager.handleCommandExecuteBefore(
-        { command: "preset", sessionID: "s1", arguments: "cheap" },
-        output1
+        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
+        output1,
       );
-      expect(getOutputText(output1)).toContain("Switched");
+      expect(getOutputText(output1)).toContain('Switched');
 
       // List presets should now show cheap as active
       const output2 = createOutput();
       await manager.handleCommandExecuteBefore(
-        { command: "preset", sessionID: "s1", arguments: "" },
-        output2
+        { command: 'preset', sessionID: 's1', arguments: '' },
+        output2,
       );
-      expect(getOutputText(output2)).toContain("cheap ← active");
+      expect(getOutputText(output2)).toContain('cheap ← active');
 
       // Switch to powerful
       const output3 = createOutput();
       await manager.handleCommandExecuteBefore(
-        { command: "preset", sessionID: "s1", arguments: "powerful" },
-        output3
+        { command: 'preset', sessionID: 's1', arguments: 'powerful' },
+        output3,
       );
       expect(getOutputText(output3)).toContain('Switched to preset "powerful"');
 
       // List should now show powerful as active
       const output4 = createOutput();
       await manager.handleCommandExecuteBefore(
-        { command: "preset", sessionID: "s1", arguments: "" },
-        output4
+        { command: 'preset', sessionID: 's1', arguments: '' },
+        output4,
       );
-      expect(getOutputText(output4)).toContain("powerful ← active");
+      expect(getOutputText(output4)).toContain('powerful ← active');
     });
   });
 
-  describe("registerCommand", () => {
-    test("registers preset command when not present", () => {
+  describe('registerCommand', () => {
+    test('registers preset command when not present', () => {
       const ctx = createMockContext();
       const config: PluginConfig = {};
       const manager = createPresetManager(ctx, config);
@@ -553,15 +557,15 @@ describe("createPresetManager", () => {
       const command = (opencodeConfig.command as Record<string, unknown>)
         .preset as { template: string; description: string };
       expect(command).toBeDefined();
-      expect(command.template).toContain("presets");
-      expect(command.description).toContain("/preset");
+      expect(command.template).toContain('presets');
+      expect(command.description).toContain('/preset');
     });
 
-    test("does not overwrite existing preset command", () => {
+    test('does not overwrite existing preset command', () => {
       const ctx = createMockContext();
       const config: PluginConfig = {};
       const manager = createPresetManager(ctx, config);
-      const existing = { template: "custom", description: "custom" };
+      const existing = { template: 'custom', description: 'custom' };
       const opencodeConfig: Record<string, unknown> = {
         command: { preset: existing },
       };
@@ -569,8 +573,196 @@ describe("createPresetManager", () => {
       manager.registerCommand(opencodeConfig);
 
       expect((opencodeConfig.command as Record<string, unknown>).preset).toBe(
-        existing
+        existing,
       );
     });
   });
+
+  describe('preset switching stale state', () => {
+    test('reset updates for agents removed when switching presets', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        presets: {
+          cheap: {
+            oracle: { model: 'cheap-model', temperature: 0.3 },
+          },
+          powerful: {
+            orchestrator: { model: 'powerful-model' },
+          },
+        },
+        agents: {
+          oracle: { model: 'baseline-model' },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output1 = createOutput();
+
+      // Switch to cheap first
+      await manager.handleCommandExecuteBefore(
+        { 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();
+
+      const output2 = createOutput();
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'powerful' },
+        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);
+    });
+
+    test('no reset updates when new preset covers same agents', async () => {
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        presets: {
+          cheap: {
+            oracle: { model: 'a' },
+          },
+          cheaper: {
+            oracle: { model: 'b' },
+          },
+        },
+      };
+      const manager = createPresetManager(ctx, config);
+      const output1 = createOutput();
+
+      // Switch to cheap first
+      await manager.handleCommandExecuteBefore(
+        { 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();
+
+      const output2 = createOutput();
+      await manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: 'cheaper' },
+        output2,
+      );
+
+      // Second update should only have oracle, no reset updates
+      expect(ctx.client.config.update).toHaveBeenCalledWith({
+        body: {
+          agent: {
+            oracle: { model: 'b' },
+          },
+        },
+      });
+
+      // Cleanup
+      setActiveRuntimePreset(null);
+    });
+
+    test('preset state rolled back on config.update error', async () => {
+      const ctx = createMockContext();
+      ctx.client.config.update = mock(async () => {
+        throw new Error('Server unavailable');
+      });
+      const config: PluginConfig = {
+        presets: {
+          cheap: {
+            oracle: { model: 'a' },
+          },
+          expensive: {
+            oracle: { model: 'b' },
+          },
+        },
+      };
+      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(
+        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
+        output1,
+      );
+      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
+      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);
+    });
+
+    test('activePreset syncs from runtime-preset state on factory creation', () => {
+      // Set runtime preset before creating manager
+      setActiveRuntimePreset('cheap');
+
+      const ctx = createMockContext();
+      const config: PluginConfig = {
+        presets: {
+          cheap: {
+            oracle: { model: 'a' },
+          },
+          powerful: {
+            oracle: { model: 'b' },
+          },
+        },
+      };
+
+      // Create manager - should sync from module-level state
+      const manager = createPresetManager(ctx, config);
+
+      // List presets should show cheap as active
+      const output = createOutput();
+      manager.handleCommandExecuteBefore(
+        { command: 'preset', sessionID: 's1', arguments: '' },
+        output,
+      );
+
+      const text = getOutputText(output);
+      expect(text).toContain('cheap ← active');
+      expect(text).toContain('powerful');
+
+      // Cleanup
+      setActiveRuntimePreset(null);
+    });
+  });
 });

+ 61 - 16
src/tools/preset-manager.ts

@@ -5,6 +5,12 @@ import type {
   PluginConfig,
   Preset,
 } from '../config';
+import { AGENT_ALIASES } from '../config/constants';
+import {
+  getActiveRuntimePreset,
+  rollbackRuntimePreset,
+  setActiveRuntimePresetWithPrevious,
+} from '../config/runtime-preset';
 import { createInternalAgentTextPart } from '../utils';
 
 const COMMAND_NAME = 'preset';
@@ -21,7 +27,10 @@ const COMMAND_NAME = 'preset';
  * this tracker may become stale until the next /preset call.
  */
 export function createPresetManager(ctx: PluginInput, config: PluginConfig) {
-  let activePreset: string | null = config.preset ?? null;
+  // Sync from module-level state in case of plugin re-init — the runtime
+  // preset persists across dispose()/re-init cycles.
+  let activePreset: string | null =
+    getActiveRuntimePreset() ?? config.preset ?? null;
 
   /**
    * Handle the /preset command from command.execute.before hook.
@@ -124,13 +133,41 @@ export function createPresetManager(ctx: PluginInput, config: PluginConfig) {
       }
     > = {};
     for (const [agentName, override] of Object.entries(preset)) {
+      const resolvedName = AGENT_ALIASES[agentName] ?? agentName;
       const agentConfig = mapOverrideToAgentConfig(override);
       if (Object.keys(agentConfig).length > 0) {
-        agentUpdates[agentName] = agentConfig;
+        agentUpdates[resolvedName] = agentConfig;
       }
     }
 
-    if (Object.keys(agentUpdates).length === 0) {
+    // 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) {
+          resetUpdates[resolvedOld] = mapOverrideToAgentConfig(baseline);
+        }
+      }
+    }
+
+    const allUpdates = { ...resetUpdates, ...agentUpdates };
+    if (Object.keys(allUpdates).length === 0) {
       output.parts.push(
         createInternalAgentTextPart(
           `Preset "${presetName}" is empty (no agent overrides defined).`,
@@ -139,31 +176,39 @@ export function createPresetManager(ctx: PluginInput, config: PluginConfig) {
       return;
     }
 
+    const previousPreset = activePreset;
+    setActiveRuntimePresetWithPrevious(presetName);
+
     try {
       await ctx.client.config.update({
-        body: { agent: agentUpdates },
+        body: { agent: allUpdates },
       });
 
       activePreset = presetName;
 
-      const summary = Object.entries(agentUpdates)
-        .map(([name, cfg]) => {
-          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');
-          return parts.join(' → ');
-        })
-        .join('\n');
+      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(', ')}`,
+        );
+      }
 
       output.parts.push(
         createInternalAgentTextPart(
-          `Switched to preset "${presetName}":\n${summary}`,
+          `Switched to preset "${presetName}":\n${summaryParts.join('\n')}`,
         ),
       );
     } catch (err) {
+      rollbackRuntimePreset(previousPreset);
       output.parts.push(
         createInternalAgentTextPart(
           `Failed to switch preset "${presetName}": ${String(err)}`,