diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 7c174659..7fffdc21 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -76,6 +76,7 @@ import { configureOpenCodeRuntimeProviders, resetOpenCodeRuntimeProviders } from import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js'; import { createSessionAssistRuntime } from './lib/session-assist/runtime.js'; import { createSessionGoalRuntime } from './lib/session-goal/runtime.js'; +import { applySmallModelOverrideToOpenCodeConfig } from './lib/small-model/config-injection.js'; import { createContextObligatoryRuntime } from './lib/context-obligatory/runtime.js'; import { createSessionKnowledgeRuntime } from './lib/session-knowledge/runtime.js'; import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js'; @@ -1204,11 +1205,25 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({ const managedEnv = includeControl || includeWeb || includeMemory ? await (agentToolRuntime?.prepareManagedOpenCodeEnv({ includeControl, includeWeb, includeMemory }) || {}) : {}; - if (settings?.optimizeSystemPrompt !== true) return managedEnv; + const envWithSystemPrompt = settings?.optimizeSystemPrompt === true + ? { + ...managedEnv, + ...(await systemPromptRuntime.prepareManagedOpenCodeEnv( + managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT, + )), + } + : managedEnv; - const configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT; - const systemPromptEnv = await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent); - return { ...managedEnv, ...systemPromptEnv }; + // Apply the explicit Small Model override to the managed OpenCode config + // so OpenCode's own title/summary generation uses the user's chosen model. + const configContent = envWithSystemPrompt.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT; + const withSmallModel = applySmallModelOverrideToOpenCodeConfig({ + configContent, + smallModelUseDefault: settings?.smallModelUseDefault, + smallModelOverride: settings?.smallModelOverride, + }); + if (withSmallModel === configContent) return envWithSystemPrompt; + return { ...envWithSystemPrompt, OPENCODE_CONFIG_CONTENT: withSmallModel }; }, }); diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index 51bfe98f..209cb749 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -135,6 +135,17 @@ other runtime API. - `routes.js` — `GET /api/small-model` (resolution preview) and `POST /api/small-model/generate` (`{ prompt, system?, maxOutputTokens?, model?, directory? }` → `{ text, providerID, modelID, source }`). +- `config-injection.js` — applies the Settings → Chat → Small Model override + to the config injected into the **managed OpenCode process** + (`OPENCODE_CONFIG_CONTENT`), so OpenCode's own internal `small_model` + consumers — session title and summary generation — use the user's explicit + choice instead of OpenCode's fallback chain. Only an explicit override + (`smallModelUseDefault === false` with a non-empty `smallModelOverride`) is + injected; "use default" leaves the config untouched so OpenCode's own + resolution stays authoritative. Wired into `getManagedOpenCodeEnv` in + `server/index.js`; the pure helper is unit-tested in + `config-injection.test.js`. External OpenCode servers are unaffected (they + are not launched with this env). ## Which providers the pickers may offer diff --git a/packages/web/server/lib/small-model/config-injection.js b/packages/web/server/lib/small-model/config-injection.js new file mode 100644 index 00000000..3738ec8e --- /dev/null +++ b/packages/web/server/lib/small-model/config-injection.js @@ -0,0 +1,51 @@ +/** + * Applies the user's explicit Small Model override (Settings → Chat → Small + * Model) to the configuration injected into the managed OpenCode process. + * + * OpenCode's own session-title and summary generation reads `small_model` + * from its config layers. Previously the OpenChamber settings override only + * fed OpenChamber's own `/api/small-model/generate` utility service, so a + * configured Small Model never reached OpenCode's title generation and + * sessions kept their fallback/untitled state. Injecting the override as + * `small_model` in the managed `OPENCODE_CONFIG_CONTENT` closes that gap for + * the managed server. + * + * Only an explicit override applies (`smallModelUseDefault === false` with a + * non-empty `smallModelOverride`). "Use default" leaves the config untouched, + * so OpenCode's own resolution chain (config `small_model`, then its family + * scan) stays authoritative — this mirrors the precedence documented in + * `packages/web/server/lib/small-model/DOCUMENTATION.md`. + * + * Malformed user config is left untouched rather than rewritten: OpenCode's + * own loader is the right place to surface it, and silently rewriting it + * would hide the error. + */ +export const applySmallModelOverrideToOpenCodeConfig = ({ + configContent, + smallModelUseDefault, + smallModelOverride, +}) => { + if (smallModelUseDefault !== false) { + return configContent; + } + const override = typeof smallModelOverride === 'string' ? smallModelOverride.trim() : ''; + if (!override) { + return configContent; + } + + const current = (() => { + if (typeof configContent !== 'string' || configContent.trim().length === 0) { + return {}; + } + try { + return JSON.parse(configContent); + } catch { + return null; + } + })(); + if (current === null || typeof current !== 'object' || Array.isArray(current)) { + return configContent; + } + + return JSON.stringify({ ...current, small_model: override }); +}; diff --git a/packages/web/server/lib/small-model/config-injection.test.js b/packages/web/server/lib/small-model/config-injection.test.js new file mode 100644 index 00000000..22342038 --- /dev/null +++ b/packages/web/server/lib/small-model/config-injection.test.js @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { applySmallModelOverrideToOpenCodeConfig } from './config-injection.js'; + +describe('applySmallModelOverrideToOpenCodeConfig', () => { + it('leaves config unchanged when use-default is not explicitly disabled', () => { + const config = '{"model":"anthropic/claude-sonnet-4-5"}'; + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: true, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }), + ).toBe(config); + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: undefined, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }), + ).toBe(config); + }); + + it('leaves config unchanged when the override is empty or whitespace', () => { + const config = '{"model":"anthropic/claude-sonnet-4-5"}'; + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: false, + smallModelOverride: ' ', + }), + ).toBe(config); + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: false, + smallModelOverride: undefined, + }), + ).toBe(config); + }); + + it('injects small_model into an empty config', () => { + const result = applySmallModelOverrideToOpenCodeConfig({ + configContent: undefined, + smallModelUseDefault: false, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }); + expect(JSON.parse(result)).toEqual({ small_model: 'anthropic/claude-haiku-4-5' }); + }); + + it('injects small_model while preserving existing config keys and plugins', () => { + const result = applySmallModelOverrideToOpenCodeConfig({ + configContent: '{"model":"anthropic/claude-sonnet-4-5","plugin":["file:///tool.js"]}', + smallModelUseDefault: false, + smallModelOverride: 'google/gemini-2.5-flash', + }); + expect(JSON.parse(result)).toEqual({ + model: 'anthropic/claude-sonnet-4-5', + plugin: ['file:///tool.js'], + small_model: 'google/gemini-2.5-flash', + }); + }); + + it('replaces an existing small_model with the override', () => { + const result = applySmallModelOverrideToOpenCodeConfig({ + configContent: '{"small_model":"anthropic/claude-haiku-4-5"}', + smallModelUseDefault: false, + smallModelOverride: 'google/gemini-2.5-flash', + }); + expect(JSON.parse(result)).toEqual({ small_model: 'google/gemini-2.5-flash' }); + }); + + it('leaves malformed config untouched instead of rewriting it', () => { + const config = '{not-valid-json'; + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: false, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }), + ).toBe(config); + const arrayConfig = '["not","an","object"]'; + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: arrayConfig, + smallModelUseDefault: false, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }), + ).toBe(arrayConfig); + }); +});