1 Commits

Author SHA1 Message Date
Danny Kim 41f7d6e64f Show profile and add OpenRouter fallback
Check / deno (pull_request) Successful in 1m24s
2026-09-24 10:23:30 +09:00
13 changed files with 429 additions and 78 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ runs:
# This step assumes this is `node:24-bookworm` container. # This step assumes this is `node:24-bookworm` container.
- name: Install dependencies - name: Install dependencies
shell: bash 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 # A local action lives inside the writable checkout. Execute a root-owned
# copy so an agent changing this repository cannot replace a later phase. # copy so an agent changing this repository cannot replace a later phase.
- name: Protect action source - name: Protect action source
+5 -2
View File
@@ -1,7 +1,10 @@
reviewer-token: v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY reviewer-token: v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY
profiles: profiles:
- type: codex
model: gpt-5.6-terra
- type: claude - type: claude
model: claude-sonnet-5 model: claude-sonnet-5
token: v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg token: v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg
- type: codex - type: opencode
model: gpt-5.6-terra model: openrouter/qwen/qwen3.8-27b
token: v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0
+9 -3
View File
@@ -4,6 +4,7 @@ import type { BotProfile } from "./profiles.ts";
const DEFAULT_MODELS: Record<BotProfile["type"], string> = { const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
claude: "claude-sonnet-5", claude: "claude-sonnet-5",
codex: "gpt-5.6-terra", codex: "gpt-5.6-terra",
opencode: "openrouter/qwen/qwen3.8-27b",
}; };
export type BotCredentials = { export type BotCredentials = {
@@ -110,13 +111,18 @@ export async function parseCredentials(
} }
} }
const { type, model, token } = profile; const { type, model, token } = profile;
if (type !== "claude" && type !== "codex") { if (type !== "claude" && type !== "codex" && type !== "opencode") {
throw new Error(`${at}.type: expected \"claude\" or \"codex\"`); throw new Error(
`${at}.type: expected \"claude\", \"codex\", or \"opencode\"`,
);
} }
if (model !== undefined && (typeof model !== "string" || model === "")) { if (model !== undefined && (typeof model !== "string" || model === "")) {
throw new Error(`${at}.model: expected a model name`); 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`); throw new Error(`${at}.token: expected an encrypted credential`);
} }
if (token !== undefined && typeof token !== "string") { if (token !== undefined && typeof token !== "string") {
+10 -3
View File
@@ -46,22 +46,29 @@ Deno.test("decrypts the central reviewer token and ordered profiles", async () =
` `
reviewer-token: ${ENCRYPTED} reviewer-token: ${ENCRYPTED}
profiles: profiles:
- type: claude
token: ${ENCRYPTED}
- type: codex - type: codex
model: gpt-custom model: gpt-custom
- type: claude
token: ${ENCRYPTED}
- type: opencode
token: ${ENCRYPTED}
`, `,
MASTER_KEY, MASTER_KEY,
), ),
{ {
reviewerToken: "secret-token", reviewerToken: "secret-token",
profiles: [ profiles: [
{ type: "codex", model: "gpt-custom" },
{ {
type: "claude", type: "claude",
model: "claude-sonnet-5", model: "claude-sonnet-5",
token: "secret-token", token: "secret-token",
}, },
{ type: "codex", model: "gpt-custom" }, {
type: "opencode",
model: "openrouter/qwen/qwen3.8-27b",
token: "secret-token",
},
], ],
}, },
); );
+59
View File
@@ -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<void>;
export async function collectOpenCodeOutput(
stdout: ReadableStream<Uint8Array>,
stderr: ReadableStream<Uint8Array>,
writeStdout: ChunkWriter,
writeStderr: ChunkWriter,
): Promise<{ stdout: string; stderr: string }> {
const collect = async (
stream: ReadableStream<Uint8Array>,
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<string, unknown>;
if (type !== "text" || typeof part !== "object" || part === null) continue;
const text = (part as Record<string, unknown>).text;
if (typeof text === "string" && text.trim() !== "") result = text;
}
if (result === undefined) throw new Error("OpenCode produced no text output");
return result;
}
+76
View File
@@ -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<Uint8Array>({
start(controller) {
controller.enqueue(encode("first\n"));
controller.enqueue(encode("second\n"));
controller.close();
},
});
const stderr = new ReadableStream<Uint8Array>({
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"]);
});
+83 -53
View File
@@ -4,6 +4,7 @@ import {
type RuntimeManifest, type RuntimeManifest,
type RuntimeOutcome, type RuntimeOutcome,
} from "./runtime.ts"; } from "./runtime.ts";
import { withProfile } from "./profiles.ts";
export type PostContext = { export type PostContext = {
apiUrl: string; apiUrl: string;
@@ -25,6 +26,9 @@ const VERDICTS: Record<string, [event: string, mark: string]> = {
Approved: ["APPROVED", "✅"], Approved: ["APPROVED", "✅"],
"Changes requested": ["REQUEST_CHANGES", "🛑"], "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[] { function parseAnchors(text: string, sourcePath: string): Anchor[] {
const entries: unknown = JSON.parse(text); const entries: unknown = JSON.parse(text);
@@ -61,15 +65,24 @@ async function readOutcome(path: string): Promise<RuntimeOutcome> {
if ( if (
outcome.ok !== true || typeof outcome.body !== "string" || outcome.ok !== true || typeof outcome.body !== "string" ||
typeof outcome.reviewPath !== "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"); throw new Error("invalid agent outcome");
} }
const profile = outcome.profile as Record<string, unknown>;
if (typeof profile.type !== "string" || typeof profile.model !== "string") {
throw new Error("invalid agent outcome");
}
return { return {
ok: true, ok: true,
body: outcome.body, body: outcome.body,
reviewPath: outcome.reviewPath, reviewPath: outcome.reviewPath,
anchorsPath: outcome.anchorsPath, 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)) const reviewerToken = (await Deno.readTextFile(manifest.reviewerFile))
.trim(); .trim();
const request = async (path: string, body: unknown): Promise<void> => { const request = async (path: string, body: unknown): Promise<void> => {
const response = await fetcher(`${context.apiUrl}/${path}`, { let response: Response;
method: "POST", try {
headers: { response = await fetcher(`${context.apiUrl}/${path}`, {
Authorization: `token ${reviewerToken}`, method: "POST",
"Content-Type": "application/json", headers: {
}, Authorization: `token ${reviewerToken}`,
body: JSON.stringify(body), "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) { 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) => const postComment = (body: string) =>
@@ -131,56 +152,65 @@ export async function postRuntime(
try { try {
outcome = await readOutcome(manifest.outcomeFile); outcome = await readOutcome(manifest.outcomeFile);
} catch (error) { } catch (error) {
await postComment("AI bot failed."); await postComment(FAILURE);
throw new Error("agent phase failed", { cause: error }); throw new Error("agent phase failed", { cause: error });
} }
if (!outcome.ok) { if (!outcome.ok) {
await postComment("AI bot failed."); await postComment(FAILURE);
throw new Error("agent phase failed"); throw new Error("agent phase failed");
} }
const review = await readAgentArtifact( try {
manifest, const review = await readAgentArtifact(
outcome.reviewPath, manifest,
"review.md", 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,
})),
},
); );
await postReview(anchors).catch(async (error: Error) => { if (review === null) {
if (anchors.length === 0) throw error; if (context.eventName === "pull_request") {
console.error(`inline comments rejected: ${error.message}`); throw new Error("agent did not write a pull request review");
await postReview([]); }
}); 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 { } finally {
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch( await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(
(error) => { (error) => {
+60 -4
View File
@@ -29,6 +29,7 @@ async function fixture(
const outcome: RuntimeOutcome = source.ok const outcome: RuntimeOutcome = source.ok
? { ? {
...source, ...source,
profile: { type: "codex", model: "gpt-test" },
reviewPath: `${profileHome}/review.md`, reviewPath: `${profileHome}/review.md`,
anchorsPath: `${profileHome}/anchors.json`, anchorsPath: `${profileHome}/anchors.json`,
} }
@@ -96,7 +97,9 @@ Deno.test("posts a successful issue result and removes runtime credentials", asy
requests[0].url, requests[0].url,
`${context.apiUrl}/repos/owner/repo/issues/7/comments`, `${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, { assertEquals(requests[0].init?.headers, {
Authorization: "token reviewer-secret", Authorization: "token reviewer-secret",
"Content-Type": "application/json", "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: 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 { comments: unknown[] }).comments, []);
assertEquals(
(bodies[1] as { body: string }).body,
"✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`",
);
assert(await missing(result.runtimeDir)); 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", "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(!JSON.stringify(bodies).includes("reviewer-secret"));
assert(await missing(result.runtimeDir)); 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", "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)); assert(await missing(result.runtimeDir));
}); });
@@ -181,6 +196,24 @@ Deno.test("removes runtime credentials when posting fails", async () => {
assert(await missing(result.runtimeDir)); 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 () => { Deno.test("refuses to follow an agent artifact symlink", async () => {
if (Deno.build.os === "windows") return; if (Deno.build.os === "windows") return;
const result = await fixture({ ok: true, body: "done" }); 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", "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)); assert(await missing(result.runtimeDir));
}); });
+20 -4
View File
@@ -1,9 +1,22 @@
export type BotProfile = { export type BotProfile = {
type: "claude" | "codex"; type: "claude" | "codex" | "opencode";
model: string; model: string;
token?: string; token?: string;
}; };
export function profileFooter(
profile: Pick<BotProfile, "type" | "model">,
): string {
return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
}
export function withProfile(
body: string,
profile: Pick<BotProfile, "type" | "model">,
): string {
return `${body}\n\n${profileFooter(profile)}`;
}
function isUsageExhausted(error: unknown): boolean { 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 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)); .test(String(error));
@@ -75,9 +88,12 @@ export function agentEnvironment(
result.HOME = profile.home; result.HOME = profile.home;
if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`; if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`;
if (token) { if (token) {
result[ const tokenName = {
profile.type === "claude" ? "CLAUDE_CODE_OAUTH_TOKEN" : "OPENAI_API_KEY" claude: "CLAUDE_CODE_OAUTH_TOKEN",
] = token; codex: "OPENAI_API_KEY",
opencode: "OPENROUTER_API_KEY",
}[profile.type];
result[tokenName] = token;
} }
return result; return result;
} }
+26
View File
@@ -1,10 +1,18 @@
import { assertEquals, assertRejects } from "jsr:@std/assert@1"; import { assertEquals, assertRejects } from "jsr:@std/assert@1";
import { import {
agentEnvironment, agentEnvironment,
profileFooter,
runProfiles, runProfiles,
sanitizedEnvironment, sanitizedEnvironment,
} from "./profiles.ts"; } 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 () => { Deno.test("falls through only after usage exhaustion", async () => {
const visited: string[] = []; const visited: string[] = [];
const result = await runProfiles( 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", 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",
});
});
+56 -8
View File
@@ -17,7 +17,13 @@ import {
agentEnvironment, agentEnvironment,
runProfiles, runProfiles,
sanitizedEnvironment, sanitizedEnvironment,
withProfile,
} from "./profiles.ts"; } from "./profiles.ts";
import {
collectOpenCodeOutput,
openCodeArgs,
parseOpenCodeEvents,
} from "./opencode.ts";
import { import {
readRuntimeManifest, readRuntimeManifest,
type RuntimeOutcome, type RuntimeOutcome,
@@ -30,6 +36,7 @@ type AttemptResult = {
body: string; body: string;
reviewPath: string; reviewPath: string;
anchorsPath: string; anchorsPath: string;
profile: Pick<RuntimeProfile, "type" | "model">;
}; };
const env = (name: string): string => { const env = (name: string): string => {
@@ -109,7 +116,6 @@ async function runAgentCommand(
async function prepareProfile( async function prepareProfile(
profile: RuntimeProfile, profile: RuntimeProfile,
token: string | undefined,
): Promise<void> { ): Promise<void> {
await runAgentCommand(profile, "git", [ await runAgentCommand(profile, "git", [
"config", "config",
@@ -117,15 +123,15 @@ async function prepareProfile(
"--add", "--add",
"safe.directory", "safe.directory",
WORKSPACE, WORKSPACE,
], token); ]);
await runAgentCommand(profile, "npx", [ await runAgentCommand(profile, "npx", [
"-y", "-y",
...SKILLS_INSTALL.split(" "), ...SKILLS_INSTALL.split(" "),
"--global", "--global",
"--agent", "--agent",
profile.type === "claude" ? "claude-code" : "codex", profile.type === "claude" ? "claude-code" : profile.type,
"--yes", "--yes",
], token); ]);
} }
async function renderPrompt( async function renderPrompt(
@@ -253,7 +259,7 @@ async function codexDeviceLogin(
]); ]);
if (exited) throw new Error("AI bot login produced no instructions"); 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]); const [result] = await Promise.all([status, drained]);
if (!result.success) { if (!result.success) {
throw new Error(`codex login exited with ${result.code}`); throw new Error(`codex login exited with ${result.code}`);
@@ -357,6 +363,41 @@ async function runCodex(
return outputPath; return outputPath;
} }
async function runOpenCode(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
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<AttemptResult> { async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
const token = await readProfileToken(profile); const token = await readProfileToken(profile);
const authentication = profile.type === "codex" const authentication = profile.type === "codex"
@@ -376,14 +417,16 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
profile.gid, profile.gid,
); );
} }
await prepareProfile(profile, token); await prepareProfile(profile);
const rendered = await renderPrompt(profile); const rendered = await renderPrompt(profile);
reviewPath = rendered.reviewPath; reviewPath = rendered.reviewPath;
anchorsPath = rendered.anchorsPath; anchorsPath = rendered.anchorsPath;
if (profile.type === "claude") { if (profile.type === "claude") {
body = await runClaude(profile, token ?? "", rendered.prompt); body = await runClaude(profile, token ?? "", rendered.prompt);
} else { } else if (profile.type === "codex") {
resultPath = await runCodex(profile, rendered.prompt, authentication!); resultPath = await runCodex(profile, rendered.prompt, authentication!);
} else {
body = await runOpenCode(profile, token ?? "", rendered.prompt);
} }
} finally { } finally {
await killProfileProcesses(profile.uid); await killProfileProcesses(profile.uid);
@@ -397,7 +440,12 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
) { ) {
throw new Error("agent attempt produced no result"); 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<void> { async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
+1
View File
@@ -26,6 +26,7 @@ export type RuntimeOutcome =
body: string; body: string;
reviewPath: string; reviewPath: string;
anchorsPath: string; anchorsPath: string;
profile: Pick<RuntimeProfile, "type" | "model">;
} }
| { ok: false }; | { ok: false };
+23
View File
@@ -163,3 +163,26 @@ Deno.test("uses the standard Skills installer without custom discovery", async (
assert(error instanceof Deno.errors.NotFound); 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:"));
});