From c783935936eb55da86d1f48cb421be5ea84da574 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Mon, 19 Oct 2026 19:46:31 +0900 Subject: [PATCH] Turn on upper-snake-string-unions and convert profile types, failure reasons and anchor sides at their boundaries --- profiles.json | 6 +-- prompt.md | 6 +-- scripts/project-lint.ts | 5 +-- src/post/post.test.ts | 34 ++++++++--------- src/post/post.ts | 18 ++++----- src/processes/isolation.test.ts | 2 +- src/profiles/profiles.test.ts | 68 ++++++++++++++++----------------- src/profiles/profiles.ts | 46 +++++++++++++++------- src/run/index.ts | 20 ++++++++-- src/runtime/credentials.test.ts | 14 +++---- src/runtime/credentials.ts | 28 +++++++------- src/runtime/runtime.test.ts | 4 +- tests/workflow.test.ts | 6 +-- 13 files changed, 144 insertions(+), 113 deletions(-) diff --git a/profiles.json b/profiles.json index f7ef3a0..535fe04 100644 --- a/profiles.json +++ b/profiles.json @@ -2,16 +2,16 @@ "reviewer-token": "v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY", "profiles": [ { - "type": "codex", + "type": "CODEX", "model": "gpt-5.6-terra" }, { - "type": "claude", + "type": "CLAUDE", "model": "claude-sonnet-5", "token": "v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg" }, { - "type": "opencode", + "type": "OPENCODE", "model": "openrouter/qwen/qwen3.8-27b", "token": "v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0" } diff --git a/prompt.md b/prompt.md index 4529f72..b30464d 100644 --- a/prompt.md +++ b/prompt.md @@ -47,9 +47,9 @@ achieved. Every finding that belongs to one line of the diff goes on that line instead of into the body. Write those to `${ANCHORS_PATH}` as a JSON array, each entry -`{"path": "", "line": , "side": "new" | -"old", "body": ""}`. -`side` is `new` for a line in the head file and `old` for one only in the base +`{"path": "", "line": , "side": "NEW" | +"OLD", "body": ""}`. +`side` is `NEW` for a line in the head file and `OLD` for one only in the base file; `line` is that file's own line number, and it must be a line the diff touches, or Gitea refuses the anchor. Write the file only when there is something to anchor, and keep each body to the what, the why, and the how, with diff --git a/scripts/project-lint.ts b/scripts/project-lint.ts index 08d0723..588dcf3 100644 --- a/scripts/project-lint.ts +++ b/scripts/project-lint.ts @@ -257,9 +257,8 @@ const rules: DummyRuleMap = { "consistent-return": "off", // TypeScript's type checking covers exhaustive switches. "typescript/switch-exhaustiveness-check": "off", - // PENDING USER: union members are values in profiles.json, the Gitea API, - // and posted comments; renaming them changes behavior. - "explicit-types/upper-snake-string-unions": "off", + // A `default` on a switch over a union would hide a new member from TS. + "default-case": "off", // Off, too much churn: // Literal numbers read better than a constant per use. "no-magic-numbers": "off", diff --git a/src/post/post.test.ts b/src/post/post.test.ts index 3b6953e..0570247 100644 --- a/src/post/post.test.ts +++ b/src/post/post.test.ts @@ -65,7 +65,7 @@ function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest { profiles: [ { index: 0, - type: "claude", + type: "CLAUDE", model: "test", uid: currentUid(), gid: currentGid(), @@ -89,7 +89,7 @@ async function fixture( const outcome: RuntimeOutcome = source.ok ? { ...source, - profile: { type: "codex", model: "gpt-test" }, + profile: { type: "CODEX", model: "gpt-test" }, reviewPath: `${profileHome}/review.md`, anchorsPath: `${profileHome}/anchors.json`, } @@ -147,7 +147,7 @@ test("posts a successful issue result and removes runtime credentials", async () const [request] = requests; expect(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`); expect(requestBody(request?.init)).toStrictEqual({ - body: "done\n\n> Profile: `codex` · `gpt-test`", + body: "done\n\n> Profile: `CODEX` · `gpt-test`", }); expect(request?.init?.headers).toStrictEqual({ Authorization: "token reviewer-secret", @@ -158,7 +158,7 @@ test("posts a successful issue result and removes runtime credentials", async () test("retries a rejected inline review without anchors", async () => { const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [ - { path: "run.ts", line: 1, side: "new", body: "Finding" }, + { path: "run.ts", line: 1, side: "NEW", body: "Finding" }, ]); const bodies: unknown[] = []; @@ -178,11 +178,11 @@ test("retries a rejected inline review without anchors", async () => { ); expect(bodies[0]).toMatchObject({ - comments: [{ body: "Finding\n\n> Profile: `codex` · `gpt-test`" }], + comments: [{ body: "Finding\n\n> Profile: `CODEX` · `gpt-test`" }], }); expect(bodies[1]).toMatchObject({ comments: [], - body: "✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`", + body: "✅ Approved\nReady\n\n> Profile: `CODEX` · `gpt-test`", }); assert(await missing(result.runtimeDir)); }); @@ -191,9 +191,9 @@ test("reports each tried profile and keeps the action failed", async () => { const result = await fixture({ ok: false, failures: [ - { type: "codex", model: "gpt-test", reason: "sign-in failed" }, - { type: "claude", model: "claude-test", reason: "usage exhausted" }, - { type: "opencode", model: "openrouter/test", reason: "failed" }, + { type: "CODEX", model: "gpt-test", reason: "SIGN_IN_FAILED" }, + { type: "CLAUDE", model: "claude-test", reason: "USAGE_EXHAUSTED" }, + { type: "OPENCODE", model: "openrouter/test", reason: "FAILED" }, ], }); const bodies: unknown[] = []; @@ -205,9 +205,9 @@ test("reports each tried profile and keeps the action failed", async () => { expect(bodies).toStrictEqual([ { body: - "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (sign-in failed), " + - "`claude` · `claude-test` (usage exhausted), " + - "`opencode` · `openrouter/test` (failed)", + "AI bot failed.\n\n> Tried: `CODEX` · `gpt-test` (sign-in failed), " + + "`CLAUDE` · `claude-test` (usage exhausted), " + + "`OPENCODE` · `openrouter/test` (failed)", }, ]); assert(!JSON.stringify(bodies).includes("reviewer-secret")); @@ -221,9 +221,9 @@ test("drops failure entries that are not known labels", async () => { JSON.stringify({ ok: false, failures: [ - { type: "codex", model: "gpt-test", reason: "token sk-secret leaked" }, - { type: "shell", model: "x", reason: "failed" }, - { type: "claude", model: "claude-test", reason: "sign-in failed" }, + { type: "CODEX", model: "gpt-test", reason: "token sk-secret leaked" }, + { type: "shell", model: "x", reason: "FAILED" }, + { type: "CLAUDE", model: "claude-test", reason: "SIGN_IN_FAILED" }, ], }), ); @@ -235,7 +235,7 @@ test("drops failure entries that are not known labels", async () => { expect(bodies).toStrictEqual([ { - body: "AI bot failed.\n\n> Tried: `claude` · `claude-test` (sign-in failed)", + body: "AI bot failed.\n\n> Tried: `CLAUDE` · `claude-test` (sign-in failed)", }, ]); }); @@ -315,7 +315,7 @@ test("reports an attributed failure when a pull request review is missing", asyn expect(bodies).toStrictEqual([ { - body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)", + body: "AI bot failed.\n\n> Tried: `CODEX` · `gpt-test` (failed)", }, ]); assert(await missing(result.runtimeDir)); diff --git a/src/post/post.ts b/src/post/post.ts index 511c9ac..3886e5c 100644 --- a/src/post/post.ts +++ b/src/post/post.ts @@ -16,7 +16,7 @@ export interface PostContext { interface Anchor { path: string; line: number; - side: "new" | "old"; + side: "NEW" | "OLD"; body: string; } @@ -26,8 +26,8 @@ const VERDICTS = new Map([ ["Approved", ["APPROVED", "✅"]], ["Changes requested", ["REQUEST_CHANGES", "🛑"]], ]); -const PROFILE_TYPES = new Set(["claude", "codex", "opencode"]); -const FAILURE_REASONS = new Set(["sign-in failed", "usage exhausted", "failed"]); +const PROFILE_TYPES = new Set(["CLAUDE", "CODEX", "OPENCODE"]); +const FAILURE_REASONS = new Set(["SIGN_IN_FAILED", "USAGE_EXHAUSTED", "FAILED"]); const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value); const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value); const failureComment = (failures: ProfileFailure[]): string => @@ -56,7 +56,7 @@ function parseAnchors(text: string, sourcePath: string): Anchor[] { const path = field(entry, "path"); const line = field(entry, "line"); const givenSide = field(entry, "side"); - const side = givenSide === undefined ? "new" : givenSide; + const side = givenSide === undefined ? "NEW" : givenSide; const body = field(entry, "body"); if (typeof path !== "string" || path === "") { throw new TypeError(`${at}.path: expected a repository path`); @@ -64,8 +64,8 @@ function parseAnchors(text: string, sourcePath: string): Anchor[] { if (typeof line !== "number" || !Number.isInteger(line) || line < 1) { throw new TypeError(`${at}.line: expected a line number`); } - if (side !== "new" && side !== "old") { - throw new Error(`${at}.side: expected "new" or "old"`); + if (side !== "NEW" && side !== "OLD") { + throw new Error(`${at}.side: expected "NEW" or "OLD"`); } if (typeof body !== "string" || body.trim() === "") { throw new TypeError(`${at}.body: expected the finding`); @@ -216,8 +216,8 @@ export async function postRuntime( 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, + new_position: side === "NEW" ? line : 0, + old_position: side === "OLD" ? line : 0, })), }); }; @@ -234,7 +234,7 @@ export async function postRuntime( if (error instanceof GiteaRequestError) { throw error; } - await postComment(failureComment([{ ...outcome.profile, reason: "failed" }])); + await postComment(failureComment([{ ...outcome.profile, reason: "FAILED" }])); throw error; } } finally { diff --git a/src/processes/isolation.test.ts b/src/processes/isolation.test.ts index 38bb634..b580a5b 100644 --- a/src/processes/isolation.test.ts +++ b/src/processes/isolation.test.ts @@ -11,7 +11,7 @@ import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from const profile = (uid: number, home: string): RuntimeProfile => ({ index: uid - 20_000, - type: "claude", + type: "CLAUDE", model: "test", uid, gid: uid, diff --git a/src/profiles/profiles.test.ts b/src/profiles/profiles.test.ts index e70bd67..5441a91 100644 --- a/src/profiles/profiles.test.ts +++ b/src/profiles/profiles.test.ts @@ -11,8 +11,8 @@ import { } from "./profiles.ts"; test("formats profile attribution as a Markdown quote", () => { - expect(profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" })).toBe( - "> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`", + expect(profileFooter({ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" })).toBe( + "> Profile: `OPENCODE` · `openrouter/qwen/qwen3.8-27b`", ); }); @@ -20,12 +20,12 @@ test("falls through only after usage exhaustion", async () => { const visited: string[] = []; const result = await runProfiles( [ - { type: "claude", model: "claude-sonnet-5", token: "first" }, - { type: "codex", model: "gpt-5.6-terra" }, + { type: "CLAUDE", model: "claude-sonnet-5", token: "first" }, + { type: "CODEX", model: "gpt-5.6-terra" }, ], async (profile) => { visited.push(profile.type); - if (profile.type === "claude") { + if (profile.type === "CLAUDE") { throw new Error("You've hit your usage limit"); } const resolved = await Promise.resolve("done"); @@ -34,17 +34,17 @@ test("falls through only after usage exhaustion", async () => { ); expect(result).toBe("done"); - expect(visited).toStrictEqual(["claude", "codex"]); + expect(visited).toStrictEqual(["CLAUDE", "CODEX"]); }); test("recognizes Claude's weekly limit message", async () => { const result = await runProfiles( [ - { type: "claude", model: "claude-sonnet-5", token: "first" }, - { type: "codex", model: "gpt-5.6-terra" }, + { type: "CLAUDE", model: "claude-sonnet-5", token: "first" }, + { type: "CODEX", model: "gpt-5.6-terra" }, ], async (profile) => { - if (profile.type === "claude") { + if (profile.type === "CLAUDE") { throw new Error("You've hit your weekly limit · resets 12am (UTC)"); } const resolved = await Promise.resolve("fallback"); @@ -60,11 +60,11 @@ test("recognizes provider API exhaustion status", async () => { ["API Error: 402 Insufficient funds", "HTTP error: 429"].map(async (message) => { const result = await runProfiles( [ - { type: "claude", model: "claude-sonnet-5", token: "first" }, - { type: "codex", model: "gpt-5.6-terra" }, + { type: "CLAUDE", model: "claude-sonnet-5", token: "first" }, + { type: "CODEX", model: "gpt-5.6-terra" }, ], async (profile) => { - if (profile.type === "claude") { + if (profile.type === "CLAUDE") { throw new Error(message); } const resolved = await Promise.resolve("fallback"); @@ -82,15 +82,15 @@ test("falls through after a sign-in failure and records each try", async () => { const failures: ProfileFailure[] = []; const result = await runProfiles( [ - { type: "codex", model: "gpt-5.6-terra" }, - { type: "claude", model: "claude-sonnet-5", token: "first" }, - { type: "opencode", model: "openrouter/qwen/qwen3.8-27b" }, + { type: "CODEX", model: "gpt-5.6-terra" }, + { type: "CLAUDE", model: "claude-sonnet-5", token: "first" }, + { type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" }, ], async (profile) => { - if (profile.type === "codex") { + if (profile.type === "CODEX") { throw new AgentAuthError("codex sign-in failed"); } - if (profile.type === "claude") { + if (profile.type === "CLAUDE") { throw new Error("API Error: 429"); } const resolved = await Promise.resolve("fallback"); @@ -101,12 +101,12 @@ test("falls through after a sign-in failure and records each try", async () => { expect(result).toBe("fallback"); expect(failures).toStrictEqual([ - { type: "codex", model: "gpt-5.6-terra", reason: "sign-in failed" }, - { type: "claude", model: "claude-sonnet-5", reason: "usage exhausted" }, + { type: "CODEX", model: "gpt-5.6-terra", reason: "SIGN_IN_FAILED" }, + { type: "CLAUDE", model: "claude-sonnet-5", reason: "USAGE_EXHAUSTED" }, ]); expect(failureFooter(failures)).toStrictEqual( - "> Tried: `codex` · `gpt-5.6-terra` (sign-in failed), " + - "`claude` · `claude-sonnet-5` (usage exhausted)", + "> Tried: `CODEX` · `gpt-5.6-terra` (sign-in failed), " + + "`CLAUDE` · `claude-sonnet-5` (usage exhausted)", ); }); @@ -115,8 +115,8 @@ test("throws the last sign-in failure when every profile fails", async () => { await expect( runProfiles( [ - { type: "codex", model: "gpt-5.6-terra" }, - { type: "claude", model: "claude-sonnet-5", token: "first" }, + { type: "CODEX", model: "gpt-5.6-terra" }, + { type: "CLAUDE", model: "claude-sonnet-5", token: "first" }, ], (profile) => { throw new AgentAuthError(`${profile.type} sign-in failed`); @@ -126,10 +126,10 @@ test("throws the last sign-in failure when every profile fails", async () => { ).rejects.toThrow( expect.toSatisfy( (error: unknown) => - error instanceof AgentAuthError && error.message.includes("claude sign-in failed"), + error instanceof AgentAuthError && error.message.includes("CLAUDE sign-in failed"), ), ); - expect(failures.map(({ reason }) => reason)).toStrictEqual(["sign-in failed", "sign-in failed"]); + expect(failures.map(({ reason }) => reason)).toStrictEqual(["SIGN_IN_FAILED", "SIGN_IN_FAILED"]); }); test("preserves an ordinary agent failure", async () => { @@ -137,8 +137,8 @@ test("preserves an ordinary agent failure", async () => { await expect( runProfiles( [ - { type: "claude", model: "claude-sonnet-5", token: "first" }, - { type: "codex", model: "gpt-5.6-terra" }, + { type: "CLAUDE", model: "claude-sonnet-5", token: "first" }, + { type: "CODEX", model: "gpt-5.6-terra" }, ], (profile) => { visited.push(profile.type); @@ -146,7 +146,7 @@ test("preserves an ordinary agent failure", async () => { }, ), ).rejects.toThrow("tests failed"); - expect(visited).toStrictEqual(["claude"]); + expect(visited).toStrictEqual(["CLAUDE"]); }); test("does not mistake incidental quota text for exhaustion", async () => { @@ -154,8 +154,8 @@ test("does not mistake incidental quota text for exhaustion", async () => { await expect( runProfiles( [ - { type: "claude", model: "claude-sonnet-5", token: "first" }, - { type: "codex", model: "gpt-5.6-terra" }, + { type: "CLAUDE", model: "claude-sonnet-5", token: "first" }, + { type: "CODEX", model: "gpt-5.6-terra" }, ], (profile) => { visited.push(profile.type); @@ -163,7 +163,7 @@ test("does not mistake incidental quota text for exhaustion", async () => { }, ), ).rejects.toThrow("quota parser test failed"); - expect(visited).toStrictEqual(["claude"]); + expect(visited).toStrictEqual(["CLAUDE"]); }); test("exposes only the selected profile credential to the agent", () => { @@ -183,7 +183,7 @@ test("exposes only the selected profile credential to the agent", () => { ]); const profile = { index: 0, - type: "claude" as const, + type: "CLAUDE" as const, model: "claude-sonnet-5", uid: 20_000, gid: 20_000, @@ -209,7 +209,7 @@ test("exposes only the selected profile credential to the agent", () => { test("uses a private Codex home without inventing an API credential", () => { const profile = { index: 1, - type: "codex" as const, + type: "CODEX" as const, model: "gpt-5.6-terra", uid: 20_001, gid: 20_001, @@ -229,7 +229,7 @@ test("uses a private Codex home without inventing an API credential", () => { test("exposes an OpenRouter key only to the OpenCode profile", () => { const profile = { index: 2, - type: "opencode" as const, + type: "OPENCODE" as const, model: "openrouter/qwen/qwen3.8-27b", uid: 20_002, gid: 20_002, diff --git a/src/profiles/profiles.ts b/src/profiles/profiles.ts index 48edcd0..2d25a17 100644 --- a/src/profiles/profiles.ts +++ b/src/profiles/profiles.ts @@ -1,6 +1,6 @@ // The agent and model a profile runs, which every report names. export interface ProfileIdentity { - type: "claude" | "codex" | "opencode"; + type: "CLAUDE" | "CODEX" | "OPENCODE"; model: string; } @@ -16,7 +16,19 @@ export function withProfile(body: string, profile: ProfileIdentity): string { return `${body}\n\n${profileFooter(profile)}`; } -export type FailureReason = "sign-in failed" | "usage exhausted" | "failed"; +export type FailureReason = "SIGN_IN_FAILED" | "USAGE_EXHAUSTED" | "FAILED"; + +// The words a failure reason reads as in logs and posted comments. +function toMessage(reason: FailureReason): string { + switch (reason) { + case "SIGN_IN_FAILED": + return "sign-in failed"; + case "USAGE_EXHAUSTED": + return "usage exhausted"; + case "FAILED": + return "failed"; + } +} export interface ProfileFailure extends ProfileIdentity { reason: FailureReason; @@ -44,9 +56,9 @@ function isUsageExhausted(error: unknown): boolean { function failureReason(error: unknown): FailureReason { if (error instanceof AgentAuthError) { - return "sign-in failed"; + return "SIGN_IN_FAILED"; } - return isUsageExhausted(error) ? "usage exhausted" : "failed"; + return isUsageExhausted(error) ? "USAGE_EXHAUSTED" : "FAILED"; } export function failureFooter(failures: ProfileFailure[]): string { @@ -54,7 +66,7 @@ export function failureFooter(failures: ProfileFailure[]): string { return "> Profile: none"; } return `> Tried: ${failures - .map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`) + .map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${toMessage(reason)})`) .join(", ")}`; } @@ -71,10 +83,10 @@ export async function runProfiles( } catch (error) { const reason = failureReason(error); failures.push({ type: profile.type, model: profile.model, reason }); - if (reason === "failed" || index === profiles.length - 1) { + if (reason === "FAILED" || index === profiles.length - 1) { throw error; } - console.error(`${profile.type} ${reason}; trying next profile`); + console.error(`${profile.type} ${toMessage(reason)}; trying next profile`); } } throw new Error("profiles must contain at least one profile"); @@ -120,6 +132,18 @@ export function sanitizedEnvironment( return result; } +// The environment variable each agent CLI reads its credential from. +function tokenVariable(type: ProfileIdentity["type"]): string { + switch (type) { + case "CLAUDE": + return "CLAUDE_CODE_OAUTH_TOKEN"; + case "CODEX": + return "OPENAI_API_KEY"; + case "OPENCODE": + return "OPENROUTER_API_KEY"; + } +} + export function agentEnvironment( environment: ReadonlyMap, profile: ProfileHome, @@ -127,15 +151,11 @@ export function agentEnvironment( ): Map { const result = sanitizedEnvironment(environment); result.set("HOME", profile.home); - if (profile.type === "codex") { + if (profile.type === "CODEX") { result.set("CODEX_HOME", `${profile.home}/.codex`); } if (token !== undefined && token !== "") { - const tokenName = { - claude: "CLAUDE_CODE_OAUTH_TOKEN", - codex: "OPENAI_API_KEY", - opencode: "OPENROUTER_API_KEY", - }[profile.type]; + const tokenName = tokenVariable(profile.type); result.set(tokenName, token); } return result; diff --git a/src/run/index.ts b/src/run/index.ts index 6fcc6dc..5d6e22b 100644 --- a/src/run/index.ts +++ b/src/run/index.ts @@ -150,6 +150,18 @@ async function runAgentCommand( } } +// The agent name the skills installer knows each profile type by. +function skillsAgent(type: RuntimeProfile["type"]): string { + switch (type) { + case "CLAUDE": + return "claude-code"; + case "CODEX": + return "codex"; + case "OPENCODE": + return "opencode"; + } +} + async function prepareProfile(profile: RuntimeProfile): Promise { await runAgentCommand(profile, "git", [ "config", @@ -163,7 +175,7 @@ async function prepareProfile(profile: RuntimeProfile): Promise { ...SKILLS_INSTALL.split(" "), "--global", "--agent", - profile.type === "claude" ? "claude-code" : profile.type, + skillsAgent(profile.type), "--yes", ]); } @@ -451,7 +463,7 @@ async function runOpenCode( async function runProfile(profile: RuntimeProfile): Promise { const token = await readProfileToken(profile); - const authentication = profile.type === "codex" ? codexAuthentication(token ?? undefined) : null; + const authentication = profile.type === "CODEX" ? codexAuthentication(token ?? undefined) : null; let stagedCodexAuth: string | null = null; let body: string | null = null; let resultPath: string | null = null; @@ -465,9 +477,9 @@ async function runProfile(profile: RuntimeProfile): Promise { const rendered = await renderPrompt(profile); reviewPath = rendered.reviewPath; anchorsPath = rendered.anchorsPath; - if (profile.type === "claude") { + if (profile.type === "CLAUDE") { body = await runClaude(profile, token ?? "", rendered.prompt); - } else if (profile.type === "codex") { + } else if (profile.type === "CODEX") { resultPath = await runCodex( profile, rendered.prompt, diff --git a/src/runtime/credentials.test.ts b/src/runtime/credentials.test.ts index c99873d..ef9b43d 100644 --- a/src/runtime/credentials.test.ts +++ b/src/runtime/credentials.test.ts @@ -31,9 +31,9 @@ test("decrypts the central reviewer token and ordered profiles", async () => { JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [ - { type: "codex", model: "gpt-custom" }, - { type: "claude", token: ENCRYPTED }, - { type: "opencode", token: ENCRYPTED }, + { type: "CODEX", model: "gpt-custom" }, + { type: "CLAUDE", token: ENCRYPTED }, + { type: "OPENCODE", token: ENCRYPTED }, ], }), MASTER_KEY, @@ -41,14 +41,14 @@ test("decrypts the central reviewer token and ordered profiles", async () => { ).toStrictEqual({ reviewerToken: "secret-token", profiles: [ - { type: "codex", model: "gpt-custom" }, + { type: "CODEX", model: "gpt-custom" }, { - type: "claude", + type: "CLAUDE", model: "claude-sonnet-5", token: "secret-token", }, { - type: "opencode", + type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b", token: "secret-token", }, @@ -60,7 +60,7 @@ test("rejects malformed central credentials", async () => { await expect(parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow("reviewer-token"); await expect( parseCredentials( - JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "claude" }] }), + JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "CLAUDE" }] }), MASTER_KEY, ), ).rejects.toThrow("profiles[0].token"); diff --git a/src/runtime/credentials.ts b/src/runtime/credentials.ts index f578886..3cf4fd0 100644 --- a/src/runtime/credentials.ts +++ b/src/runtime/credentials.ts @@ -3,18 +3,18 @@ import { webcrypto } from "node:crypto"; import { field, isMapping } from "#common"; import type { BotProfile } from "#profiles"; -interface DefaultModels { - claude: string; - codex: string; - opencode: string; +// The model a profile runs when profiles.json names none. +function defaultModel(type: BotProfile["type"]): string { + switch (type) { + case "CLAUDE": + return "claude-sonnet-5"; + case "CODEX": + return "gpt-5.6-terra"; + case "OPENCODE": + return "openrouter/qwen/qwen3.8-27b"; + } } -const DEFAULT_MODELS: DefaultModels = { - claude: "claude-sonnet-5", - codex: "gpt-5.6-terra", - opencode: "openrouter/qwen/qwen3.8-27b", -}; - export interface BotCredentials { reviewerToken: string; profiles: BotProfile[]; @@ -91,13 +91,13 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P const type = field(entry, "type"); const model = field(entry, "model"); const token = field(entry, "token"); - if (type !== "claude" && type !== "codex" && type !== "opencode") { - throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`); + 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" || type === "opencode") && 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") { @@ -105,7 +105,7 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P } return { type, - model: model ?? DEFAULT_MODELS[type], + model: model ?? defaultModel(type), ...(typeof token === "string" && { token: await decryptCredential(token, masterKey), }), diff --git a/src/runtime/runtime.test.ts b/src/runtime/runtime.test.ts index a408740..55b6e6c 100644 --- a/src/runtime/runtime.test.ts +++ b/src/runtime/runtime.test.ts @@ -32,7 +32,7 @@ test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credenti `${action}/profiles.json`, JSON.stringify({ "reviewer-token": ENCRYPTED, - profiles: [{ type: "claude", token: ENCRYPTED }, { type: "codex" }], + profiles: [{ type: "CLAUDE", token: ENCRYPTED }, { type: "CODEX" }], }), ); @@ -77,7 +77,7 @@ test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fail `${action}/profiles.json`, JSON.stringify({ "reviewer-token": ENCRYPTED, - profiles: [{ type: "claude", token: ENCRYPTED }], + profiles: [{ type: "CLAUDE", token: ENCRYPTED }], }), ); const result = spawnSync( diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index 26d4225..9564744 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -194,9 +194,9 @@ test("central profiles fall back from Codex to Claude to OpenCode", async () => const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8")); const profiles = sequence(mapping(credentials, "profiles.json").get("profiles"), "profiles"); expect(profiles.map((profile) => profile.get("type"))).toStrictEqual([ - "codex", - "claude", - "opencode", + "CODEX", + "CLAUDE", + "OPENCODE", ]); expect(profiles[2]?.get("model")).toBe("openrouter/qwen/qwen3.8-27b"); assert(String(profiles[2]?.get("token")).startsWith("v1:"));