diff --git a/action.yml b/action.yml index c99d3af..2757ede 100644 --- a/action.yml +++ b/action.yml @@ -18,7 +18,7 @@ runs: # This step assumes this is `node:24-bookworm` container. - name: Install dependencies shell: bash - run: npm install -g @openai/codex @anthropic-ai/claude-code deno + run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai deno # A local action lives inside the writable checkout. Execute a root-owned # copy so an agent changing this repository cannot replace a later phase. - name: Protect action source diff --git a/profiles.yml b/profiles.yml index cf09504..57d8e46 100644 --- a/profiles.yml +++ b/profiles.yml @@ -1,7 +1,10 @@ reviewer-token: v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY profiles: + - type: codex + model: gpt-5.6-terra - type: claude model: claude-sonnet-5 token: v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg - - type: codex - model: gpt-5.6-terra + - type: opencode + model: openrouter/qwen/qwen3.8-27b + token: v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0 diff --git a/src/credentials.ts b/src/credentials.ts index 98aa1da..bed63d6 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -4,6 +4,7 @@ import type { BotProfile } from "./profiles.ts"; const DEFAULT_MODELS: Record = { claude: "claude-sonnet-5", codex: "gpt-5.6-terra", + opencode: "openrouter/qwen/qwen3.8-27b", }; export type BotCredentials = { @@ -110,13 +111,18 @@ export async function parseCredentials( } } const { type, model, token } = profile; - if (type !== "claude" && type !== "codex") { - throw new Error(`${at}.type: expected \"claude\" or \"codex\"`); + if (type !== "claude" && type !== "codex" && type !== "opencode") { + throw new Error( + `${at}.type: expected \"claude\", \"codex\", or \"opencode\"`, + ); } if (model !== undefined && (typeof model !== "string" || model === "")) { throw new Error(`${at}.model: expected a model name`); } - if (type === "claude" && typeof token !== "string") { + if ( + (type === "claude" || type === "opencode") && + typeof token !== "string" + ) { throw new Error(`${at}.token: expected an encrypted credential`); } if (token !== undefined && typeof token !== "string") { diff --git a/src/credentials_test.ts b/src/credentials_test.ts index 9b2907a..62a4241 100644 --- a/src/credentials_test.ts +++ b/src/credentials_test.ts @@ -46,22 +46,29 @@ Deno.test("decrypts the central reviewer token and ordered profiles", async () = ` reviewer-token: ${ENCRYPTED} profiles: - - type: claude - token: ${ENCRYPTED} - type: codex model: gpt-custom + - type: claude + token: ${ENCRYPTED} + - type: opencode + token: ${ENCRYPTED} `, MASTER_KEY, ), { reviewerToken: "secret-token", profiles: [ + { type: "codex", model: "gpt-custom" }, { type: "claude", model: "claude-sonnet-5", token: "secret-token", }, - { type: "codex", model: "gpt-custom" }, + { + type: "opencode", + model: "openrouter/qwen/qwen3.8-27b", + token: "secret-token", + }, ], }, ); diff --git a/src/opencode.ts b/src/opencode.ts new file mode 100644 index 0000000..abae6db --- /dev/null +++ b/src/opencode.ts @@ -0,0 +1,26 @@ +export function openCodeArgs(model: string, prompt: string): string[] { + return [ + "run", + "--format", + "json", + "--auto", + "--model", + model, + prompt, + ]; +} + +export function parseOpenCodeEvents(output: string): string { + let result: string | undefined; + for (const line of output.split("\n")) { + if (line.trim() === "") continue; + const event: unknown = JSON.parse(line); + if (typeof event !== "object" || event === null) continue; + const { type, part } = event as Record; + if (type !== "text" || typeof part !== "object" || part === null) continue; + const text = (part as Record).text; + if (typeof text === "string" && text.trim() !== "") result = text; + } + if (result === undefined) throw new Error("OpenCode produced no text output"); + return result; +} diff --git a/src/opencode_test.ts b/src/opencode_test.ts new file mode 100644 index 0000000..097f9b4 --- /dev/null +++ b/src/opencode_test.ts @@ -0,0 +1,40 @@ +import { assertEquals, assertRejects } from "jsr:@std/assert@1"; +import { openCodeArgs, parseOpenCodeEvents } from "./opencode.ts"; + +Deno.test("builds a non-interactive auto-approved OpenCode command", () => { + assertEquals( + openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this"), + [ + "run", + "--format", + "json", + "--auto", + "--model", + "openrouter/qwen/qwen3.8-27b", + "Review this", + ], + ); +}); + +Deno.test("returns the final visible OpenCode text event", () => { + assertEquals( + parseOpenCodeEvents([ + JSON.stringify({ type: "step_start", part: { type: "step-start" } }), + JSON.stringify({ type: "text", part: { type: "text", text: "draft" } }), + JSON.stringify({ type: "text", part: { type: "text", text: "done" } }), + JSON.stringify({ type: "step_finish", part: { type: "step-finish" } }), + ].join("\n")), + "done", + ); +}); + +Deno.test("rejects a successful OpenCode run without visible text", async () => { + await assertRejects( + async () => { + parseOpenCodeEvents('{"type":"step_start"}'); + await Promise.resolve(); + }, + Error, + "no text output", + ); +}); diff --git a/src/post.ts b/src/post.ts index 9bb4d57..c0d8aaf 100644 --- a/src/post.ts +++ b/src/post.ts @@ -4,6 +4,7 @@ import { type RuntimeManifest, type RuntimeOutcome, } from "./runtime.ts"; +import { withProfile } from "./profiles.ts"; export type PostContext = { apiUrl: string; @@ -61,15 +62,24 @@ async function readOutcome(path: string): Promise { if ( outcome.ok !== true || typeof outcome.body !== "string" || typeof outcome.reviewPath !== "string" || - typeof outcome.anchorsPath !== "string" + typeof outcome.anchorsPath !== "string" || + typeof outcome.profile !== "object" || outcome.profile === null ) { throw new Error("invalid agent outcome"); } + const profile = outcome.profile as Record; + if (typeof profile.type !== "string" || typeof profile.model !== "string") { + throw new Error("invalid agent outcome"); + } return { ok: true, body: outcome.body, reviewPath: outcome.reviewPath, anchorsPath: outcome.anchorsPath, + profile: { + type: profile.type as "claude" | "codex" | "opencode", + model: profile.model, + }, }; } @@ -131,11 +141,15 @@ export async function postRuntime( try { outcome = await readOutcome(manifest.outcomeFile); } catch (error) { - await postComment("AI bot failed."); + await postComment( + "AI bot failed.\n\n> Profile: none · all profiles failed", + ); throw new Error("agent phase failed", { cause: error }); } if (!outcome.ok) { - await postComment("AI bot failed."); + await postComment( + "AI bot failed.\n\n> Profile: none · all profiles failed", + ); throw new Error("agent phase failed"); } @@ -148,7 +162,7 @@ export async function postRuntime( if (context.eventName === "pull_request") { throw new Error("agent did not write a pull request review"); } - await postComment(outcome.body); + await postComment(withProfile(outcome.body, outcome.profile)); return; } @@ -166,11 +180,14 @@ export async function postRuntime( request( `repos/${context.repository}/pulls/${context.issueIndex}/reviews`, { - body: stripAnsi([`${mark} ${verdict.trim()}`, ...rest].join("\n")), + body: stripAnsi(withProfile( + [`${mark} ${verdict.trim()}`, ...rest].join("\n"), + outcome.profile, + )), event, comments: entries.map(({ path, line, side, body }) => ({ path, - body: stripAnsi(body), + body: stripAnsi(withProfile(body, outcome.profile)), new_position: side === "new" ? line : 0, old_position: side === "old" ? line : 0, })), diff --git a/src/post_test.ts b/src/post_test.ts index d3250ab..419f4f9 100644 --- a/src/post_test.ts +++ b/src/post_test.ts @@ -29,6 +29,7 @@ async function fixture( const outcome: RuntimeOutcome = source.ok ? { ...source, + profile: { type: "codex", model: "gpt-test" }, reviewPath: `${profileHome}/review.md`, anchorsPath: `${profileHome}/anchors.json`, } @@ -96,7 +97,9 @@ Deno.test("posts a successful issue result and removes runtime credentials", asy requests[0].url, `${context.apiUrl}/repos/owner/repo/issues/7/comments`, ); - assertEquals(JSON.parse(String(requests[0].init?.body)), { body: "done" }); + assertEquals(JSON.parse(String(requests[0].init?.body)), { + body: "done\n\n> Profile: `codex` · `gpt-test`", + }); assertEquals(requests[0].init?.headers, { Authorization: "token reviewer-secret", "Content-Type": "application/json", @@ -123,7 +126,15 @@ Deno.test("retries a rejected inline review without anchors", async () => { }); assertEquals((bodies[0] as { comments: unknown[] }).comments.length, 1); + assertEquals( + (bodies[0] as { comments: Array<{ body: string }> }).comments[0].body, + "Finding\n\n> Profile: `codex` · `gpt-test`", + ); assertEquals((bodies[1] as { comments: unknown[] }).comments, []); + assertEquals( + (bodies[1] as { body: string }).body, + "✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`", + ); assert(await missing(result.runtimeDir)); }); @@ -141,7 +152,9 @@ Deno.test("reports a generic agent failure and keeps the action failed", async ( "agent phase failed", ); - assertEquals(bodies, [{ body: "AI bot failed." }]); + assertEquals(bodies, [{ + body: "AI bot failed.\n\n> Profile: none · all profiles failed", + }]); assert(!JSON.stringify(bodies).includes("reviewer-secret")); assert(await missing(result.runtimeDir)); }); @@ -161,7 +174,9 @@ Deno.test("reports a generic failure when the runner wrote no outcome", async () "agent phase failed", ); - assertEquals(bodies, [{ body: "AI bot failed." }]); + assertEquals(bodies, [{ + body: "AI bot failed.\n\n> Profile: none · all profiles failed", + }]); assert(await missing(result.runtimeDir)); }); diff --git a/src/profiles.ts b/src/profiles.ts index b164c12..66c2352 100644 --- a/src/profiles.ts +++ b/src/profiles.ts @@ -1,9 +1,22 @@ export type BotProfile = { - type: "claude" | "codex"; + type: "claude" | "codex" | "opencode"; model: string; token?: string; }; +export function profileFooter( + profile: Pick, +): string { + return `> Profile: \`${profile.type}\` · \`${profile.model}\``; +} + +export function withProfile( + body: string, + profile: Pick, +): string { + return `${body}\n\n${profileFooter(profile)}`; +} + function isUsageExhausted(error: unknown): boolean { return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/i .test(String(error)); @@ -75,9 +88,12 @@ export function agentEnvironment( result.HOME = profile.home; if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`; if (token) { - result[ - profile.type === "claude" ? "CLAUDE_CODE_OAUTH_TOKEN" : "OPENAI_API_KEY" - ] = token; + const tokenName = { + claude: "CLAUDE_CODE_OAUTH_TOKEN", + codex: "OPENAI_API_KEY", + opencode: "OPENROUTER_API_KEY", + }[profile.type]; + result[tokenName] = token; } return result; } diff --git a/src/profiles_test.ts b/src/profiles_test.ts index 25cecbd..a87ba25 100644 --- a/src/profiles_test.ts +++ b/src/profiles_test.ts @@ -1,10 +1,18 @@ import { assertEquals, assertRejects } from "jsr:@std/assert@1"; import { agentEnvironment, + profileFooter, runProfiles, sanitizedEnvironment, } from "./profiles.ts"; +Deno.test("formats profile attribution as a Markdown quote", () => { + assertEquals( + profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" }), + "> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`", + ); +}); + Deno.test("falls through only after usage exhaustion", async () => { const visited: string[] = []; const result = await runProfiles( @@ -158,3 +166,21 @@ Deno.test("uses a private Codex home without inventing an API credential", () => CODEX_HOME: "/runtime/profile-1/.codex", }); }); + +Deno.test("exposes an OpenRouter key only to the OpenCode profile", () => { + const profile = { + index: 2, + type: "opencode" as const, + model: "openrouter/qwen/qwen3.8-27b", + uid: 20_002, + gid: 20_002, + home: "/runtime/profile-2", + tokenFile: "/runtime/profile-2/token", + }; + + assertEquals(agentEnvironment({ PATH: "/bin" }, profile, "selected"), { + PATH: "/bin", + HOME: "/runtime/profile-2", + OPENROUTER_API_KEY: "selected", + }); +}); diff --git a/src/run.ts b/src/run.ts index 846f104..2beacc6 100644 --- a/src/run.ts +++ b/src/run.ts @@ -17,7 +17,9 @@ import { agentEnvironment, runProfiles, sanitizedEnvironment, + withProfile, } from "./profiles.ts"; +import { openCodeArgs, parseOpenCodeEvents } from "./opencode.ts"; import { readRuntimeManifest, type RuntimeOutcome, @@ -30,6 +32,7 @@ type AttemptResult = { body: string; reviewPath: string; anchorsPath: string; + profile: Pick; }; const env = (name: string): string => { @@ -123,7 +126,7 @@ async function prepareProfile( ...SKILLS_INSTALL.split(" "), "--global", "--agent", - profile.type === "claude" ? "claude-code" : "codex", + profile.type === "claude" ? "claude-code" : profile.type, "--yes", ], token); } @@ -253,7 +256,7 @@ async function codexDeviceLogin( ]); if (exited) throw new Error("AI bot login produced no instructions"); } - await postAuthorComment(shown); + await postAuthorComment(withProfile(shown, profile)); const [result] = await Promise.all([status, drained]); if (!result.success) { throw new Error(`codex login exited with ${result.code}`); @@ -357,6 +360,30 @@ async function runCodex( return outputPath; } +async function runOpenCode( + profile: RuntimeProfile, + token: string, + prompt: string, +): Promise { + const result = await agentCommand( + profile, + "opencode", + openCodeArgs(profile.model, prompt), + { + cwd: WORKSPACE, + env: agentEnvironment(PROCESS_ENV, profile, token), + clearEnv: true, + stdout: "piped", + stderr: "piped", + }, + ).output(); + const error = new TextDecoder().decode(result.stderr).trim(); + if (!result.success) { + throw new Error(error || `opencode exited with ${result.code}`); + } + return parseOpenCodeEvents(new TextDecoder().decode(result.stdout)); +} + async function runProfile(profile: RuntimeProfile): Promise { const token = await readProfileToken(profile); const authentication = profile.type === "codex" @@ -382,8 +409,10 @@ async function runProfile(profile: RuntimeProfile): Promise { anchorsPath = rendered.anchorsPath; if (profile.type === "claude") { body = await runClaude(profile, token ?? "", rendered.prompt); - } else { + } else if (profile.type === "codex") { resultPath = await runCodex(profile, rendered.prompt, authentication!); + } else { + body = await runOpenCode(profile, token ?? "", rendered.prompt); } } finally { await killProfileProcesses(profile.uid); @@ -397,7 +426,12 @@ async function runProfile(profile: RuntimeProfile): Promise { ) { throw new Error("agent attempt produced no result"); } - return { body, reviewPath, anchorsPath }; + return { + body, + reviewPath, + anchorsPath, + profile: { type: profile.type, model: profile.model }, + }; } async function writeOutcome(outcome: RuntimeOutcome): Promise { diff --git a/src/runtime.ts b/src/runtime.ts index e2bd944..9c7ac24 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -26,6 +26,7 @@ export type RuntimeOutcome = body: string; reviewPath: string; anchorsPath: string; + profile: Pick; } | { ok: false }; diff --git a/src/workflow_test.ts b/src/workflow_test.ts index 28a4e51..8761302 100644 --- a/src/workflow_test.ts +++ b/src/workflow_test.ts @@ -163,3 +163,20 @@ Deno.test("uses the standard Skills installer without custom discovery", async ( assert(error instanceof Deno.errors.NotFound); } }); + +Deno.test("installs the OpenCode CLI", async () => { + const action = await Deno.readTextFile("action.yml"); + assert(action.includes("opencode-ai")); +}); + +Deno.test("central profiles fall back from Codex to Claude to OpenCode", async () => { + const credentials = await yaml("profiles.yml"); + const profiles = sequence(credentials.profiles, "profiles"); + assertEquals(profiles.map((profile) => profile.type), [ + "codex", + "claude", + "opencode", + ]); + assertEquals(profiles[2].model, "openrouter/qwen/qwen3.8-27b"); + assert(String(profiles[2].token).startsWith("v1:")); +});