Skip to content

Commit 0dd5541

Browse files
authored
Merge pull request #420 from ReqX/fix/351-preset-runtime-reinit
fix(preset): persist runtime preset across plugin re-inits
2 parents c269e67 + 890d64b commit 0dd5541

8 files changed

Lines changed: 680 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: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
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+
type AgentOverrideConfig,
6+
deepMerge,
7+
loadPluginConfig,
8+
type MultiplexerConfig,
9+
} from './config';
10+
import { AGENT_ALIASES } from './config/constants';
511
import { parseList } from './config/agent-mcps';
12+
import {
13+
getActiveRuntimePreset,
14+
getPreviousRuntimePreset,
15+
setActiveRuntimePreset,
16+
} from './config/runtime-preset';
617
import { CouncilManager } from './council';
718
import {
819
createApplyPatchHook,
@@ -85,6 +96,11 @@ async function probeJSDOM(): Promise<string | null> {
8596
}
8697
}
8798

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

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

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

0 commit comments

Comments
 (0)