Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode-agent-modes",
"version": "0.1.2",
"version": "0.2.0",
"description": "OpenCode plugin to switch agent modes between performance and economy presets",
"module": "src/index.ts",
"main": "dist/index.js",
Expand Down
199 changes: 195 additions & 4 deletions src/modes/manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { OpencodeClient } from '@opencode-ai/sdk'
import type {
ModePreset,
ModeSwitcherConfig,
OhMyOpencodeConfig,
OpencodeConfig,
} from '../config/types.ts'
import { createMockOpencodeClient, sampleConfigs } from '../test-utils/mocks.ts'

/**
* Creates a deep copy of the sample plugin config for isolated test use.
*/
function clonePluginConfig(): ModeSwitcherConfig {
return JSON.parse(
JSON.stringify(sampleConfigs.pluginConfig)
) as ModeSwitcherConfig
}

/**
* Mock implementation of ModeManager for testing purposes.
*
Expand All @@ -23,6 +33,9 @@ class MockModeManager {
private ohMyConfig: OhMyOpencodeConfig | null = null
private client: OpencodeClient

/** Tracks whether a drift-toast was shown during initialize */
lastDriftToast: string | null = null

constructor(client: OpencodeClient) {
this.client = client
}
Expand All @@ -41,11 +54,79 @@ class MockModeManager {

async initialize(): Promise<void> {
if (!this.config) {
// Deep copy to avoid state sharing between tests
this.config = JSON.parse(
JSON.stringify(sampleConfigs.pluginConfig)
) as ModeSwitcherConfig
this.config = clonePluginConfig()
}
await this.applyCurrentModeIfNeeded()
}

private async applyCurrentModeIfNeeded(): Promise<void> {
if (!this.config) {
return
}

const preset = this.config.presets[this.config.currentMode]
if (!preset) {
return
}

const drifted = this.hasConfigDrift(preset)
if (!drifted) {
return
}

// Apply preset to in-memory configs
if (this.opencodeConfig) {
if (preset.model) {
this.opencodeConfig.model = preset.model
}
this.opencodeConfig.agent = this.opencodeConfig.agent || {}
for (const [name, p] of Object.entries(preset.opencode)) {
this.opencodeConfig.agent[name] = {
...this.opencodeConfig.agent[name],
model: p.model,
}
}
}

if (this.ohMyConfig) {
this.ohMyConfig.agents = this.ohMyConfig.agents || {}
for (const [name, p] of Object.entries(preset['oh-my-opencode'])) {
this.ohMyConfig.agents[name] = { model: p.model }
}
}

this.lastDriftToast = `Applied "${this.config.currentMode}" mode. Restart opencode to take effect.`
}

private hasConfigDrift(preset: ModePreset): boolean {
// Check global model
if (preset.model && this.opencodeConfig) {
if (this.opencodeConfig.model !== preset.model) {
return true
}
}

// Check opencode agent models
if (this.opencodeConfig?.agent) {
for (const [name, p] of Object.entries(preset.opencode)) {
const actual = this.opencodeConfig.agent[name]
if (actual?.model !== p.model) {
return true
}
}
}

// Check oh-my-opencode agent models
if (this.ohMyConfig?.agents) {
for (const [name, p] of Object.entries(preset['oh-my-opencode'])) {
const actual = this.ohMyConfig.agents[name]
if (actual?.model !== p.model) {
return true
}
}
}

return false
}

private async ensureConfig(): Promise<ModeSwitcherConfig> {
Expand Down Expand Up @@ -384,4 +465,114 @@ describe('ModeManager', () => {
expect(result).toBe(false)
})
})

