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
54 changes: 54 additions & 0 deletions src/config/initializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,4 +236,58 @@ describe('initializer', () => {
expect(validateConfig(loadedConfig)).toBe(true)
})
})

describe('oh-my-opencode optional preset', () => {
test('validateConfig returns true for preset without oh-my-opencode key', () => {
const config: ModeSwitcherConfig = {
currentMode: 'performance',
showToastOnStartup: true,
presets: {
performance: {
description: 'High-performance models for complex tasks',
model: 'anthropic/claude-sonnet-4',
opencode: {
build: { model: 'anthropic/claude-sonnet-4' },
},
// Note: no 'oh-my-opencode' key
},
},
}
expect(validateConfig(config)).toBe(true)
})

test('validateConfig returns true for config where some presets have oh-my-opencode and some do not', () => {
const config: ModeSwitcherConfig = {
currentMode: 'performance',
showToastOnStartup: true,
presets: {
performance: {
description: 'With oh-my-opencode',
opencode: {},
'oh-my-opencode': { agents: {} },
},
economy: {
description: 'Without oh-my-opencode',
opencode: {},
// Note: no 'oh-my-opencode' key
},
},
}
expect(validateConfig(config)).toBe(true)
})

test('preset without oh-my-opencode key has undefined value for that field', () => {
const preset: ModePreset = {
description: 'No oh-my-opencode',
model: 'anthropic/claude-sonnet-4',
opencode: {
build: { model: 'anthropic/claude-sonnet-4' },
},
// Note: no 'oh-my-opencode' key
}

// Accessing undefined key should return undefined (not throw)
expect(preset['oh-my-opencode']).toBeUndefined()
})
})
})
12 changes: 10 additions & 2 deletions src/config/initializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ function applyEconomyModel(
* files and preserves their entire structure as-is. The hierarchical
* structure (agent, agents, categories, etc.) is maintained exactly.
*
* If `oh-my-opencode.json` does not exist (i.e., the user does not use the
* oh-my-opencode plugin), the `'oh-my-opencode'` key is omitted from the
* returned preset entirely.
*
* @returns Promise resolving to a ModePreset with performance-oriented models
* @example
* ```typescript
Expand All @@ -86,7 +90,7 @@ async function buildPerformancePreset(): Promise<ModePreset> {
description: 'High-performance models for complex tasks',
...(globalModel && { model: globalModel }),
opencode: opencodePreset,
'oh-my-opencode': ohMyOpencodePreset,
...(ohMyOpencodeConfig && { 'oh-my-opencode': ohMyOpencodePreset }),
}
}

Expand All @@ -98,6 +102,10 @@ async function buildPerformancePreset(): Promise<ModePreset> {
* `opencode/glm-4.7-free` model. The hierarchical structure is
* preserved while model values are updated recursively.
*
* If `oh-my-opencode.json` does not exist (i.e., the user does not use the
* oh-my-opencode plugin), the `'oh-my-opencode'` key is omitted from the
* returned preset entirely.
*
* @returns Promise resolving to a ModePreset with economy-oriented models
* @example
* ```typescript
Expand Down Expand Up @@ -125,7 +133,7 @@ async function buildEconomyPreset(): Promise<ModePreset> {
description: 'Cost-efficient free model for routine tasks',
model: DEFAULT_ECONOMY_MODEL,
opencode: opencodePreset,
'oh-my-opencode': ohMyOpencodePreset,
...(ohMyOpencodeConfig && { 'oh-my-opencode': ohMyOpencodePreset }),
}
}

Expand Down
5 changes: 4 additions & 1 deletion src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,15 @@ export type HierarchicalPreset = Record<
* Both opencode and oh-my-opencode use the same HierarchicalPreset type,
* allowing them to have arbitrary nested structures that are handled
* uniformly by recursive merge functions.
*
* The `oh-my-opencode` field is optional to support users who do not use the
* oh-my-opencode plugin. When absent, all oh-my-opencode related processing is skipped.
*/
export interface ModePreset {
description: string
model?: string
opencode: HierarchicalPreset
'oh-my-opencode': HierarchicalPreset
'oh-my-opencode'?: HierarchicalPreset
}

