diff --git a/README.md b/README.md index d2a913a..8371f82 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,27 @@ The first resolution sets a silent baseline. When an alias later changes target, a toast and `/model-aliases` shows the previous target, the current target and when the change was detected. Changing an alias's match or filter rules resets its baseline. +### Explain one alias + +Run `/model-aliases explain github-copilot/sonnet` to see matching patterns, +rejected candidates and their reasons, and why the winner was selected. Eligible +runners-up show whether they lost on release date or the descending model ID +tie-break. Models outside the alias provider/include patterns are counted rather +than listed. The regular `/model-aliases` view remains compact. + +The backend exposes `explain({ alias: "github-copilot/sonnet" })` on the existing +`opencode-model-aliases` RPC. Its structured report comes from the same resolution +that materialized the alias, confirmed against the final catalog. It contains +public decision fields only, with stable reason codes and deterministic ordering. +An unresolved alias identifies the failed stage; an inactive alias retains its +selection explanation. Failed refreshes or conflicting downstream rewrites return +`unavailable`, and an unconfigured key returns `unknown-alias`. + +Explanation uses the catalog visible to the plugin's transform; the existing +config-disabled model limitation still applies. In strict mode, a startup failure +prevents the plugin and its RPC from becoming available. Explanation performs no +model requests and adds no persistent history or automatic logging. + ## Limitations - Only the `latest` strategy exists. The plugin can't rank by price or quality. diff --git a/scripts/smoke-opencode.mjs b/scripts/smoke-opencode.mjs index 99a1945..8507e31 100644 --- a/scripts/smoke-opencode.mjs +++ b/scripts/smoke-opencode.mjs @@ -547,6 +547,47 @@ async function main() { } } + // Explain the same packed product through the real host's RPC boundary. + const explained = await runSpawn( + "opencode", + [ + "api", + "--standalone", + "post", + "/api/rpc/opencode-model-aliases/explain", + "--data", + JSON.stringify({ input: { alias: ALIAS_KEY } }), + ], + { cwd: project, env, timeoutMs: CLI_TIMEOUT_MS }, + ); + let explanation; + try { + explanation = JSON.parse(explained.stdout).output; + } catch {} + const selected = explanation?.explanation?.candidates?.filter( + (candidate) => candidate.outcome === "selected", + ); + if ( + explained.code !== 0 || + explained.timedOut || + explained.spawnError || + explanation?.status !== "active" || + explanation?.explanation?.winner !== `${PROVIDER}/${TARGET}` || + selected?.length !== 1 || + selected[0]?.id !== `${PROVIDER}/${TARGET}` || + selected[0]?.reasons?.[0]?.code !== "newest-release" + ) { + problems.push( + `explain RPC did not describe the actual latest winner (exit=${explained.code}): ${explained.stdout.slice(0, 3000)} ${explained.stderr + .split("\n") + .filter((line) => /error|invalid|Error/.test(line)) + .slice(0, 5) + .join("\n")}`, + ); + } + if (JSON.stringify(explanation ?? {}).includes(DUMMY_KEY)) + problems.push("explain RPC exposed provider credentials"); + if (problems.length === 0) { const baseline = reportRows.find((row) => row.key === ALIAS_KEY); if (baseline.transition !== undefined) diff --git a/src/explain.ts b/src/explain.ts new file mode 100644 index 0000000..722e83b --- /dev/null +++ b/src/explain.ts @@ -0,0 +1,161 @@ +import { isPlainObject } from "./config.js"; +import { sanitize } from "./report.js"; +import type { Stage } from "./resolve.js"; + +export interface ExplanationReason { + code: string; + message: string; +} + +export interface CandidateExplanation { + id: string; + matchedPatterns: string[]; + stage: Stage["name"]; + outcome: "selected" | "eligible" | "rejected"; + reasons: ExplanationReason[]; + released?: number; +} + +/** Public, primitive-only snapshot of the decisions made by one resolver run. */ +export interface ResolutionExplanation { + alias: string; + strategy: "latest"; + stages: Stage[]; + unmatched: number; + candidates: CandidateExplanation[]; + winner?: string; + failure?: ExplanationReason & { stage: Stage["name"] }; +} + +export type ExplainResponse = + | { status: "active" | "inactive" | "unresolved"; explanation: ResolutionExplanation } + | { status: "unknown-alias" } + | { status: "unavailable" }; + +function isStage(value: unknown): value is Stage["name"] { + return value === "matching" || value === "filtering" || value === "selection"; +} +function reason(value: unknown): value is ExplanationReason { + return ( + isPlainObject(value) && typeof value.code === "string" && typeof value.message === "string" + ); +} +function count(value: unknown): boolean { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + +/** Validate the transport before rendering; the UI never evaluates selection rules. */ +export function isExplainResponse(value: unknown): value is ExplainResponse { + if (!isPlainObject(value)) return false; + if (value.status === "unknown-alias" || value.status === "unavailable") return true; + if (value.status !== "active" && value.status !== "inactive" && value.status !== "unresolved") + return false; + const report = value.explanation; + if ( + !isPlainObject(report) || + typeof report.alias !== "string" || + report.strategy !== "latest" || + !count(report.unmatched) || + !Array.isArray(report.stages) || + !Array.isArray(report.candidates) + ) + return false; + if (report.winner !== undefined && typeof report.winner !== "string") return false; + if ( + report.failure !== undefined && + (!isPlainObject(report.failure) || !reason(report.failure) || !isStage(report.failure.stage)) + ) + return false; + if ( + value.status === "unresolved" + ? report.failure === undefined || report.winner !== undefined + : report.winner === undefined || report.failure !== undefined + ) + return false; + return ( + report.stages.every( + (stage) => isPlainObject(stage) && isStage(stage.name) && count(stage.accepted), + ) && + report.candidates.every( + (candidate) => + isPlainObject(candidate) && + typeof candidate.id === "string" && + Array.isArray(candidate.matchedPatterns) && + candidate.matchedPatterns.every((pattern) => typeof pattern === "string") && + isStage(candidate.stage) && + (candidate.outcome === "selected" || + candidate.outcome === "eligible" || + candidate.outcome === "rejected") && + Array.isArray(candidate.reasons) && + candidate.reasons.every(reason) && + (candidate.released === undefined || + (typeof candidate.released === "number" && + Number.isFinite(candidate.released) && + candidate.released > 0)), + ) + ); +} + +export function formatCandidateDetail(candidate: CandidateExplanation): string { + const marker = + candidate.outcome === "selected" ? "✓" : candidate.outcome === "rejected" ? "✗" : "·"; + const lines = [ + `${marker} ${sanitize(candidate.id)} (${candidate.outcome})`, + ` matched: ${candidate.matchedPatterns.map(sanitize).join(", ")}`, + ]; + if (candidate.stage === "selection") + lines.push(" passed enabled/status and configured requirement filters"); + if (candidate.outcome === "rejected") lines.push(` rejected at: ${candidate.stage}`); + if (candidate.released !== undefined) { + const date = new Date(candidate.released); + lines.push( + ` released: ${Number.isFinite(date.getTime()) ? date.toISOString() : candidate.released}`, + ); + } + for (const reason of candidate.reasons) lines.push(` ${sanitize(reason.message)}`); + return lines.join("\n"); +} + +export function formatExplanationOverview( + response: Extract, +): string { + const report = response.explanation; + const lines = [ + `Alias: ${sanitize(report.alias)}`, + `Strategy: ${report.strategy}`, + `Status: ${response.status}`, + report.stages.map((stage) => `${stage.name}: ${stage.accepted}`).join(" → "), + ]; + if (report.winner) lines.push(`Winner: ${sanitize(report.winner)}`); + if (report.failure) + lines.push(`Failed at ${report.failure.stage}: ${sanitize(report.failure.message)}`); + lines.push( + `Candidates: ${report.candidates.length}`, + `Other catalog models: ${report.unmatched} did not match the alias provider/include patterns.`, + ); + return lines.join("\n"); +} + +export function formatExplanation(response: ExplainResponse): string { + if (response.status === "unknown-alias") + return "Unknown alias. Use /model-aliases to view configured aliases."; + if (response.status === "unavailable") + return "Model alias explanation is unavailable: the current mapping could not be confirmed."; + const report = response.explanation; + const lines = [ + `Alias: ${sanitize(report.alias)}`, + `Strategy: ${report.strategy}`, + `Status: ${response.status}`, + ]; + lines.push(report.stages.map((stage) => `${stage.name}: ${stage.accepted}`).join(" → ")); + if (report.failure) + lines.push(`Failed at ${report.failure.stage}: ${sanitize(report.failure.message)}`); + for (const candidate of report.candidates) { + lines.push("", formatCandidateDetail(candidate)); + } + lines.push( + "", + `Other catalog models: ${report.unmatched} did not match the alias provider/include patterns.`, + ); + return lines.join("\n"); +} diff --git a/src/plugin.ts b/src/plugin.ts index cff253f..32f4852 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,6 +1,7 @@ import { type Model, Plugin } from "@opencode/plugin"; import { isPlainObject, type NormalizedConfig, type Options } from "./config.js"; import { loadConfigFile } from "./config-file.js"; +import type { ExplainResponse, ResolutionExplanation } from "./explain.js"; import { createHistory } from "./history.js"; import { aliasDisplayName } from "./names.js"; import { normalizeOptions } from "./normalize.js"; @@ -40,7 +41,12 @@ interface FloatingEditor { * materialize; the caller only publishes them if the whole replay (including * materialization) succeeded. */ -function replay(config: NormalizedConfig, editor: FloatingEditor): AliasReportRow[] { +interface ReplayReport { + rows: readonly AliasReportRow[]; + explanations: ReadonlyMap; +} + +function replay(config: NormalizedConfig, editor: FloatingEditor): ReplayReport { const snapshot = editor.list(); // Configuration collision: the alias id already exists as a source model. @@ -107,7 +113,10 @@ function replay(config: NormalizedConfig, editor: FloatingEditor): AliasReportRo } } - return buildRows(results); + return { + rows: buildRows(results), + explanations: new Map(results.map(({ alias, result }) => [alias.key, result.explanation])), + }; } export default Plugin.define({ @@ -189,16 +198,15 @@ export default Plugin.define({ // describable current mapping. It is replaced once per replay, only if // the whole replay/materialization succeeded; on any failure it is // cleared so partial or stale mappings are never described as current. - let reportRows: readonly AliasReportRow[] | null = null; + let report: ReplayReport | null = null; const registration = await ctx.model.transform((hostEditor) => { try { // Single host→adapter boundary: the host DeepMutable degrades strings // with brand; here it is adapted to the clean editor view. - const rows = replay(normalized.config, hostEditor as unknown as FloatingEditor); - reportRows = rows; + report = replay(normalized.config, hostEditor as unknown as FloatingEditor); } catch (error) { - reportRows = null; + report = null; if (initializing && !hasInitialError) { hasInitialError = true; initialError = error; @@ -242,10 +250,11 @@ export default Plugin.define({ // one; unavailability text and empty rows. return { text: UNAVAILABLE_REPORT, rows: [] }; } - const snapshot = reportRows; - if (snapshot === null) { + const current = report; + if (current === null) { return { text: UNAVAILABLE_REPORT, rows: [] }; } + const snapshot = current.rows; // Final visibility by primitives: an alias disabled by a later // policy is not labeled active; a retired alias keeps the existing // behavior (inactive). @@ -269,17 +278,57 @@ export default Plugin.define({ } } const rows = await history.observe(snapshot, visible); + // Storage is asynchronous: a host replay may supersede this decision while + // history is being saved. Never present that old mapping as current. + if (report !== current) return { text: UNAVAILABLE_REPORT, rows: [] }; return { text: formatReport(rows, visible), rows: buildInspectRows(rows, visible), + report: current, + visible, }; }; let pending = Promise.resolve(); let stopped = false; const inspect = () => { - const result = pending.then(() => - stopped ? { text: UNAVAILABLE_REPORT, rows: [] } : readInspection(), + const result = pending.then(async () => { + const response = stopped ? { text: UNAVAILABLE_REPORT, rows: [] } : await readInspection(); + return { text: response.text, rows: response.rows }; + }); + pending = result.then( + () => {}, + () => {}, ); + return result; + }; + const explain = (input: unknown): Promise => { + if ( + !isPlainObject(input) || + typeof input.alias !== "string" || + input.alias.length === 0 || + Object.keys(input).some((key) => key !== "alias") + ) { + return Promise.reject(new Error("Expected { alias: }")); + } + const alias = input.alias; + const result = pending.then(async (): Promise => { + if (stopped) return { status: "unavailable" }; + if (!normalized.config.aliases.some((entry) => entry.key === alias)) + return { status: "unknown-alias" }; + const inspection = await readInspection(); + const explanation = inspection.report?.explanations.get(alias); + // Escaped display keys are not identities: different raw keys can render + // identically (for example a newline and a literal "\\u000a"). + const row = inspection.report?.rows.find((entry) => entry.key === alias); + if (!explanation || !row) return { status: "unavailable" }; + const status = + row.status === "unresolved" + ? "unresolved" + : inspection.visible?.has(alias) + ? "active" + : "inactive"; + return { status, explanation: structuredClone(explanation) }; + }); pending = result.then( () => {}, () => {}, @@ -288,7 +337,7 @@ export default Plugin.define({ }; let rpcRegistration: { dispose: () => Promise }; try { - rpcRegistration = await ctx.rpc.register(ModelAliasesRpc, { inspect }); + rpcRegistration = await ctx.rpc.register(ModelAliasesRpc, { inspect, explain }); } catch (error) { await registration.dispose(); throw error; diff --git a/src/resolve.ts b/src/resolve.ts index 87b2e2b..eb04486 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -1,5 +1,7 @@ import type { Candidate, NormalizedAlias } from "./config.js"; import { failure, type ResolveFailure } from "./errors.js"; +import type { CandidateExplanation, ResolutionExplanation } from "./explain.js"; +import { sanitize } from "./report.js"; export interface Stage { name: "matching" | "filtering" | "selection"; @@ -17,7 +19,7 @@ function canonical(candidate: Candidate): string { /** Reliable date: finite number greater than 0 (milliseconds). */ function releasedMs(candidate: Candidate): number { - const released = candidate.time.released; + const released = candidate.time?.released; return typeof released === "number" && Number.isFinite(released) && released > 0 ? released : 0; } @@ -87,86 +89,160 @@ function requirementSummary(alias: NormalizedAlias, keys: ReadonlySet): export function resolveLatest( models: readonly T[], alias: NormalizedAlias, -): { ok: true; model: T; stages: Stage[] } | { ok: false; failure: ResolveFailure } { +): ResolveResult & { explanation: ResolutionExplanation } { + const stages: Stage[] = []; + const explanation: ResolutionExplanation = { + alias: sanitize(alias.key), + strategy: "latest", + stages, + unmatched: 0, + candidates: [], + }; + const details: Array<{ id: string; detail: CandidateExplanation }> = []; + const fail = (stage: Stage["name"], kind: ResolveFailure["kind"], reason: string) => { + explanation.failure = { stage, code: kind, message: sanitize(reason) }; + return { ok: false as const, failure: failure(kind, reason), explanation }; + }; if (!Array.isArray(models)) { - return { ok: false, failure: failure("no-candidates", "source list must be an array") }; + return fail("matching", "no-candidates", "source list must be an array"); } - const stages: Stage[] = []; - // Stage 1: matching — includes and excludes against the canonical id. - const matched = models.filter((candidate) => { - // Defense in depth: provider equality does not depend on the matchers. - if (candidate.providerID !== alias.provider) return false; + // Record decisions where they are made; never run a second resolver for explain. + const matched = models.flatMap((candidate) => { const id = canonical(candidate); - if (!alias.includes.some((check) => check(id))) return false; - return !alias.excludes.some((check) => check(id)); + if (candidate.providerID !== alias.provider) { + explanation.unmatched++; + return []; + } + const included = alias.includes.map((check) => check(id)); + const patterns = alias.match.filter((_, index) => included[index]); + if (!included.some(Boolean)) { + explanation.unmatched++; + return []; + } + const exclusions = alias.excludes.map((check) => check(id)); + const excluded = alias.exclude.filter((_, index) => exclusions[index]); + const detail: CandidateExplanation = { + id: sanitize(id), + matchedPatterns: patterns.map(sanitize), + stage: "matching", + outcome: "rejected", + reasons: [], + }; + details.push({ id, detail }); + if (exclusions.some(Boolean)) { + detail.reasons.push({ + code: "excluded-pattern", + message: `Excluded by ${excluded.map(sanitize).join(", ")}`, + }); + } + return exclusions.some(Boolean) ? [] : [{ candidate, detail }]; }); + // Sort raw IDs, before escaping, using locale-independent code unit order. + explanation.candidates = details + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + .map(({ detail }) => detail); stages.push({ name: "matching", accepted: matched.length }); if (matched.length === 0) { - return { - ok: false, - failure: - models.length === 0 - ? failure("no-candidates", "source list is empty") - : failure("no-eligible", "no candidate matched match/exclude patterns"), - }; + return models.length === 0 + ? fail("matching", "no-candidates", "source list is empty") + : fail("matching", "no-eligible", "no candidate matched match/exclude patterns"); } - // Stage 2: filtering — enabled + statuses + configured capability/context - // requirements, all AND together in the eligibility stage. - const statusEligible = matched.filter( - (candidate) => candidate.enabled !== false && alias.statuses.includes(candidate.status), - ); - const eligible = statusEligible.filter( - (candidate) => unmetRequirements(candidate, alias).length === 0, - ); + const statusEligible = matched.filter(({ candidate, detail }) => { + detail.stage = "filtering"; + if (candidate.enabled === false) + detail.reasons.push({ code: "disabled", message: "Model is disabled" }); + if (!alias.statuses.includes(candidate.status)) + detail.reasons.push({ + code: "status-not-allowed", + message: `Status "${sanitize(candidate.status)}" is not allowed`, + }); + return detail.reasons.length === 0; + }); + const unmet = new Set(); + const eligible = statusEligible.filter(({ candidate, detail }) => { + const keys = unmetRequirements(candidate, alias); + for (const key of keys) { + unmet.add(key); + const value = + key === "minContext" + ? candidate.limit?.context + : candidate.capabilities?.[key as "tools" | "input" | "output"]; + const missing = + key === "minContext" + ? typeof value !== "number" || !Number.isFinite(value) + : key === "tools" + ? typeof value !== "boolean" + : !Array.isArray(value); + detail.reasons.push({ + code: missing ? "missing-metadata" : "requirement-not-met", + message: `${missing ? "Missing or invalid metadata for" : "Does not satisfy"} ${sanitize(requirementSummary(alias, new Set([key])))}`, + }); + } + return keys.length === 0; + }); stages.push({ name: "filtering", accepted: eligible.length }); if (eligible.length === 0) { - // Keep the old diagnostic when enabled/status checks already dropped - // everyone; otherwise the new requirements are the identifiable cause. - if (statusEligible.length === 0) { - return { - ok: false, - failure: failure("no-eligible", "no candidate passed enabled/status filtering"), - }; - } - // Agrega las claves de requisitos incumplidos individualmente en todo el - // conjunto de candidatos, deduplicadas, para listar solo los checks que - // realmente fallan. - const unmet = new Set(); - for (const candidate of statusEligible) { - for (const key of unmetRequirements(candidate, alias)) unmet.add(key); - } - return { - ok: false, - failure: failure( - "no-eligible", - `no candidate satisfied all configured requirements (unmet across the candidate set: ${requirementSummary(alias, unmet)})`, - ), - }; + return fail( + "filtering", + "no-eligible", + statusEligible.length === 0 + ? "no candidate passed enabled/status filtering" + : `no candidate satisfied all configured requirements (unmet across the candidate set: ${requirementSummary(alias, unmet)})`, + ); } - // Stage 3: selection — latest; unknown dates never win. - const known = eligible.filter((candidate) => releasedMs(candidate) > 0); + const known = eligible.filter(({ candidate, detail }) => { + detail.stage = "selection"; + const released = releasedMs(candidate); + if (released === 0) { + detail.reasons.push({ + code: "missing-metadata", + message: "Missing or invalid time.released timestamp", + }); + return false; + } + detail.released = released; + return true; + }); + stages.push({ name: "selection", accepted: known.length > 0 ? 1 : 0 }); if (known.length === 0) { - return { - ok: false, - failure: failure( - "missing-metadata", - "no eligible candidate has a reliable time.released timestamp", - ), - }; + return fail( + "selection", + "missing-metadata", + "no eligible candidate has a reliable time.released timestamp", + ); } - const sorted = [...known].sort((a, b) => { + const sorted = [...known].sort(({ candidate: a }, { candidate: b }) => { const delta = releasedMs(b) - releasedMs(a); if (delta !== 0) return delta; - if (a.id === b.id) return 0; - // Descending, code unit order, locale-independent. - return a.id < b.id ? 1 : -1; + return a.id < b.id ? 1 : a.id > b.id ? -1 : 0; }); - const winner = sorted[0]; - if (!winner) { - return { ok: false, failure: failure("no-eligible", "selection produced no candidate") }; + const first = sorted[0]; + if (!first) return fail("selection", "no-eligible", "selection produced no candidate"); + const winner = first.candidate; + const second = sorted[1]?.candidate; + const tied = second !== undefined && releasedMs(second) === releasedMs(winner); + for (const { candidate, detail } of sorted) { + detail.outcome = candidate === winner ? "selected" : "eligible"; + const tie = releasedMs(candidate) === releasedMs(winner); + detail.reasons.push( + candidate === winner + ? { + code: tied ? "id-tiebreak" : "newest-release", + message: tied + ? "Newest release; won the descending model ID tie-break" + : "Newest eligible candidate with a reliable release timestamp", + } + : { + code: tie ? "id-tiebreak" : "older-release", + message: tie + ? "Same release timestamp; lost the descending model ID tie-break" + : "Older release than the selected candidate", + }, + ); } - return { ok: true, model: winner, stages: [...stages, { name: "selection", accepted: 1 }] }; + explanation.winner = sanitize(canonical(winner)); + return { ok: true, model: winner, stages, explanation }; } diff --git a/src/rpc.ts b/src/rpc.ts index cb034fa..0764dc0 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -1,15 +1,86 @@ import type { Rpc } from "@opencode/plugin"; +const stageSchema = { type: "string", enum: ["matching", "filtering", "selection"] } as const; +const reasonProperties = { code: { type: "string" }, message: { type: "string" } } as const; + /** * Public RPC contract of the plugin, consumed by the TUI (which imports only - * this module, never the backend barrel): id "opencode-model-aliases" and a - * single `inspect` method with empty object input and `{ text, rows }` output, no - * events. Portable definition built from plain JSON Schema objects, no schema + * this module, never the backend barrel): id "opencode-model-aliases" with + * `inspect` and opt-in `explain` methods, no events. Portable definition built from plain JSON Schema objects, no schema * library at runtime (pinned @opencode/plugin 2.0.16). */ export const ModelAliasesRpc = { id: "opencode-model-aliases", methods: { + explain: { + input: { + type: "object", + properties: { alias: { type: "string", minLength: 1 } }, + required: ["alias"], + additionalProperties: false, + }, + output: { + type: "object", + properties: { + status: { + type: "string", + enum: ["active", "inactive", "unresolved", "unknown-alias", "unavailable"], + }, + explanation: { + type: "object", + properties: { + alias: { type: "string" }, + strategy: { type: "string", enum: ["latest"] }, + unmatched: { type: "integer", minimum: 0 }, + winner: { type: "string" }, + stages: { + type: "array", + items: { + type: "object", + properties: { name: stageSchema, accepted: { type: "integer", minimum: 0 } }, + required: ["name", "accepted"], + additionalProperties: false, + }, + }, + failure: { + type: "object", + properties: { ...reasonProperties, stage: stageSchema }, + required: ["code", "message", "stage"], + additionalProperties: false, + }, + candidates: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + matchedPatterns: { type: "array", items: { type: "string" } }, + stage: stageSchema, + outcome: { type: "string", enum: ["selected", "eligible", "rejected"] }, + released: { type: "number", exclusiveMinimum: 0 }, + reasons: { + type: "array", + items: { + type: "object", + properties: reasonProperties, + required: ["code", "message"], + additionalProperties: false, + }, + }, + }, + required: ["id", "matchedPatterns", "stage", "outcome", "reasons"], + additionalProperties: false, + }, + }, + }, + required: ["alias", "strategy", "unmatched", "stages", "candidates"], + additionalProperties: false, + }, + }, + required: ["status"], + additionalProperties: false, + }, + }, inspect: { input: { type: "object", properties: {}, additionalProperties: false }, output: { diff --git a/src/tui.ts b/src/tui.ts index fb6aab8..e46c9be 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -1,4 +1,11 @@ import type { Plugin } from "@opencode/plugin/tui"; +import { + type CandidateExplanation, + formatCandidateDetail, + formatExplanation, + formatExplanationOverview, + isExplainResponse, +} from "./explain.js"; import type { InspectReportRow } from "./report.js"; import { ModelAliasesRpc } from "./rpc.js"; import { type AliasTransition, isAliasTransition } from "./transition.js"; @@ -8,7 +15,7 @@ const COMMAND_TITLE = "Model aliases"; const SLASH_COMMAND_NAME = "model-aliases"; const USAGE_MESSAGE = - "Unexpected arguments. Use /model-aliases without arguments to view configured model aliases."; + 'Unexpected arguments. Use /model-aliases or /model-aliases explain . Quote the alias if it contains spaces: /model-aliases explain "".'; const ERROR_MESSAGE = "Unable to load model aliases. Please reload or try again."; export type InspectResponseRow = InspectReportRow; @@ -193,7 +200,140 @@ const plugin = { arguments: true as const, }, run: async (input?: string) => { - if (input !== undefined && input.trim() !== "") { + const args = input?.trim() ?? ""; + // Alias keys may contain spaces; a quoted argument preserves the exact + // key, while the unquoted form keeps the historical single-token shape. + const explainMatch = /^explain\s+("([^"]*)"|(\S+))$/.exec(args); + const quoted = explainMatch?.[2]; + const alias = quoted !== undefined ? quoted : explainMatch?.[3]; + if (explainMatch && alias !== undefined && alias.length > 0) { + const location = context.location ?? context.data.location.default(); + try { + const response = await context.client + .rpc(ModelAliasesRpc) + .explain({ alias }, { location }); + if (!isExplainResponse(response)) { + await context.ui.dialog.alert({ title: COMMAND_TITLE, message: ERROR_MESSAGE }); + return; + } + if (response.status === "unknown-alias" || response.status === "unavailable") { + await context.ui.dialog.alert({ + title: "Model alias explanation", + message: formatExplanation(response), + }); + return; + } + + const report = response.explanation; + if (report.candidates.length === 0) { + await context.ui.dialog.alert({ + title: "Model alias explanation", + message: formatExplanation(response), + }); + return; + } + + const compactStage = (name: string) => { + if (name === "matching") return "match"; + if (name === "filtering") return "filter"; + if (name === "selection") return "select"; + return name; + }; + + const options: Array<{ + category?: string; + title: string; + description?: string; + footer?: string; + value: string; + }> = [ + { + category: "overview", + title: "Overview", + description: report.stages + .map((s) => `${compactStage(s.name)}: ${s.accepted}`) + .join(" → "), + footer: response.status, + value: "__overview__", + }, + ]; + + const providerPrefix = report.alias.includes("/") + ? `${report.alias.split("/")[0]}/` + : ""; + + const selectedCandidates = report.candidates.filter((c) => c.outcome === "selected"); + const eligibleCandidates = report.candidates.filter((c) => c.outcome === "eligible"); + const rejectedCandidates = report.candidates.filter((c) => c.outcome === "rejected"); + + // Explanation IDs are escaped display text, not identities: two raw + // IDs can escape to the same string (a newline vs a literal + // "\\u000a"). Selection values index the candidates losslessly. + const valueOfCandidate = (candidate: CandidateExplanation) => + `__candidate_${report.candidates.indexOf(candidate)}`; + const candidateByValue = new Map( + report.candidates.map((candidate) => [valueOfCandidate(candidate), candidate]), + ); + + for (const candidate of [ + ...selectedCandidates, + ...eligibleCandidates, + ...rejectedCandidates, + ]) { + const title = candidate.id.startsWith(providerPrefix) + ? candidate.id.slice(providerPrefix.length) + : candidate.id; + + options.push({ + category: candidate.outcome, + title, + ...(candidate.outcome === "rejected" ? { footer: candidate.stage } : {}), + value: valueOfCandidate(candidate), + }); + } + + let current: string | undefined; + while (true) { + const selectedKey = await context.ui.dialog.select({ + title: `Model alias explanation: ${report.alias}`, + placeholder: "Filter candidates...", + options, + ...(current === undefined ? {} : { current }), + }); + + if (selectedKey === undefined) { + return; + } + current = selectedKey; + + let confirmed: boolean | undefined; + if (selectedKey === "__overview__") { + confirmed = await context.ui.dialog.confirm({ + title: `Model alias explanation: ${report.alias}`, + message: formatExplanationOverview(response), + label: { confirm: "Back to candidates", cancel: "Exit" }, + }); + } else { + const selectedCandidate = candidateByValue.get(selectedKey); + if (selectedCandidate) { + confirmed = await context.ui.dialog.confirm({ + title: `Candidate: ${selectedCandidate.id}`, + message: formatCandidateDetail(selectedCandidate), + label: { confirm: "Back to candidates", cancel: "Exit" }, + }); + } + } + + if (confirmed !== true) { + return; + } + } + } catch { + await context.ui.dialog.alert({ title: COMMAND_TITLE, message: ERROR_MESSAGE }); + } + return; + } + if (args !== "") { await context.ui.dialog.alert({ title: COMMAND_TITLE, message: USAGE_MESSAGE, diff --git a/tests/explain.test.ts b/tests/explain.test.ts new file mode 100644 index 0000000..5f6f88f --- /dev/null +++ b/tests/explain.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; +import type { AliasConfig, Candidate } from "../src/config.js"; +import { + formatCandidateDetail, + formatExplanation, + formatExplanationOverview, + isExplainResponse, +} from "../src/explain.js"; +import { normalizeOptions } from "../src/normalize.js"; +import { resolveLatest } from "../src/resolve.js"; + +function model(id: string, overrides: Partial = {}): Candidate { + return { + id, + providerID: "p", + enabled: true, + status: "active", + time: { released: 1000 }, + ...overrides, + }; +} +function resolve(models: Candidate[], options: Partial = {}) { + const normalized = normalizeOptions({ aliases: { "p/alias": { match: "p/m*", ...options } } }); + if (!normalized.ok || !normalized.config.aliases[0]) throw new Error("Invalid fixture"); + return resolveLatest(models, normalized.config.aliases[0]); +} + +describe("resolution explanation", () => { + it("records matching, exclusions, ranking and aggregate non-matches deterministically", () => { + const models = [ + model("m-old"), + model("m-new", { time: { released: 2000 } }), + model("m-excluded"), + model("unrelated"), + model("m-foreign", { providerID: "q" }), + ]; + const result = resolve(models, { exclude: "p/m-excluded" }); + expect(result.ok && result.model.id).toBe("m-new"); + expect(result.explanation).toEqual( + resolve([...models].reverse(), { exclude: "p/m-excluded" }).explanation, + ); + expect(result.explanation.unmatched).toBe(2); + expect(result.explanation.candidates.map((c) => [c.id, c.outcome, c.reasons[0]?.code])).toEqual( + [ + ["p/m-excluded", "rejected", "excluded-pattern"], + ["p/m-new", "selected", "newest-release"], + ["p/m-old", "eligible", "older-release"], + ], + ); + expect(result.explanation.candidates.every((c) => c.matchedPatterns[0] === "p/m*")).toBe(true); + }); + + it("explains both sides of a model ID tie-break", () => { + const result = resolve([model("m-a"), model("m-z")]); + expect(result.explanation.winner).toBe("p/m-z"); + expect(result.explanation.candidates.map((c) => c.reasons[0]?.code)).toEqual([ + "id-tiebreak", + "id-tiebreak", + ]); + }); + + it("records the failed stage for empty, unmatched, filtered and undated catalogs", () => { + for (const [models, stage, code] of [ + [[], "matching", "no-candidates"], + [[model("other")], "matching", "no-eligible"], + [[model("m-off", { enabled: false })], "filtering", "no-eligible"], + [[model("m-beta", { status: "beta" })], "filtering", "no-eligible"], + [[model("m-undated", { time: { released: Number.NaN } })], "selection", "missing-metadata"], + ] as const) { + const result = resolve([...models]); + expect(result.ok).toBe(false); + expect(result.explanation.failure).toMatchObject({ stage, code }); + expect(result.explanation.stages.at(-1)).toEqual({ name: stage, accepted: 0 }); + expect(isExplainResponse({ status: "unresolved", explanation: result.explanation })).toBe( + true, + ); + } + }); + + it("distinguishes missing metadata from incompatible requirements and preserves reason order", () => { + const result = resolve( + [ + model("m-absent"), + model("m-wrong", { + capabilities: { tools: false, input: [], output: [] }, + limit: { context: 10 }, + }), + ], + { + filter: { + capabilities: { tools: true, input: ["text"], output: ["text"] }, + minContext: 100, + }, + }, + ); + const [absent, wrong] = result.explanation.candidates; + expect(absent?.reasons.map((r) => r.code)).toEqual(Array(4).fill("missing-metadata")); + expect(wrong?.reasons.map((r) => r.code)).toEqual(Array(4).fill("requirement-not-met")); + expect(wrong?.reasons.map((r) => r.message)).toEqual([ + "Does not satisfy capabilities.tools=true", + "Does not satisfy capabilities.input includes [text]", + "Does not satisfy capabilities.output includes [text]", + "Does not satisfy minContext>=100", + ]); + expect(resolve([model("m-absent")]).ok).toBe(true); + }); + + it("never serializes opaque metadata and escapes terminal control characters", () => { + const source = { + ...model("m-\u001b[31m"), + headers: { Authorization: "SECRET" }, + settings: { apiKey: "SECRET" }, + }; + const result = resolve([source]); + const serialized = JSON.stringify(result.explanation); + expect(serialized).not.toContain("SECRET"); + expect(serialized).not.toContain("headers"); + const response = { status: "active" as const, explanation: result.explanation }; + expect(isExplainResponse(response)).toBe(true); + expect(formatExplanation(response)).not.toContain("\u001b"); + }); + + it("rejects malformed RPC data and renders unavailable and unknown aliases", () => { + for (const value of [ + null, + {}, + { status: "active" }, + { status: "active", explanation: { stages: [] } }, + ]) { + expect(isExplainResponse(value)).toBe(false); + } + const valid = { status: "active", explanation: resolve([model("m-a")]).explanation }; + for (const released of [0, -1, Number.NaN, "date"]) { + const invalid = structuredClone(valid); + Object.assign(invalid.explanation.candidates[0] ?? {}, { released }); + expect(isExplainResponse(invalid)).toBe(false); + } + expect(formatExplanation({ status: "unknown-alias" })).toContain("Unknown alias"); + expect(formatExplanation({ status: "unavailable" })).toContain("unavailable"); + }); + + it("formats candidate details and overview with sanitized fields and stage indicators", () => { + const result = resolve([model("m-a"), model("m-b", { enabled: false })]); + const response = { status: "active" as const, explanation: result.explanation }; + const overview = formatExplanationOverview(response); + expect(overview).toContain("Alias: p/alias"); + expect(overview).toContain("Strategy: latest"); + expect(overview).toContain("Status: active"); + expect(overview).toContain("Winner: p/m-a"); + expect(overview).toContain("Candidates: 2"); + + const [winner, rejected] = result.explanation.candidates; + expect(winner).toBeDefined(); + expect(rejected).toBeDefined(); + if (winner && rejected) { + const winnerDetail = formatCandidateDetail(winner); + expect(winnerDetail).toContain("✓ p/m-a (selected)"); + expect(winnerDetail).toContain("passed enabled/status and configured requirement filters"); + + const rejectedDetail = formatCandidateDetail(rejected); + expect(rejectedDetail).toContain("✗ p/m-b (rejected)"); + expect(rejectedDetail).toContain("rejected at: filtering"); + expect(rejectedDetail).toContain("Model is disabled"); + } + }); +}); + +it("rejects coerced enum values and incomplete resolution outcomes", () => { + const explanation = resolve([model("m-a")]).explanation; + expect(isExplainResponse({ status: ["active"], explanation })).toBe(false); + const stages = structuredClone(explanation); + Object.assign(stages.stages[0] ?? {}, { name: ["matching"] }); + expect(isExplainResponse({ status: "active", explanation: stages })).toBe(false); + const candidates = structuredClone(explanation); + Object.assign(candidates.candidates[0] ?? {}, { outcome: ["selected"] }); + expect(isExplainResponse({ status: "active", explanation: candidates })).toBe(false); + const noWinner = structuredClone(explanation); + delete noWinner.winner; + expect(isExplainResponse({ status: "active", explanation: noWinner })).toBe(false); + expect(isExplainResponse({ status: "unresolved", explanation })).toBe(false); +}); diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 4577784..1ac3e56 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -637,3 +637,134 @@ describe("opencode-model-aliases plugin: escape de caracteres de control", () => expect(message).toContain("match is required"); }); }); + +describe("explain RPC", () => { + it("returns the materialized decision, refreshes it, and never expands inspect", async () => { + const harness = createHarness({ sources: DEFAULT_SOURCES(), options: SIMPLE_OPTIONS() }); + const stop = await floatingModels.setup(harness.ctx); + const explain = harness.rpc.handlers[0]?.explain; + if (!explain) throw new Error("missing explain handler"); + const first = await explain({ alias: "github-copilot/sonnet" }, {}); + expect(first).toMatchObject({ + status: "active", + explanation: { winner: "github-copilot/sonnet-4" }, + }); + expect(Object.keys((await harness.rpc.handlers[0]?.inspect?.({}, {})) as object)).toEqual([ + "text", + "rows", + ]); + harness.addSource( + sourceModel({ id: "sonnet-5", providerID: "github-copilot", released: 9999 }), + ); + const next = await explain({ alias: "github-copilot/sonnet" }, {}); + expect(next).toMatchObject({ + status: "active", + explanation: { winner: "github-copilot/sonnet-5" }, + }); + expect(harness.view().get("github-copilot/sonnet")?.modelID).toBe("sonnet-5"); + expect(await explain({ alias: "github-copilot/unknown" }, {})).toEqual({ + status: "unknown-alias", + }); + await expect(explain({ alias: 1 }, {})).rejects.toThrow("Expected"); + await stop?.(); + expect(await explain({ alias: "github-copilot/sonnet" }, {})).toEqual({ + status: "unavailable", + }); + }); + + it("explains unresolved aliases and refuses stale or conflicting mappings", async () => { + const harness = createHarness({ sources: DEFAULT_SOURCES(), options: SIMPLE_OPTIONS() }); + const stop = await floatingModels.setup(harness.ctx); + const explain = harness.rpc.handlers[0]?.explain; + if (!explain) throw new Error("missing explain handler"); + harness.failNextList(new Error("PRIVATE")); + expect(await explain({ alias: "github-copilot/sonnet" }, {})).toEqual({ + status: "unavailable", + }); + const later = await harness.ctx.model.transform((editor) => { + editor.update("github-copilot", "sonnet", (model) => { + model.enabled = false; + }); + }); + expect(await explain({ alias: "github-copilot/sonnet" }, {})).toMatchObject({ + status: "inactive", + }); + await later.dispose(); + const rewrite = await harness.ctx.model.transform((editor) => { + editor.update("github-copilot", "sonnet", (model) => { + (model as unknown as { modelID: string }).modelID = "other"; + }); + }); + expect(await explain({ alias: "github-copilot/sonnet" }, {})).toEqual({ + status: "unavailable", + }); + await rewrite.dispose(); + harness.removeSource("github-copilot", "sonnet-4"); + expect(await explain({ alias: "github-copilot/sonnet" }, {})).toMatchObject({ + status: "unresolved", + explanation: { failure: { stage: "matching" } }, + }); + // A collision invalidates the whole replay instead of serving an old explanation. + harness.addSource(sourceModel({ id: "sonnet", providerID: "github-copilot", released: 9999 })); + expect(await explain({ alias: "github-copilot/sonnet" }, {})).toEqual({ + status: "unavailable", + }); + await stop?.(); + }); +}); + +describe("inspection snapshot regressions", () => { + it.each(["inspect", "explain"])( + "%s does not publish a snapshot superseded while history is saved", + async (method) => { + const harness = createHarness({ sources: DEFAULT_SOURCES(), options: SIMPLE_OPTIONS() }); + const stop = await floatingModels.setup(harness.ctx); + const handler = harness.rpc.handlers[0]?.[method]; + if (!handler) throw new Error("missing RPC handler"); + harness.addSource( + sourceModel({ id: "sonnet-5", providerID: "github-copilot", released: 5000 }), + ); + vi.spyOn(harness.ctx.storage, "set").mockImplementationOnce(async () => { + harness.addSource( + sourceModel({ id: "sonnet-6", providerID: "github-copilot", released: 6000 }), + ); + harness.replay(); + }); + const response = await handler( + method === "explain" ? { alias: "github-copilot/sonnet" } : {}, + {}, + ); + expect(response).toMatchObject( + method === "explain" ? { status: "unavailable" } : { rows: [] }, + ); + const next = await harness.rpc.handlers[0]?.explain?.({ alias: "github-copilot/sonnet" }, {}); + expect(next).toMatchObject({ + status: "active", + explanation: { winner: "github-copilot/sonnet-6" }, + }); + await stop?.(); + }, + ); + + it("looks up status by raw alias identity even when display escapes collide", async () => { + const harness = createHarness({ + sources: DEFAULT_SOURCES(), + options: { + aliases: { + "github-copilot/a\n": { match: "github-copilot/missing-*" }, + "github-copilot/a\\u000a": { match: "github-copilot/sonnet-*" }, + }, + }, + }); + const stop = await floatingModels.setup(harness.ctx); + const response = await harness.rpc.handlers[0]?.explain?.( + { alias: "github-copilot/a\\u000a" }, + {}, + ); + expect(response).toMatchObject({ + status: "active", + explanation: { winner: "github-copilot/sonnet-4" }, + }); + await stop?.(); + }); +}); diff --git a/tests/report.test.ts b/tests/report.test.ts index 7ca7ddd..7753690 100644 --- a/tests/report.test.ts +++ b/tests/report.test.ts @@ -71,9 +71,9 @@ afterEach(() => { }); describe("ModelAliasesRpc contract", () => { - it("fija id, método único inspect con esquema JSON vacío, salida {text, rows} y sin eventos", () => { + it("preserva inspect y añade explain sin eventos", () => { expect(ModelAliasesRpc.id).toBe("opencode-model-aliases"); - expect(Object.keys(ModelAliasesRpc.methods)).toEqual(["inspect"]); + expect(Object.keys(ModelAliasesRpc.methods)).toEqual(["explain", "inspect"]); expect(ModelAliasesRpc.methods.inspect.input).toEqual({ type: "object", properties: {}, diff --git a/tests/tui.test.ts b/tests/tui.test.ts index d528eda..d7e7e44 100644 --- a/tests/tui.test.ts +++ b/tests/tui.test.ts @@ -48,6 +48,13 @@ interface SelectCall { title: string; placeholder?: string; options: SelectOption[]; + current?: string; +} + +interface ConfirmCall { + title: string; + message: string; + label?: { confirm?: string; cancel?: string }; } interface StrictContextOptions { @@ -56,13 +63,15 @@ interface StrictContextOptions { saveNotification?: () => Promise; location?: { directory: string } | undefined; defaultLocation?: { directory: string } | undefined; + explainHandler?: (input: unknown, options?: { location?: unknown }) => Promise; inspectHandler?: | (( input: Record, options?: { location?: unknown } | undefined, ) => Promise) | undefined; - selectReturnValue?: string | undefined; + selectReturnValue?: string | undefined | (() => string | undefined); + confirmReturnValue?: boolean | undefined | (() => boolean | undefined); } const SAMPLE_ROW_ACTIVE: InspectResponseRow = { @@ -170,11 +179,13 @@ function createStrictContext(options?: StrictContextOptions) { const returnedLayers: KeymapLayer[] = []; const activeCommands: KeymapCommand[] = []; const alerts: Array<{ title: string; message: string }> = []; + const confirms: ConfirmCall[] = []; const selectCalls: SelectCall[] = []; const inspectCalls: Array<{ input: unknown; options?: unknown }> = []; const defaultLoc = options?.defaultLocation ?? { directory: "/default/workspace" }; - let simulatedSelectReturn: string | undefined = options?.selectReturnValue; + let simulatedSelectReturn = options?.selectReturnValue; + let simulatedConfirmReturn = options?.confirmReturnValue; let slotDisposed = false; let activeAppRender: (() => null) | null = null; @@ -221,6 +232,9 @@ function createStrictContext(options?: StrictContextOptions) { rpc: vi.fn((definition: unknown) => { expect(definition).toBe(ModelAliasesRpc); return { + explain: vi.fn(async (input: unknown, rpcOptions?: { location?: unknown }) => { + return options?.explainHandler?.(input, rpcOptions) ?? { status: "unknown-alias" }; + }), inspect: vi.fn( async (input: Record, rpcOptions?: { location?: unknown }) => { inspectCalls.push({ input, options: rpcOptions }); @@ -267,9 +281,18 @@ function createStrictContext(options?: StrictContextOptions) { alerts.push(opts); }); + const confirmSpy = vi.fn(async (opts: ConfirmCall) => { + confirms.push(opts); + return typeof simulatedConfirmReturn === "function" + ? simulatedConfirmReturn() + : simulatedConfirmReturn; + }); + const selectSpy = vi.fn(async (opts: SelectCall) => { selectCalls.push(opts); - return simulatedSelectReturn; + return typeof simulatedSelectReturn === "function" + ? simulatedSelectReturn() + : simulatedSelectReturn; }); const slotImpl = vi.fn((claim: SlotClaim) => { @@ -288,11 +311,11 @@ function createStrictContext(options?: StrictContextOptions) { slot: slotImpl, dialog: { alert: alertSpy, + confirm: confirmSpy, select: selectSpy, show: forbiddenProxy("ui.dialog.show"), set: forbiddenProxy("ui.dialog.set"), clear: forbiddenProxy("ui.dialog.clear"), - confirm: forbiddenProxy("ui.dialog.confirm"), prompt: forbiddenProxy("ui.dialog.prompt"), }, toast: { @@ -354,11 +377,16 @@ function createStrictContext(options?: StrictContextOptions) { defaultLocationSpy, slotImpl, alerts, + confirms, + confirmSpy, selectCalls, inspectCalls, activeCommands, returnedLayers, - setSelectReturn: (val: string | undefined) => { + setConfirmReturn: (val: boolean | undefined | (() => boolean | undefined)) => { + simulatedConfirmReturn = val; + }, + setSelectReturn: (val: string | undefined | (() => string | undefined)) => { simulatedSelectReturn = val; }, remountSlot: () => { @@ -714,6 +742,7 @@ describe("TUI security, validation, and error boundaries", () => { expect(harness.inspectCalls).toHaveLength(2); expect(harness.selectSpy).toHaveBeenCalledTimes(1); expect(harness.alertSpy).toHaveBeenCalledTimes(1); + expect(harness.confirmSpy).not.toHaveBeenCalled(); }); it("passes explicit location when context provides location", async () => { @@ -1061,3 +1090,512 @@ describe("TUI change notifications", () => { expect(failure.alerts).toHaveLength(0); }); }); + +describe("TUI explain action", () => { + it("renders interactive dialog.select with overview and candidates grouped by outcome", async () => { + const explainHandler = vi.fn(async () => ({ + status: "active", + explanation: { + alias: "p/alias", + strategy: "latest", + unmatched: 3, + stages: [ + { name: "matching", accepted: 2 }, + { name: "filtering", accepted: 2 }, + { name: "selection", accepted: 1 }, + ], + winner: "p/winner", + candidates: [ + { + id: "p/winner", + matchedPatterns: ["p/*"], + outcome: "selected", + stage: "selection", + released: 2000, + reasons: [{ code: "newest-release", message: "Newest eligible candidate" }], + }, + { + id: "p/older", + matchedPatterns: ["p/*"], + outcome: "eligible", + stage: "selection", + released: 1000, + reasons: [ + { code: "older-release", message: "Older release than the selected candidate" }, + ], + }, + { + id: "p/rejected", + matchedPatterns: ["p/*"], + outcome: "rejected", + stage: "filtering", + reasons: [{ code: "requirement-not-met", message: "Does not satisfy minContext>=100" }], + }, + ], + }, + })); + const location = { directory: "/project" }; + const harness = createStrictContext({ explainHandler, location }); + const stop = plugin.setup(harness.context); + await harness.activeCommands[0]?.run(" explain p/alias "); + expect(explainHandler).toHaveBeenCalledWith({ alias: "p/alias" }, { location }); + expect(harness.selectCalls).toHaveLength(1); + const call = harness.selectCalls[0]; + expect(call?.title).toBe("Model alias explanation: p/alias"); + expect(call?.placeholder).toBe("Filter candidates..."); + expect(call?.options).toEqual([ + { + category: "overview", + title: "Overview", + description: "match: 2 → filter: 2 → select: 1", + footer: "active", + value: "__overview__", + }, + { + category: "selected", + title: "winner", + footer: undefined, + value: "__candidate_0", + }, + { + category: "eligible", + title: "older", + footer: undefined, + value: "__candidate_1", + }, + { + category: "rejected", + title: "rejected", + footer: "filtering", + value: "__candidate_2", + }, + ]); + expect(harness.alerts).toHaveLength(0); + await stop?.(); + }); + + it("enforces narrow-row information policy for small terminals", async () => { + const explainHandler = vi.fn(async () => ({ + status: "active", + explanation: { + alias: "opencode/zen-plan", + strategy: "latest", + unmatched: 3, + stages: [ + { name: "matching", accepted: 9 }, + { name: "filtering", accepted: 9 }, + { name: "selection", accepted: 1 }, + ], + winner: "opencode/longcat-2.5-preview-free", + candidates: [ + { + id: "opencode/longcat-2.5-preview-free", + matchedPatterns: ["opencode/*-free"], + outcome: "selected", + stage: "selection", + released: 1700000000000, + reasons: [ + { + code: "newest-release", + message: "Newest eligible candidate with a reliable release timestamp", + }, + ], + }, + { + id: "opencode/muse-spark-1.3-contributor-free", + matchedPatterns: ["opencode/*-free"], + outcome: "eligible", + stage: "selection", + released: 1600000000000, + reasons: [ + { code: "older-release", message: "Older release than the selected candidate" }, + ], + }, + { + id: "opencode/exo-free", + matchedPatterns: ["opencode/*-free"], + outcome: "rejected", + stage: "matching", + reasons: [{ code: "excluded-pattern", message: "Excluded by opencode/exo-*" }], + }, + { + id: "opencode/mimo-v2.6-flash-free", + matchedPatterns: ["opencode/*-free"], + outcome: "rejected", + stage: "filtering", + reasons: [ + { code: "requirement-not-met", message: "Does not satisfy minContext>=256000" }, + ], + }, + ], + }, + })); + const harness = createStrictContext({ + explainHandler, + selectReturnValue: "__candidate_3", + }); + const stop = plugin.setup(harness.context); + await harness.activeCommands[0]?.run("explain opencode/zen-plan"); + expect(harness.selectCalls).toHaveLength(1); + const call = harness.selectCalls[0]; + + // Overview: compact title and stage names, preserves status footer + const overview = call?.options.find((o) => o.value === "__overview__"); + expect(overview?.title).toBe("Overview"); + expect(overview?.description).toBe("match: 9 → filter: 9 → select: 1"); + expect(overview?.footer).toBe("active"); + + // Candidates: strip provider prefix for compact title + expect(call?.options.map((o) => o.title)).toEqual([ + "Overview", + "longcat-2.5-preview-free", + "muse-spark-1.3-contributor-free", + "exo-free", + "mimo-v2.6-flash-free", + ]); + + // Candidates: no reason descriptions in rows to avoid horizontal clipping + const candidateOptions = call?.options.filter((o) => o.value !== "__overview__"); + expect(candidateOptions?.every((o) => o.description === undefined)).toBe(true); + + // Candidates: non-rejected rows omit footers; rejected rows show only stage + const selectedOpt = call?.options.find((o) => o.value === "__candidate_0"); + expect(selectedOpt?.footer).toBeUndefined(); + const eligibleOpt = call?.options.find((o) => o.value === "__candidate_1"); + expect(eligibleOpt?.footer).toBeUndefined(); + const rejectedMatching = call?.options.find((o) => o.value === "__candidate_2"); + expect(rejectedMatching?.footer).toBe("matching"); + const rejectedFiltering = call?.options.find((o) => o.value === "__candidate_3"); + expect(rejectedFiltering?.footer).toBe("filtering"); + + // Detail confirm dialog preserves full canonical ID, full reasons, and navigation labels + expect(harness.confirms.at(-1)?.title).toBe("Candidate: opencode/mimo-v2.6-flash-free"); + expect(harness.confirms.at(-1)?.message).toContain( + "✗ opencode/mimo-v2.6-flash-free (rejected)", + ); + expect(harness.confirms.at(-1)?.message).toContain("Does not satisfy minContext>=256000"); + expect(harness.confirms.at(-1)?.label).toEqual({ + confirm: "Back to candidates", + cancel: "Exit", + }); + + await stop?.(); + }); + + it("opens candidate detail or overview confirm dialog when selected from dialog", async () => { + const explainHandler = vi.fn(async () => ({ + status: "active", + explanation: { + alias: "p/alias", + strategy: "latest", + unmatched: 3, + stages: [ + { name: "matching", accepted: 1 }, + { name: "filtering", accepted: 1 }, + { name: "selection", accepted: 1 }, + ], + winner: "p/model", + candidates: [ + { + id: "p/model", + matchedPatterns: ["p/*"], + outcome: "selected", + stage: "selection", + released: 1000, + reasons: [{ code: "newest-release", message: "Newest eligible candidate" }], + }, + ], + }, + })); + const harness = createStrictContext({ explainHandler, selectReturnValue: "__candidate_0" }); + const stop = plugin.setup(harness.context); + await harness.activeCommands[0]?.run("explain p/alias"); + expect(harness.confirms.at(-1)?.title).toBe("Candidate: p/model"); + expect(harness.confirms.at(-1)?.message).toContain("✓ p/model (selected)"); + expect(harness.confirms.at(-1)?.message).toContain("Newest eligible candidate"); + expect(harness.confirms.at(-1)?.label).toEqual({ + confirm: "Back to candidates", + cancel: "Exit", + }); + + harness.setSelectReturn("__overview__"); + await harness.activeCommands[0]?.run("explain p/alias"); + expect(harness.confirms.at(-1)?.title).toBe("Model alias explanation: p/alias"); + expect(harness.confirms.at(-1)?.message).toContain("matching: 1 → filtering: 1 → selection: 1"); + expect(harness.confirms.at(-1)?.message).toContain("Winner: p/model"); + expect(harness.confirms.at(-1)?.label).toEqual({ + confirm: "Back to candidates", + cancel: "Exit", + }); + await stop?.(); + }); + + it("returns to candidate list on Enter/confirm and exits on Escape/cancel", async () => { + const explainHandler = vi.fn(async () => ({ + status: "active", + explanation: { + alias: "p/alias", + strategy: "latest", + unmatched: 1, + stages: [ + { name: "matching", accepted: 2 }, + { name: "filtering", accepted: 2 }, + { name: "selection", accepted: 1 }, + ], + winner: "p/m1", + candidates: [ + { + id: "p/m1", + matchedPatterns: ["p/*"], + outcome: "selected", + stage: "selection", + released: 2000, + reasons: [{ code: "newest-release", message: "Newest eligible candidate" }], + }, + { + id: "p/m2", + matchedPatterns: ["p/*"], + outcome: "eligible", + stage: "selection", + released: 1000, + reasons: [{ code: "older-release", message: "Older release" }], + }, + ], + }, + })); + + // Sequence: + // 1st select: "p/m1" -> confirm returns true (Enter/Back) + // 2nd select: "p/m2" -> confirm returns false (Escape/Exit) + const selectSequence = ["__candidate_0", "__candidate_1"]; + const confirmSequence = [true, false]; + const harness = createStrictContext({ + explainHandler, + selectReturnValue: () => selectSequence.shift(), + confirmReturnValue: () => confirmSequence.shift(), + }); + const stop = plugin.setup(harness.context); + + await harness.activeCommands[0]?.run("explain p/alias"); + + // 2 select calls because 1st confirmed (looped back), and 2nd canceled (exited) + expect(harness.selectCalls).toHaveLength(2); + expect(harness.confirms).toHaveLength(2); + expect(harness.confirms[0]?.title).toBe("Candidate: p/m1"); + expect(harness.confirms[1]?.title).toBe("Candidate: p/m2"); + + await stop?.(); + }); + + it("supports quoted alias names containing spaces and preserves the exact key", async () => { + const explainHandler = vi.fn(async (): Promise => ({ status: "unknown-alias" })); + const location = { directory: "/project" }; + const harness = createStrictContext({ explainHandler, location }); + const stop = plugin.setup(harness.context); + + await harness.activeCommands[0]?.run('explain "p/my alias"'); + expect(explainHandler).toHaveBeenCalledWith({ alias: "p/my alias" }, { location }); + + explainHandler.mockClear(); + await harness.activeCommands[0]?.run('explain "p/inner spaces"'); + expect(explainHandler).toHaveBeenCalledWith({ alias: "p/inner spaces" }, { location }); + + // Unquoted arguments keep the historical single-token form, and a bare + // empty quote is still a usage error. + explainHandler.mockClear(); + await harness.activeCommands[0]?.run("explain p/alias"); + expect(explainHandler).toHaveBeenCalledWith({ alias: "p/alias" }, { location }); + explainHandler.mockClear(); + await harness.activeCommands[0]?.run('explain ""'); + expect(explainHandler).not.toHaveBeenCalled(); + expect(harness.alerts.at(-1)?.message).toMatch(/unexpected arguments/i); + + await stop?.(); + }); + + it("maps colliding escaped candidate IDs to distinct lossless selection values", async () => { + // Both raw IDs ("p/m-" + newline and a literal backslash-u000a) escape to + // the same sanitized display ID, which is all the RPC can deliver. + const escapedID = "p/m-\\u000a"; + const explainHandler = vi.fn(async () => ({ + status: "active", + explanation: { + alias: "p/alias", + strategy: "latest", + unmatched: 0, + stages: [ + { name: "matching", accepted: 2 }, + { name: "filtering", accepted: 2 }, + { name: "selection", accepted: 1 }, + ], + winner: escapedID, + candidates: [ + { + id: escapedID, + matchedPatterns: ["p/*"], + outcome: "eligible", + stage: "selection", + released: 1000, + reasons: [{ code: "older-release", message: "Older release (newline raw ID)" }], + }, + { + id: escapedID, + matchedPatterns: ["p/*"], + outcome: "selected", + stage: "selection", + released: 2000, + reasons: [{ code: "newest-release", message: "Newest eligible candidate" }], + }, + ], + }, + })); + const location = { directory: "/project" }; + const harness = createStrictContext({ + explainHandler, + location, + // Simulate selecting the second (selected-outcome) candidate. + selectReturnValue: () => + harness.selectCalls[0]?.options.find((o) => o.category === "selected")?.value, + }); + const stop = plugin.setup(harness.context); + await harness.activeCommands[0]?.run("explain p/alias"); + + const values = harness.selectCalls[0]?.options.map((o) => o.value) ?? []; + expect(new Set(values).size).toBe(values.length); + expect(values[1]).not.toBe(values[2]); + + // The selected candidate is the selected-outcome one, not the first match + // of the shared display ID. + expect(harness.confirms.at(-1)?.title).toBe(`Candidate: ${escapedID}`); + expect(harness.confirms.at(-1)?.message).toContain("✓ p/m-\\u000a (selected)"); + expect(harness.confirms.at(-1)?.message).toContain("Newest eligible candidate"); + expect(harness.confirms.at(-1)?.message).not.toContain("Older release (newline raw ID)"); + + await stop?.(); + }); + + it("passes the confirmed selection as current when the selector repeats", async () => { + const explainHandler = vi.fn(async () => ({ + status: "active", + explanation: { + alias: "p/alias", + strategy: "latest", + unmatched: 0, + stages: [ + { name: "matching", accepted: 2 }, + { name: "filtering", accepted: 2 }, + { name: "selection", accepted: 1 }, + ], + winner: "p/m1", + candidates: [ + { + id: "p/m1", + matchedPatterns: ["p/*"], + outcome: "selected", + stage: "selection", + released: 2000, + reasons: [{ code: "newest-release", message: "Newest eligible candidate" }], + }, + { + id: "p/m2", + matchedPatterns: ["p/*"], + outcome: "eligible", + stage: "selection", + released: 1000, + reasons: [{ code: "older-release", message: "Older release" }], + }, + ], + }, + })); + const selectSequence = ["__candidate_0", undefined]; + const harness = createStrictContext({ + explainHandler, + confirmReturnValue: true, + selectReturnValue: () => selectSequence.shift(), + }); + const stop = plugin.setup(harness.context); + + await harness.activeCommands[0]?.run("explain p/alias"); + + expect(harness.selectCalls).toHaveLength(2); + expect(harness.selectCalls[0]?.current).toBeUndefined(); + expect(harness.selectCalls[1]?.current).toBe("__candidate_0"); + expect(harness.confirms).toHaveLength(1); + + await stop?.(); + }); + + it("exits on Escape from initial candidate list without opening confirm", async () => { + const explainHandler = vi.fn(async () => ({ + status: "active", + explanation: { + alias: "p/alias", + strategy: "latest", + unmatched: 0, + stages: [{ name: "matching", accepted: 1 }], + winner: "p/m1", + candidates: [ + { + id: "p/m1", + matchedPatterns: ["p/*"], + outcome: "selected", + stage: "selection", + reasons: [], + }, + ], + }, + })); + const harness = createStrictContext({ explainHandler, selectReturnValue: undefined }); + const stop = plugin.setup(harness.context); + + await harness.activeCommands[0]?.run("explain p/alias"); + + expect(harness.selectCalls).toHaveLength(1); + expect(harness.confirms).toHaveLength(0); + expect(harness.alerts).toHaveLength(0); + + await stop?.(); + }); + + it("renders short alert directly when candidate list is empty", async () => { + const explainHandler = vi.fn(async () => ({ + status: "unresolved", + explanation: { + alias: "p/alias", + strategy: "latest", + unmatched: 0, + stages: [{ name: "matching", accepted: 0 }], + failure: { stage: "matching", code: "no-candidates", message: "no candidate matched" }, + candidates: [], + }, + })); + const harness = createStrictContext({ explainHandler }); + const stop = plugin.setup(harness.context); + await harness.activeCommands[0]?.run("explain p/alias"); + expect(harness.selectCalls).toHaveLength(0); + expect(harness.alerts.at(-1)?.title).toBe("Model alias explanation"); + expect(harness.alerts.at(-1)?.message).toContain("Failed at matching: no candidate matched"); + await stop?.(); + }); + + it("rejects invalid syntax locally and handles unknown, unavailable, malformed and failed RPC", async () => { + const explainHandler = vi.fn(async (): Promise => ({ status: "unknown-alias" })); + const harness = createStrictContext({ explainHandler }); + const stop = plugin.setup(harness.context); + for (const input of ["explain", "explain p/a extra", "reload"]) + await harness.activeCommands[0]?.run(input); + expect(explainHandler).not.toHaveBeenCalled(); + await harness.activeCommands[0]?.run("explain p/a"); + expect(harness.alerts.at(-1)?.message).toContain("Unknown alias"); + explainHandler.mockResolvedValueOnce({ status: "unavailable" }); + await harness.activeCommands[0]?.run("explain p/a"); + expect(harness.alerts.at(-1)?.message).toContain("unavailable"); + explainHandler.mockResolvedValueOnce({ status: "active", explanation: {} }); + await harness.activeCommands[0]?.run("explain p/a"); + expect(harness.alerts.at(-1)?.message).toContain("Unable to load"); + explainHandler.mockRejectedValueOnce(new Error("PRIVATE")); + await harness.activeCommands[0]?.run("explain p/a"); + expect(harness.alerts.at(-1)?.message).not.toContain("PRIVATE"); + await stop?.(); + }); +});