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
79 changes: 79 additions & 0 deletions plugins/opencode/prompts/review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<role>
You are OpenCode performing a balanced, high-signal software review.
Your job is to find real bugs, correctness issues, and regressions in the change.
</role>

<task>
Review the provided repository context for bugs, correctness issues, regressions, and quality problems.
Target: {{TARGET_LABEL}}
User focus: {{USER_FOCUS}}
</task>

<operating_stance>
Be honest, calibrated, and thorough.
Flag what matters. Acknowledge what looks correct.
Do not manufacture skepticism or invent problems where none exist.
If the change is solid, say so clearly.
</operating_stance>

<focus_areas>
Prioritize findings that affect correctness, reliability, or security:
- logic errors, off-by-one mistakes, and incorrect control flow
- broken contracts, type mismatches, and violated invariants
- resource leaks, missing cleanup, and unhandled error paths
- race conditions, ordering assumptions, and stale-state bugs
- data loss, corruption, duplication, or irreversible state changes
- regressions that break existing behavior or compatibility
- missing tests or verification for critical paths
- unclear or misleading code that hides a real defect
</focus_areas>

<review_method>
Read each changed file carefully.
Trace the data flow and control flow through the change.
Check edge cases: empty input, null values, timeouts, failures in dependencies.
If the user supplied a focus area, weight it, but still review the full change.
{{REVIEW_COLLECTION_GUIDANCE}}
</review_method>

<finding_bar>
Report findings with severity and concrete evidence.
Each finding must include:
1. What is the defect?
2. Where is it (file and line range)?
3. What is the impact?
4. How can it be fixed?
Skip style nits, naming preferences, and opinion-only feedback.
Do not report issues you cannot support from the code.
</finding_bar>

<structured_output_contract>
Return only valid JSON matching the provided schema.
Keep the output compact and specific.
Use `needs-attention` for any material finding.
Use `approve` only if the change looks correct and safe.
Every finding must include:
- the affected file
- `line_start` and `line_end`
- a confidence score from 0 to 1
- a concrete recommendation
Write the summary as a terse, actionable assessment.
</structured_output_contract>

<calibration_rules>
Report the strongest findings first.
Group related issues rather than splitting them.
If the change is clean, say so directly and return no findings.
</calibration_rules>

<final_check>
Before finalizing, check that each finding is:
- a real defect, not a style preference
- tied to a concrete code location
- supported by evidence in the provided context
- actionable for an engineer fixing the issue
</final_check>

<repository_context>
{{REVIEW_INPUT}}
</repository_context>
37 changes: 37 additions & 0 deletions plugins/opencode/scripts/lib/opencode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1220,6 +1220,29 @@ export function getSessionRuntimeStatus(env = process.env, cwd = process.cwd())
};
}

function extractConnectedProviderIds(provider) {
if (!provider || typeof provider !== "object") {
return [];
}
// The 1.17.15 /provider response carries connected provider ids in
// `connected`; tolerate id-object entries but no invented field aliases.
const connected = provider.connected ?? [];
if (!Array.isArray(connected)) {
return [];
}
return connected
.map((entry) => {
if (typeof entry === "string") {
return entry;
}
if (entry && typeof entry === "object" && typeof entry.id === "string") {
return entry.id;
}
return null;
})
.filter(Boolean);
}

