From 439ed66c7b137842c48d4e71db71bc6b9cff9972 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:46:12 -0500 Subject: [PATCH] fix(core): migrate legacy small model (#40966) Co-authored-by: Aiden Cline --- packages/core/src/config/normalize.ts | 19 ++++++++++++++- packages/core/src/v1/config/migrate.ts | 12 ++++++++-- packages/core/test/config/config.test.ts | 14 +++++++++++ .../core/test/config/normalization.test.ts | 24 +++++++++++++++++-- packages/www/content/docs/migrate-v1.mdx | 3 ++- 5 files changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/core/src/config/normalize.ts b/packages/core/src/config/normalize.ts index ee340f70740..d80429648b9 100644 --- a/packages/core/src/config/normalize.ts +++ b/packages/core/src/config/normalize.ts @@ -41,7 +41,7 @@ export type Result = | { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] } const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const -const unsupportedTopLevel = ["logLevel", "server", "small_model", "subagent_depth", "layout"] as const +const unsupportedTopLevel = ["logLevel", "server", "subagent_depth", "layout"] as const const unsupportedExperimental = [ "disable_paste_summary", "batch_tool", @@ -113,6 +113,23 @@ export function normalize(input: unknown): Result { const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) => canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)), ) + const legacySmallModel = own(input, "small_model") + ? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics) + : undefined + const migratedSmallModel = legacySmallModel + ? ConfigMigrateV1.migrate({ small_model: legacySmallModel }).agents?.title?.model + : undefined + if (legacySmallModel && !migratedSmallModel) + diagnostics.push({ + kind: "unsupported", + path: ["small_model"], + message: "omitted unsupported legacy model reference", + }) + if (migratedSmallModel) + legacyAgents.title = { + model: migratedSmallModel, + ...legacyAgents.title, + } const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) => canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })), ) diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 11d15f5c06d..40775c089be 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -119,8 +119,16 @@ function agents(info: typeof ConfigV1.Info.Type) { ...Object.entries(info.agent ?? {}), ...Object.entries(info.mode ?? {}).map(([name, agent]) => [name, { ...agent, mode: "primary" as const }] as const), ] - if (!entries.length) return undefined - return Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : []))) + const result = Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : []))) + const small = modelSelection(info.small_model) + if (!small) return entries.length ? result : undefined + return { + ...result, + title: { + model: small, + ...result.title, + }, + } } export function migrateAgent(info: ConfigAgentV1.Info) { diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 1d72dfe18f5..b2f449fbc74 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -512,6 +512,20 @@ describe("Config", () => { }), ) + it.effect("migrates the v1 small model to the title agent", () => + Effect.sync(() => { + expect( + ConfigMigrateV1.migrate({ + small_model: "anthropic/claude-haiku-4-5", + agent: { title: { prompt: "Custom title prompt" } }, + }).agents?.title, + ).toEqual({ + model: { providerID: "anthropic", model: "claude-haiku-4-5" }, + system: "Custom title prompt", + }) + }), + ) + it.effect("migrates v1 provider lists to policies", () => Effect.sync(() => { expect( diff --git a/packages/core/test/config/normalization.test.ts b/packages/core/test/config/normalization.test.ts index 97048e727c7..9c89bf45526 100644 --- a/packages/core/test/config/normalization.test.ts +++ b/packages/core/test/config/normalization.test.ts @@ -149,6 +149,28 @@ describe("ConfigNormalize", () => { expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow() }) + test("migrates the legacy small model to the title agent", () => { + const result = normalized({ + small_model: "anthropic/claude-haiku-4-5", + agent: { title: { prompt: "Custom title prompt" } }, + }) + expect(result.encoded.agents).toEqual({ + title: { + model: { providerID: "anthropic", model: "claude-haiku-4-5" }, + system: "Custom title prompt", + }, + }) + expect(result.diagnostics).toEqual([]) + }) + + test("omits an invalid legacy small model without exposing its value", () => { + const secret = "do-not-log-this-value" + const result = normalized({ small_model: secret }) + expect(result.encoded.agents).toBeUndefined() + expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([["unsupported", ["small_model"]]]) + expect(JSON.stringify(result.diagnostics)).not.toContain(secret) + }) + test("recovers malformed named entries and retains a valid legacy collision", () => { const result = normalized({ command: { fallback: { template: "legacy" } }, @@ -390,7 +412,6 @@ describe("ConfigNormalize", () => { const secret = "do-not-log-this-value" const result = normalized({ logLevel: "DEBUG", - small_model: secret, agent: { reviewer: { name: secret, prompt: "review" } }, provider: { custom: { @@ -409,7 +430,6 @@ describe("ConfigNormalize", () => { }) expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([ ["logLevel"], - ["small_model"], ["agent", "reviewer", "name"], ["provider", "custom", "id"], ["provider", "custom", "whitelist"], diff --git a/packages/www/content/docs/migrate-v1.mdx b/packages/www/content/docs/migrate-v1.mdx index ad110a48582..0c891676c6b 100644 --- a/packages/www/content/docs/migrate-v1.mdx +++ b/packages/www/content/docs/migrate-v1.mdx @@ -419,6 +419,8 @@ The V1 provider filters do not have one-to-one native V2 config fields, but thei - `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers. - `disabled_providers` becomes internal deny policies for the listed providers. +- `small_model` becomes the `model` selection for the built-in `title` agent. Native V2 configuration should use + `agents.title.model` instead. You may keep these fields in V1 syntax. OpenCode normalizes them without warning. @@ -430,7 +432,6 @@ they are not mistaken for active configuration: - `logLevel`: use `OPENCODE_LOG_LEVEL` when starting OpenCode. - `server`: use the V2 service and explicit server options; the server API is an intentional breaking change. - `layout`: remove it; V1 already treated it as deprecated and always used stretch layout. -- `small_model`: V2 selects models for internal maintenance agents without a separate top-level field. - Top-level `subagent_depth`: use `experimental.subagent_depth` instead. - `compaction.tail_turns` and `compaction.prune`: V2 uses `compaction.keep.tokens` and checkpoint-based compaction instead. - Agent `name` inside V1 JSON configuration.