Skip to content

Commit 56f12e6

Browse files
committed
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.
1 parent 4df830f commit 56f12e6

8 files changed

Lines changed: 656 additions & 164 deletions

File tree

docs/preset-switching.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ Switch agent model presets at runtime without restarting OpenCode using the `/pr
1313

1414
1. Define named presets in `oh-my-opencode-slim.jsonc` under the `presets` field
1515
2. Run `/preset <name>` to switch. The plugin calls the OpenCode SDK's `config.update()` method, which triggers a server-side cache invalidation
16-
3. The next LLM call uses the new models and settings
16+
3. Agents covered by the new preset get the preset's values
17+
4. Agents that were in the *previous* preset but are *not* in the new one are reset to their config-file baseline values
18+
5. The next LLM call uses the new models and settings
1719

1820
## Example Configuration
1921

@@ -61,9 +63,9 @@ There are two ways to activate a preset:
6163
| Method | How | Persists? |
6264
|--------|-----|-----------|
6365
| Config file | Set `"preset": "cheap"` in `oh-my-opencode-slim.jsonc` | Yes, across restarts |
64-
| `/preset` command | Run `/preset cheap` during a session | No, reverts on restart |
66+
| `/preset` command | Run `/preset cheap` during a session | Across re-inits, not restarts |
6567

66-
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.
68+
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.
6769

6870
## Example Output
6971

@@ -92,6 +94,11 @@ Usage: /preset <name> to switch.
9294
Switched to preset "powerful":
9395
orchestrator → model: openai/gpt-5.5
9496
oracle → model: anthropic/claude-opus-4-6
97+
Reset to baseline: explorer
9598
```
9699

100+
The "Reset to baseline" line appears when agents from the previous preset
101+
are not present in the new one. Those agents are reverted to their
102+
config-file defaults.
103+
97104
> See [Configuration](configuration.md) for the full preset option reference.

src/config/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export * from './constants';
22
export * from './council-schema';
3-
export { loadAgentPrompt, loadPluginConfig } from './loader';
3+
export { deepMerge, loadAgentPrompt, loadPluginConfig } from './loader';
44
export * from './schema';
55
export { getAgentOverride, getCustomAgentNames } from './utils';

