Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions client/src/lib/agentModelOptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { AgentModelCatalog } from "@shared/schema";
import {
buildReviewModelOptions,
findReviewModelSelection,
} from "./agentModelOptions";

const catalog: AgentModelCatalog = {
codex: [
{ value: "", label: "CLI default" },
{ value: "gpt-5.6-sol", label: "gpt-5.6-sol" },
{ value: "gpt-5.6-luna", label: "gpt-5.6-luna" },
],
claude: [
{ value: "", label: "CLI default" },
{ value: "opus", label: "opus" },
{ value: "sonnet", label: "sonnet" },
],
};

test("buildReviewModelOptions requires an explicit model and excludes the active primary model", () => {
const options = buildReviewModelOptions(catalog, {
agent: "claude",
model: "opus",
});

assert.deepEqual(
options.map(({ agent, model }) => [agent, model]),
[
["codex", "gpt-5.6-sol"],
["codex", "gpt-5.6-luna"],
["claude", "sonnet"],
],
);
});

test("findReviewModelSelection returns the agent and model represented by the selected option", () => {
const options = buildReviewModelOptions(catalog, {
agent: "codex",
model: "gpt-5.6-sol",
});
const sonnet = options.find((option) => option.model === "sonnet");

assert.deepEqual(findReviewModelSelection(options, sonnet?.value ?? ""), {
agent: "claude",
model: "sonnet",
});
assert.equal(findReviewModelSelection(options, "unknown"), null);
});
44 changes: 44 additions & 0 deletions client/src/lib/agentModelOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { AgentModelCatalog, Config } from "@shared/schema";

export type ReviewModelSelection = {
agent: Config["reviewAgent"];
model: string;
};

export type ReviewModelOption = ReviewModelSelection & {
value: string;
label: string;
};

function selectionValue(agent: Config["reviewAgent"], model: string): string {
return `${agent}:${encodeURIComponent(model)}`;
}

export function buildReviewModelOptions(
catalog: AgentModelCatalog,
primary: ReviewModelSelection,
): ReviewModelOption[] {
const optionsFor = (agent: Config["reviewAgent"], label: string) =>
catalog[agent]
.filter((option) => option.value.length > 0)
.filter((option) => agent !== primary.agent || option.value !== primary.model)
.map((option) => ({
agent,
model: option.value,
value: selectionValue(agent, option.value),
label: `${label} · ${option.label}`,
}));

return [
...optionsFor("codex", "Codex"),
...optionsFor("claude", "Claude"),
];
}

