From 8c7ee08fbff6f1bdfaa89437327d00c7e0cd6781 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 19 Oct 2026 10:40:14 -0700 Subject: [PATCH 1/4] fix(core): broadcast models.dev refresh globally and hide retired models --- packages/app/src/providers/models/models.tsx | 14 ++++-- packages/core/src/model.ts | 13 ++++-- packages/core/src/models-dev.ts | 2 +- packages/core/src/session/to-session-error.ts | 7 ++- packages/core/test/catalog.test.ts | 8 +++- packages/core/test/models.test.ts | 46 +++++++++++++++++++ packages/core/test/session-error.test.ts | 16 +++++++ packages/tui/src/component/dialog-model.tsx | 4 +- packages/tui/src/context/local.tsx | 4 +- 9 files changed, 101 insertions(+), 13 deletions(-) diff --git a/packages/app/src/providers/models/models.tsx b/packages/app/src/providers/models/models.tsx index 33b67a9d520f..0b3730b6801e 100644 --- a/packages/app/src/providers/models/models.tsx +++ b/packages/app/src/providers/models/models.tsx @@ -28,10 +28,16 @@ const createModelsController = (directory: Accessor) => { const available = createMemo(() => providers.connected().flatMap((p) => - Object.values(p.models).map((m) => ({ - ...m, - provider: p, - })), + Object.values(p.models).flatMap((m) => + m.status === "deprecated" + ? [] + : [ + { + ...m, + provider: p, + }, + ], + ), ), ) diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index c0246ee9bd3d..97d7dbed14bc 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -223,7 +223,15 @@ const layer = Layer.effect( const all = Array.from(byProvider.values()) .flatMap((models) => Array.from(models.values())) .sort((left, right) => right.time.released - left.time.released) - cached = freeze({ data, all, available: preferFree(all.filter((model) => model.enabled)), byProvider }, true) + cached = freeze( + { + data, + all, + available: preferFree(all.filter((model) => model.enabled && model.status !== "deprecated")), + byProvider, + }, + true, + ) return cached } }) @@ -266,7 +274,7 @@ const layer = Layer.effect( const value = yield* read() const requested = value.data.defaultModel const model = requested && value.byProvider.get(requested.providerID)?.get(requested.modelID) - return model?.enabled ? model : value.available.find(supportsText) + return model?.enabled && model.status !== "deprecated" ? model : value.available.find(supportsText) }), small: Effect.fn("Model.small")(function* (providerID) { const value = yield* read() @@ -293,7 +301,6 @@ const PREFERRED_FREE = [ "big-pickle", "deepseek-v4.1-flash-free", "glm-5.3-flash-free", - "muse-spark-1.3-contributor-free", ] function preferFree(models: Info[]) { diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 54c197d1aa44..cc4b42635f3e 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -420,7 +420,7 @@ export const layer = (options?: Options) => yield* decodeCatalog(text) yield* writeCache(text, digest) yield* invalidate - yield* bus.publish(ModelsDev.Event.Refreshed, {}) + yield* bus.publish(ModelsDev.Event.Refreshed, {}, { global: true }) }), ) .pipe( diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index d8eb0f683afa..947a48064f30 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -94,11 +94,16 @@ export function contentFilterError(summary: string, reason: FinishReasonDetails) } } +const retiredModelMessage = "This model was retired, pick another" + function providerError(type: string, reason: AIError["reason"]): SessionError.Error { const status = reason.http?.status return { type, - message: Object.entries(tokenSharingMessages).find(([code]) => reason.body?.includes(code))?.[1] ?? reason.message, + message: + status === 410 + ? retiredModelMessage + : (Object.entries(tokenSharingMessages).find(([code]) => reason.body?.includes(code))?.[1] ?? reason.message), ...(status === undefined ? {} : { status }), ...(reason.body === undefined ? {} : { response: { body: reason.body } }), } diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 8eed25ba9172..0243672bff2f 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -576,18 +576,22 @@ describe("Provider and Model", () => { const models = yield* Model.Service yield* providers.transform((editor) => { editor.update(Provider.ID.opencode, () => {}) - ;["muse-spark-1.3-contributor-free", "new-free", "deepseek-v4.1-flash-free", "big-pickle"].forEach( + ;["glm-5.3-flash-free", "new-free", "deepseek-v4.1-flash-free", "big-pickle"].forEach( (id, index) => editor.models.update(Provider.ID.opencode, Model.ID.make(id), (model) => { model.time.released = 4000 - index * 1000 }), ) + editor.models.update(Provider.ID.opencode, Model.ID.make("muse-spark-1.3-contributor-free"), (model) => { + model.time.released = 5000 + model.status = "deprecated" + }) }) expect((yield* models.available()).map((model) => model.id)).toEqual([ Model.ID.make("big-pickle"), Model.ID.make("deepseek-v4.1-flash-free"), - Model.ID.make("muse-spark-1.3-contributor-free"), + Model.ID.make("glm-5.3-flash-free"), Model.ID.make("new-free"), ]) expect((yield* models.default())?.id).toBe(Model.ID.make("big-pickle")) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 20422913f6a9..2919c8c2b4e9 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -7,9 +7,12 @@ import { LayerNodePlatform } from "@opencode/util/effect/app-node-platform" import { LayerNode } from "@opencode/util/effect/layer-node" import { Bus } from "@opencode/core/bus" import { KV } from "@opencode/core/kv" +import { Location } from "@opencode/core/location" import { Model } from "@opencode/core/model" import { bodyDigest, ModelsDev } from "@opencode/core/models-dev" import { Provider } from "@opencode/core/provider" +import { AbsolutePath } from "@opencode/core/schema" +import { location } from "./fixture/location" import { it } from "./lib/effect" const cacheKey = "models-dev:catalog" @@ -485,6 +488,49 @@ describe("ModelsDev Service", () => { }), ) + it.live("refresh publishes Refreshed globally across locations", () => + Effect.gen(function* () { + const cache = makeCache() + writeCache(cache, fixture, Date.now() - 10 * 60 * 1000) + const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + yield* provided( + state, + cache, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + const bus = yield* Bus.Service + const refreshed = yield* bus + .subscribe(ModelsDev.Event.Refreshed) + .pipe( + Stream.take(1), + Stream.runCollect, + Effect.provideService( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("/other-location") })), + ), + Effect.forkScoped, + ) + .pipe( + Effect.flatMap((fiber) => + Effect.gen(function* () { + yield* Effect.yieldNow + yield* svc.refresh(true).pipe( + Effect.provideService( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("/initial-location") })), + ), + ) + return yield* Fiber.join(fiber) + }), + ), + ) + expect(refreshed.length).toBe(1) + expect(refreshed[0]?.location).toBeUndefined() + }), + ) + }), + ) + it.live("refresh(false) stays quiet when the fetched body matches the cached digest", () => Effect.gen(function* () { const cache = makeCache() diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts index 19728224936c..80db4fc160f6 100644 --- a/packages/core/test/session-error.test.ts +++ b/packages/core/test/session-error.test.ts @@ -126,6 +126,22 @@ describe("toSessionError", () => { message: "bad gateway", status: 502, }) + expect( + toSessionError( + llm( + new InvalidRequestError({ + message: "Model muse-spark-1.3-contributor-free has been deprecated.", + body: "Model muse-spark-1.3-contributor-free has been deprecated.", + http: new HttpContext({ url: "https://opencode.ai/zen/v1/chat/completions", status: 410, headers: {} }), + }), + ), + ), + ).toEqual({ + type: "provider.invalid-request", + message: "This model was retired, pick another", + status: 410, + response: { body: "Model muse-spark-1.3-contributor-free has been deprecated." }, + }) }) test("preserves provider response body without HTTP status", () => { diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index 344d9fcf0419..fe50204b62de 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -37,7 +37,9 @@ export function DialogModel(props: { providerID?: string }) { function toOptions(items: typeof favorites, category: string) { if (!showSections) return [] return items.flatMap((item) => { - const model = models().find((model) => model.providerID === item.providerID && model.id === item.modelID) + const model = models().find( + (model) => model.providerID === item.providerID && model.id === item.modelID && model.status !== "deprecated", + ) if (!model) return [] const provider = providers().get(model.providerID) return [ diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index d3304eb3390b..fa381f428214 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -45,7 +45,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const providers = () => data.location.provider.list(location.ref) function isModelValid(model: ModelPreferenceModel) { - return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID) + return !!models()?.some( + (item) => item.providerID === model.providerID && item.id === model.modelID && item.status !== "deprecated", + ) } function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) { From 5a36113cdfd85b518bac4ec5de2339576827689d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 19 Oct 2026 11:41:09 -0700 Subject: [PATCH 2/4] feat: offer another model when a provider retires the current one --- packages/app/e2e/regression/composer.spec.ts | 49 +++++++++++++++ packages/app/src/session/composer/region.tsx | 33 ++++++++++ packages/app/src/session/revert.ts | 5 +- packages/core/src/session/to-session-error.ts | 14 +++-- packages/core/test/session-error.test.ts | 13 +++- packages/session-ui/src/actions.ts | 4 ++ .../src/components/session-model-retired.tsx | 22 +++++++ .../src/components/session-turn.css | 38 ++++++++++++ .../session-ui/src/timeline/projection.ts | 14 ++++- .../src/timeline/rows-current.test.ts | 41 +++++++++++++ .../src/timeline/session-timeline-row.tsx | 8 ++- .../session-ui/src/timeline/timeline-row.ts | 4 +- .../feature-plugins/system/notifications.ts | 9 ++- packages/tui/src/routes/session/index.tsx | 39 +++++++++++- packages/tui/test/app-lifecycle.test.tsx | 61 +++++++++++++++++++ .../test/cli/cmd/tui/notifications.test.ts | 12 +++- packages/ui/src/i18n/en.ts | 3 + 17 files changed, 354 insertions(+), 15 deletions(-) create mode 100644 packages/session-ui/src/components/session-model-retired.tsx diff --git a/packages/app/e2e/regression/composer.spec.ts b/packages/app/e2e/regression/composer.spec.ts index 87fe50661dff..d7bf0f7d1e53 100644 --- a/packages/app/e2e/regression/composer.spec.ts +++ b/packages/app/e2e/regression/composer.spec.ts @@ -4,6 +4,55 @@ import { openDraft, openSession } from "../utils/workspace" test.use({ permissions: ["clipboard-read", "clipboard-write"] }) +test("a retired model offers another model and hands the prompt back", async ({ page }) => { + const sessionID = "ses_model_retired" + const model = { id: "muse-spark-1.3-contributor-free", providerID: "opencode" } + // The prompt comes back only for a failure that just happened. + const now = Date.now() + + const view = await openSession(page, { + name: "ModelRetired", + sessions: [{ id: sessionID, agent: "build", model }], + pageMessages: () => ({ + items: [ + { id: "msg_retired_user", type: "user", text: "Refactor the parser", time: { created: now } }, + { + id: "msg_retired_assistant", + type: "assistant", + agent: "build", + model, + content: [], + time: { created: now + 1 }, + }, + ], + }), + }) + + await expect(page.getByText("Refactor the parser", { exact: true })).toBeVisible() + + await view.push([ + { + id: "evt_model_retired", + created: now + 2, + type: "session.step.failed", + location: { directory: view.directory }, + durable: { aggregateID: sessionID, seq: 1, version: 1 }, + data: { + sessionID, + assistantMessageID: "msg_retired_assistant", + error: { type: "provider.model-retired", message: "This model was retired, pick another", status: 410 }, + }, + }, + ]) + + const notice = page.getByRole("status").filter({ hasText: "This model was retired" }) + await expect(notice).toBeVisible() + await expect(page.getByText("This model was retired, pick another")).toHaveCount(0) + await expect(view.editor).toHaveText("Refactor the parser") + await notice.getByRole("button", { name: "Pick another model", exact: true }).click() + await expect(page.getByRole("dialog", { name: "Select model" })).toBeVisible() +}) + test("a catalog refresh cannot replace an existing session's model", async ({ page }) => { const alpha = { id: "alpha", name: "Alpha", variants: { high: {} } } const beta = { id: "beta", name: "Beta" } diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index 072f61caf18c..83c2e3b1c4d8 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -34,6 +34,8 @@ import { createSessionRequestModel } from "../requests/model" import type { Region } from "@/runtime/extension/panels" import { SessionLocationMissing } from "./location-missing" +const RECALL_WINDOW = 60_000 + export function createActiveSessionRegion(input: { session: SessionModel screen: SessionScreenLayout @@ -190,6 +192,36 @@ export function createActiveSessionRegion(input: { const revertMessage: NonNullable = ({ messageID }) => revert.to(messageID) + const modelRetired: NonNullable = { + chooseModel: () => command.trigger("model.choose"), + } + + // A retired model fails the prompt after it was sent. Hand its text back so picking another model and resending is + // one step, unless the user has already started typing something else. This follows the transcript rather than the + // failure event: a first prompt from a draft can fail before this session's screen mounts. + const recalled = new Set() + + const retiredPrompt = createMemo(() => { + const last = input.session.history.messages().at(-1) + const error = last?.type === "assistant" || last?.type === "idle" ? last.error : undefined + + if (!last || error?.type !== "provider.model-retired") return + + // Only a failure that just happened, so reopening an old session leaves the composer alone. + if (Date.now() - last.time.created > RECALL_WINDOW) return + + return input.session.history.userMessages().at(-1) + }) + + createEffect( + on(retiredPrompt, (message) => { + if (!message || recalled.has(message.id)) return + recalled.add(message.id) + + if (promptLength(prompt.current()) === 0) revert.recall(message) + }), + ) + const notifyFailed = () => { showToast({ title: language.t("common.requestFailed") }) } @@ -281,6 +313,7 @@ export function createActiveSessionRegion(input: { return pendingSteer }, openAttachment, + modelRetired, } satisfies SessionUserActions, }, requests: state, diff --git a/packages/app/src/session/revert.ts b/packages/app/src/session/revert.ts index 94eed7b1118a..ca79e163561a 100644 --- a/packages/app/src/session/revert.ts +++ b/packages/app/src/session/revert.ts @@ -115,7 +115,10 @@ export function createSessionRevert(input: { ) } - return { to, undo, redo } + /** Puts a sent prompt back in the composer without rewinding the Session. */ + const recall = (message: SessionMessageUser) => restore(prompt.capture(), message) + + return { to, undo, redo, recall } } export type SessionRevert = ReturnType diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index 947a48064f30..eb3da721a76f 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -98,12 +98,18 @@ const retiredModelMessage = "This model was retired, pick another" function providerError(type: string, reason: AIError["reason"]): SessionError.Error { const status = reason.http?.status + // Providers answer 410 Gone for a model they no longer serve; clients that know `provider.model-retired` offer a + // way to pick another one. + if (status === 410) + return { + type: "provider.model-retired", + message: retiredModelMessage, + status, + ...(reason.body === undefined ? {} : { response: { body: reason.body } }), + } return { type, - message: - status === 410 - ? retiredModelMessage - : (Object.entries(tokenSharingMessages).find(([code]) => reason.body?.includes(code))?.[1] ?? reason.message), + message: Object.entries(tokenSharingMessages).find(([code]) => reason.body?.includes(code))?.[1] ?? reason.message, ...(status === undefined ? {} : { status }), ...(reason.body === undefined ? {} : { response: { body: reason.body } }), } diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts index 80db4fc160f6..3dd510282b36 100644 --- a/packages/core/test/session-error.test.ts +++ b/packages/core/test/session-error.test.ts @@ -137,11 +137,22 @@ describe("toSessionError", () => { ), ), ).toEqual({ - type: "provider.invalid-request", + type: "provider.model-retired", message: "This model was retired, pick another", status: 410, response: { body: "Model muse-spark-1.3-contributor-free has been deprecated." }, }) + // The status decides, whichever reason the provider's 410 was decoded as. + expect( + toSessionError( + llm( + new ProviderInternalError({ + message: "gone", + http: new HttpContext({ url: "https://example.com", status: 410, headers: {} }), + }), + ), + ).type, + ).toBe("provider.model-retired") }) test("preserves provider response body without HTTP status", () => { diff --git a/packages/session-ui/src/actions.ts b/packages/session-ui/src/actions.ts index a78ad656a5cb..3d88fb4f6fff 100644 --- a/packages/session-ui/src/actions.ts +++ b/packages/session-ui/src/actions.ts @@ -37,4 +37,8 @@ export type SessionUserActions = { queue: (input: { sessionID: string; messageID: string }) => Promise remove: (input: { sessionID: string; messageID: string }) => Promise } + /** The way past a model the provider retired. Without it the failure shows as plain text. */ + modelRetired?: { + chooseModel: () => void + } } diff --git a/packages/session-ui/src/components/session-model-retired.tsx b/packages/session-ui/src/components/session-model-retired.tsx new file mode 100644 index 000000000000..23a85dfed8b7 --- /dev/null +++ b/packages/session-ui/src/components/session-model-retired.tsx @@ -0,0 +1,22 @@ +import { Button } from "@opencode/ui/button" +import { useI18n } from "@opencode/ui/context/i18n" +import type { SessionUserActions } from "../actions" + +/** The provider no longer serves the step's model: a dead end only until another model is picked. */ +export function SessionModelRetired(props: { actions: NonNullable }) { + const i18n = useI18n() + + return ( +
+
+
{i18n.t("ui.sessionTurn.modelRetired.title")}
+
{i18n.t("ui.sessionTurn.modelRetired.description")}
+
+
+ +
+
+ ) +} diff --git a/packages/session-ui/src/components/session-turn.css b/packages/session-ui/src/components/session-turn.css index 64140e661b6a..ad8a69a2f3bf 100644 --- a/packages/session-ui/src/components/session-turn.css +++ b/packages/session-ui/src/components/session-turn.css @@ -115,6 +115,44 @@ } } + [data-component="session-model-retired"] { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + border-radius: var(--radius-md); + background: var(--v2-background-bg-layer-01); + box-shadow: inset 0 0 0 1px var(--v2-border-border-muted); + font-family: var(--v2-font-family-sans); + font-size: 13px; + line-height: var(--line-height-compact); + letter-spacing: -0.04px; + + [data-slot="session-model-retired-text"] { + display: flex; + flex-direction: column; + gap: 4px; + } + + [data-slot="session-model-retired-title"] { + color: var(--v2-text-text-base); + font-weight: 530; + } + + [data-slot="session-model-retired-description"] { + color: var(--v2-text-text-muted); + font-weight: 440; + text-wrap: pretty; + } + + [data-slot="session-model-retired-actions"] { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + } + } + [data-component="card"][data-kind="session-error-card"] { --card-pad-y: 0px; --card-pad-l: 0px; diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index 03dffd592354..7bf93d4910a0 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -564,7 +564,11 @@ export namespace Timeline { if (isActive && lastAssistant?.retry) rows.push(new TimelineRow.Retry({ userMessageID: turnID })) else if (lastAssistant?.error && !isInterrupted(lastAssistant.error)) rows.push( - new TimelineRow.Error({ userMessageID: turnID, text: unwrapErrorMessage(lastAssistant.error.message) }), + new TimelineRow.Error({ + userMessageID: turnID, + text: unwrapErrorMessage(lastAssistant.error.message), + ...errorKind(lastAssistant.error), + }), ) } @@ -584,6 +588,7 @@ export namespace Timeline { new TimelineRow.Error({ userMessageID: turnID, text: unwrapErrorMessage(entry.message.error.message), + ...errorKind(entry.message.error), }), ) @@ -634,6 +639,13 @@ export namespace Timeline { } } +// A failure the transcript can offer a way past, decided by its type rather than its wording. +function errorKind(error: SessionMessageAssistant["error"]) { + if (error?.type === "provider.model-retired") return { kind: "model-retired" as const } + + return {} +} + function isInterrupted(error: SessionMessageAssistant["error"]) { return error?.type.toLowerCase().includes("abort") || error?.type.toLowerCase().includes("interrupt") } diff --git a/packages/session-ui/src/timeline/rows-current.test.ts b/packages/session-ui/src/timeline/rows-current.test.ts index 6ebf476d9018..2649d4c73173 100644 --- a/packages/session-ui/src/timeline/rows-current.test.ts +++ b/packages/session-ui/src/timeline/rows-current.test.ts @@ -1453,6 +1453,47 @@ describe("current session timeline rows", () => { ) }) + test("marks a retired model by its error type, whichever message records the failure", () => { + const error = { type: "provider.model-retired", message: "This model was retired, pick another", status: 410 } + const user = { id: "msg_user", type: "user", text: "prompt", time: { created: 1 } } satisfies SessionMessageInfo + + const assistant = { + id: "msg_assistant", + type: "assistant", + agent: "build", + model: { id: "muse-spark-1.3-contributor-free", providerID: "opencode" }, + content: [], + finish: "error", + error, + time: { created: 2, completed: 3 }, + } satisfies SessionMessageAssistant + + const idle = [ + user, + { id: "msg_idle", type: "idle", outcome: "failed", error, time: { created: 4 } }, + ] satisfies SessionMessageInfo[] + + // The same words under another type stay a plain error. + const invalid = [ + user, + { ...assistant, error: { type: "provider.invalid-request", message: "This model was retired, pick another" } }, + ] satisfies SessionMessageInfo[] + + expect(Timeline.constructSessionMessageRows([user, assistant], true, { type: "idle" }).rows.at(-1)).toEqual( + new TimelineRow.Error({ + userMessageID: "msg_user", + text: "This model was retired, pick another", + kind: "model-retired", + }), + ) + expect(Timeline.constructSessionMessageRows(idle, true, { type: "idle" }).rows.at(-1)).toMatchObject({ + kind: "model-retired", + }) + expect(Timeline.constructSessionMessageRows(invalid, true, { type: "idle" }).rows.at(-1)).toEqual( + new TimelineRow.Error({ userMessageID: "msg_user", text: "This model was retired, pick another" }), + ) + }) + test("summarizes a turn's changed files after the idle marker that ends it", () => { const step = (id: string, files?: string[]) => ({ diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index 6515f49d891d..dd52301199f8 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -30,6 +30,7 @@ import { ToolRegistry, type ContextGroupPart } from "../tools/tool-renderer" import { GenericTool } from "../components/basic-tool" import { SessionRetry } from "../components/session-retry" import { SessionError } from "../components/session-error" +import { SessionModelRetired } from "../components/session-model-retired" import { timelineCategory, type TimelineDetail } from "./detail" import { currentToolFailed } from "../message/current-tool-state" import { @@ -1013,7 +1014,12 @@ export function createSessionTimelineRowRenderer(input: { return (
- + } + > + {(actions) => } +
) diff --git a/packages/session-ui/src/timeline/timeline-row.ts b/packages/session-ui/src/timeline/timeline-row.ts index 8d81e4db7d72..98c1c8b4c471 100644 --- a/packages/session-ui/src/timeline/timeline-row.ts +++ b/packages/session-ui/src/timeline/timeline-row.ts @@ -57,9 +57,11 @@ export namespace TimelineRow { ref: PartRef }> {} + /** `model-retired` marks a step the provider refused because it no longer serves the model. */ export class Error extends Data.TaggedClass("Error")<{ userMessageID: string text: string + kind?: "model-retired" }> {} export class Retry extends Data.TaggedClass("Retry")<{ @@ -142,7 +144,7 @@ export type TimelineRowMap = { } Thinking: { userMessageID: string; ref: PartRef } Retry: { userMessageID: string } - Error: { userMessageID: string; text: string } + Error: { userMessageID: string; text: string; kind?: "model-retired" } CompactionQueued: { userMessageID: string; inboxID: string } TurnDiff: { userMessageID: string; messageID: string; from: string; files: readonly string[] } Activity: { userMessageID: string } diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index 5435f49689fc..252974192f66 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -67,7 +67,14 @@ export default Plugin.define({ } errored.add(sessionID) notify(context, sessionID, event.data.error.message, "error") - context.ui.toast.show({ sessionID, title: "Session failed", message: event.data.error.message, variant: "error" }) + // The transcript already explains a retired model and points to /models; an error toast would repeat it. + if (event.data.error.type !== "provider.model-retired") + context.ui.toast.show({ + sessionID, + title: "Session failed", + message: event.data.error.message, + variant: "error", + }) ended(sessionID) }), ] diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 4d7d6d199c85..5b3ddd1f74c0 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -390,6 +390,17 @@ export function Session(props: { seeded = true r.set(route.prompt) } + // A retired model fails the prompt after it was sent. Hand its text back so picking another model and resending is + // one step, unless something else is already being typed. + onCleanup( + data.on("session.step.failed", (event) => { + const current = prompt() + if (event.data.sessionID !== route.sessionID || event.data.error.type !== "provider.model-retired") return + if (!current || current.current.text.trim()) return + const message = messages().findLast((item) => item.type === "user") + if (message?.type === "user") current.set({ ...projectedPromptInput(message), pasted: [] }) + }), + ) /** Runs after layout has settled (two frames), unless the transcript was torn down. */ const afterLayout = (continuation: () => void) => { @@ -1909,9 +1920,16 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) { return ( <> - - Error: {errorMessage(props.message.error)} - + + Error: {errorMessage(props.message.error)} + + } + > + + @@ -2382,6 +2400,21 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp ) } +// The provider no longer serves this model. The prompt keeps focus for the restored text, so the way forward is a +// command to type or a click rather than a key binding. +function ModelRetiredNotice(props: { message: SessionMessageAssistant }) { + const theme = useTheme() + const keymap = Keymap.use() + return ( + + {props.message.error?.message} + keymap.dispatch("model.list")}> + /models + + + ) +} + function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) { const theme = useTheme() const [seconds, setSeconds] = createSignal(0) diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index d3394318874e..74201e1775cf 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -1701,6 +1701,67 @@ test("server plugin failures share one notice and use source names before an ID expect(setup.captureCharFrame()).toContain("Open plugins") }) +test("a retired model points to /models and hands the prompt back", async () => { + await using state = await tmpdir() + const session = { + id: "ses_model_retired", + projectID: "proj_test", + location: { directory }, + title: "Retired model", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + } + const model = { id: "muse-spark-1.3-contributor-free", providerID: "opencode" } + await using setup = await createAppFixture({ + width: 100, + state: state.path, + args: { sessionID: session.id }, + config: { animations: false, tabs: { mode: "off" } }, + fetch: (url) => { + if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) + if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (url.pathname === `/api/session/${session.id}/message`) + return json({ + data: [ + { id: "msg_retired_user", type: "user", text: "Refactor the parser", time: { created: 1 } }, + { + id: "msg_retired_assistant", + type: "assistant", + agent: "build", + model, + content: [], + time: { created: 2 }, + }, + ], + cursor: {}, + }) + if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname)) + return json({ data: [] }) + return undefined + }, + }) + await setup.ready + await setup.waitForFrame((frame) => frame.includes("Refactor the parser")) + setup.events.emit({ + id: "evt_model_retired", + created: 3, + type: "session.step.failed", + durable: { aggregateID: session.id, seq: 1, version: 1 }, + data: { + sessionID: session.id, + assistantMessageID: "msg_retired_assistant", + error: { type: "provider.model-retired", message: "This model was retired, pick another", status: 410 }, + }, + }) + await setup.waitForFrame((frame) => frame.includes("This model was retired, pick another")) + const frame = setup.captureCharFrame() + expect(frame).toMatch(/^ +\/models *$/m) + expect(frame).not.toContain("Error:") + // The prompt shows once in the transcript and once more back in the composer. + await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 3) +}) + test.each([44, 100])( "retry countdown updates and clears with the retry lifecycle at width %s", async (width) => { diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index 75ef0d263fe5..d378ac3e4c84 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -108,7 +108,7 @@ function executionSucceeded(id: string, sessionID = "session"): OpenCodeEvent { } } -function executionFailed(id: string, sessionID = "session"): OpenCodeEvent { +function executionFailed(id: string, sessionID = "session", type = "unknown"): OpenCodeEvent { return { id, created: 0, @@ -116,7 +116,7 @@ function executionFailed(id: string, sessionID = "session"): OpenCodeEvent { durable: durable(sessionID), data: { sessionID, - error: { type: "unknown", message: "boom" }, + error: { type, message: "boom" }, }, } } @@ -159,6 +159,14 @@ describe("internal notifications TUI plugin", () => { expect(harness.toasts).toHaveLength(2) }) + test("leaves a retired model to the transcript instead of an error toast", async () => { + const harness = await setup() + harness.emit(executionStarted("started")) + harness.emit(executionFailed("failed", "session", "provider.model-retired")) + expect(harness.toasts).toEqual([]) + expect(harness.notifications).toHaveLength(1) + }) + test.each([{ type: "home" }, { type: "session", sessionID: "other" }])( "leaves routing of other sessions' failures to the session-scoped toast (%j)", async (route) => { diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 783b2e06b784..86b7bf7016ff 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -75,6 +75,9 @@ const source = { "ui.sessionTurn.retry.attemptWaiting.other": "Attempt {{attempt}} - retrying in {{count}}s", "ui.sessionTurn.retry.attemptRetryingNow": "Attempt {{attempt}} - retrying", "ui.sessionTurn.retry.geminiHot": "gemini is way too hot right now", + "ui.sessionTurn.modelRetired.title": "This model was retired", + "ui.sessionTurn.modelRetired.description": "Pick another model to send your message again.", + "ui.sessionTurn.modelRetired.chooseModel": "Pick another model", "ui.sessionTurn.error.freeUsageExceeded": "Free usage exceeded", "ui.sessionTurn.error.addCredits": "Add credits", From fea41e82d97cfaeddcba8f4af1ef94ae7f0857b3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 19 Oct 2026 23:41:41 -0700 Subject: [PATCH 3/4] fix(core): trim to catalog broadcast and retired-model filter Drop the 410 model-retired error and the desktop/TUI/session-ui surfaces; keep the global ModelsDev refresh and deprecated-model filtering. --- packages/app/e2e/regression/composer.spec.ts | 49 --------------- packages/app/src/providers/models/models.tsx | 14 ++--- packages/app/src/session/composer/region.tsx | 33 ---------- packages/app/src/session/revert.ts | 5 +- packages/core/src/session/to-session-error.ts | 11 ---- packages/core/test/session-error.test.ts | 27 -------- packages/session-ui/src/actions.ts | 4 -- .../src/components/session-model-retired.tsx | 22 ------- .../src/components/session-turn.css | 38 ------------ .../session-ui/src/timeline/projection.ts | 14 +---- .../src/timeline/rows-current.test.ts | 41 ------------- .../src/timeline/session-timeline-row.tsx | 8 +-- .../session-ui/src/timeline/timeline-row.ts | 4 +- packages/tui/src/component/dialog-model.tsx | 4 +- packages/tui/src/context/local.tsx | 4 +- .../feature-plugins/system/notifications.ts | 9 +-- packages/tui/src/routes/session/index.tsx | 39 +----------- packages/tui/test/app-lifecycle.test.tsx | 61 ------------------- .../test/cli/cmd/tui/notifications.test.ts | 12 +--- packages/ui/src/i18n/en.ts | 3 - 20 files changed, 16 insertions(+), 386 deletions(-) delete mode 100644 packages/session-ui/src/components/session-model-retired.tsx diff --git a/packages/app/e2e/regression/composer.spec.ts b/packages/app/e2e/regression/composer.spec.ts index d7bf0f7d1e53..87fe50661dff 100644 --- a/packages/app/e2e/regression/composer.spec.ts +++ b/packages/app/e2e/regression/composer.spec.ts @@ -4,55 +4,6 @@ import { openDraft, openSession } from "../utils/workspace" test.use({ permissions: ["clipboard-read", "clipboard-write"] }) -test("a retired model offers another model and hands the prompt back", async ({ page }) => { - const sessionID = "ses_model_retired" - const model = { id: "muse-spark-1.3-contributor-free", providerID: "opencode" } - // The prompt comes back only for a failure that just happened. - const now = Date.now() - - const view = await openSession(page, { - name: "ModelRetired", - sessions: [{ id: sessionID, agent: "build", model }], - pageMessages: () => ({ - items: [ - { id: "msg_retired_user", type: "user", text: "Refactor the parser", time: { created: now } }, - { - id: "msg_retired_assistant", - type: "assistant", - agent: "build", - model, - content: [], - time: { created: now + 1 }, - }, - ], - }), - }) - - await expect(page.getByText("Refactor the parser", { exact: true })).toBeVisible() - - await view.push([ - { - id: "evt_model_retired", - created: now + 2, - type: "session.step.failed", - location: { directory: view.directory }, - durable: { aggregateID: sessionID, seq: 1, version: 1 }, - data: { - sessionID, - assistantMessageID: "msg_retired_assistant", - error: { type: "provider.model-retired", message: "This model was retired, pick another", status: 410 }, - }, - }, - ]) - - const notice = page.getByRole("status").filter({ hasText: "This model was retired" }) - await expect(notice).toBeVisible() - await expect(page.getByText("This model was retired, pick another")).toHaveCount(0) - await expect(view.editor).toHaveText("Refactor the parser") - await notice.getByRole("button", { name: "Pick another model", exact: true }).click() - await expect(page.getByRole("dialog", { name: "Select model" })).toBeVisible() -}) - test("a catalog refresh cannot replace an existing session's model", async ({ page }) => { const alpha = { id: "alpha", name: "Alpha", variants: { high: {} } } const beta = { id: "beta", name: "Beta" } diff --git a/packages/app/src/providers/models/models.tsx b/packages/app/src/providers/models/models.tsx index 0b3730b6801e..33b67a9d520f 100644 --- a/packages/app/src/providers/models/models.tsx +++ b/packages/app/src/providers/models/models.tsx @@ -28,16 +28,10 @@ const createModelsController = (directory: Accessor) => { const available = createMemo(() => providers.connected().flatMap((p) => - Object.values(p.models).flatMap((m) => - m.status === "deprecated" - ? [] - : [ - { - ...m, - provider: p, - }, - ], - ), + Object.values(p.models).map((m) => ({ + ...m, + provider: p, + })), ), ) diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index 83c2e3b1c4d8..072f61caf18c 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -34,8 +34,6 @@ import { createSessionRequestModel } from "../requests/model" import type { Region } from "@/runtime/extension/panels" import { SessionLocationMissing } from "./location-missing" -const RECALL_WINDOW = 60_000 - export function createActiveSessionRegion(input: { session: SessionModel screen: SessionScreenLayout @@ -192,36 +190,6 @@ export function createActiveSessionRegion(input: { const revertMessage: NonNullable = ({ messageID }) => revert.to(messageID) - const modelRetired: NonNullable = { - chooseModel: () => command.trigger("model.choose"), - } - - // A retired model fails the prompt after it was sent. Hand its text back so picking another model and resending is - // one step, unless the user has already started typing something else. This follows the transcript rather than the - // failure event: a first prompt from a draft can fail before this session's screen mounts. - const recalled = new Set() - - const retiredPrompt = createMemo(() => { - const last = input.session.history.messages().at(-1) - const error = last?.type === "assistant" || last?.type === "idle" ? last.error : undefined - - if (!last || error?.type !== "provider.model-retired") return - - // Only a failure that just happened, so reopening an old session leaves the composer alone. - if (Date.now() - last.time.created > RECALL_WINDOW) return - - return input.session.history.userMessages().at(-1) - }) - - createEffect( - on(retiredPrompt, (message) => { - if (!message || recalled.has(message.id)) return - recalled.add(message.id) - - if (promptLength(prompt.current()) === 0) revert.recall(message) - }), - ) - const notifyFailed = () => { showToast({ title: language.t("common.requestFailed") }) } @@ -313,7 +281,6 @@ export function createActiveSessionRegion(input: { return pendingSteer }, openAttachment, - modelRetired, } satisfies SessionUserActions, }, requests: state, diff --git a/packages/app/src/session/revert.ts b/packages/app/src/session/revert.ts index ca79e163561a..94eed7b1118a 100644 --- a/packages/app/src/session/revert.ts +++ b/packages/app/src/session/revert.ts @@ -115,10 +115,7 @@ export function createSessionRevert(input: { ) } - /** Puts a sent prompt back in the composer without rewinding the Session. */ - const recall = (message: SessionMessageUser) => restore(prompt.capture(), message) - - return { to, undo, redo, recall } + return { to, undo, redo } } export type SessionRevert = ReturnType diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index eb3da721a76f..d8eb0f683afa 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -94,19 +94,8 @@ export function contentFilterError(summary: string, reason: FinishReasonDetails) } } -const retiredModelMessage = "This model was retired, pick another" - function providerError(type: string, reason: AIError["reason"]): SessionError.Error { const status = reason.http?.status - // Providers answer 410 Gone for a model they no longer serve; clients that know `provider.model-retired` offer a - // way to pick another one. - if (status === 410) - return { - type: "provider.model-retired", - message: retiredModelMessage, - status, - ...(reason.body === undefined ? {} : { response: { body: reason.body } }), - } return { type, message: Object.entries(tokenSharingMessages).find(([code]) => reason.body?.includes(code))?.[1] ?? reason.message, diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts index 3dd510282b36..19728224936c 100644 --- a/packages/core/test/session-error.test.ts +++ b/packages/core/test/session-error.test.ts @@ -126,33 +126,6 @@ describe("toSessionError", () => { message: "bad gateway", status: 502, }) - expect( - toSessionError( - llm( - new InvalidRequestError({ - message: "Model muse-spark-1.3-contributor-free has been deprecated.", - body: "Model muse-spark-1.3-contributor-free has been deprecated.", - http: new HttpContext({ url: "https://opencode.ai/zen/v1/chat/completions", status: 410, headers: {} }), - }), - ), - ), - ).toEqual({ - type: "provider.model-retired", - message: "This model was retired, pick another", - status: 410, - response: { body: "Model muse-spark-1.3-contributor-free has been deprecated." }, - }) - // The status decides, whichever reason the provider's 410 was decoded as. - expect( - toSessionError( - llm( - new ProviderInternalError({ - message: "gone", - http: new HttpContext({ url: "https://example.com", status: 410, headers: {} }), - }), - ), - ).type, - ).toBe("provider.model-retired") }) test("preserves provider response body without HTTP status", () => { diff --git a/packages/session-ui/src/actions.ts b/packages/session-ui/src/actions.ts index 3d88fb4f6fff..a78ad656a5cb 100644 --- a/packages/session-ui/src/actions.ts +++ b/packages/session-ui/src/actions.ts @@ -37,8 +37,4 @@ export type SessionUserActions = { queue: (input: { sessionID: string; messageID: string }) => Promise remove: (input: { sessionID: string; messageID: string }) => Promise } - /** The way past a model the provider retired. Without it the failure shows as plain text. */ - modelRetired?: { - chooseModel: () => void - } } diff --git a/packages/session-ui/src/components/session-model-retired.tsx b/packages/session-ui/src/components/session-model-retired.tsx deleted file mode 100644 index 23a85dfed8b7..000000000000 --- a/packages/session-ui/src/components/session-model-retired.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Button } from "@opencode/ui/button" -import { useI18n } from "@opencode/ui/context/i18n" -import type { SessionUserActions } from "../actions" - -/** The provider no longer serves the step's model: a dead end only until another model is picked. */ -export function SessionModelRetired(props: { actions: NonNullable }) { - const i18n = useI18n() - - return ( -
-
-
{i18n.t("ui.sessionTurn.modelRetired.title")}
-
{i18n.t("ui.sessionTurn.modelRetired.description")}
-
-
- -
-
- ) -} diff --git a/packages/session-ui/src/components/session-turn.css b/packages/session-ui/src/components/session-turn.css index ad8a69a2f3bf..64140e661b6a 100644 --- a/packages/session-ui/src/components/session-turn.css +++ b/packages/session-ui/src/components/session-turn.css @@ -115,44 +115,6 @@ } } - [data-component="session-model-retired"] { - display: flex; - flex-direction: column; - gap: 12px; - padding: 12px; - border-radius: var(--radius-md); - background: var(--v2-background-bg-layer-01); - box-shadow: inset 0 0 0 1px var(--v2-border-border-muted); - font-family: var(--v2-font-family-sans); - font-size: 13px; - line-height: var(--line-height-compact); - letter-spacing: -0.04px; - - [data-slot="session-model-retired-text"] { - display: flex; - flex-direction: column; - gap: 4px; - } - - [data-slot="session-model-retired-title"] { - color: var(--v2-text-text-base); - font-weight: 530; - } - - [data-slot="session-model-retired-description"] { - color: var(--v2-text-text-muted); - font-weight: 440; - text-wrap: pretty; - } - - [data-slot="session-model-retired-actions"] { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - } - } - [data-component="card"][data-kind="session-error-card"] { --card-pad-y: 0px; --card-pad-l: 0px; diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index 7bf93d4910a0..03dffd592354 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -564,11 +564,7 @@ export namespace Timeline { if (isActive && lastAssistant?.retry) rows.push(new TimelineRow.Retry({ userMessageID: turnID })) else if (lastAssistant?.error && !isInterrupted(lastAssistant.error)) rows.push( - new TimelineRow.Error({ - userMessageID: turnID, - text: unwrapErrorMessage(lastAssistant.error.message), - ...errorKind(lastAssistant.error), - }), + new TimelineRow.Error({ userMessageID: turnID, text: unwrapErrorMessage(lastAssistant.error.message) }), ) } @@ -588,7 +584,6 @@ export namespace Timeline { new TimelineRow.Error({ userMessageID: turnID, text: unwrapErrorMessage(entry.message.error.message), - ...errorKind(entry.message.error), }), ) @@ -639,13 +634,6 @@ export namespace Timeline { } } -// A failure the transcript can offer a way past, decided by its type rather than its wording. -function errorKind(error: SessionMessageAssistant["error"]) { - if (error?.type === "provider.model-retired") return { kind: "model-retired" as const } - - return {} -} - function isInterrupted(error: SessionMessageAssistant["error"]) { return error?.type.toLowerCase().includes("abort") || error?.type.toLowerCase().includes("interrupt") } diff --git a/packages/session-ui/src/timeline/rows-current.test.ts b/packages/session-ui/src/timeline/rows-current.test.ts index 71a195995e8b..816e95cf48a6 100644 --- a/packages/session-ui/src/timeline/rows-current.test.ts +++ b/packages/session-ui/src/timeline/rows-current.test.ts @@ -1451,47 +1451,6 @@ describe("current session timeline rows", () => { ) }) - test("marks a retired model by its error type, whichever message records the failure", () => { - const error = { type: "provider.model-retired", message: "This model was retired, pick another", status: 410 } - const user = { id: "msg_user", type: "user", text: "prompt", time: { created: 1 } } satisfies SessionMessageInfo - - const assistant = { - id: "msg_assistant", - type: "assistant", - agent: "build", - model: { id: "muse-spark-1.3-contributor-free", providerID: "opencode" }, - content: [], - finish: "error", - error, - time: { created: 2, completed: 3 }, - } satisfies SessionMessageAssistant - - const idle = [ - user, - { id: "msg_idle", type: "idle", outcome: "failed", error, time: { created: 4 } }, - ] satisfies SessionMessageInfo[] - - // The same words under another type stay a plain error. - const invalid = [ - user, - { ...assistant, error: { type: "provider.invalid-request", message: "This model was retired, pick another" } }, - ] satisfies SessionMessageInfo[] - - expect(Timeline.constructSessionMessageRows([user, assistant], true, { type: "idle" }).rows.at(-1)).toEqual( - new TimelineRow.Error({ - userMessageID: "msg_user", - text: "This model was retired, pick another", - kind: "model-retired", - }), - ) - expect(Timeline.constructSessionMessageRows(idle, true, { type: "idle" }).rows.at(-1)).toMatchObject({ - kind: "model-retired", - }) - expect(Timeline.constructSessionMessageRows(invalid, true, { type: "idle" }).rows.at(-1)).toEqual( - new TimelineRow.Error({ userMessageID: "msg_user", text: "This model was retired, pick another" }), - ) - }) - test("summarizes a turn's changed files after the idle marker that ends it", () => { const step = (id: string, files?: string[]) => ({ diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index dd52301199f8..6515f49d891d 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -30,7 +30,6 @@ import { ToolRegistry, type ContextGroupPart } from "../tools/tool-renderer" import { GenericTool } from "../components/basic-tool" import { SessionRetry } from "../components/session-retry" import { SessionError } from "../components/session-error" -import { SessionModelRetired } from "../components/session-model-retired" import { timelineCategory, type TimelineDetail } from "./detail" import { currentToolFailed } from "../message/current-tool-state" import { @@ -1014,12 +1013,7 @@ export function createSessionTimelineRowRenderer(input: { return (
- } - > - {(actions) => } - +
) diff --git a/packages/session-ui/src/timeline/timeline-row.ts b/packages/session-ui/src/timeline/timeline-row.ts index 98c1c8b4c471..8d81e4db7d72 100644 --- a/packages/session-ui/src/timeline/timeline-row.ts +++ b/packages/session-ui/src/timeline/timeline-row.ts @@ -57,11 +57,9 @@ export namespace TimelineRow { ref: PartRef }> {} - /** `model-retired` marks a step the provider refused because it no longer serves the model. */ export class Error extends Data.TaggedClass("Error")<{ userMessageID: string text: string - kind?: "model-retired" }> {} export class Retry extends Data.TaggedClass("Retry")<{ @@ -144,7 +142,7 @@ export type TimelineRowMap = { } Thinking: { userMessageID: string; ref: PartRef } Retry: { userMessageID: string } - Error: { userMessageID: string; text: string; kind?: "model-retired" } + Error: { userMessageID: string; text: string } CompactionQueued: { userMessageID: string; inboxID: string } TurnDiff: { userMessageID: string; messageID: string; from: string; files: readonly string[] } Activity: { userMessageID: string } diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index eb3dcf4d85f1..7ae17d5f46d3 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -37,9 +37,7 @@ export function DialogModel(props: { providerID?: string }) { function toOptions(items: typeof favorites, category: string) { if (!showSections) return [] return items.flatMap((item) => { - const model = models().find( - (model) => model.providerID === item.providerID && model.id === item.modelID && model.status !== "deprecated", - ) + const model = models().find((model) => model.providerID === item.providerID && model.id === item.modelID) if (!model) return [] const provider = providers().get(model.providerID) return [ diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index fa381f428214..d3304eb3390b 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -45,9 +45,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const providers = () => data.location.provider.list(location.ref) function isModelValid(model: ModelPreferenceModel) { - return !!models()?.some( - (item) => item.providerID === model.providerID && item.id === model.modelID && item.status !== "deprecated", - ) + return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID) } function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) { diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index 252974192f66..5435f49689fc 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -67,14 +67,7 @@ export default Plugin.define({ } errored.add(sessionID) notify(context, sessionID, event.data.error.message, "error") - // The transcript already explains a retired model and points to /models; an error toast would repeat it. - if (event.data.error.type !== "provider.model-retired") - context.ui.toast.show({ - sessionID, - title: "Session failed", - message: event.data.error.message, - variant: "error", - }) + context.ui.toast.show({ sessionID, title: "Session failed", message: event.data.error.message, variant: "error" }) ended(sessionID) }), ] diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index cf16146ac3e7..52fd66ce1105 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -392,17 +392,6 @@ export function Session(props: { seeded = true r.set(route.prompt) } - // A retired model fails the prompt after it was sent. Hand its text back so picking another model and resending is - // one step, unless something else is already being typed. - onCleanup( - data.on("session.step.failed", (event) => { - const current = prompt() - if (event.data.sessionID !== route.sessionID || event.data.error.type !== "provider.model-retired") return - if (!current || current.current.text.trim()) return - const message = messages().findLast((item) => item.type === "user") - if (message?.type === "user") current.set({ ...projectedPromptInput(message), pasted: [] }) - }), - ) /** Runs after layout has settled (two frames), unless the transcript was torn down. */ const afterLayout = (continuation: () => void) => { @@ -1922,16 +1911,9 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) { return ( <> - - Error: {errorMessage(props.message.error)} -
- } - > - - + + Error: {errorMessage(props.message.error)} + @@ -2402,21 +2384,6 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp ) } -// The provider no longer serves this model. The prompt keeps focus for the restored text, so the way forward is a -// command to type or a click rather than a key binding. -function ModelRetiredNotice(props: { message: SessionMessageAssistant }) { - const theme = useTheme() - const keymap = Keymap.use() - return ( - - {props.message.error?.message} - keymap.dispatch("model.list")}> - /models - - - ) -} - function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) { const theme = useTheme() const [seconds, setSeconds] = createSignal(0) diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 74201e1775cf..d3394318874e 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -1701,67 +1701,6 @@ test("server plugin failures share one notice and use source names before an ID expect(setup.captureCharFrame()).toContain("Open plugins") }) -test("a retired model points to /models and hands the prompt back", async () => { - await using state = await tmpdir() - const session = { - id: "ses_model_retired", - projectID: "proj_test", - location: { directory }, - title: "Retired model", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: 1 }, - } - const model = { id: "muse-spark-1.3-contributor-free", providerID: "opencode" } - await using setup = await createAppFixture({ - width: 100, - state: state.path, - args: { sessionID: session.id }, - config: { animations: false, tabs: { mode: "off" } }, - fetch: (url) => { - if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) - if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) - if (url.pathname === `/api/session/${session.id}/message`) - return json({ - data: [ - { id: "msg_retired_user", type: "user", text: "Refactor the parser", time: { created: 1 } }, - { - id: "msg_retired_assistant", - type: "assistant", - agent: "build", - model, - content: [], - time: { created: 2 }, - }, - ], - cursor: {}, - }) - if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname)) - return json({ data: [] }) - return undefined - }, - }) - await setup.ready - await setup.waitForFrame((frame) => frame.includes("Refactor the parser")) - setup.events.emit({ - id: "evt_model_retired", - created: 3, - type: "session.step.failed", - durable: { aggregateID: session.id, seq: 1, version: 1 }, - data: { - sessionID: session.id, - assistantMessageID: "msg_retired_assistant", - error: { type: "provider.model-retired", message: "This model was retired, pick another", status: 410 }, - }, - }) - await setup.waitForFrame((frame) => frame.includes("This model was retired, pick another")) - const frame = setup.captureCharFrame() - expect(frame).toMatch(/^ +\/models *$/m) - expect(frame).not.toContain("Error:") - // The prompt shows once in the transcript and once more back in the composer. - await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 3) -}) - test.each([44, 100])( "retry countdown updates and clears with the retry lifecycle at width %s", async (width) => { diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index d378ac3e4c84..75ef0d263fe5 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -108,7 +108,7 @@ function executionSucceeded(id: string, sessionID = "session"): OpenCodeEvent { } } -function executionFailed(id: string, sessionID = "session", type = "unknown"): OpenCodeEvent { +function executionFailed(id: string, sessionID = "session"): OpenCodeEvent { return { id, created: 0, @@ -116,7 +116,7 @@ function executionFailed(id: string, sessionID = "session", type = "unknown"): O durable: durable(sessionID), data: { sessionID, - error: { type, message: "boom" }, + error: { type: "unknown", message: "boom" }, }, } } @@ -159,14 +159,6 @@ describe("internal notifications TUI plugin", () => { expect(harness.toasts).toHaveLength(2) }) - test("leaves a retired model to the transcript instead of an error toast", async () => { - const harness = await setup() - harness.emit(executionStarted("started")) - harness.emit(executionFailed("failed", "session", "provider.model-retired")) - expect(harness.toasts).toEqual([]) - expect(harness.notifications).toHaveLength(1) - }) - test.each([{ type: "home" }, { type: "session", sessionID: "other" }])( "leaves routing of other sessions' failures to the session-scoped toast (%j)", async (route) => { diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 86b7bf7016ff..783b2e06b784 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -75,9 +75,6 @@ const source = { "ui.sessionTurn.retry.attemptWaiting.other": "Attempt {{attempt}} - retrying in {{count}}s", "ui.sessionTurn.retry.attemptRetryingNow": "Attempt {{attempt}} - retrying", "ui.sessionTurn.retry.geminiHot": "gemini is way too hot right now", - "ui.sessionTurn.modelRetired.title": "This model was retired", - "ui.sessionTurn.modelRetired.description": "Pick another model to send your message again.", - "ui.sessionTurn.modelRetired.chooseModel": "Pick another model", "ui.sessionTurn.error.freeUsageExceeded": "Free usage exceeded", "ui.sessionTurn.error.addCredits": "Add credits", From 08a50a577481eb2551100e9a932d354d5b214c5a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 20 Oct 2026 00:02:22 -0700 Subject: [PATCH 4/4] fix(core): drop deprecated-model filter from Model --- packages/core/src/model.ts | 13 +++---------- packages/core/test/catalog.test.ts | 8 ++------ 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 97d7dbed14bc..c0246ee9bd3d 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -223,15 +223,7 @@ const layer = Layer.effect( const all = Array.from(byProvider.values()) .flatMap((models) => Array.from(models.values())) .sort((left, right) => right.time.released - left.time.released) - cached = freeze( - { - data, - all, - available: preferFree(all.filter((model) => model.enabled && model.status !== "deprecated")), - byProvider, - }, - true, - ) + cached = freeze({ data, all, available: preferFree(all.filter((model) => model.enabled)), byProvider }, true) return cached } }) @@ -274,7 +266,7 @@ const layer = Layer.effect( const value = yield* read() const requested = value.data.defaultModel const model = requested && value.byProvider.get(requested.providerID)?.get(requested.modelID) - return model?.enabled && model.status !== "deprecated" ? model : value.available.find(supportsText) + return model?.enabled ? model : value.available.find(supportsText) }), small: Effect.fn("Model.small")(function* (providerID) { const value = yield* read() @@ -301,6 +293,7 @@ const PREFERRED_FREE = [ "big-pickle", "deepseek-v4.1-flash-free", "glm-5.3-flash-free", + "muse-spark-1.3-contributor-free", ] function preferFree(models: Info[]) { diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 0243672bff2f..8eed25ba9172 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -576,22 +576,18 @@ describe("Provider and Model", () => { const models = yield* Model.Service yield* providers.transform((editor) => { editor.update(Provider.ID.opencode, () => {}) - ;["glm-5.3-flash-free", "new-free", "deepseek-v4.1-flash-free", "big-pickle"].forEach( + ;["muse-spark-1.3-contributor-free", "new-free", "deepseek-v4.1-flash-free", "big-pickle"].forEach( (id, index) => editor.models.update(Provider.ID.opencode, Model.ID.make(id), (model) => { model.time.released = 4000 - index * 1000 }), ) - editor.models.update(Provider.ID.opencode, Model.ID.make("muse-spark-1.3-contributor-free"), (model) => { - model.time.released = 5000 - model.status = "deprecated" - }) }) expect((yield* models.available()).map((model) => model.id)).toEqual([ Model.ID.make("big-pickle"), Model.ID.make("deepseek-v4.1-flash-free"), - Model.ID.make("glm-5.3-flash-free"), + Model.ID.make("muse-spark-1.3-contributor-free"), Model.ID.make("new-free"), ]) expect((yield* models.default())?.id).toBe(Model.ID.make("big-pickle"))