From b1387329ba757e753e6de93ed1e1130580126334 Mon Sep 17 00:00:00 2001 From: JohnnyVicious Date: Sun, 12 Apr 2026 15:37:33 +0200 Subject: [PATCH] feat(review): always show the OpenCode model in review output Previously the review output dropped you straight into the verdict with no indication of which model produced it. With OpenCode's default model resolved from `~/.config/opencode/opencode.json` or whatever `/plugin install` defaulted to, it was impossible to tell from the output whether `--model openrouter/foo` actually took effect or whether you were silently getting the default. OpenCode's API already returns the resolved model on every message: POST /session/{id}/message -> { info: { model: { providerID, modelID }, ... }, parts: [...] } This change extracts that and prepends a `**Model:** ...` header to the rendered review output for both `/opencode:review` and `/opencode:adversarial-review`. The header is intentionally outside `renderReview` (the structured-JSON path) so it also shows up when the review comes back as free-text and `tryParseJson` fails. Implementation: * `lib/render.mjs#extractResponseModel(response)` returns `{ providerID, modelID }` or `null`. Defensive against missing `info`, missing `model`, non-string fields, empty strings, and null/undefined input. * `lib/render.mjs#formatModelHeader(model)` returns the markdown header (with trailing blank line so it concatenates cleanly), or empty string when the model is unknown so callers can prepend unconditionally. * Both review handlers extract the model from the response and prepend the header to whatever they were going to render. They also expose the model on the returned `result` object for any future caller that wants it. * 12 new unit tests in `tests/render.test.mjs` covering both helpers: well-formed input, missing/empty/non-string fields, null/undefined. Test count: 62 -> 74. All passing. --- plugins/opencode/scripts/lib/render.mjs | 34 ++++++++ .../opencode/scripts/opencode-companion.mjs | 23 +++++- tests/render.test.mjs | 80 ++++++++++++++++++- 3 files changed, 132 insertions(+), 5 deletions(-) diff --git a/plugins/opencode/scripts/lib/render.mjs b/plugins/opencode/scripts/lib/render.mjs index b91b3a3..843f6a4 100644 --- a/plugins/opencode/scripts/lib/render.mjs +++ b/plugins/opencode/scripts/lib/render.mjs @@ -1,4 +1,9 @@ // Output rendering for the OpenCode companion. +// +// Modified by JohnnyVicious (2026): added `extractResponseModel` and +// `formatModelHeader` so review output always shows which model OpenCode +// used to generate it. (Apache License 2.0 §4(b) modification notice — +// see NOTICE.) /** * Render a status snapshot as human-readable text. @@ -152,6 +157,35 @@ function extractMessageText(msg) { return JSON.stringify(msg); } +/** + * Extract the model that OpenCode used to generate a response. The + * shape is `{ info: { model: { providerID, modelID } } }` for messages + * coming from `POST /session/{id}/message` and `GET /session/{id}/message`. + * Returns null if the response is missing or malformed (e.g. error + * envelopes, the schema-dump bug we hit on `GET /provider`). + * @param {any} response + * @returns {{ providerID: string, modelID: string } | null} + */ +export function extractResponseModel(response) { + const model = response?.info?.model; + if (!model) return null; + if (typeof model.providerID !== "string" || typeof model.modelID !== "string") return null; + if (!model.providerID || !model.modelID) return null; + return { providerID: model.providerID, modelID: model.modelID }; +} + +/** + * Format a model object as a markdown header for prepending to review + * output. Returns an empty string when the model is unknown so callers + * can unconditionally concatenate it. + * @param {{ providerID: string, modelID: string } | null} model + * @returns {string} + */ +export function formatModelHeader(model) { + if (!model) return ""; + return `**Model:** \`${model.providerID}/${model.modelID}\`\n\n`; +} + /** * Render setup status. * @param {object} status diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index 83904f9..2e99b80 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -13,7 +13,10 @@ // - `handleSetup` reads OpenCode's auth.json directly via // `getConfiguredProviders` instead of probing the `GET /provider` HTTP // endpoint, which returns a TypeScript schema dump rather than the -// user's configured credentials. +// user's configured credentials; +// - extract the model OpenCode actually used (from `response.info.model`) +// and prepend it as a `**Model:** ...` header to every review output +// so users always see which model produced the review. // (Apache License 2.0 §4(b) modification notice.) import path from "node:path"; @@ -27,7 +30,14 @@ import { resolveWorkspace } from "./lib/workspace.mjs"; import { loadState, updateState, upsertJob, generateJobId, jobDataPath } from "./lib/state.mjs"; import { buildStatusSnapshot, resolveResultJob, resolveCancelableJob, enrichJob } from "./lib/job-control.mjs"; import { createJobRecord, runTrackedJob, getClaudeSessionId } from "./lib/tracked-jobs.mjs"; -import { renderStatus, renderResult, renderReview, renderSetup } from "./lib/render.mjs"; +import { + renderStatus, + renderResult, + renderReview, + renderSetup, + extractResponseModel, + formatModelHeader, +} from "./lib/render.mjs"; import { buildReviewPrompt, buildTaskPrompt } from "./lib/prompts.mjs"; import { getDiff, getStatus as getGitStatus, detectPrReference } from "./lib/git.mjs"; import { readJson } from "./lib/fs.mjs"; @@ -172,11 +182,13 @@ async function handleReview(argv) { // Try to parse structured output const text = extractResponseText(response); let structured = tryParseJson(text); + const usedModel = extractResponseModel(response); return { - rendered: structured ? renderReview(structured) : text, + rendered: formatModelHeader(usedModel) + (structured ? renderReview(structured) : text), raw: response, structured, + model: usedModel, }; }); @@ -249,11 +261,13 @@ async function handleAdversarialReview(argv) { const text = extractResponseText(response); let structured = tryParseJson(text); + const usedModel = extractResponseModel(response); return { - rendered: structured ? renderReview(structured) : text, + rendered: formatModelHeader(usedModel) + (structured ? renderReview(structured) : text), raw: response, structured, + model: usedModel, }; }); @@ -585,6 +599,7 @@ function extractResponseText(response) { return JSON.stringify(response, null, 2); } + /** * Try to parse a string as JSON, returning null on failure. * @param {string} text diff --git a/tests/render.test.mjs b/tests/render.test.mjs index ab37b43..3a76d81 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -1,6 +1,13 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { renderStatus, renderResult, renderReview, renderSetup } from "../plugins/opencode/scripts/lib/render.mjs"; +import { + renderStatus, + renderResult, + renderReview, + renderSetup, + extractResponseModel, + formatModelHeader, +} from "../plugins/opencode/scripts/lib/render.mjs"; describe("renderStatus", () => { it("renders empty state", () => { @@ -80,3 +87,74 @@ describe("renderResult", () => { assert.ok(output.includes("Connection timeout")); }); }); + +describe("extractResponseModel", () => { + it("returns providerID/modelID for a well-formed response", () => { + const r = { + info: { model: { providerID: "openrouter", modelID: "minimax/minimax-m2.5:free" } }, + parts: [], + }; + assert.deepEqual(extractResponseModel(r), { + providerID: "openrouter", + modelID: "minimax/minimax-m2.5:free", + }); + }); + + it("returns null when info is missing", () => { + assert.equal(extractResponseModel({ parts: [] }), null); + }); + + it("returns null when info.model is missing", () => { + assert.equal(extractResponseModel({ info: { role: "assistant" } }), null); + }); + + it("returns null when providerID is missing", () => { + assert.equal(extractResponseModel({ info: { model: { modelID: "x" } } }), null); + }); + + it("returns null when modelID is missing", () => { + assert.equal(extractResponseModel({ info: { model: { providerID: "x" } } }), null); + }); + + it("returns null when providerID/modelID are empty strings", () => { + assert.equal( + extractResponseModel({ info: { model: { providerID: "", modelID: "" } } }), + null + ); + }); + + it("returns null when providerID/modelID are not strings", () => { + assert.equal( + extractResponseModel({ info: { model: { providerID: 1, modelID: 2 } } }), + null + ); + }); + + it("returns null for null/undefined input", () => { + assert.equal(extractResponseModel(null), null); + assert.equal(extractResponseModel(undefined), null); + }); +}); + +describe("formatModelHeader", () => { + it("formats a model as a markdown header", () => { + const out = formatModelHeader({ + providerID: "openrouter", + modelID: "minimax/minimax-m2.5:free", + }); + assert.equal(out, "**Model:** `openrouter/minimax/minimax-m2.5:free`\n\n"); + }); + + it("returns empty string when model is null", () => { + assert.equal(formatModelHeader(null), ""); + }); + + it("returns empty string when model is undefined", () => { + assert.equal(formatModelHeader(undefined), ""); + }); + + it("output ends with a blank line so it can be safely concatenated", () => { + const header = formatModelHeader({ providerID: "x", modelID: "y" }); + assert.ok(header.endsWith("\n\n"), "expected trailing blank line"); + }); +});