From 7c5e56e3b63f5a3fb2d0c95eb65ce9684114ab78 Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:57:13 -0500 Subject: [PATCH 1/8] fix(title): rank bounded same-provider recovery models --- .../src/session/title-recovery-models.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 packages/opencode/src/session/title-recovery-models.ts diff --git a/packages/opencode/src/session/title-recovery-models.ts b/packages/opencode/src/session/title-recovery-models.ts new file mode 100644 index 000000000..ce86cdfcb --- /dev/null +++ b/packages/opencode/src/session/title-recovery-models.ts @@ -0,0 +1,41 @@ +/** + * Order bounded title-generation recovery models without changing provider/account + * ownership. The caller supplies only models from the original provider and + * resolves every choice against its committed provider route before execution. + * + * This is a last resort after explicitly configured models and the normal + * small-model selection. It must never expand into all configured providers. + */ +export function rankTitleRecoveryModels< + T extends { + readonly id: string + readonly status: string + readonly capabilities: { readonly toolcall: boolean } + readonly cost: { readonly input: number; readonly output: number } + }, +>( + models: readonly T[], + excluded: ReadonlySet, + limit = 2, +): T[] { + if (limit <= 0) return [] + return models + .filter( + (model) => + model.status === "active" && + model.capabilities.toolcall === true && + !excluded.has(model.id) && + Number.isFinite(model.cost.input) && + Number.isFinite(model.cost.output) && + model.cost.input >= 0 && + model.cost.output >= 0, + ) + .sort((left, right) => { + // A typical title request has more input than output tokens. Compare + // prices within the *same provider*; never select another billing domain. + const a = left.cost.input * 4 + left.cost.output + const b = right.cost.input * 4 + right.cost.output + return a - b || left.id.localeCompare(right.id) + }) + .slice(0, limit) +} From ebe4b286c951f375f7ea844046d6309cb3633620 Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:57:41 -0500 Subject: [PATCH 2/8] fix(title): keep bounded fallback on the committed provider route --- packages/opencode/src/session/prompt.ts | 33 +++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 46aa47c16..a1e107ced 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -27,6 +27,7 @@ import { TurnCheckpoint } from "./checkpoint" import { Session } from "./session" import { Agent } from "../agent/agent" import { Provider } from "@/provider/provider" +import { rankTitleRecoveryModels } from "./title-recovery-models" import { type ModelMessage, type Tool as AITool, tool, jsonSchema } from "ai" import type { JSONSchema7 } from "@ai-sdk/provider" @@ -697,7 +698,7 @@ const layer = Layer.effect( ...(input.route ? { route: input.route } : {}), }) const small = yield* provider.getSmallModel(input.providerID) - const candidates = [ + const configuredCandidates = [ input.model ? yield* resolve( input.model.providerID, @@ -711,7 +712,6 @@ const layer = Layer.effect( ? yield* resolve( ag.model.providerID, ag.model.modelID, - ag.model.accountID, ag.model.accountID !== undefined, true, ) @@ -719,6 +719,35 @@ const layer = Layer.effect( small ? yield* resolve(small.providerID, small.id) : undefined, yield* resolve(input.providerID, input.modelID), ].filter((item): item is MaintenanceModelResolution => item !== undefined) + + // Configured candidates can collapse to a single model. Keep recovery + // within the original provider: never silently cross account/provider + // boundaries, and never bypass the authoritative route resolver. + const excluded = new Set( + configuredCandidates + .filter((candidate) => candidate.model.providerID === input.providerID) + .map((candidate) => String(candidate.model.id)), + ) + const recoveryModels = yield* provider.getProvider(input.providerID).pipe( + Effect.map((info) => + rankTitleRecoveryModels(Object.values(info.models).filter(Provider.isLanguageModel), excluded, 8), + ), + Effect.catchCause((cause) => + Effect.logWarning("title recovery catalog unavailable", { + sessionID: input.sessionID, + providerID: input.providerID, + error: String(Cause.squash(cause)), + }).pipe(Effect.as([] as Provider.Model[])), + ), + ) + const recoveryCandidates: MaintenanceModelResolution[] = [] + for (const model of recoveryModels) { + const resolved = yield* resolve(input.providerID, model.id) + if (!resolved) continue + recoveryCandidates.push(resolved) + if (recoveryCandidates.length === 2) break + } + const candidates = [...configuredCandidates, ...recoveryCandidates] const seen = new Set() const uniqueCandidates = candidates.filter((item) => { const key = [ From 21d679c3a5ac57124b4dc0a6ce9882e415e720bd Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:57:59 -0500 Subject: [PATCH 3/8] test(title): cover bounded fallback ranking and eligibility --- .../session/title-recovery-models.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 packages/opencode/test/session/title-recovery-models.test.ts diff --git a/packages/opencode/test/session/title-recovery-models.test.ts b/packages/opencode/test/session/title-recovery-models.test.ts new file mode 100644 index 000000000..a395f0c9d --- /dev/null +++ b/packages/opencode/test/session/title-recovery-models.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test" +import { rankTitleRecoveryModels } from "../../src/session/title-recovery-models" + +const model = ( + id: string, + input: number, + output: number, + status = "active", + toolcall = true, +) => ({ id, status, capabilities: { toolcall }, cost: { input, output } }) + +describe("title model recovery ranking", () => { + test("recovers from a deduplicated sole model with cheaper same-provider candidates", () => { + const models = [ + model("qwen3.8-flash", 0, 0), + model("other-paid", 0.1, 0.5), + model("other-free", 0, 0), + ] + const result = rankTitleRecoveryModels(models, new Set(["qwen3.8-flash"])) + expect(result.map((item) => item.id)).toEqual(["other-free", "other-paid"]) + }) + + test("rejects models that cannot provide the required terminal tool", () => { + const models = [ + model("unavailable", 0, 0, "deprecated"), + model("no-tools", 0, 0, "active", false), + model("eligible", 2, 3), + model("experimental", 0, 0, "beta"), + ] + expect(rankTitleRecoveryModels(models, new Set()).map((item) => item.id)).toEqual(["eligible"]) + }) + + test("has stable ordering on equal cost and strictly bounds fallback work", () => { + const models = [model("z", 1, 1), model("a", 1, 1), model("b", 1, 1)] + expect(rankTitleRecoveryModels(models, new Set(), 2).map((item) => item.id)).toEqual(["a", "b"]) + expect(rankTitleRecoveryModels(models, new Set(), 0)).toEqual([]) + }) + + test("does not mutate the provider catalog and excludes invalid prices", () => { + const models = [ + model("valid", 0.2, 1), + model("negative", -1, 0), + model("nan", Number.NaN, 0), + ] + const before = models.slice() + expect(rankTitleRecoveryModels(models, new Set()).map((item) => item.id)).toEqual(["valid"]) + expect(models).toEqual(before) + }) +}) From bd98ac7271f6ea43dec0ed07a099d68da1361407 Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:58:29 -0500 Subject: [PATCH 4/8] fix(title): preserve explicit title-agent account affinity --- packages/opencode/src/session/prompt.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a1e107ced..506f47597 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -712,6 +712,7 @@ const layer = Layer.effect( ? yield* resolve( ag.model.providerID, ag.model.modelID, + ag.model.accountID, ag.model.accountID !== undefined, true, ) From c81e7de96838cd554bf7b1980efdfbfe84d992ff Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:59:17 -0500 Subject: [PATCH 5/8] test(title): exercise HTTP 400 then same-provider recovery in real server --- .../test/server/httpapi-session.test.ts | 69 ++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 7eb1d985b..d4d32fafd 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -52,7 +52,7 @@ import * as DateTime from "effect/DateTime" import { eq } from "drizzle-orm" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideInstanceEffect, TestInstance, tmpdirScoped } from "../fixture/fixture" -import { TestLLMServer, reply } from "../lib/llm-server" +import { TestLLMServer, reply, httpError } from "../lib/llm-server" import { testProviderConfig } from "../lib/test-provider" import { pollWithTimeout, awaitWithTimeout, testEffect } from "../lib/effect" @@ -2475,6 +2475,73 @@ describe("session HttpApi", () => { 20_000, ) + it.live("regenerateTitle recovers on a second model after a provider HTTP 400", () => + Effect.gen(function* () { + const llm = yield* TestLLMServer + yield* llm.push( + httpError(400, { error: { type: "invalid_request_error", message: "Bad Request" } }), + reply().tool(SessionTitle.GENERATED_TITLE_TOOL, { title: "Fallback Title" }).item(), + ) + + const base = testProviderConfig(llm.url) + const config = { + ...base, + provider: { + test: { + ...base.provider.test, + models: { + ...base.provider.test.models, + "recovery-model": { + ...base.provider.test.models["test-model"], + id: "recovery-model", + name: "Recovery Model", + }, + }, + }, + }, + } + const directory = yield* tmpdirScoped({ git: true, config }) + const session = yield* createSession({ title: "Original Title" }).pipe(provideInstanceEffect(directory)) + const svc = yield* Session.Service + const message = yield* svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + agent: "build", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test-model") }, + time: { created: Date.now() }, + }) + yield* svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: message.id, + type: "text", + text: "Recover the title without changing provider", + }) + + const response = yield* request(pathFor(SessionPaths.regenerateTitle, { sessionID: session.id }), { + method: "POST", + headers: { "x-opencode-directory": directory, "content-type": "application/json" }, + body: JSON.stringify({}), + }) + expect(response.status).toBe(204) + + const title = yield* pollWithTimeout( + Effect.gen(function* () { + const current = yield* Session.use.get(session.id).pipe(provideInstanceEffect(directory), Effect.orDie) + return current.title === "Original Title" ? undefined : current.title + }), + "title recovery failed after provider 400", + "10 seconds", + ) + expect(title).toBe("Fallback Title") + const inputs = yield* llm.inputs + expect(inputs).toHaveLength(2) + expect(inputs.map((body) => body.model)).toEqual(["test-model", "recovery-model"]) + }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))), + 20_000, + ) + it.live("regenerateTitle repairs prose in the same title-agent conversation", () => Effect.gen(function* () { const llm = yield* TestLLMServer From 35bd1b53d17beb99dc31a596c2841cb8bf93ea85 Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:01:14 -0500 Subject: [PATCH 6/8] ci(title): execute ranked fallback and provider HTTP 400 regressions --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8889a0ec8..f5e7ea5a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,13 @@ jobs: # Fail-closed channel behavior and the staged-layout/runtime-discovery # contract for the managed worktree-store sidecar. Focused on purpose: the # full desktop suite needs packaged native resources this job does not build. + # Exercise the actual HTTP-400 -> second-model retitle path in addition + # to the cheaper pure candidate-ranking cases. + - name: Title recovery regressions + run: | + bun test ./packages/opencode/test/session/title-recovery-models.test.ts + bun test ./packages/opencode/test/server/httpapi-session.test.ts --test-name-pattern "regenerateTitle recovers on a second model" + - name: Desktop worktree-store packaging tests working-directory: packages/desktop run: bun run test:worktree-store From 1e1a815722061c56bbb3c1724312c4d00ffa735b Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:02:00 -0500 Subject: [PATCH 7/8] perf(title): defer recovery discovery until configured models fail --- packages/opencode/src/session/prompt.ts | 82 +++++++++++++++---------- 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 506f47597..033b2e58b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -721,47 +721,65 @@ const layer = Layer.effect( yield* resolve(input.providerID, input.modelID), ].filter((item): item is MaintenanceModelResolution => item !== undefined) - // Configured candidates can collapse to a single model. Keep recovery - // within the original provider: never silently cross account/provider - // boundaries, and never bypass the authoritative route resolver. - const excluded = new Set( - configuredCandidates - .filter((candidate) => candidate.model.providerID === input.providerID) - .map((candidate) => String(candidate.model.id)), - ) - const recoveryModels = yield* provider.getProvider(input.providerID).pipe( - Effect.map((info) => - rankTitleRecoveryModels(Object.values(info.models).filter(Provider.isLanguageModel), excluded, 8), - ), - Effect.catchCause((cause) => - Effect.logWarning("title recovery catalog unavailable", { - sessionID: input.sessionID, - providerID: input.providerID, - error: String(Cause.squash(cause)), - }).pipe(Effect.as([] as Provider.Model[])), - ), - ) - const recoveryCandidates: MaintenanceModelResolution[] = [] - for (const model of recoveryModels) { - const resolved = yield* resolve(input.providerID, model.id) - if (!resolved) continue - recoveryCandidates.push(resolved) - if (recoveryCandidates.length === 2) break - } - const candidates = [...configuredCandidates, ...recoveryCandidates] const seen = new Set() - const uniqueCandidates = candidates.filter((item) => { - const key = [ + const candidateKey = (item: MaintenanceModelResolution) => + [ item.model.providerID, item.model.id, item.route?.routeKind ?? "legacy", item.route?.accountID ?? "", ].join("/") + const uniqueCandidates = configuredCandidates.filter((item) => { + const key = candidateKey(item) if (seen.has(key)) return false seen.add(key) return true }) const toolCandidates = uniqueCandidates.filter((item) => item.model.capabilities.toolcall) + + // Normal title generation must not perform extra catalog reads or route + // resolution. Only an exhausted candidate queue activates recovery. + // Stay within the original provider, inheriting the committed route when + // one exists; never silently choose an unrelated provider/account. + let recoveryLoaded = false + const loadRecovery = Effect.fn("SessionPrompt.loadTitleRecovery")(function* () { + if (recoveryLoaded) return + recoveryLoaded = true + const excluded = new Set( + configuredCandidates + .filter((candidate) => candidate.model.providerID === input.providerID) + .map((candidate) => String(candidate.model.id)), + ) + const recoveryModels = yield* provider.getProvider(input.providerID).pipe( + Effect.map((info) => + rankTitleRecoveryModels(Object.values(info.models).filter(Provider.isLanguageModel), excluded, 8), + ), + Effect.catchCause((cause) => + Effect.logWarning("title recovery catalog unavailable", { + sessionID: input.sessionID, + providerID: input.providerID, + error: String(Cause.squash(cause)), + }).pipe(Effect.as([] as Provider.Model[])), + ), + ) + const added: string[] = [] + for (const model of recoveryModels) { + const candidate = yield* resolve(input.providerID, model.id) + if (!candidate || !candidate.model.capabilities.toolcall) continue + const key = candidateKey(candidate) + if (seen.has(key)) continue + seen.add(key) + toolCandidates.push(candidate) + added.push(String(candidate.model.id)) + if (added.length === 2) break + } + yield* Effect.logInfo("title recovery candidates", { + sessionID: input.sessionID, + providerID: input.providerID, + models: added, + }) + }) + if (toolCandidates.length === 0) yield* loadRecovery() yield* Effect.logInfo("title generation candidates", { sessionID: input.sessionID, candidates: toolCandidates.map((item) => `${item.model.providerID}/${item.model.id}`), @@ -803,7 +821,8 @@ const layer = Layer.effect( text: titleRequestText, }) } - for (const candidate of toolCandidates) { + for (let index = 0; index < toolCandidates.length; index++) { + const candidate = toolCandidates[index]! const mdl = candidate.model const candidateRoute = candidate.route const candidateUsageRoute = usageRoute(candidateRoute) @@ -1080,6 +1099,7 @@ const layer = Layer.effect( ), ) if (title) return title + if (index === toolCandidates.length - 1) yield* loadRecovery() } yield* Effect.logWarning("all title model candidates failed", { sessionID: input.sessionID, From 04e4703bcb880e561bf91bc263dd856fe24ad0e3 Mon Sep 17 00:00:00 2001 From: Jackson Cummings <126146472+thelabcorner@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:02:18 -0500 Subject: [PATCH 8/8] ci(title): execute regression tests before unrelated full-repo gates --- .github/workflows/ci.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5e7ea5a8..cf3e6eb4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,13 @@ jobs: - name: Install workspace run: bun install --frozen-lockfile + # Exercise the actual HTTP-400 -> second-model retitle path in addition + # to the cheaper pure candidate-ranking cases. + - name: Title recovery regressions + run: | + bun test ./packages/opencode/test/session/title-recovery-models.test.ts + bun test ./packages/opencode/test/server/httpapi-session.test.ts --test-name-pattern "regenerateTitle recovers on a second model" + - name: Lint run: bun run lint @@ -52,13 +59,6 @@ jobs: # Fail-closed channel behavior and the staged-layout/runtime-discovery # contract for the managed worktree-store sidecar. Focused on purpose: the # full desktop suite needs packaged native resources this job does not build. - # Exercise the actual HTTP-400 -> second-model retitle path in addition - # to the cheaper pure candidate-ranking cases. - - name: Title recovery regressions - run: | - bun test ./packages/opencode/test/session/title-recovery-models.test.ts - bun test ./packages/opencode/test/server/httpapi-session.test.ts --test-name-pattern "regenerateTitle recovers on a second model" - - name: Desktop worktree-store packaging tests working-directory: packages/desktop run: bun run test:worktree-store