Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
58 changes: 0 additions & 58 deletions docs/oh-my-opencode-slim.md

This file was deleted.

22 changes: 12 additions & 10 deletions src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
() => {},
() => {},
Expand Down
4 changes: 2 additions & 2 deletions src/transition.ts
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
92 changes: 49 additions & 43 deletions src/tui.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
Expand Down Expand Up @@ -113,6 +100,7 @@ const plugin = {
string,
{
shown: Map<string, string>;
unsaved: Map<string, string>;
save: (mutation: (draft: { aliases: Record<string, string> }) => void) => Promise<void>;
}
>();
Expand All @@ -123,43 +111,61 @@ 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) {
const [saved, save] = context.storage.store<{ aliases: Record<string, string> }>(
`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
Expand Down
12 changes: 12 additions & 0 deletions tests/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
47 changes: 46 additions & 1 deletion tests/tui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ interface SelectCall {
interface StrictContextOptions {
notificationStorage?: Map<string, { aliases: Record<string, string> }>;
storageFailure?: boolean;
saveNotification?: () => Promise<void>;
location?: { directory: string } | undefined;
defaultLocation?: { directory: string } | undefined;
inspectHandler?:
Expand Down Expand Up @@ -327,6 +328,7 @@ function createStrictContext(options?: StrictContextOptions) {
return [
state,
async (mutation: (draft: typeof state) => void) => {
await options?.saveNotification?.();
mutation(state);
},
] as const;
Expand Down Expand Up @@ -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" } },
Expand Down Expand Up @@ -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<void>((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<void>((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<typeof transition> | undefined;
const shared = new Map<string, { aliases: Record<string, string> }>();
Expand Down
Loading