Files
Jordan Humberto de Souza 720492d632 fix(opencode): support OpenCode 2.x via native tools and commands (#1213)
* fix(opencode): support OpenCode 2.x via native tools and commands

The single-file plugin (open-code-review.ts) cannot load on OpenCode 2.x:
the 2.x loader requires a default-exported { id, effect | setup } and has
no custom-tool registration API. It also fails on 1.x for most users
because nothing installs the @opencode-ai/plugin dependency the file
imports.

Ship the same ocr_review / ocr_health features as 2.x-native custom
tools plus /ocr-review /ocr-health commands, and document the
@opencode-ai/plugin dependency step for both versions.

* fix(opencode): dual V1+V2 plugin entrypoint instead of separate files

Replace the tools/+commands/ split with the documented dual plugin form:
default-export { ...Plugin.define({ id, setup }), server }. V2 registers
ocr_review/ocr_health via ctx.tool.transform and /ocr-review//ocr-health
via ctx.command.transform, reusing the same OCR logic as V1. Add
@opencode/plugin beta devDependency and a test for the default export.

* fix(opencode): review feedback: typeless V2 import, strict schemas, tests

- Import @opencode/plugin as types only and export a plain dual object,
  so OpenCode 1.x never needs the V2 beta package at runtime (verified
  in the built output: only node:*, @opencode-ai/plugin imports remain).
- V2 numeric inputs now require positive integers
  ({ type: integer, minimum: 1 }), matching the V1 zod schema.
- V2 commands keep the V1 sentence break, skip user-defined names like
  the V1 ??= guards, and resolveSessionCwd falls back to the plugin
  location when the session lookup fails.
- README: single download block, corrected 30-minute tool timeout,
  deduped project section.
- Track package-lock.json (drop the local ignore) so npm ci works.
- Move the V2 stub harness into the test suite (+6 tests, 29 passing).
- Verified live on OpenCode 1.18.30 sandbox: plugin loads with no
  errors, single init across sessions, both commands registered once.
2026-09-14 15:31:42 +08:00

695 lines
21 KiB
JavaScript

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
import assert from "node:assert/strict"
import { access, chmod, copyFile, link, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { delimiter, join } from "node:path"
import test from "node:test"
import { OpenCodeReviewPlugin } from "../dist/open-code-review.js"
async function loadPlugin(worktree) {
const logs = []
const hooks = await OpenCodeReviewPlugin({
client: {
app: {
log: async (entry) => logs.push(entry),
},
},
worktree,
})
return { hooks, logs }
}
async function withTemporaryDirectory(callback) {
const directory = await mkdtemp(join(tmpdir(), "ocr-opencode-test-"))
try {
return await callback(directory)
} finally {
await rm(directory, {
recursive: true,
force: true,
maxRetries: 20,
retryDelay: 50,
})
}
}
async function withFakeOcr(source, callback) {
return await withTemporaryDirectory(async (directory) => {
if (process.platform === "win32") {
const executable = join(directory, "ocr.exe")
try {
await link(process.execPath, executable)
} catch {
await copyFile(process.execPath, executable)
}
for (const command of ["review", "version", "llm"]) {
await writeFile(
join(directory, command),
`process.argv.splice(2, 0, ${JSON.stringify(command)})\n${source}\n`,
)
}
} else {
const executable = join(directory, "ocr")
await writeFile(executable, `#!/usr/bin/env node\n${source}\n`)
await chmod(executable, 0o755)
}
const previousPath = process.env.PATH
process.env.PATH = `${directory}${delimiter}${previousPath ?? ""}`
try {
return await callback(directory)
} finally {
process.env.PATH = previousPath
}
})
}
function toolContext(worktree, signal = new AbortController().signal) {
return {
worktree,
directory: worktree,
abort: signal,
}
}
async function waitForFile(path, timeoutMs = 1_000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
await access(path)
return
} catch {
await new Promise((resolve) => setTimeout(resolve, 10))
}
}
throw new Error(`Timed out waiting for ${path}`)
}
function isProcessRunning(pid) {
try {
process.kill(pid, 0)
return true
} catch (error) {
if (error.code === "ESRCH") return false
throw error
}
}
async function waitForProcessExit(pid, timeoutMs = 5_000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (!isProcessRunning(pid)) return
await new Promise((resolve) => setTimeout(resolve, 25))
}
throw new Error(`Process ${pid} did not exit within ${timeoutMs}ms`)
}
async function withShortOverallTimeout(timeoutMs, callback) {
const originalSetTimeout = globalThis.setTimeout
const overallTimeouts = []
globalThis.setTimeout = (handler, delay, ...args) => {
if (delay >= 60 * 1000) {
overallTimeouts.push(delay)
return originalSetTimeout(handler, timeoutMs, ...args)
}
return originalSetTimeout(handler, delay, ...args)
}
try {
await callback()
return overallTimeouts
} finally {
globalThis.setTimeout = originalSetTimeout
}
}
test("module exposes only one OpenCode plugin entry point", async () => {
const module = await import("../dist/open-code-review.js")
assert.deepEqual(Object.keys(module).sort(), ["OpenCodeReviewPlugin", "default"])
})
test("default export serves both plugin APIs", async () => {
const module = await import("../dist/open-code-review.js")
assert.equal(module.default.id, "open-code-review")
assert.equal(typeof module.default.setup, "function")
assert.equal(module.default.server, module.OpenCodeReviewPlugin)
})
test("plugin registers tools and preserves existing user commands", async () => {
const { hooks, logs } = await loadPlugin("/tmp/project")
assert.deepEqual(Object.keys(hooks.tool).sort(), ["ocr_health", "ocr_review"])
assert.equal(logs.length, 1)
const config = {
command: {
"ocr-review": {
template: "Keep my custom review command.",
},
},
}
await hooks.config(config)
assert.equal(config.command["ocr-review"].template, "Keep my custom review command.")
assert.match(config.command["ocr-health"].template, /ocr_health/)
})
test("plugin still registers tools when the telemetry log call fails", async () => {
const hooks = await OpenCodeReviewPlugin({
client: {
app: {
log: async () => {
throw new Error("log service unavailable")
},
},
},
worktree: "/tmp/project",
})
assert.deepEqual(Object.keys(hooks.tool).sort(), ["ocr_health", "ocr_review"])
})
test("fake OCR helper removes its temporary directory", async () => {
let temporaryDirectory
await withFakeOcr("", async (directory) => {
temporaryDirectory = directory
})
await assert.rejects(access(temporaryDirectory), { code: "ENOENT" })
})
test("ocr_review creates agent-friendly workspace arguments", async () => {
await withFakeOcr(
"console.log(JSON.stringify({status:'success', argv:process.argv.slice(2)}))",
async (worktree) => {
const { hooks } = await loadPlugin(worktree)
const output = await hooks.tool.ocr_review.execute(
{ background: "Add rate limiting", timeoutMinutes: 30 },
toolContext(worktree),
)
assert.deepEqual(JSON.parse(output).argv, [
"review",
"--audience",
"agent",
"--format",
"json",
"--repo",
worktree,
"--background",
"Add rate limiting",
"--timeout",
"30",
])
},
)
})
test("ocr_review passes suspicious-looking refs as one argv value without a shell", async () => {
await withFakeOcr(
"console.log(JSON.stringify({status:'success', argv:process.argv.slice(2)}))",
async (worktree) => {
const { hooks } = await loadPlugin(worktree)
const output = await hooks.tool.ocr_review.execute(
{
commit: "main; touch /tmp/unsafe",
exclude: "**/*.generated.ts,dist/**",
},
toolContext(worktree),
)
assert.deepEqual(JSON.parse(output).argv, [
"review",
"--audience",
"agent",
"--format",
"json",
"--repo",
worktree,
"--commit",
"main; touch /tmp/unsafe",
"--exclude",
"**/*.generated.ts,dist/**",
])
},
)
})
test("ocr_review rejects incompatible review targets before starting OCR", async () => {
await withTemporaryDirectory(async (worktree) => {
const { hooks } = await loadPlugin(worktree)
await assert.rejects(
hooks.tool.ocr_review.execute(
{ commit: "abc", from: "main", to: "feature" },
toolContext(worktree),
),
/either 'commit' or a 'from'\/'to' range/,
)
await assert.rejects(
hooks.tool.ocr_review.execute(
{ from: "main" },
toolContext(worktree),
),
/Both 'from' and 'to'/,
)
await assert.rejects(
hooks.tool.ocr_review.execute(
{ preview: true, resume: "session-1" },
toolContext(worktree),
),
/cannot be used together/,
)
await assert.rejects(
hooks.tool.ocr_review.execute(
{ resume: "session-1", commit: "abc" },
toolContext(worktree),
),
/'resume' cannot be combined/,
)
await assert.rejects(
hooks.tool.ocr_review.execute(
{ resume: "session-1", from: "main", to: "feature" },
toolContext(worktree),
),
/'resume' cannot be combined/,
)
})
})
test("preview omits JSON mode and adds --preview", async () => {
await withFakeOcr(
"console.log(process.argv.slice(2).join('\\n'))",
async (worktree) => {
const { hooks } = await loadPlugin(worktree)
const output = await hooks.tool.ocr_review.execute(
{ preview: true },
toolContext(worktree),
)
assert.deepEqual(output.split("\n"), [
"review",
"--audience",
"agent",
"--repo",
worktree,
"--preview",
])
},
)
})
test("ocr_review reports non-zero exits with OCR output", async () => {
await withFakeOcr(
"console.error('missing credentials'); process.exit(7)",
async (worktree) => {
const { hooks } = await loadPlugin(worktree)
await assert.rejects(
hooks.tool.ocr_review.execute({}, toolContext(worktree)),
(error) => {
assert.equal(error.name, "OcrExecutionError")
assert.equal(error.exitCode, 7)
assert.match(error.message, /missing credentials/)
return true
},
)
},
)
})
test("ocr_review explains how to install a missing OCR executable", async () => {
await withTemporaryDirectory(async (directory) => {
const previousPath = process.env.PATH
process.env.PATH = directory
try {
const { hooks } = await loadPlugin(directory)
await assert.rejects(
hooks.tool.ocr_review.execute({}, toolContext(directory)),
/npm install -g @alibaba-group\/open-code-review/,
)
} finally {
process.env.PATH = previousPath
}
})
})
test("ocr_review terminates when OpenCode cancels the tool", async () => {
await withFakeOcr(
"setInterval(() => {}, 1000)",
async (worktree) => {
const { hooks } = await loadPlugin(worktree)
const controller = new AbortController()
setTimeout(() => controller.abort(), 20)
await assert.rejects(
hooks.tool.ocr_review.execute(
{},
toolContext(worktree, controller.signal),
),
/cancelled by OpenCode/,
)
},
)
})
test("ocr_review force-kills a child that ignores cancellation", async () => {
await withFakeOcr(
[
"require('node:fs').writeFileSync('ocr-child.pid', String(process.pid))",
"process.on('SIGTERM', () => {})",
"setInterval(() => {}, 1000)",
].join("\n"),
async (worktree) => {
const { hooks } = await loadPlugin(worktree)
const controller = new AbortController()
const execution = hooks.tool.ocr_review.execute(
{},
toolContext(worktree, controller.signal),
)
const pidPath = join(worktree, "ocr-child.pid")
await waitForFile(pidPath)
const pid = Number(await readFile(pidPath, "utf8"))
controller.abort()
await assert.rejects(execution, /cancelled by OpenCode/)
try {
await waitForProcessExit(pid)
} finally {
if (isProcessRunning(pid)) {
process.kill(pid, "SIGKILL")
}
}
},
)
})
test("ocr_review kills the whole process group on cancellation", { skip: process.platform === "win32" }, async () => {
const grandchildSource = [
"require('node:fs').writeFileSync('grandchild.pid', String(process.pid))",
"setInterval(() => {}, 1000)",
].join("\n")
await withFakeOcr(
[
"const { spawn } = require('node:child_process')",
`const g = spawn(process.execPath, ['-e', ${JSON.stringify(grandchildSource)}], { stdio: 'ignore' })`,
"g.unref()",
"setInterval(() => {}, 1000)",
].join("\n"),
async (worktree) => {
const { hooks } = await loadPlugin(worktree)
const controller = new AbortController()
const execution = hooks.tool.ocr_review.execute(
{},
toolContext(worktree, controller.signal),
)
const pidPath = join(worktree, "grandchild.pid")
await waitForFile(pidPath)
const pid = Number(await readFile(pidPath, "utf8"))
controller.abort()
await assert.rejects(execution, /cancelled by OpenCode/)
try {
await waitForProcessExit(pid)
} finally {
if (isProcessRunning(pid)) {
process.kill(pid, "SIGKILL")
}
}
},
)
})
test("ocr_review defaults to 30-minute overall timeout", async () => {
await withFakeOcr(
"console.log('{\"status\":\"success\",\"findings\":[]}')",
async (worktree) => {
const { hooks } = await loadPlugin(worktree)
const originalSetTimeout = globalThis.setTimeout
const overallTimeouts = []
globalThis.setTimeout = (handler, delay, ...args) => {
if (delay >= 60 * 1000) {
overallTimeouts.push(delay)
}
return originalSetTimeout(handler, delay, ...args)
}
try {
await hooks.tool.ocr_review.execute({}, toolContext(worktree))
} finally {
globalThis.setTimeout = originalSetTimeout
}
assert.deepEqual(overallTimeouts, [30 * 60 * 1000])
},
)
})
test("ocr_review keeps per-file and overall timeouts independent", async () => {
await withFakeOcr(
"setInterval(() => {}, 1000)",
async (worktree) => {
const { hooks } = await loadPlugin(worktree)
const overallTimeouts = await withShortOverallTimeout(20, async () => {
await assert.rejects(
hooks.tool.ocr_review.execute(
{ timeoutMinutes: 30, overallTimeoutMinutes: 45 },
toolContext(worktree),
),
/timed out after 2700 seconds/,
)
})
assert.deepEqual(overallTimeouts, [45 * 60 * 1000])
},
)
})
test("ocr_review enforces one output limit across stdout and stderr", async () => {
await withFakeOcr(
[
"process.stdout.write('a'.repeat(6 * 1024 * 1024))",
"process.stderr.write('b'.repeat(6 * 1024 * 1024))",
].join("\n"),
async (worktree) => {
const { hooks } = await loadPlugin(worktree)
await assert.rejects(
hooks.tool.ocr_review.execute({}, toolContext(worktree)),
/output exceeded the 10485760-byte safety limit/,
)
},
)
})
test("ocr_review rejects invalid JSON output", async () => {
await withFakeOcr(
"console.log('not json')",
async (worktree) => {
const { hooks } = await loadPlugin(worktree)
await assert.rejects(
hooks.tool.ocr_review.execute({}, toolContext(worktree)),
/invalid JSON/,
)
},
)
})
test("ocr_review reports no output instead of invalid JSON when OCR prints nothing", async () => {
await withFakeOcr("", async (worktree) => {
const { hooks } = await loadPlugin(worktree)
const output = await hooks.tool.ocr_review.execute({}, toolContext(worktree))
assert.match(output, /No changes detected/)
})
})
test("ocr_review preserves valid JSON after validation", async () => {
await withFakeOcr(
"console.log('{\"status\":\"success\",\"findings\":[]}')",
async (worktree) => {
const { hooks } = await loadPlugin(worktree)
const output = await hooks.tool.ocr_review.execute(
{},
toolContext(worktree),
)
assert.equal(output, "{\"status\":\"success\",\"findings\":[]}")
},
)
})
test("ocr_health reports both version success and LLM failure", async () => {
await withFakeOcr(
[
"if (process.argv[2] === 'version') {",
" console.log('OpenCodeReview 1.2.3')",
"} else {",
" console.error('missing LLM credentials')",
" process.exitCode = 7",
"}",
].join("\n"),
async (worktree) => {
const { hooks } = await loadPlugin(worktree)
const output = await hooks.tool.ocr_health.execute(
{},
toolContext(worktree),
)
assert.match(output, /OpenCodeReview 1\.2\.3/)
assert.match(output, /LLM connection check failed: missing LLM credentials/)
},
)
})
test("ocr_health works when no abort signal is provided", async () => {
await withFakeOcr(
[
"if (process.argv[2] === 'version') {",
" console.log('OpenCodeReview 1.2.3')",
"} else {",
" console.error('missing LLM credentials')",
" process.exitCode = 7",
"}",
].join("\n"),
async (worktree) => {
const { hooks } = await loadPlugin(worktree)
const output = await hooks.tool.ocr_health.execute(
{},
{ worktree, directory: worktree },
)
assert.match(output, /OpenCodeReview 1\.2\.3/)
assert.match(output, /LLM connection check failed: missing LLM credentials/)
},
)
})
test("ocr_health preserves OpenCode cancellation", async () => {
await withFakeOcr(
"setInterval(() => {}, 1000)",
async (worktree) => {
const { hooks } = await loadPlugin(worktree)
const controller = new AbortController()
const execution = hooks.tool.ocr_health.execute(
{},
toolContext(worktree, controller.signal),
)
setTimeout(() => controller.abort(), 20)
await assert.rejects(execution, /cancelled by OpenCode/)
},
)
})
function stubV2Context({ directory, commands = [], failSessionLookup = false } = {}) {
const tools = []
const addedCommands = []
const prompts = []
return {
ctx: {
tool: {
transform: async (callback) => callback({
add: (definition) => tools.push(definition),
list: () => [],
get: () => undefined,
update: () => {},
remove: () => {},
namespace: () => {},
}),
reload: async () => {},
},
command: {
list: async () => ({ data: commands.map((name) => ({ name })) }),
transform: async (callback) => callback({
add: (definition) => addedCommands.push(definition),
}),
reload: async () => {},
},
session: {
get: async () => {
if (failSessionLookup) throw new Error("session gone")
return { location: { directory } }
},
prompt: async (input) => {
prompts.push(input)
return {}
},
},
location: { directory },
},
tools,
addedCommands,
prompts,
}
}
function v2ToolContext() {
return {
sessionID: "session-test",
agent: "build",
messageID: "message-test",
id: "call-test",
progress: async () => {},
}
}
test("v2 setup registers both tools and both commands", async () => {
const module = await import("../dist/open-code-review.js")
const { ctx, tools, addedCommands } = stubV2Context({ directory: "/tmp/project" })
await module.default.setup(ctx)
assert.deepEqual(tools.map((definition) => definition.name).sort(), ["ocr_health", "ocr_review"])
assert.deepEqual(addedCommands.map((definition) => definition.name).sort(), ["ocr-health", "ocr-review"])
})
test("v2 numeric inputs require positive integers", async () => {
const module = await import("../dist/open-code-review.js")
const { ctx, tools } = stubV2Context({ directory: "/tmp/project" })
await module.default.setup(ctx)
const review = tools.find((definition) => definition.name === "ocr_review")
for (const field of ["concurrency", "timeoutMinutes", "overallTimeoutMinutes", "maxTools", "maxGitProcesses"]) {
assert.deepEqual(
{ type: review.input.properties[field].type, minimum: review.input.properties[field].minimum },
{ type: "integer", minimum: 1 },
)
}
})
test("v2 preserves user-defined commands", async () => {
const module = await import("../dist/open-code-review.js")
const { ctx, addedCommands } = stubV2Context({ directory: "/tmp/project", commands: ["ocr-review"] })
await module.default.setup(ctx)
assert.deepEqual(addedCommands.map((definition) => definition.name), ["ocr-health"])
})
test("v2 ocr_review preview resolves cwd from the session location", async () => {
await withFakeOcr(
"console.log(process.argv.slice(2).join('\\n'))",
async (worktree) => {
const module = await import("../dist/open-code-review.js")
const { ctx, tools } = stubV2Context({ directory: worktree })
await module.default.setup(ctx)
const review = tools.find((definition) => definition.name === "ocr_review")
const output = await review.execute({ preview: true }, v2ToolContext())
assert.deepEqual(output.content.split("\n"), [
"review",
"--audience",
"agent",
"--repo",
worktree,
"--preview",
])
},
)
})
test("v2 falls back to the plugin location when session lookup fails", async () => {
await withFakeOcr(
"console.log(process.argv.slice(2).join('\\n'))",
async (worktree) => {
const module = await import("../dist/open-code-review.js")
const { ctx, tools } = stubV2Context({ directory: worktree, failSessionLookup: true })
await module.default.setup(ctx)
const review = tools.find((definition) => definition.name === "ocr_review")
const output = await review.execute({ preview: true }, v2ToolContext())
assert.match(output.content, new RegExp(`--repo\n${worktree}\n--preview`))
},
)
})
test("v2 ocr-review command renders review intent with sentence break", async () => {
const module = await import("../dist/open-code-review.js")
const { ctx, addedCommands, prompts } = stubV2Context({ directory: "/tmp/project" })
await module.default.setup(ctx)
const command = addedCommands.find((definition) => definition.name === "ocr-review")
await command.execute({ sessionID: "session-test", prompt: { text: "my staged changes" }, delivery: "steer" })
assert.match(prompts[0].text, /business context:my staged changes\. If no target is specified/)
await command.execute({ sessionID: "session-test", prompt: {}, delivery: "steer" })
assert.match(prompts[1].text, /business context:\. If no target is specified/)
assert.doesNotMatch(prompts[1].text, / /)
})