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"); + }); +});