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
34 changes: 34 additions & 0 deletions plugins/opencode/scripts/lib/render.mjs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand Down
23 changes: 19 additions & 4 deletions plugins/opencode/scripts/opencode-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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,
};
});

Expand Down Expand Up @@ -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,
};
});

Expand Down Expand Up @@ -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
Expand Down
80 changes: 79 additions & 1 deletion tests/render.test.mjs
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});