Skip to content

Commit 32f4027

Browse files
authored
Merge pull request #5 from j4rviscmd/feat/apply-mode-on-startup
feat: apply currentMode preset on startup when config drift detected
2 parents 1e1c988 + 410426f commit 32f4027

File tree

3 files changed

+300
-12
lines changed

3 files changed

+300
-12
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencode-agent-modes",
3-
"version": "0.1.2",
3+
"version": "0.2.0",
44
"description": "OpenCode plugin to switch agent modes between performance and economy presets",
55
"module": "src/index.ts",
66
"main": "dist/index.js",

src/modes/manager.test.ts

Lines changed: 195 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
import { beforeEach, describe, expect, test } from 'bun:test'
22
import type { OpencodeClient } from '@opencode-ai/sdk'
33
import type {
4+
ModePreset,
45
ModeSwitcherConfig,
56
OhMyOpencodeConfig,
67
OpencodeConfig,
78
} from '../config/types.ts'
89
import { createMockOpencodeClient, sampleConfigs } from '../test-utils/mocks.ts'
910

11+
/**
12+
* Creates a deep copy of the sample plugin config for isolated test use.
13+
*/
14+
function clonePluginConfig(): ModeSwitcherConfig {
15+
return JSON.parse(
16+
JSON.stringify(sampleConfigs.pluginConfig)
17+
) as ModeSwitcherConfig
18+
}
19+
1020
/**
1121
* Mock implementation of ModeManager for testing purposes.
1222
*
@@ -23,6 +33,9 @@ class MockModeManager {
2333
private ohMyConfig: OhMyOpencodeConfig | null = null
2434
private client: OpencodeClient
2535

36+
/** Tracks whether a drift-toast was shown during initialize */
37+
lastDriftToast: string | null = null
38+
2639
constructor(client: OpencodeClient) {
2740
this.client = client
2841
}
@@ -41,11 +54,79 @@ class MockModeManager {
4154

4255
async initialize(): Promise<void> {
4356
if (!this.config) {
44-
// Deep copy to avoid state sharing between tests
45-
this.config = JSON.parse(
46-
JSON.stringify(sampleConfigs.pluginConfig)
47-
) as ModeSwitcherConfig
57+
this.config = clonePluginConfig()
58+
}
59+
await this.applyCurrentModeIfNeeded()
60+
}
61+
62+
private async applyCurrentModeIfNeeded(): Promise<void> {
63+
if (!this.config) {
64+
return
65+
}
66+
67+
const preset = this.config.presets[this.config.currentMode]
68+
if (!preset) {
69+
return
4870
}
71+
72+
const drifted = this.hasConfigDrift(preset)
73+
if (!drifted) {
74+
return
75+
}
76+
77+
// Apply preset to in-memory configs
78+
if (this.opencodeConfig) {
79+
if (preset.model) {
80+
this.opencodeConfig.model = preset.model
81+
}
82+
this.opencodeConfig.agent = this.opencodeConfig.agent || {}
83+
for (const [name, p] of Object.entries(preset.opencode)) {
84+
this.opencodeConfig.agent[name] = {
85+
...this.opencodeConfig.agent[name],
86+
model: p.model,
87+
}
88+
}
89+
}
90+
91+
if (this.ohMyConfig) {
92+
this.ohMyConfig.agents = this.ohMyConfig.agents || {}
93+
for (const [name, p] of Object.entries(preset['oh-my-opencode'])) {
94+
this.ohMyConfig.agents[name] = { model: p.model }
95+
}
96+
}
97+
98+
this.lastDriftToast = `Applied "${this.config.currentMode}" mode. Restart opencode to take effect.`
99+
}
100+
101+
private hasConfigDrift(preset: ModePreset): boolean {
102+
// Check global model
103+
if (preset.model && this.opencodeConfig) {
104+
if (this.opencodeConfig.model !== preset.model) {
105+
return true
106+
}
107+
}
108+
109+
// Check opencode agent models
110+
if (this.opencodeConfig?.agent) {
111+
for (const [name, p] of Object.entries(preset.opencode)) {
112+
const actual = this.opencodeConfig.agent[name]
113+
if (actual?.model !== p.model) {
114+
return true
115+
}
116+
}
117+
}
118+
119+
// Check oh-my-opencode agent models
120+
if (this.ohMyConfig?.agents) {
121+
for (const [name, p] of Object.entries(preset['oh-my-opencode'])) {
122+
const actual = this.ohMyConfig.agents[name]
123+
if (actual?.model !== p.model) {
124+
return true
125+
}
126+
}
127+
}
128+
129+
return false
49130
}
50131

51132
private async ensureConfig(): Promise<ModeSwitcherConfig> {
@@ -384,4 +465,114 @@ describe('ModeManager', () => {
384465
expect(result).toBe(false)
385466
})
386467
})
468+
469+
describe('applyCurrentModeIfNeeded', () => {
470+
test('applies preset when opencode.json has drifted', async () => {
471+
// Set currentMode to economy but opencode.json has performance models
472+
const config = clonePluginConfig()
473+
config.currentMode = 'economy'
474+
manager.setConfig(config)
475+
manager.setOpencodeConfig({
476+
model: 'anthropic/claude-sonnet-4',
477+
agent: {
478+
build: { model: 'anthropic/claude-sonnet-4' },
479+
plan: { model: 'anthropic/claude-sonnet-4' },
480+
},
481+
})
482+
483+
await manager.initialize()
484+
485+
expect(manager.lastDriftToast).toContain('economy')
486+
expect(manager.lastDriftToast).toContain('Restart opencode')
487+
})
488+
489+
test('applies preset when oh-my-opencode.json has drifted', async () => {
490+
const config = clonePluginConfig()
491+
config.currentMode = 'economy'
492+
manager.setConfig(config)
493+
manager.setOhMyConfig({
494+
agents: {
495+
coder: { model: 'anthropic/claude-sonnet-4' },
496+
},
497+
})
498+
499+
await manager.initialize()
500+
501+
expect(manager.lastDriftToast).toContain('economy')
502+
})
503+
504+
test('does not apply when configs match preset', async () => {
505+
// Set currentMode to economy and configs already match
506+
const config = clonePluginConfig()
507+
config.currentMode = 'economy'
508+
manager.setConfig(config)
509+
manager.setOpencodeConfig({
510+
model: 'opencode/glm-4.7-free',
511+
agent: {
512+
build: { model: 'opencode/glm-4.7-free' },
513+
plan: { model: 'opencode/glm-4.7-free' },
514+
},
515+
})
516+
manager.setOhMyConfig({
517+
agents: {
518+
coder: { model: 'opencode/glm-4.7-free' },
519+
},
520+
})
521+
522+
await manager.initialize()
523+
524+
expect(manager.lastDriftToast).toBeNull()
525+
})
526+
527+
test('does nothing when preset is not found', async () => {
528+
const config: ModeSwitcherConfig = {
529+
currentMode: 'nonexistent',
530+
showToastOnStartup: true,
531+
presets: {
532+
performance: {
533+
description: 'Test',
534+
opencode: {},
535+
'oh-my-opencode': {},
536+
},
537+
},
538+
}
539+
manager.setConfig(config)
540+
manager.setOpencodeConfig({
541+
model: 'anthropic/claude-sonnet-4',
542+
agent: { build: { model: 'anthropic/claude-sonnet-4' } },
543+
})
544+
545+
await manager.initialize()
546+
547+
expect(manager.lastDriftToast).toBeNull()
548+
})
549+
550+
test('does nothing when no config files exist', async () => {
551+
const config = clonePluginConfig()
552+
config.currentMode = 'economy'
553+
manager.setConfig(config)
554+
// Don't set opencodeConfig or ohMyConfig
555+
556+
await manager.initialize()
557+
558+
expect(manager.lastDriftToast).toBeNull()
559+
})
560+
561+
test('detects drift on global model mismatch', async () => {
562+
const config = clonePluginConfig()
563+
config.currentMode = 'economy'
564+
manager.setConfig(config)
565+
manager.setOpencodeConfig({
566+
model: 'anthropic/claude-sonnet-4', // Mismatch
567+
agent: {
568+
build: { model: 'opencode/glm-4.7-free' },
569+
plan: { model: 'opencode/glm-4.7-free' },
570+
},
571+
})
572+
573+
await manager.initialize()
574+
575+
expect(manager.lastDriftToast).not.toBeNull()
576+
})
577+
})
387578
})

