diff --git a/README.md b/README.md index ab00468..0a0122f 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,6 @@ Then define aliases in `.opencode/opencode-model-aliases.jsonc` next to your pro Restart OpenCode. Type `/model-aliases` to see what each alias resolved to. -See [the OMO-slim example](https://github.com/vmvarela/opencode-model-aliases/blob/master/docs/oh-my-opencode-slim.md) for agent presets using stable IDs. - > OpenCode caches installed plugin packages. Apply a newer release with > `opencode plugin update opencode-model-aliases@latest`; restarting alone won't update it. diff --git a/docs/oh-my-opencode-slim.md b/docs/oh-my-opencode-slim.md deleted file mode 100644 index 29203f2..0000000 --- a/docs/oh-my-opencode-slim.md +++ /dev/null @@ -1,58 +0,0 @@ -# Stable model references in OMO-slim presets - -An OMO-slim preset can refer to a floating alias instead of a particular model generation. -Model selection stays provider-local and uses release metadata from OpenCode's catalog. - -Load the alias plugin before OMO-slim in `opencode.json`: - -```json -{ - "plugins": [ - { "package": "opencode-model-aliases@latest" }, - { "package": "oh-my-opencode-slim@latest" } - ] -} -``` - -Define the policy in `.opencode/opencode-model-aliases.jsonc`: - -```jsonc -{ - "aliases": { - "opencode-go/glm-latest": { - "match": "opencode-go/glm-*", - "filter": { "capabilities": { "tools": true } } - }, - "opencode-go/kimi-code-latest": { - "match": "opencode-go/kimi-*-code", - "filter": { "capabilities": { "tools": true } } - } - } -} -``` - -Use the full `provider/alias` references in your OMO-slim configuration: - -```json -{ - "preset": "floating-go", - "presets": { - "floating-go": { - "orchestrator": { "model": "opencode-go/glm-latest" }, - "fixer": { "model": "opencode-go/kimi-code-latest" } - } - } -} -``` - -Merge these entries into your existing preset and keep the other agent settings. -Check `opencode models --refresh` for the provider and model IDs available to your account, -then restart and inspect `/model-aliases`. Patterns must match real catalog entries: -the plugin cannot discover missing providers or models. Variant availability comes from -the selected model; a floating alias does not guarantee that a particular variant exists. - -When a later catalog refresh changes an eligible target, the alias ID stays the same and -the plugin records the transition. This complements OMO-slim's orchestration and preset -switching; it does not implement retries, failover or model routing. - -Upstream configuration: [OMO-slim presets](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/docs/configuration.md). diff --git a/src/history.ts b/src/history.ts index 851a5df..11ac462 100644 --- a/src/history.ts +++ b/src/history.ts @@ -4,17 +4,17 @@ import { isPlainObject, type NormalizedAlias } from "./config.js"; import type { AliasReportRow } from "./report.js"; import { type AliasTransition, isAliasTransition } from "./transition.js"; -interface Target { +type Target = { providerID: string; catalogID: string; wireModelID: string; -} -interface Entry { +}; +type Entry = { key: string; policy: string; target: Target; transition?: AliasTransition; -} +}; function hash(value: unknown): string { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); @@ -115,7 +115,13 @@ export async function createHistory( wireModelID: row.wireModelID ?? row.catalogID, }; const previous = entries.get(row.key); - if (previous && JSON.stringify(previous.target) === JSON.stringify(target)) continue; + if ( + previous && + previous.target.providerID === target.providerID && + previous.target.catalogID === target.catalogID && + previous.target.wireModelID === target.wireModelID + ) + continue; entries.set(row.key, { key: row.key, policy, @@ -137,11 +143,7 @@ export async function createHistory( } if (dirty) { try { - // Round-trip strips optional undefined fields to the SDK's JSON type. - await storage.set( - storageKey, - JSON.parse(JSON.stringify({ version: 1, aliases: [...entries.values()] })), - ); + await storage.set(storageKey, { version: 1, aliases: [...entries.values()] }); dirty = false; } catch { // Retry the same state on the next confirmed read, without a fake change. diff --git a/src/plugin.ts b/src/plugin.ts index 2a4629b..cff253f 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -277,7 +277,9 @@ export default Plugin.define({ let pending = Promise.resolve(); let stopped = false; const inspect = () => { - const result = pending.then(() => readInspection()); + const result = pending.then(() => + stopped ? { text: UNAVAILABLE_REPORT, rows: [] } : readInspection(), + ); pending = result.then( () => {}, () => {}, diff --git a/src/transition.ts b/src/transition.ts index c95d6e9..5b3939d 100644 --- a/src/transition.ts +++ b/src/transition.ts @@ -1,13 +1,13 @@ import { isPlainObject } from "./config.js"; -export interface AliasTransition { +export type AliasTransition = { readonly id: string; readonly from: string; readonly to: string; readonly fromWireModelID: string; readonly toWireModelID: string; readonly changedAt: string; -} +}; export function isAliasTransition(value: unknown): value is AliasTransition { return ( diff --git a/src/tui.ts b/src/tui.ts index d45b81a..fb6aab8 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -1,4 +1,5 @@ import type { Plugin } from "@opencode/plugin/tui"; +import type { InspectReportRow } from "./report.js"; import { ModelAliasesRpc } from "./rpc.js"; import { type AliasTransition, isAliasTransition } from "./transition.js"; @@ -10,21 +11,7 @@ const USAGE_MESSAGE = "Unexpected arguments. Use /model-aliases without arguments to view configured model aliases."; const ERROR_MESSAGE = "Unable to load model aliases. Please reload or try again."; -export interface InspectResponseRow { - readonly transition?: AliasTransition; - readonly key: string; - readonly displayName: string; - readonly provider: string; - readonly alias: string; - readonly strategy: "latest"; - readonly status: "active" | "inactive" | "unresolved"; - readonly target?: string; - readonly catalogID?: string; - readonly providerID?: string; - readonly wireModelID?: string; - readonly failureKind?: string; - readonly failureReason?: string; -} +export type InspectResponseRow = InspectReportRow; export interface InspectResponse { readonly text: string; @@ -113,6 +100,7 @@ const plugin = { string, { shown: Map; + unsaved: Map; save: (mutation: (draft: { aliases: Record }) => void) => Promise; } >(); @@ -123,6 +111,12 @@ const plugin = { const location = context.location ?? context.data.location.default(); const response = await context.client.rpc(ModelAliasesRpc).inspect({}, { location }); if (stopped || !isInspectResponse(response)) return; + const currentLocation = context.location ?? context.data.location.default(); + if ( + location?.directory !== currentLocation?.directory || + location?.workspaceID !== currentLocation?.workspaceID + ) + return; const scope = JSON.stringify([location?.directory ?? "", location?.workspaceID ?? ""]); let client = clients.get(scope); if (!client) { @@ -130,36 +124,48 @@ const plugin = { `notifications-v1/${scope}`, { initial: { aliases: {} } }, ); - client = { shown: new Map(Object.entries(saved.aliases ?? {})), save }; + client = { + shown: new Map(Object.entries(saved.aliases ?? {})), + unsaved: new Map(), + save, + }; clients.set(scope, client); } - const { shown, save } = client; - const changed = response.rows.filter((row) => row.status === "active" && row.transition); - if (changed.length === 0) return; - const unseen = changed.filter((row) => { - return shown.get(row.key) !== row.transition?.id; - }); - if (unseen.length === 0 || stopped) return; - const messages = unseen.slice(0, 3).map((row) => { - const change = row.transition as AliasTransition; - const wire = - change.from === change.to - ? ` (wire: ${change.fromWireModelID} → ${change.toWireModelID})` - : ""; - return `${row.key}: ${change.from} → ${change.to}${wire}`; - }); - if (unseen.length > 3) messages.push(`+${unseen.length - 3} more changes`); - context.ui.toast.show({ - title: "Model aliases changed", - message: `${messages.join("\n")}\nSee /model-aliases for details.`, - variant: "info", - duration: 6000, - }); - for (const row of unseen) shown.set(row.key, (row.transition as AliasTransition).id); - await save((draft) => { - for (const row of unseen) - draft.aliases[row.key] = (row.transition as AliasTransition).id; - }); + const { shown, unsaved, save } = client; + const unseen = response.rows.filter( + (row) => + row.status === "active" && row.transition && shown.get(row.key) !== row.transition.id, + ); + if (unseen.length > 0) { + const messages = unseen.slice(0, 3).map((row) => { + const change = row.transition as AliasTransition; + const wire = + change.from === change.to + ? ` (wire: ${change.fromWireModelID} → ${change.toWireModelID})` + : ""; + return `${row.key}: ${change.from} → ${change.to}${wire}`; + }); + if (unseen.length > 3) messages.push(`+${unseen.length - 3} more changes`); + context.ui.toast.show({ + title: "Model aliases changed", + message: `${messages.join("\n")}\nSee /model-aliases for details.`, + variant: "info", + duration: 6000, + }); + for (const row of unseen) { + const id = (row.transition as AliasTransition).id; + shown.set(row.key, id); + unsaved.set(row.key, id); + } + } + // A failed disk write must remain retryable even though the toast + // has already been acknowledged in this client's memory. + if (unsaved.size > 0) { + await save((draft) => { + for (const [key, id] of unsaved) draft.aliases[key] = id; + }); + unsaved.clear(); + } }) .catch(() => { // Notifications are optional; RPC/storage failures do not open dialogs diff --git a/tests/history.test.ts b/tests/history.test.ts index ff3230c..7b3d3ee 100644 --- a/tests/history.test.ts +++ b/tests/history.test.ts @@ -88,6 +88,18 @@ describe("confirmed target history", () => { expect((await rows(host))[0]?.transition).toMatchObject({ from: "p/model-a", to: "p/model-b" }); }); + it("does not read the catalog or write history through an RPC queued after disposal", async () => { + const host = harness(); + const close = await plugin.setup(host.ctx); + await close?.(); + const reads = host.counters.list; + const saved = JSON.stringify([...host.storage.values()]); + host.addSource(B()); + expect(await rows(host)).toEqual([]); + expect(host.counters.list).toBe(reads); + expect(JSON.stringify([...host.storage.values()])).toBe(saved); + }); + it("records execution identity changes even if the catalog identity does not change", async () => { const host = harness(); await plugin.setup(host.ctx); diff --git a/tests/tui.test.ts b/tests/tui.test.ts index d4e7612..d528eda 100644 --- a/tests/tui.test.ts +++ b/tests/tui.test.ts @@ -53,6 +53,7 @@ interface SelectCall { interface StrictContextOptions { notificationStorage?: Map }>; storageFailure?: boolean; + saveNotification?: () => Promise; location?: { directory: string } | undefined; defaultLocation?: { directory: string } | undefined; inspectHandler?: @@ -327,6 +328,7 @@ function createStrictContext(options?: StrictContextOptions) { return [ state, async (mutation: (draft: typeof state) => void) => { + await options?.saveNotification?.(); mutation(state); }, ] as const; @@ -742,7 +744,7 @@ describe("TUI security, validation, and error boundaries", () => { await command?.run(); - expect(harness.defaultLocationSpy).toHaveBeenCalledTimes(2); + expect(harness.defaultLocationSpy).toHaveBeenCalled(); expect(harness.inspectCalls[0]).toEqual({ input: {}, options: { location: { directory: "/fallback/default" } }, @@ -961,6 +963,49 @@ describe("TUI change notifications", () => { expect(formatDetailMessage(row)).toContain("Detected: 2026-10-06T12:00:00.000Z"); }); + it("retries failed acknowledgements without showing the toast again", async () => { + const save = vi + .fn() + .mockRejectedValueOnce(new Error("disk unavailable")) + .mockResolvedValue(undefined); + const response = async () => ({ + text: "ok", + rows: [{ ...SAMPLE_ROW_ACTIVE, transition: transition() }], + }); + const first = createStrictContext({ inspectHandler: response, saveNotification: save }); + const close = plugin.setup(first.context); + await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1)); + first.emitModelUpdate(); + await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(2)); + expect(first.toasts).toHaveLength(1); + if (typeof close === "function") await close(); + const restarted = createStrictContext({ + inspectHandler: response, + notificationStorage: first.notificationStorage, + }); + const stop = plugin.setup(restarted.context); + await new Promise((resolve) => setImmediate(resolve)); + expect(restarted.toasts).toHaveLength(0); + if (typeof stop === "function") await stop(); + }); + + it("discards a notification response when the active location changed while awaiting RPC", async () => { + let reply!: (value: unknown) => void; + const host = createStrictContext({ + inspectHandler: () => + new Promise((resolve) => { + reply = resolve; + }), + }); + const stop = plugin.setup(host.context); + await vi.waitFor(() => expect(host.inspectCalls).toHaveLength(1)); + host.defaultLocationSpy.mockReturnValue({ directory: "/different-project" }); + reply({ text: "ok", rows: [{ ...SAMPLE_ROW_ACTIVE, transition: transition() }] }); + await new Promise((resolve) => setImmediate(resolve)); + expect(host.toasts).toHaveLength(0); + if (typeof stop === "function") await stop(); + }); + it("lets already open clients show a transition independently and groups multiple changes", async () => { let change: ReturnType | undefined; const shared = new Map }>();