export async function getAuthStatus(cwd) {
const availability = getAvailability(cwd);
if (!availability.available) {
Expand All @@ -1239,6 +1262,20 @@ export async function getAuthStatus(cwd) {
if (config?.error && provider?.error) {
throw config.error;
}

const connectedProviders = extractConnectedProviderIds(provider);
const isConnected = connectedProviders.length > 0;

if (!isConnected) {
return buildAuthStatus({
loggedIn: false,
detail: "No OpenCode provider is connected. Configure and connect an OpenCode provider, then rerun /opencode:setup.",
source: "server",
available: true,
provider: null
});
}

const providerID =
provider?.id ??
provider?.providerID ??
Expand Down
25 changes: 19 additions & 6 deletions plugins/opencode/scripts/opencode-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -238,10 +238,10 @@ async function handleSetup(argv) {
outputResult(options.json ? finalReport : renderSetupReport(finalReport), options.json);
}

function buildAdversarialReviewPrompt(context, focusText) {
const template = loadPromptTemplate(ROOT_DIR, "adversarial-review");
function buildReviewPrompt(context, focusText, { templateName, reviewKind }) {
const template = loadPromptTemplate(ROOT_DIR, templateName);
return interpolateTemplate(template, {
REVIEW_KIND: "Adversarial Review",
REVIEW_KIND: reviewKind,
TARGET_LABEL: context.target.label,
USER_FOCUS: focusText || "No extra focus provided.",
REVIEW_COLLECTION_GUIDANCE: context.collectionGuidance,
Expand Down Expand Up @@ -339,7 +339,10 @@ async function executeReviewRun(request) {
const reviewName = request.reviewName ?? "Review";

const context = collectReviewContext(request.cwd, target);
const prompt = buildAdversarialReviewPrompt(context, focusText);
const prompt = buildReviewPrompt(context, focusText, {
templateName: request.promptTemplate ?? "adversarial-review",
reviewKind: reviewName
});
const result = await runServerReview(context.repoRoot, {
prompt,
model: request.model,
Expand Down Expand Up @@ -746,6 +749,7 @@ async function handleReviewCommand(argv, config) {
model: options.model,
focusText,
reviewName: config.reviewName,
promptTemplate: config.promptTemplate,
onProgress: progress
}),
{ json: options.json }
Expand All @@ -754,7 +758,15 @@ async function handleReviewCommand(argv, config) {

async function handleReview(argv) {
return handleReviewCommand(argv, {
reviewName: "Review"
reviewName: "Review",
promptTemplate: "review",
validateRequest: (target, focusText) => {
if (focusText) {
throw new Error(
"/opencode:review does not accept positional focus text. Use /opencode:adversarial-review for a steerable challenge review with custom focus."
);
}
}
});
}

Expand Down Expand Up @@ -1167,7 +1179,8 @@ async function main() {
break;
case "adversarial-review":
await handleReviewCommand(argv, {
reviewName: "Adversarial Review"
reviewName: "Adversarial Review",
promptTemplate: "adversarial-review"
});
break;
case "task":
Expand Down
10 changes: 9 additions & 1 deletion tests/fake-opencode-fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,15 @@ const server = http.createServer(async (req, res) => {
}

if (req.method === "GET" && url.pathname === "/provider") {
sendJson(res, { id: "openai", name: "OpenAI" });
if (process.env.FAKE_OPENCODE_PROVIDER_FAIL === "1") {
sendJson(res, { error: "provider endpoint failed" }, 500);
return;
}
if (process.env.FAKE_OPENCODE_NO_PROVIDER === "1") {
sendJson(res, { all: [], default: {}, connected: [] });
} else {
sendJson(res, { all: [{ id: "openai" }], default: {}, connected: ["openai"] });
}
return;
}

Expand Down
123 changes: 123 additions & 0 deletions tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1385,3 +1385,126 @@ test("runServerTurn deletes only sessions created by a failed captureTurn", asyn
resumedFetch.restore();
}
});

test("adversarial-review prompt uses the adversarial-review.md template", { skip: LOCAL_LISTEN_SKIP }, () => {
const repo = makeTempDir();
const binDir = makeTempDir();
installFakeOpencode(binDir);
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "before\n");
run("git", ["add", "README.md"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });
fs.writeFileSync(path.join(repo, "README.md"), "after\n");
const env = buildTestEnv(binDir);

try {
const result = run("node", [SCRIPT, "adversarial-review", "--json"], {
cwd: repo,
env
});

assert.equal(result.status, 0, result.stderr);
const fakeState = readFakeState(binDir);
const prompt = fakeState.lastMessage.prompt;
assert.match(prompt, /adversarial software review/);
assert.match(prompt, /break confidence/);
assert.match(prompt, /Default to skepticism/);
} finally {
cleanupServer(repo, env);
}
});

test("review prompt uses the neutral review.md template", { skip: LOCAL_LISTEN_SKIP }, () => {
const repo = makeTempDir();
const binDir = makeTempDir();
installFakeOpencode(binDir);
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "before\n");
run("git", ["add", "README.md"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });
fs.writeFileSync(path.join(repo, "README.md"), "after\n");
const env = buildTestEnv(binDir);

try {
const result = run("node", [SCRIPT, "review", "--json"], {
cwd: repo,
env
});

assert.equal(result.status, 0, result.stderr);
const fakeState = readFakeState(binDir);
const prompt = fakeState.lastMessage.prompt;
assert.doesNotMatch(prompt, /break confidence/);
assert.doesNotMatch(prompt, /Default to skepticism/);
assert.match(prompt, /balanced, high-signal software review/);
assert.match(prompt, /find real bugs, correctness issues/);
} finally {
cleanupServer(repo, env);
}
});

test("review rejects positional focus text with a clear error", () => {
const repo = makeTempDir();
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "test\n");
run("git", ["add", "README.md"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });

const result = run("node", [SCRIPT, "review", "check auth paths"], {
cwd: repo,
env: process.env
});

assert.notEqual(result.status, 0, result.stdout);
assert.match(result.stderr, /does not accept positional focus text/);
assert.match(result.stderr, /adversarial-review/);
});

test("setup reports not ready when no provider is connected", { skip: LOCAL_LISTEN_SKIP }, () => {
const repo = makeTempDir();
const binDir = makeTempDir();
installFakeOpencode(binDir);
const env = buildTestEnv(binDir, {
FAKE_OPENCODE_NO_PROVIDER: "1"
});

try {
const result = run("node", [SCRIPT, "setup", "--json"], {
cwd: repo,
env
});

assert.equal(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.equal(payload.ready, false);
assert.equal(payload.opencode.available, true);
assert.equal(payload.auth.loggedIn, false);
assert.match(payload.auth.detail, /No OpenCode provider is connected/);
assert.ok(payload.nextSteps.some((step) => /provider/i.test(step)), "nextSteps should include a provider config step");
} finally {
cleanupServer(repo, env);
}
});

test("setup reports loggedIn false when /provider endpoint fails", { skip: LOCAL_LISTEN_SKIP }, () => {
const repo = makeTempDir();
const binDir = makeTempDir();
installFakeOpencode(binDir);
const env = buildTestEnv(binDir, {
FAKE_OPENCODE_PROVIDER_FAIL: "1"
});

try {
const result = run("node", [SCRIPT, "setup", "--json"], {
cwd: repo,
env
});

assert.equal(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.equal(payload.auth.loggedIn, false);
assert.equal(payload.ready, false);
} finally {
cleanupServer(repo, env);
}
});