src/modes/manager.ts

Lines changed: 104 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export class ModeManager {
5959
*/
6060
async initialize(): Promise<void> {
6161
this.config = await initializeConfig()
62+
await this.applyCurrentModeIfNeeded()
6263
}
6364

6465
/**
@@ -78,6 +79,104 @@ export class ModeManager {
7879
return this.config
7980
}
8081

82+
/**
83+
* Checks if actual config files have drifted from the current
84+
* mode preset and applies the preset if needed.
85+
*
86+
* This handles the case where a user manually edits
87+
* `agent-mode-switcher.json` to change `currentMode` while
88+
* OpenCode is not running. On next startup, the actual config
89+
* files are updated to match the expected preset values,
90+
* and a toast notification prompts the user to restart.
91+
*
92+
* @private
93+
*/
94+
private async applyCurrentModeIfNeeded(): Promise<void> {
95+
if (!this.config) {
96+
return
97+
}
98+
99+
const preset = this.config.presets[this.config.currentMode]
100+
if (!preset) {
101+
return
102+
}
103+
104+
const drifted = await this.hasConfigDrift(preset)
105+
if (!drifted) {
106+
return
107+
}
108+
109+
// Apply the preset to actual config files
110+
await this.updateOpencodeConfig(preset.model, preset.opencode)
111+
await this.updateOhMyOpencodeConfig(preset['oh-my-opencode'])
112+
113+
// Notify user to restart (fire-and-forget to avoid blocking
114+
// plugin initialization when UI is not yet ready).
115+
// TODO: Currently toast is likely not displayed because UI is
116+
// not initialized at this point. To reliably show the toast,
117+
// use setTimeout for delayed execution or an onReady lifecycle
118+
// hook if OpenCode adds one in the future.
119+
this.client.tui
120+
.showToast({
121+
body: {
122+
title: 'Mode Applied',
123+
message: `Applied "${this.config.currentMode}" mode. Restart opencode to take effect.`,
124+
variant: 'warning',
125+
duration: 5000,
126+
},
127+
})
128+
.catch(() => {
129+
// Toast might not be available during early initialization
130+
})
131+
}
132+
133+
/**
134+
* Compares a mode preset against the actual opencode.json and
135+
* oh-my-opencode.json files to detect configuration drift.
136+
*
137+
* Checks global model and per-agent model values. Returns true
138+
* if any expected value differs from the actual file content.
139+
*
140+
* @param preset - The mode preset to compare against
141+
* @returns True if actual configs differ from the preset
142+
* @private
143+
*/
144+
private async hasConfigDrift(preset: ModePreset): Promise<boolean> {
145+
const opencodeConfig = await loadOpencodeConfig()
146+
const ohMyConfig = await loadOhMyOpencodeConfig()
147+
148+
// Check global model in opencode.json
149+
if (preset.model && opencodeConfig) {
150+
if (opencodeConfig.model !== preset.model) {
151+
return true
152+
}
153+
}
154+
155+
// Check opencode agent models
156+
if (opencodeConfig?.agent) {
157+
for (const [agentName, agentPreset] of Object.entries(preset.opencode)) {
158+
const actual = opencodeConfig.agent[agentName]
159+
if (actual?.model !== agentPreset.model) {
160+
return true
161+
}
162+
}
163+
}
164+
165+
// Check oh-my-opencode agent models
166+
if (ohMyConfig?.agents) {
167+
for (const [agentName, agentPreset] of Object.entries(
168+
preset['oh-my-opencode']
169+
)) {
170+
const actual = ohMyConfig.agents[agentName]
171+
if (actual?.model !== agentPreset.model) {
172+
return true
173+
}
174+
}
175+
}
176+
177+
return false
178+
}
179+
81180
/**
82181
* Gets the name of the currently active mode.
83182
*
@@ -316,13 +415,11 @@ export class ModeManager {
316415
}
317416

318417
// Update agent section (preserve other settings)
319-
if (Object.keys(agentPresets).length > 0) {
320-
opencodeConfig.agent = opencodeConfig.agent || {}
321-
for (const [agentName, preset] of Object.entries(agentPresets)) {
322-
opencodeConfig.agent[agentName] = {
323-
...opencodeConfig.agent[agentName],
324-
model: preset.model,
325-
}
418+
opencodeConfig.agent = opencodeConfig.agent || {}
419+
for (const [agentName, preset] of Object.entries(agentPresets)) {
420+
opencodeConfig.agent[agentName] = {
421+
...opencodeConfig.agent[agentName],
422+
model: preset.model,
326423
}
327424
}
328425

0 commit comments

Comments
 (0)