1 Commits

Author SHA1 Message Date
Danny Kim 54f704b660 Show profile and add OpenRouter fallback
Agents / agent (pull_request) Has been cancelled
Check / deno (pull_request) Successful in 1m30s
2026-09-24 10:18:11 +09:00
6 changed files with 63 additions and 182 deletions
-33
View File
@@ -10,39 +10,6 @@ export function openCodeArgs(model: string, prompt: string): string[] {
];
}
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")) {
+1 -37
View File
@@ -1,9 +1,5 @@
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
import {
collectOpenCodeOutput,
openCodeArgs,
parseOpenCodeEvents,
} from "./opencode.ts";
import { openCodeArgs, parseOpenCodeEvents } from "./opencode.ts";
Deno.test("builds a non-interactive auto-approved OpenCode command", () => {
assertEquals(
@@ -42,35 +38,3 @@ Deno.test("rejects a successful OpenCode run without visible text", async () =>
"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"]);
});
+50 -57
View File
@@ -26,9 +26,6 @@ const VERDICTS: Record<string, [event: string, mark: string]> = {
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);
@@ -131,9 +128,7 @@ export async function postRuntime(
body: JSON.stringify(body),
});
if (!response.ok) {
throw new GiteaRequestError(
`${path}: ${response.status} ${await response.text()}`,
);
throw new Error(`${path}: ${response.status} ${await response.text()}`);
}
};
const postComment = (body: string) =>
@@ -146,65 +141,63 @@ export async function postRuntime(
try {
outcome = await readOutcome(manifest.outcomeFile);
} catch (error) {
await postComment(FAILURE);
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(FAILURE);
await postComment(
"AI bot failed.\n\n> Profile: none · all profiles failed",
);
throw new Error("agent phase failed");
}
try {
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(withProfile(outcome.body, outcome.profile));
return;
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");
}
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;
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([]);
});
} finally {
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(
(error) => {
+1 -24
View File
@@ -212,29 +212,6 @@ Deno.test("refuses to follow an agent artifact symlink", async () => {
"regular private file",
);
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",
}]);
assertEquals(requests, 0);
assert(await missing(result.runtimeDir));
});
+11 -25
View File
@@ -19,11 +19,7 @@ import {
sanitizedEnvironment,
withProfile,
} from "./profiles.ts";
import {
collectOpenCodeOutput,
openCodeArgs,
parseOpenCodeEvents,
} from "./opencode.ts";
import { openCodeArgs, parseOpenCodeEvents } from "./opencode.ts";
import {
readRuntimeManifest,
type RuntimeOutcome,
@@ -116,6 +112,7 @@ async function runAgentCommand(
async function prepareProfile(
profile: RuntimeProfile,
token: string | undefined,
): Promise<void> {
await runAgentCommand(profile, "git", [
"config",
@@ -123,7 +120,7 @@ async function prepareProfile(
"--add",
"safe.directory",
WORKSPACE,
]);
], token);
await runAgentCommand(profile, "npx", [
"-y",
...SKILLS_INSTALL.split(" "),
@@ -131,7 +128,7 @@ async function prepareProfile(
"--agent",
profile.type === "claude" ? "claude-code" : profile.type,
"--yes",
]);
], token);
}
async function renderPrompt(
@@ -368,7 +365,7 @@ async function runOpenCode(
token: string,
prompt: string,
): Promise<string> {
const process = agentCommand(
const result = await agentCommand(
profile,
"opencode",
openCodeArgs(profile.model, prompt),
@@ -379,23 +376,12 @@ async function runOpenCode(
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}`);
).output();
const error = new TextDecoder().decode(result.stderr).trim();
if (!result.success) {
throw new Error(error || `opencode exited with ${result.code}`);
}
return parseOpenCodeEvents(streams.stdout);
return parseOpenCodeEvents(new TextDecoder().decode(result.stdout));
}
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
@@ -417,7 +403,7 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
profile.gid,
);
}
await prepareProfile(profile);
await prepareProfile(profile, token);
const rendered = await renderPrompt(profile);
reviewPath = rendered.reviewPath;
anchorsPath = rendered.anchorsPath;
-6
View File
@@ -169,12 +169,6 @@ Deno.test("installs the OpenCode CLI", async () => {
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");