/**
Expand Down
110 changes: 107 additions & 3 deletions src/modes/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ class MockModeManager {
)
}

if (this.ohMyConfig) {
if (this.ohMyConfig && preset['oh-my-opencode']) {
deepMergeModel(
this.ohMyConfig as Record<string, unknown>,
preset['oh-my-opencode']
Expand Down Expand Up @@ -130,6 +130,7 @@ class MockModeManager {
// Check oh-my-opencode: recursively
if (
this.ohMyConfig &&
preset['oh-my-opencode'] &&
hasDriftRecursive(
this.ohMyConfig as Record<string, unknown>,
preset['oh-my-opencode']
Expand Down Expand Up @@ -188,7 +189,9 @@ class MockModeManager {
: 'Global model: (not set)'

const opencodeTree = formatHierarchicalTree(preset.opencode)
const ohMyOpencodeTree = formatHierarchicalTree(preset['oh-my-opencode'])
const ohMyOpencodeTree = preset['oh-my-opencode']
? formatHierarchicalTree(preset['oh-my-opencode'])
: ''

return [
`Current mode: ${currentMode}`,
Expand Down Expand Up @@ -222,7 +225,9 @@ class MockModeManager {
}

// Simulate updating oh-my-opencode.json
if (this.ohMyConfig) {
if (!preset['oh-my-opencode']) {
results.push('oh-my-opencode.json: skipped (not configured)')
} else if (this.ohMyConfig) {
results.push('oh-my-opencode.json: updated')
} else {
results.push('oh-my-opencode.json: skipped (not found)')
Expand Down Expand Up @@ -657,4 +662,103 @@ describe('ModeManager', () => {
expect(manager.lastDriftToast).not.toBeNull()
})
})

describe('oh-my-opencode optional (no oh-my-opencode key in preset)', () => {
/**
* A config without 'oh-my-opencode' key in the preset, simulating
* a user who does not use oh-my-opencode.
*/
function cloneConfigWithoutOhMy(): ModeSwitcherConfig {
return {
currentMode: 'performance',
showToastOnStartup: true,
presets: {
performance: {
description: 'High-performance models for complex tasks',
model: 'anthropic/claude-sonnet-4',
opencode: {
build: { model: 'anthropic/claude-sonnet-4' },
plan: { model: 'anthropic/claude-sonnet-4' },
},
// Note: no 'oh-my-opencode' key
},
economy: {
description: 'Cost-efficient free model for routine tasks',
model: 'opencode/glm-4.7-free',
opencode: {
build: { model: 'opencode/glm-4.7-free' },
plan: { model: 'opencode/glm-4.7-free' },
},
// Note: no 'oh-my-opencode' key
},
},
}
}

test('switchMode returns "skipped (not configured)" for oh-my-opencode when preset has no oh-my-opencode key', async () => {
manager.setConfig(cloneConfigWithoutOhMy())
manager.setOpencodeConfig(sampleConfigs.opencodeConfig)
manager.setOhMyConfig(sampleConfigs.ohMyOpencodeConfig)
await manager.initialize()

const result = await manager.switchMode('economy')
expect(result).toContain('oh-my-opencode.json: skipped (not configured)')
})

test('getStatus does not crash when preset has no oh-my-opencode key', async () => {
manager.setConfig(cloneConfigWithoutOhMy())
await manager.initialize()

const status = await manager.getStatus()
expect(status).toContain('Current mode: performance')
expect(status).toContain('Oh-my-opencode config:')
expect(status).toContain('(none configured)')
})

test('initialize does not crash on drift check when preset has no oh-my-opencode key', async () => {
const config = cloneConfigWithoutOhMy()
config.currentMode = 'economy'
manager.setConfig(config)
// opencode drifted, oh-my-opencode present in FS but preset has no oh-my-opencode key
manager.setOpencodeConfig({
model: 'anthropic/claude-sonnet-4', // mismatch with economy
agent: {
build: { model: 'anthropic/claude-sonnet-4' },
plan: { model: 'anthropic/claude-sonnet-4' },
},
})
manager.setOhMyConfig(sampleConfigs.ohMyOpencodeConfig)

// Should not throw
await manager.initialize()

// Drift detected via opencode config, so toast should be set
expect(manager.lastDriftToast).not.toBeNull()
expect(manager.lastDriftToast).toContain('economy')
})