export function findReviewModelSelection(
options: readonly ReviewModelOption[],
value: string,
): ReviewModelSelection | null {
const selected = options.find((option) => option.value === value);
return selected ? { agent: selected.agent, model: selected.model } : null;
}
13 changes: 13 additions & 0 deletions client/src/lib/fullAppQaSurface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,7 @@ test("settings keeps the QA-tested configuration, token, and runtime controls wi
const { sourceFile } = await parseProjectFile("client/src/pages/settings.tsx");

assertHasQueryKey(sourceFile, "settings config query", "/api/config");
assertHasQueryKey(sourceFile, "detected agent models query", "/api/agent-models");
assertHasQueryKey(sourceFile, "runtime query", "/api/runtime");
assertHasQueryKey(sourceFile, "repo settings query", "/api/repos/settings");
assertHasQueryKey(sourceFile, "GitHub auth status query", "/api/github-auth/status");
Expand All @@ -488,6 +489,16 @@ test("settings keeps the QA-tested configuration, token, and runtime controls wi
assertHasApiRequest(sourceFile, "manual release mutation", "POST", "/api/repos/release");

assertHasJsxAttribute(sourceFile, "id", "coding agent selector", "settings-coding-agent");
assertHasExpression(
sourceFile,
"review model selection maps to config fields",
/findReviewModelSelection\(reviewModelOptions,[\s\S]*?updateConfigMutation\.mutate\(\{[\s\S]*?reviewAgent: selection\.agent,[\s\S]*?reviewModel: selection\.model/,
);
assertHasExpression(
sourceFile,
"review toggle requires a selected model",
/disabled=\{updateConfigMutation\.isPending \|\| !selectedReviewOption\}/,
);
for (const [label, testId] of [
["add PR input", "input-add-pr"],
["add PR submit", "button-add-pr"],
Expand All @@ -498,6 +509,8 @@ test("settings keeps the QA-tested configuration, token, and runtime controls wi
["remote access save", "button-save-remote-access"],
["repo sync action", "button-sync-repos"],
["fallback toggle", "checkbox-fallback-to-next-coding-agent"],
["second-model review toggle", "checkbox-second-model-review"],
["review model selector", "select-review-model"],
["auto fix conflicts toggle", "checkbox-auto-resolve-conflicts"],
["auto update docs toggle", "checkbox-auto-update-docs"],
["runtime drain button", "button-toggle-drain"],
Expand Down
100 changes: 81 additions & 19 deletions client/src/pages/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { useEffect, useState, type ReactNode } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient, apiRequest } from "@/lib/queryClient";
import { getRepoHref } from "@/lib/repoHref";
import type { Config, ReleaseRun, RuntimeState, WatchedRepo } from "@shared/schema";
import { buildReviewModelOptions, findReviewModelSelection } from "@/lib/agentModelOptions";
import type { AgentModelCatalog, AgentModelOption, Config, ReleaseRun, RuntimeState, WatchedRepo } from "@shared/schema";
import { AppHeader } from "@/components/AppHeader";
import { UpdateBanner } from "@/components/UpdateBanner";
import { toast } from "@/hooks/use-toast";
Expand Down Expand Up @@ -50,21 +51,20 @@ const DEFAULT_SETTING_VALUES = {
deploymentCheckPollSeconds: 60,
};

const CODEX_MODEL_OPTIONS = [
{ value: "", label: "CLI default" },
{ value: "gpt-5.5", label: "gpt-5.5" },
{ value: "gpt-5.4", label: "gpt-5.4" },
{ value: "gpt-5.4-mini", label: "gpt-5.4-mini" },
{ value: "gpt-5.3-codex", label: "gpt-5.3-codex" },
{ value: "gpt-5.3-codex-spark", label: "gpt-5.3-codex-spark" },
{ value: "gpt-5.2", label: "gpt-5.2" },
] as const;
const EMPTY_AGENT_MODEL_CATALOG: AgentModelCatalog = {
codex: [{ value: "", label: "CLI default" }],
claude: [{ value: "", label: "CLI default" }],
};

const CLAUDE_MODEL_OPTIONS = [
{ value: "", label: "CLI default" },
{ value: "opus", label: "opus" },
{ value: "sonnet", label: "sonnet" },
] as const;
function includeSelectedModel(
options: readonly AgentModelOption[],
selected: string | null | undefined,
): AgentModelOption[] {
if (!selected || options.some((option) => option.value === selected)) {
return [...options];
}
return [...options, { value: selected, label: selected }];
}

const CODEX_REASONING_OPTIONS = [
{ value: "default", label: "CLI default" },
Expand Down Expand Up @@ -406,6 +406,23 @@ export default function Settings() {
const { data: config } = useQuery<Config>({
queryKey: ["/api/config"],
});
const { data: agentModelCatalog } = useQuery<AgentModelCatalog>({
queryKey: ["/api/agent-models"],
staleTime: 60_000,
});
const modelCatalog = agentModelCatalog ?? EMPTY_AGENT_MODEL_CATALOG;
const codexModelOptions = includeSelectedModel(modelCatalog.codex, config?.codexModel);
const claudeModelOptions = includeSelectedModel(modelCatalog.claude, config?.claudeModel);
const primaryModel = config?.codingAgent === "codex"
? config.codexModel
: config?.claudeModel ?? "";
const reviewModelOptions = buildReviewModelOptions(modelCatalog, {
agent: config?.codingAgent ?? "claude",
model: primaryModel,
});
const selectedReviewOption = reviewModelOptions.find((option) =>
option.agent === config?.reviewAgent && option.model === config.reviewModel
);

const [newGithubToken, setNewGithubToken] = useState("");
const [showTokenInput, setShowTokenInput] = useState(false);
Expand Down Expand Up @@ -494,6 +511,7 @@ export default function Settings() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/config"] });
queryClient.invalidateQueries({ queryKey: ["/api/agent-models"] });
queryClient.invalidateQueries({ queryKey: ["/api/onboarding/status"] });
queryClient.invalidateQueries({ queryKey: ["/api/github-auth/status"] });
toast({ description: "Settings saved." });
Expand Down Expand Up @@ -1044,7 +1062,7 @@ export default function Settings() {
className="border border-border bg-transparent px-2 py-1 text-body focus:border-primary focus:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:opacity-50"
>
<option value="">Global</option>
{CODEX_MODEL_OPTIONS.filter((option) => option.value !== "").map((option) => (
{includeSelectedModel(codexModelOptions, repo.codexModel).filter((option) => option.value !== "").map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
Expand Down Expand Up @@ -1090,7 +1108,7 @@ export default function Settings() {
className="border border-border bg-transparent px-2 py-1 text-body focus:border-primary focus:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:opacity-50"
>
<option value="">Global</option>
{CLAUDE_MODEL_OPTIONS.filter((option) => option.value !== "").map((option) => (
{includeSelectedModel(claudeModelOptions, repo.claudeModel).filter((option) => option.value !== "").map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
Expand Down Expand Up @@ -1187,7 +1205,7 @@ export default function Settings() {
disabled={updateConfigMutation.isPending}
className="border border-border bg-transparent px-2 py-1 text-body focus:border-primary focus:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:opacity-50"
>
{CODEX_MODEL_OPTIONS.map((option) => (
{codexModelOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
Expand Down Expand Up @@ -1219,7 +1237,7 @@ export default function Settings() {
disabled={updateConfigMutation.isPending}
className="border border-border bg-transparent px-2 py-1 text-body focus:border-primary focus:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:opacity-50"
>
{CLAUDE_MODEL_OPTIONS.map((option) => (
{claudeModelOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
Expand All @@ -1241,6 +1259,50 @@ export default function Settings() {
</select>
</div>
</div>
<div className="grid gap-3 border-t border-border pt-4 md:grid-cols-[minmax(0,1fr)_minmax(14rem,auto)] md:items-center">
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={config?.secondModelReviewEnabled ?? false}
onChange={(e) => updateConfigMutation.mutate({ secondModelReviewEnabled: e.target.checked })}
disabled={updateConfigMutation.isPending || !selectedReviewOption}
className="mt-1 h-4 w-4 accent-foreground"
data-testid="checkbox-second-model-review"
/>
<span>
<span className="block text-body">Second-model review</span>
<span className="block text-label text-muted-foreground">
Review and correct the primary agent&apos;s work before PatchDeck commits and pushes it.
</span>
</span>
</label>
<div className="grid gap-2">
<label htmlFor="settings-review-model" className="text-label uppercase tracking-wider text-muted-foreground">
Review model
</label>
<select
id="settings-review-model"
value={selectedReviewOption?.value ?? ""}
onChange={(e) => {
const selection = findReviewModelSelection(reviewModelOptions, e.target.value);
if (selection) {
updateConfigMutation.mutate({
reviewAgent: selection.agent,
reviewModel: selection.model,
});
}
}}
disabled={updateConfigMutation.isPending}
className="border border-border bg-transparent px-2 py-1 text-body focus:border-primary focus:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:opacity-50"
data-testid="select-review-model"
>
<option value="" disabled>Choose a review model</option>
{reviewModelOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
<label className="flex items-start justify-between gap-3">
<div>
<div className="text-body">Fallback to next coding agent</div>
Expand Down
4 changes: 3 additions & 1 deletion docs/public/agent-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ Every agent run is tracked in the patchdeck dashboard:

## Agent Selection

The active coding agent is stored in app config as `codingAgent` and can be changed from the dashboard, REST API, or MCP `update_config` tool. Agent reasoning/model behavior follows the selected CLI runtime; patchdeck does not expose a separate model-discovery or model-selection surface today.
The active coding agent is stored in app config as `codingAgent` and can be changed from the dashboard, REST API, or MCP `update_config` tool. The dashboard discovers the models advertised by the installed Codex and Claude CLIs, preserves saved choices, and lets you select models independently for each CLI.

Second-model review is opt-in. When enabled, patchdeck runs the selected reviewer agent and model against the primary agent's uncommitted work before final commit and push. The reviewer can correct concrete issues in the same isolated worktree. If that review fails, patchdeck does not bypass the requirement through the code-owner fallback.

## Customization

Expand Down
3 changes: 2 additions & 1 deletion docs/public/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ The settings page in the dashboard provides a UI for:

- **GitHub token management** — Add, remove, and reorder saved tokens before falling back to `GITHUB_TOKEN` or `gh auth`.
- **GitHub token permissions** — For fine-grained PATs, grant the watched repos `Metadata: read`, `Contents: read/write` if PatchDeck will push commits, `Issues: read/write`, `Pull requests: read/write`, and `Checks: read`. Tokens from the same GitHub account still share one rate-limit bucket.
- **Agent selection** — Choose whether autonomous runs use Claude Code or OpenAI Codex. If the default run fails and a code-owner fallback is launched, the fallback uses the same resolved agent; enabling **Fallback to next coding agent** lets patchdeck resolve that fallback to the other local CLI when needed.
- **Agent and model selection** — Choose whether autonomous runs use Claude Code or OpenAI Codex, then select from models detected from the installed CLIs. Saved choices remain available when discovery is unavailable. If the default run fails and a code-owner fallback is launched, the fallback uses the same resolved agent; enabling **Fallback to next coding agent** lets patchdeck resolve that fallback to the other local CLI when needed.
- **Second-model review** — Optionally choose a separate Codex or Claude model to review and correct the primary agent's uncommitted work before patchdeck commits and pushes it.
- **Babysitter tuning** — Control polling, batching, merge-conflict handling, release automation, and automatic docs assessment.
- **Runtime drain mode** — Pause new background automation and manual agent-triggering actions while allowing in-flight work to finish. During drain mode, the dashboard disables Run now/apply, feedback retry, Ask Agent, manual Release, and release retry actions; matching API calls return `409` instead of queueing new agent work.
- **Ignored bots** — Add or remove bot logins whose comments and reviews should be ignored.
Expand Down
Loading
Loading