describe('applyCurrentModeIfNeeded', () => {
test('applies preset when opencode.json has drifted', async () => {
// Set currentMode to economy but opencode.json has performance models
const config = clonePluginConfig()
config.currentMode = 'economy'
manager.setConfig(config)
manager.setOpencodeConfig({
model: 'anthropic/claude-sonnet-4',
agent: {
build: { model: 'anthropic/claude-sonnet-4' },
plan: { model: 'anthropic/claude-sonnet-4' },
},
})

await manager.initialize()

expect(manager.lastDriftToast).toContain('economy')
expect(manager.lastDriftToast).toContain('Restart opencode')
})

test('applies preset when oh-my-opencode.json has drifted', async () => {
const config = clonePluginConfig()
config.currentMode = 'economy'
manager.setConfig(config)
manager.setOhMyConfig({
agents: {
coder: { model: 'anthropic/claude-sonnet-4' },
},
})

await manager.initialize()

expect(manager.lastDriftToast).toContain('economy')
})

test('does not apply when configs match preset', async () => {
// Set currentMode to economy and configs already match
const config = clonePluginConfig()
config.currentMode = 'economy'
manager.setConfig(config)
manager.setOpencodeConfig({
model: 'opencode/glm-4.7-free',
agent: {
build: { model: 'opencode/glm-4.7-free' },
plan: { model: 'opencode/glm-4.7-free' },
},
})
manager.setOhMyConfig({
agents: {
coder: { model: 'opencode/glm-4.7-free' },
},
})

await manager.initialize()

expect(manager.lastDriftToast).toBeNull()
})

test('does nothing when preset is not found', async () => {
const config: ModeSwitcherConfig = {
currentMode: 'nonexistent',
showToastOnStartup: true,
presets: {
performance: {
description: 'Test',
opencode: {},
'oh-my-opencode': {},
},
},
}
manager.setConfig(config)
manager.setOpencodeConfig({
model: 'anthropic/claude-sonnet-4',
agent: { build: { model: 'anthropic/claude-sonnet-4' } },
})

await manager.initialize()

expect(manager.lastDriftToast).toBeNull()
})

test('does nothing when no config files exist', async () => {
const config = clonePluginConfig()
config.currentMode = 'economy'
manager.setConfig(config)
// Don't set opencodeConfig or ohMyConfig

await manager.initialize()

expect(manager.lastDriftToast).toBeNull()
})

test('detects drift on global model mismatch', async () => {
const config = clonePluginConfig()
config.currentMode = 'economy'
manager.setConfig(config)
manager.setOpencodeConfig({
model: 'anthropic/claude-sonnet-4', // Mismatch
agent: {
build: { model: 'opencode/glm-4.7-free' },
plan: { model: 'opencode/glm-4.7-free' },
},
})

await manager.initialize()

expect(manager.lastDriftToast).not.toBeNull()
})
})
})
111 changes: 104 additions & 7 deletions src/modes/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export class ModeManager {
*/
async initialize(): Promise<void> {
this.config = await initializeConfig()
await this.applyCurrentModeIfNeeded()
}

/**
Expand All @@ -78,6 +79,104 @@ export class ModeManager {
return this.config
}

/**
* Checks if actual config files have drifted from the current
* mode preset and applies the preset if needed.
*
* This handles the case where a user manually edits
* `agent-mode-switcher.json` to change `currentMode` while
* OpenCode is not running. On next startup, the actual config
* files are updated to match the expected preset values,
* and a toast notification prompts the user to restart.
*
* @private
*/
private async applyCurrentModeIfNeeded(): Promise<void> {
if (!this.config) {
return
}

const preset = this.config.presets[this.config.currentMode]
if (!preset) {
return
}

const drifted = await this.hasConfigDrift(preset)
if (!drifted) {
return
}

// Apply the preset to actual config files
await this.updateOpencodeConfig(preset.model, preset.opencode)
await this.updateOhMyOpencodeConfig(preset['oh-my-opencode'])

// Notify user to restart (fire-and-forget to avoid blocking
// plugin initialization when UI is not yet ready).
// TODO: Currently toast is likely not displayed because UI is
// not initialized at this point. To reliably show the toast,
// use setTimeout for delayed execution or an onReady lifecycle
// hook if OpenCode adds one in the future.
this.client.tui
.showToast({
body: {
title: 'Mode Applied',
message: `Applied "${this.config.currentMode}" mode. Restart opencode to take effect.`,
variant: 'warning',
duration: 5000,
},
})
.catch(() => {
// Toast might not be available during early initialization
})
}

/**
* Compares a mode preset against the actual opencode.json and
* oh-my-opencode.json files to detect configuration drift.
*
* Checks global model and per-agent model values. Returns true
* if any expected value differs from the actual file content.
*
* @param preset - The mode preset to compare against
* @returns True if actual configs differ from the preset
* @private
*/
private async hasConfigDrift(preset: ModePreset): Promise<boolean> {
const opencodeConfig = await loadOpencodeConfig()
const ohMyConfig = await loadOhMyOpencodeConfig()

// Check global model in opencode.json
if (preset.model && opencodeConfig) {
if (opencodeConfig.model !== preset.model) {
return true
}
}

// Check opencode agent models
if (opencodeConfig?.agent) {
for (const [agentName, agentPreset] of Object.entries(preset.opencode)) {
const actual = opencodeConfig.agent[agentName]
if (actual?.model !== agentPreset.model) {
return true
}
}
}

// Check oh-my-opencode agent models
if (ohMyConfig?.agents) {
for (const [agentName, agentPreset] of Object.entries(
preset['oh-my-opencode']
)) {
const actual = ohMyConfig.agents[agentName]
if (actual?.model !== agentPreset.model) {
return true
}
}
}

return false
}

/**
* Gets the name of the currently active mode.
*
Expand Down Expand Up @@ -316,13 +415,11 @@ export class ModeManager {
}

// Update agent section (preserve other settings)
if (Object.keys(agentPresets).length > 0) {
opencodeConfig.agent = opencodeConfig.agent || {}
for (const [agentName, preset] of Object.entries(agentPresets)) {
opencodeConfig.agent[agentName] = {
...opencodeConfig.agent[agentName],
model: preset.model,
}
opencodeConfig.agent = opencodeConfig.agent || {}
for (const [agentName, preset] of Object.entries(agentPresets)) {
opencodeConfig.agent[agentName] = {
...opencodeConfig.agent[agentName],
model: preset.model,
}
}

Expand Down