test('initialize does not apply oh-my-opencode changes when preset has no oh-my-opencode key', async () => {
const config = cloneConfigWithoutOhMy()
config.currentMode = 'economy'
manager.setConfig(config)
manager.setOpencodeConfig({
model: 'opencode/glm-4.7-free', // already matching
agent: {
build: { model: 'opencode/glm-4.7-free' },
plan: { model: 'opencode/glm-4.7-free' },
},
})
manager.setOhMyConfig({
agents: {
sisyphus: { model: 'anthropic/claude-sonnet-4' }, // different model
},
})

await manager.initialize()

// No drift (opencode matches), so toast should not be set
// and oh-my-opencode should NOT be modified (preset has no oh-my-opencode)
expect(manager.lastDriftToast).toBeNull()
})
})
})
40 changes: 31 additions & 9 deletions src/modes/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,9 @@ export class ModeManager {

// Apply the preset to actual config files
await this.updateOpencodeConfig(preset.model, preset.opencode)
await this.updateOhMyOpencodeConfig(preset['oh-my-opencode'])
if (preset['oh-my-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).
Expand Down Expand Up @@ -288,6 +290,10 @@ export class ModeManager {
* Checks global model and per-agent model values recursively. Returns true
* if any expected value differs from the actual file content.
*
* If the preset does not include an `'oh-my-opencode'` key (i.e., the user
* does not use the oh-my-opencode plugin), drift checking for that config
* file is skipped entirely.
*
* @param preset - The mode preset to compare against
* @returns True if actual configs differ from the preset
* @private
Expand Down Expand Up @@ -317,9 +323,10 @@ export class ModeManager {
return true
}

// Check oh-my-opencode: recursively check
// Check oh-my-opencode: recursively check (skip if preset has no oh-my-opencode config)
if (
ohMyConfig &&
preset['oh-my-opencode'] &&
hasDriftRecursive(
ohMyConfig as Record<string, unknown>,
preset['oh-my-opencode']
Expand Down Expand Up @@ -437,8 +444,10 @@ export class ModeManager {
// opencode: recursively format tree
const opencodeTree = formatHierarchicalTree(preset.opencode)

// oh-my-opencode: recursively format tree
const ohMyOpencodeTree = formatHierarchicalTree(preset['oh-my-opencode'])
// oh-my-opencode: recursively format tree (skip if not configured)
const ohMyOpencodeTree = preset['oh-my-opencode']
? formatHierarchicalTree(preset['oh-my-opencode'])
: ''

return [
`Current mode: ${currentMode}`,
Expand All @@ -459,7 +468,7 @@ export class ModeManager {
* This method performs the following operations:
* 1. Validates that the requested mode exists
* 2. Updates `opencode.json` with new global model and agent settings
* 3. Updates `oh-my-opencode.json` with new agent settings
* 3. Updates `oh-my-opencode.json` with new agent settings (skipped if preset has no `'oh-my-opencode'` key)
* 4. Updates `agent-mode-switcher.json` with the new current mode
* 5. Shows a toast notification (if available)
*
Expand All @@ -470,6 +479,7 @@ export class ModeManager {
* @returns Promise resolving to a formatted result message with status of each config update
* @example
* ```typescript
* // With oh-my-opencode configured:
* const result = await manager.switchMode('economy');
* console.log(result);
* // Output:
Expand All @@ -482,6 +492,18 @@ export class ModeManager {
* // - agent-mode-switcher.json: updated
* //
* // Note: Restart opencode to apply changes.
*
* // Without oh-my-opencode configured:
* // Output:
* // Switched to economy mode
* // Cost-efficient free model for routine tasks
* //
* // Results:
* // - opencode.json: updated
* // - oh-my-opencode.json: skipped (not configured)
* // - agent-mode-switcher.json: updated
* //
* // Note: Restart opencode to apply changes.
* ```
*/
async switchMode(modeName: string): Promise<string> {
Expand All @@ -502,10 +524,10 @@ export class ModeManager {
)
results.push(`opencode.json: ${opencodeResult}`)

// 2. Update oh-my-opencode.json directly (agents section only)
const ohMyResult = await this.updateOhMyOpencodeConfig(
preset['oh-my-opencode']
)
// 2. Update oh-my-opencode.json directly (agents section only, skip if not configured)
const ohMyResult = preset['oh-my-opencode']
? await this.updateOhMyOpencodeConfig(preset['oh-my-opencode'])
: 'skipped (not configured)'
results.push(`oh-my-opencode.json: ${ohMyResult}`)

// 3. Update plugin configuration
Expand Down
Loading