src/config/loader.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ function findConfigPathInDirs(
8888
* @param override - Override object whose values take precedence
8989
* @returns Merged object, or undefined if both inputs are undefined
9090
*/
91-
function deepMerge<T extends Record<string, unknown>>(
91+
export function deepMerge<T extends Record<string, unknown>>(
9292
base?: T,
9393
override?: T,
9494
): T | undefined {

src/config/runtime-preset.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import {
3+
getActiveRuntimePreset,
4+
getPreviousRuntimePreset,
5+
rollbackRuntimePreset,
6+
setActiveRuntimePreset,
7+
setActiveRuntimePresetWithPrevious,
8+
} from './runtime-preset';
9+
10+
describe('runtime-preset', () => {
11+
// Cleanup after each test to avoid state leakage
12+
test('getActiveRuntimePreset returns null initially', () => {
13+
setActiveRuntimePreset(null);
14+
expect(getActiveRuntimePreset()).toBeNull();
15+
setActiveRuntimePreset(null);
16+
});
17+
18+
test('setActiveRuntimePreset sets the active preset', () => {
19+
setActiveRuntimePreset(null);
20+
setActiveRuntimePreset('foo');
21+
expect(getActiveRuntimePreset()).toBe('foo');
22+
setActiveRuntimePreset(null);
23+
});
24+
25+
test('setActiveRuntimePresetWithPrevious sets active and previous', () => {
26+
setActiveRuntimePreset(null);
27+
setActiveRuntimePreset('old');
28+
setActiveRuntimePresetWithPrevious('new');
29+
expect(getActiveRuntimePreset()).toBe('new');
30+
expect(getPreviousRuntimePreset()).toBe('old');
31+
setActiveRuntimePreset(null);
32+
});
33+
34+
test('setActiveRuntimePresetWithPrevious with null sets previous to old', () => {
35+
setActiveRuntimePreset(null);
36+
setActiveRuntimePreset('old');
37+
setActiveRuntimePresetWithPrevious(null);
38+
expect(getActiveRuntimePreset()).toBeNull();
39+
expect(getPreviousRuntimePreset()).toBe('old');
40+
setActiveRuntimePreset(null);
41+
});
42+
43+
test('rollbackRuntimePreset restores active and clears previous', () => {
44+
setActiveRuntimePreset(null);
45+
setActiveRuntimePreset('old');
46+
setActiveRuntimePresetWithPrevious('new');
47+
rollbackRuntimePreset('old');
48+
expect(getActiveRuntimePreset()).toBe('old');
49+
expect(getPreviousRuntimePreset()).toBeNull();
50+
setActiveRuntimePreset(null);
51+
});
52+
53+
test('rollbackRuntimePreset with null clears active and previous', () => {
54+
setActiveRuntimePreset(null);
55+
setActiveRuntimePresetWithPrevious('new');
56+
rollbackRuntimePreset(null);
57+
expect(getActiveRuntimePreset()).toBeNull();
58+
expect(getPreviousRuntimePreset()).toBeNull();
59+
setActiveRuntimePreset(null);
60+
});
61+
});

src/config/runtime-preset.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Module-level runtime preset state.
3+
*
4+
* Survives plugin re-inits triggered by client.config.update() →
5+
* Instance.dispose(). The plugin function re-runs but this module-level
6+
* variable persists within the same Node.js process.
7+
*/
8+
9+
let activeRuntimePreset: string | null = null;
10+
11+
export function setActiveRuntimePreset(name: string | null): void {
12+
activeRuntimePreset = name;
13+
}
14+
15+
export function getActiveRuntimePreset(): string | null {
16+
return activeRuntimePreset;
17+
}
18+
19+
/**
20+
* Returns the name of the previously active runtime preset (before the
21+
* current one), used to compute reset diffs when switching presets.
22+
*/
23+
let previousRuntimePreset: string | null = null;
24+
25+
export function getPreviousRuntimePreset(): string | null {
26+
return previousRuntimePreset;
27+
}
28+
29+
export function setActiveRuntimePresetWithPrevious(name: string | null): void {
30+
previousRuntimePreset = activeRuntimePreset;
31+
activeRuntimePreset = name;
32+
}
33+
34+
export function rollbackRuntimePreset(previous: string | null): void {
35+
activeRuntimePreset = previous;
36+
previousRuntimePreset = null;
37+
}

src/index.ts

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
import type { Plugin } from '@opencode-ai/plugin';
22
import { createAgents, getAgentConfigs, getDisabledAgents } from './agents';
33
import { buildOrchestratorPrompt } from './agents/orchestrator';
4-
import { loadPluginConfig, type MultiplexerConfig } from './config';
4+
import {
5+
deepMerge,
6+
loadPluginConfig,
7+
type MultiplexerConfig,
8+
} from './config';
9+
import { AGENT_ALIASES } from './config/constants';
510
import { parseList } from './config/agent-mcps';
11+
import {
12+
getActiveRuntimePreset,
13+
getPreviousRuntimePreset,
14+
setActiveRuntimePreset,
15+
} from './config/runtime-preset';
616
import { CouncilManager } from './council';
717
import {
818
createApplyPatchHook,
@@ -85,6 +95,11 @@ async function probeJSDOM(): Promise<string | null> {
8595
}
8696
}
8797

98+
// Module-level runtime preset tracking. Survives plugin re-inits triggered
99+
// by client.config.update() → Instance.dispose(). When the plugin function
100+
// re-runs, it checks this variable and applies the runtime preset instead
101+
// of the config file's preset. State lives in config/runtime-preset.ts.
102+
88103
const OhMyOpenCodeLite: Plugin = async (ctx) => {
89104
const sessionId = new Date().toISOString().replace(/[-:]/g, '').slice(0, 15);
90105
initLogger(sessionId);
@@ -129,6 +144,25 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
129144

130145
try {
131146
config = loadPluginConfig(ctx.directory);
147+
148+
// Safety net: if a runtime preset was set via /preset command and
149+
// OpenCode ever fully re-runs the plugin function (not just the
150+
// config() hook), override config.preset so agents are created with
151+
// the correct models. Currently only the config() hook re-runs after
152+
// Instance.dispose(), so this is a defensive guard.
153+
const runtimePreset = getActiveRuntimePreset();
154+
if (runtimePreset && config.presets?.[runtimePreset]) {
155+
config.preset = runtimePreset;
156+
// Re-merge runtime preset into config.agents (loadPluginConfig
157+
// already merged the config-file preset, not the runtime one).
158+
// Runtime preset is override so it wins over config-file preset.
159+
const presetAgents = config.presets[runtimePreset];
160+
config.agents = deepMerge(config.agents, presetAgents);
161+
} else if (runtimePreset) {
162+
// Preset was deleted from config since last switch — clear stale state
163+
setActiveRuntimePreset(null);
164+
}
165+
132166
disabledAgents = getDisabledAgents(config);
133167
rewriteDisplayNameMentions = createDisplayNameMentionRewriter(config);
134168
agentDefs = createAgents(config);
@@ -472,6 +506,122 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
472506
}
473507
}
474508

509+
// Runtime preset override: if /preset switched to a runtime preset,
510+
// override the model/variant/temperature from the preset's agent
511+
// config. This runs after the normal model resolution because the
512+
// config() hook re-runs with stale modelArrayMap after dispose(),
513+
// but the runtime preset data is in the captured `config` closure.
514+
const runtimePresetName = getActiveRuntimePreset();
515+
if (runtimePresetName && config.presets?.[runtimePresetName]) {
516+
const runtimePreset = config.presets[runtimePresetName];
517+
for (const [agentName, override] of Object.entries(runtimePreset)) {
518+
// Resolve legacy alias keys (e.g. "explore" → "explorer")
519+
// so presets using aliases work in this path.
520+
const resolvedName = AGENT_ALIASES[agentName] ?? agentName;
521+
const entry = configAgent[resolvedName] as
522+
| Record<string, unknown>
523+
| undefined;
524+
if (!entry) continue;
525+
526+
if (typeof override.model === 'string') {
527+
entry.model = override.model;
528+
} else if (
529+
Array.isArray(override.model) &&
530+
override.model.length > 0
531+
) {
532+
const first = override.model[0];
533+
entry.model = typeof first === 'string' ? first : first.id;
534+
// Extract inline variant from array-form model entry
535+
if (typeof first !== 'string' && first.variant) {
536+
entry.variant = first.variant;
537+
}
538+
}
539+
// Explicitly set or clear scalar fields so switching from
540+
// Preset A (which sets a field) to Preset B (which doesn't)
541+
// doesn't leave stale values behind.
542+
if (typeof override.variant === 'string') {
543+
entry.variant = override.variant;
544+
} else if ('variant' in override) {
545+
delete entry.variant;
546+
}
547+
if (typeof override.temperature === 'number') {
548+
entry.temperature = override.temperature;
549+
} else if ('temperature' in override) {
550+
delete entry.temperature;
551+
}
552+
if (
553+
override.options &&
554+
typeof override.options === 'object' &&
555+
!Array.isArray(override.options)
556+
) {
557+
entry.options = override.options;
558+
} else if ('options' in override) {
559+
delete entry.options;
560+
}
561+
log('[plugin] runtime preset override', {
562+
preset: runtimePresetName,
563+
agent: agentName,
564+
model: entry.model as string,
565+
});
566+
}
567+
568+
// Reset agents from the previous preset that aren't in the new one.
569+
// The stale model resolution above overwrites the reset values sent
570+
// by preset-manager, so we re-apply them here from config-file
571+
// baseline.
572+
const prevPresetName = getPreviousRuntimePreset();
573+
if (prevPresetName && config.presets?.[prevPresetName]) {
574+
const prevPreset = config.presets[prevPresetName];
575+
// Build resolved key set from new preset for correct comparison
576+
// (handles alias keys like "explore" → "explorer")
577+
const newPresetResolved = new Set(
578+
Object.keys(runtimePreset).map(
579+
(k) => AGENT_ALIASES[k] ?? k,
580+
),
581+
);
582+
for (const agentName of Object.keys(prevPreset)) {
583+
const resolvedName =
584+
AGENT_ALIASES[agentName] ?? agentName;
585+
if (newPresetResolved.has(resolvedName))
586+
continue; // new preset handles it
587+
const entry = configAgent[resolvedName] as
588+
| Record<string, unknown>
589+
| undefined;
590+
if (!entry) continue;
591+
// Reset to config-file baseline
592+
const baseline =
593+
config.agents?.[resolvedName];
594+
if (typeof baseline?.model === 'string') {
595+
entry.model = baseline.model;
596+
}
597+
if (typeof baseline?.variant === 'string') {
598+
entry.variant = baseline.variant;
599+
} else if (baseline && 'variant' in baseline) {
600+
delete entry.variant;
601+
}
602+
if (typeof baseline?.temperature === 'number') {
603+
entry.temperature = baseline.temperature;
604+
} else if (baseline && 'temperature' in baseline) {
605+
delete entry.temperature;
606+
}
607+
if (
608+
baseline?.options &&
609+
typeof baseline.options === 'object' &&
610+
!Array.isArray(baseline.options)
611+
) {
612+
entry.options = baseline.options;
613+
} else if (baseline && 'options' in baseline) {
614+
delete entry.options;
615+
}
616+
log('[plugin] runtime preset reset from previous', {
617+
previousPreset: prevPresetName,
618+
agent: resolvedName,
619+
model: entry.model as string,
620+
});
621+
}
622+
}
623+
}
624+
475625
// Merge MCP configs
476626
const configMcp = opencodeConfig.mcp as
477627
| Record<string, unknown>

0 commit comments

Comments
 (0)