From ea20f6d629d8315a035357d461d83143a902b929 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Thu, 24 Sep 2026 01:39:49 +0000 Subject: [PATCH] Show the selected bot profile and add OpenRouter fallback (#18) Uses Codex first, then Claude, then OpenCode through OpenRouter when quota is exhausted. Every generated comment and review now identifies the selected profile and model in a final quote. The dependent action interface remains one master key. Verified formatting, lint, type checks, encrypted profile decryption, current OpenCode CLI flags, and 45 tests. Reviewed-on: https://git.capsulizers.com/commons/bot-agents/pulls/18 Reviewed-by: bot Co-authored-by: Danny Kim Co-committed-by: Danny Kim --- action.yml | 2 +- profiles.yml | 7 ++- src/credentials.ts | 12 +++- src/credentials_test.ts | 13 +++- src/opencode.ts | 59 +++++++++++++++++ src/opencode_test.ts | 76 ++++++++++++++++++++++ src/post.ts | 136 ++++++++++++++++++++++++---------------- src/post_test.ts | 64 +++++++++++++++++-- src/profiles.ts | 24 +++++-- src/profiles_test.ts | 26 ++++++++ src/run.ts | 64 ++++++++++++++++--- src/runtime.ts | 1 + src/workflow_test.ts | 23 +++++++ 13 files changed, 429 insertions(+), 78 deletions(-) create mode 100644 src/opencode.ts create mode 100644 src/opencode_test.ts 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..2ee1b99 --- /dev/null +++ b/src/opencode.ts @@ -0,0 +1,59 @@ +export function openCodeArgs(model: string, prompt: string): string[] { + return [ + "run", + "--format", + "json", + "--auto", + "--model", + model, + prompt, + ]; +} + +type ChunkWriter = (chunk: Uint8Array) => void | Promise; + +export async function collectOpenCodeOutput( + stdout: ReadableStream, + stderr: ReadableStream, + writeStdout: ChunkWriter, + writeStderr: ChunkWriter, +): Promise<{ stdout: string; stderr: string }> { + const collect = async ( + stream: ReadableStream, + write: ChunkWriter, + ) => { + const chunks: Uint8Array[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + await write(chunk); + } + const length = chunks.reduce((total, chunk) => total + chunk.length, 0); + const joined = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + joined.set(chunk, offset); + offset += chunk.length; + } + return new TextDecoder().decode(joined); + }; + const [output, error] = await Promise.all([ + collect(stdout, writeStdout), + collect(stderr, writeStderr), + ]); + return { stdout: output, stderr: error }; +} + +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..808fa98 --- /dev/null +++ b/src/opencode_test.ts @@ -0,0 +1,76 @@ +import { assertEquals, assertRejects } from "jsr:@std/assert@1"; +import { + collectOpenCodeOutput, + 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", + ); +}); + +Deno.test("streams OpenCode output while collecting it", async () => { + const encode = (value: string) => new TextEncoder().encode(value); + const stdout = new ReadableStream({ + start(controller) { + controller.enqueue(encode("first\n")); + controller.enqueue(encode("second\n")); + controller.close(); + }, + }); + const stderr = new ReadableStream({ + start(controller) { + controller.enqueue(encode("warning\n")); + controller.close(); + }, + }); + const shown: string[] = []; + + const result = await collectOpenCodeOutput( + stdout, + stderr, + (chunk: Uint8Array) => { + shown.push(`out:${new TextDecoder().decode(chunk)}`); + }, + (chunk: Uint8Array) => { + shown.push(`err:${new TextDecoder().decode(chunk)}`); + }, + ); + + assertEquals(result, { stdout: "first\nsecond\n", stderr: "warning\n" }); + assertEquals(shown, ["out:first\n", "err:warning\n", "out:second\n"]); +}); diff --git a/src/post.ts b/src/post.ts index 9bb4d57..e1e3083 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; @@ -25,6 +26,9 @@ const VERDICTS: Record = { Approved: ["APPROVED", "✅"], "Changes requested": ["REQUEST_CHANGES", "🛑"], }; +const FAILURE = "AI bot failed.\n\n> Profile: none · all profiles failed"; + +class GiteaRequestError extends Error {} function parseAnchors(text: string, sourcePath: string): Anchor[] { const entries: unknown = JSON.parse(text); @@ -61,15 +65,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, + }, }; } @@ -109,16 +122,24 @@ export async function postRuntime( const reviewerToken = (await Deno.readTextFile(manifest.reviewerFile)) .trim(); const request = async (path: string, body: unknown): Promise => { - const response = await fetcher(`${context.apiUrl}/${path}`, { - method: "POST", - headers: { - Authorization: `token ${reviewerToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); + let response: Response; + try { + response = await fetcher(`${context.apiUrl}/${path}`, { + method: "POST", + headers: { + Authorization: `token ${reviewerToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new GiteaRequestError(`${path}: ${detail}`, { cause: error }); + } if (!response.ok) { - throw new Error(`${path}: ${response.status} ${await response.text()}`); + throw new GiteaRequestError( + `${path}: ${response.status} ${await response.text()}`, + ); } }; const postComment = (body: string) => @@ -131,56 +152,65 @@ export async function postRuntime( try { outcome = await readOutcome(manifest.outcomeFile); } catch (error) { - await postComment("AI bot failed."); + await postComment(FAILURE); throw new Error("agent phase failed", { cause: error }); } if (!outcome.ok) { - await postComment("AI bot failed."); + await postComment(FAILURE); throw new Error("agent phase failed"); } - const review = await readAgentArtifact( - manifest, - outcome.reviewPath, - "review.md", - ); - if (review === null) { - if (context.eventName === "pull_request") { - throw new Error("agent did not write a pull request review"); - } - await postComment(outcome.body); - return; - } - - const [verdict, ...rest] = review.split("\n"); - const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"]; - const anchorText = await readAgentArtifact( - manifest, - outcome.anchorsPath, - "anchors.json", - ); - const anchors = anchorText === null - ? [] - : parseAnchors(anchorText, outcome.anchorsPath); - const postReview = (entries: Anchor[]) => - request( - `repos/${context.repository}/pulls/${context.issueIndex}/reviews`, - { - body: stripAnsi([`${mark} ${verdict.trim()}`, ...rest].join("\n")), - event, - comments: entries.map(({ path, line, side, body }) => ({ - path, - body: stripAnsi(body), - new_position: side === "new" ? line : 0, - old_position: side === "old" ? line : 0, - })), - }, + try { + const review = await readAgentArtifact( + manifest, + outcome.reviewPath, + "review.md", ); - await postReview(anchors).catch(async (error: Error) => { - if (anchors.length === 0) throw error; - console.error(`inline comments rejected: ${error.message}`); - await postReview([]); - }); + if (review === null) { + if (context.eventName === "pull_request") { + throw new Error("agent did not write a pull request review"); + } + await postComment(withProfile(outcome.body, outcome.profile)); + return; + } + + const [verdict, ...rest] = review.split("\n"); + const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"]; + const anchorText = await readAgentArtifact( + manifest, + outcome.anchorsPath, + "anchors.json", + ); + const anchors = anchorText === null + ? [] + : parseAnchors(anchorText, outcome.anchorsPath); + const postReview = (entries: Anchor[]) => + request( + `repos/${context.repository}/pulls/${context.issueIndex}/reviews`, + { + body: stripAnsi(withProfile( + [`${mark} ${verdict.trim()}`, ...rest].join("\n"), + outcome.profile, + )), + event, + comments: entries.map(({ path, line, side, body }) => ({ + path, + body: stripAnsi(withProfile(body, outcome.profile)), + new_position: side === "new" ? line : 0, + old_position: side === "old" ? line : 0, + })), + }, + ); + await postReview(anchors).catch(async (error: Error) => { + if (anchors.length === 0) throw error; + console.error(`inline comments rejected: ${error.message}`); + await postReview([]); + }); + } catch (error) { + if (error instanceof GiteaRequestError) throw error; + await postComment(FAILURE); + throw error; + } } finally { await Deno.remove(manifest.runtimeDir, { recursive: true }).catch( (error) => { diff --git a/src/post_test.ts b/src/post_test.ts index d3250ab..13d6874 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)); }); @@ -181,6 +196,24 @@ Deno.test("removes runtime credentials when posting fails", async () => { assert(await missing(result.runtimeDir)); }); +Deno.test("preserves a Gitea transport failure without retrying a comment", async () => { + const result = await fixture({ ok: true, body: "done" }); + let requests = 0; + + await assertRejects( + () => + postRuntime(result.manifestPath, context, () => { + requests++; + return Promise.reject(new Error(`network failure ${requests}`)); + }), + Error, + "network failure 1", + ); + + assertEquals(requests, 1); + assert(await missing(result.runtimeDir)); +}); + Deno.test("refuses to follow an agent artifact symlink", async () => { if (Deno.build.os === "windows") return; const result = await fixture({ ok: true, body: "done" }); @@ -197,6 +230,29 @@ Deno.test("refuses to follow an agent artifact symlink", async () => { "regular private file", ); - assertEquals(requests, 0); + assertEquals(requests, 1); + assert(await missing(result.runtimeDir)); +}); + +Deno.test("reports an attributed failure when a pull request review is missing", async () => { + const result = await fixture({ ok: true, body: "done" }); + const bodies: unknown[] = []; + + await assertRejects( + () => + postRuntime(result.manifestPath, { + ...context, + eventName: "pull_request", + }, (_url, init) => { + bodies.push(JSON.parse(String(init?.body))); + return Promise.resolve(new Response(null, { status: 201 })); + }), + Error, + "did not write", + ); + + 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..3c05576 100644 --- a/src/run.ts +++ b/src/run.ts @@ -17,7 +17,13 @@ import { agentEnvironment, runProfiles, sanitizedEnvironment, + withProfile, } from "./profiles.ts"; +import { + collectOpenCodeOutput, + openCodeArgs, + parseOpenCodeEvents, +} from "./opencode.ts"; import { readRuntimeManifest, type RuntimeOutcome, @@ -30,6 +36,7 @@ type AttemptResult = { body: string; reviewPath: string; anchorsPath: string; + profile: Pick; }; const env = (name: string): string => { @@ -109,7 +116,6 @@ async function runAgentCommand( async function prepareProfile( profile: RuntimeProfile, - token: string | undefined, ): Promise { await runAgentCommand(profile, "git", [ "config", @@ -117,15 +123,15 @@ async function prepareProfile( "--add", "safe.directory", WORKSPACE, - ], token); + ]); await runAgentCommand(profile, "npx", [ "-y", ...SKILLS_INSTALL.split(" "), "--global", "--agent", - profile.type === "claude" ? "claude-code" : "codex", + profile.type === "claude" ? "claude-code" : profile.type, "--yes", - ], token); + ]); } async function renderPrompt( @@ -253,7 +259,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 +363,41 @@ async function runCodex( return outputPath; } +async function runOpenCode( + profile: RuntimeProfile, + token: string, + prompt: string, +): Promise { + const process = agentCommand( + profile, + "opencode", + openCodeArgs(profile.model, prompt), + { + cwd: WORKSPACE, + env: agentEnvironment(PROCESS_ENV, profile, token), + clearEnv: true, + stdout: "piped", + stderr: "piped", + }, + ).spawn(); + const output = collectOpenCodeOutput( + process.stdout, + process.stderr, + async (chunk) => { + await Deno.stdout.write(chunk); + }, + async (chunk) => { + await Deno.stderr.write(chunk); + }, + ); + const [status, streams] = await Promise.all([process.status, output]); + const error = streams.stderr.trim(); + if (!status.success) { + throw new Error(error || `opencode exited with ${status.code}`); + } + return parseOpenCodeEvents(streams.stdout); +} + async function runProfile(profile: RuntimeProfile): Promise { const token = await readProfileToken(profile); const authentication = profile.type === "codex" @@ -376,14 +417,16 @@ async function runProfile(profile: RuntimeProfile): Promise { profile.gid, ); } - await prepareProfile(profile, token); + await prepareProfile(profile); const rendered = await renderPrompt(profile); reviewPath = rendered.reviewPath; 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 +440,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..db4581a 100644 --- a/src/workflow_test.ts +++ b/src/workflow_test.ts @@ -163,3 +163,26 @@ 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("profile preparation does not receive provider credentials", async () => { + const runner = await Deno.readTextFile("src/run.ts"); + assert(runner.includes("await prepareProfile(profile);")); + assertEquals(runner.includes("prepareProfile(profile, token)"), false); +}); + +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:")); +});