feat(codex): add GPT-6 Astra support (#12851)

## Thinking Path

> - Paperclip is the open source app that people use to manage AI agents
for work.
> - The Codex local adapter supplies model metadata to the server and
the user interface.
> - OpenAI now lists `gpt-6-astra` as a supported Codex model.
> - Paperclip did not list this model or its model-specific controls.
> - This pull request adds the model through the existing adapter
metadata path.
> - The benefit is that agents and task overrides can use the exact
model ID and supported controls.

## Linked Issues or Issue Description

**Subsystem affected**

`packages/adapters` and `ui`

**Problem or motivation**

Paperclip does not expose `gpt-6-astra` in Codex model selectors.
Operators cannot select and save the model through the normal agent and
task forms.

**Proposed solution**

Register the exact model ID in the Codex local adapter. Use the adapter
as the source for the model-specific reasoning options. Preserve the
current default model. Forward the saved model, reasoning effort, and
fast-mode controls through both Codex execution lanes.

**Alternatives considered**

A user-interface-only model list would duplicate adapter metadata. A
model alias would not match the official model ID. Both options were
rejected.

**Roadmap alignment**

This is a small adapter compatibility update. It does not duplicate a
planned item in `ROADMAP.md`.

## What Changed

- Added `gpt-6-astra` to the Codex local adapter model registry and
fast-mode support list.
- Added the official Astra reasoning efforts: `low`, `medium`, `high`,
`xhigh`, `max`, and `ultra`.
- Used the adapter metadata in agent and task model selectors.
- Preserved supported effort choices when the model changes. Cleared an
effort only when the new model does not support it.
- Added tests for registration, user-interface selection, configuration
persistence, and CLI and ACP forwarding.

## Verification

- `pnpm exec vitest run packages/adapters/codex-local/src/index.test.ts
packages/adapters/codex-local/src/server/acp.test.ts
packages/adapters/codex-local/src/server/codex-args.test.ts
packages/adapters/codex-local/src/ui/build-config.test.ts
ui/src/lib/codex-reasoning-effort.test.ts
ui/src/components/AgentConfigForm.render.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/components/NewIssueDialog.test.tsx
ui/src/lib/issue-assignee-overrides.test.ts` passed 245 tests.
- `pnpm -r typecheck` passed.
- `pnpm check:token-gates` passed all four gates across 939 files.
- `pnpm --filter @paperclipai/ui build` passed and supplied isolated
user-interface build proof.
- `pnpm build` passed.
- `pnpm test:run` passed 5,812 tests and failed 24 workspace-runtime
tests in this isolated host. The failures use invalid generated ports
above 65,535, incomplete nested-worktree fixture configuration, or
`/tmp` path aliases. The focused tests for this change all pass. GitHub
CI must pass before review handoff.
- GitHub CI run `33918372718` passed all required checks and the
aggregate verify gate on exact head
`6ac6be2cee0a5996c82bdf674fcb7f46cb4c5fde`.
- Independent engineering review approved the exact remediation head
after 170/170 reviewer tests passed.
- Greptile reported 5/5 with no open review threads on exact head
`6ac6be2cee0a5996c82bdf674fcb7f46cb4c5fde`.
- The model ID and capabilities were checked against the [official
OpenAI Codex model list](https://developers.openai.com/codex/models).

## Risks

- Low risk. The change adds one model and model-specific selector
options. It does not change the default model.
- OpenAI can change model capabilities later. The adapter metadata must
stay aligned with the official Codex metadata.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex with model ID `gpt-5.6-sol`, a 272,000-token context
window, reasoning, tool use, and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Michael Nguyen 2026-09-04 11:50:04 -10:00 committed by GitHub
parent d593463ab6
commit 77312ee2d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 436 additions and 49 deletions

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
codexLocalReasoningEffortsForModel,
DEFAULT_CODEX_LOCAL_MODEL,
isCodexLocalFastModeSupported,
models,
@ -7,23 +8,43 @@ import {
} from "./index.js";
describe("codex local adapter metadata", () => {
it("advertises current GPT-5.6 Codex-capable OpenAI models by default", () => {
it("advertises current Codex-capable OpenAI models without changing the default", () => {
const modelIds = models.map((model) => model.id);
// Default to the concrete gpt-5.6-sol slug — Codex ships no metadata for the bare gpt-5.6
// alias, so it must not be advertised or used as the default (it triggers a fallback warning).
expect(DEFAULT_CODEX_LOCAL_MODEL).toBe("gpt-5.6-sol");
expect(modelIds.slice(0, 3)).toEqual([
expect(modelIds.slice(0, 4)).toEqual([
"gpt-5.6-sol",
"gpt-6-astra",
"gpt-5.6-terra",
"gpt-5.6-luna",
]);
expect(modelIds).not.toContain("gpt-5.6");
expect(isCodexLocalFastModeSupported(DEFAULT_CODEX_LOCAL_MODEL)).toBe(true);
expect(isCodexLocalFastModeSupported("gpt-6-astra")).toBe(true);
expect(modelIds).not.toContain("gpt-5.3-codex");
expect(modelIds).not.toContain("gpt-5.3-codex-spark");
});
it("uses the reasoning efforts advertised for GPT-6 Astra", () => {
expect(codexLocalReasoningEffortsForModel("gpt-6-astra")).toEqual([
"low",
"medium",
"high",
"xhigh",
"max",
"ultra",
]);
expect(codexLocalReasoningEffortsForModel("gpt-5.6-sol")).toEqual([
"minimal",
"low",
"medium",
"high",
"xhigh",
]);
});
it("normalizes the legacy bare gpt-5.6 alias to the concrete gpt-5.6-sol slug", () => {
expect(normalizeCodexModel("gpt-5.6")).toBe("gpt-5.6-sol");
expect(normalizeCodexModel(" gpt-5.6 ")).toBe("gpt-5.6-sol");

View File

@ -13,6 +13,7 @@ export const SANDBOX_INSTALL_COMMAND = "npm install -g @openai/codex";
export const DEFAULT_CODEX_LOCAL_MODEL = PAPERCLIP_RUNNER_DEFAULT_MODELS.codex;
export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true;
export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = [
"gpt-6-astra",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
@ -32,11 +33,40 @@ const CODEX_LOCAL_MODEL_ALIASES: Readonly<Record<string, string>> = {
"gpt-5.6": "gpt-5.6-sol",
};
const CODEX_LOCAL_DEFAULT_REASONING_EFFORTS = [
"minimal",
"low",
"medium",
"high",
"xhigh",
] as const;
const CODEX_LOCAL_ASTRA_REASONING_EFFORTS = [
"low",
"medium",
"high",
"xhigh",
"max",
"ultra",
] as const;
export type CodexLocalReasoningEffort =
| (typeof CODEX_LOCAL_DEFAULT_REASONING_EFFORTS)[number]
| (typeof CODEX_LOCAL_ASTRA_REASONING_EFFORTS)[number];
export function normalizeCodexModel(model: string | null | undefined): string {
const normalizedModel = normalizeModelId(model);
return CODEX_LOCAL_MODEL_ALIASES[normalizedModel] ?? normalizedModel;
}
export function codexLocalReasoningEffortsForModel(
model: string | null | undefined,
): readonly CodexLocalReasoningEffort[] {
return normalizeCodexModel(model) === "gpt-6-astra"
? CODEX_LOCAL_ASTRA_REASONING_EFFORTS
: CODEX_LOCAL_DEFAULT_REASONING_EFFORTS;
}
export function isCodexLocalKnownModel(model: string | null | undefined): boolean {
const normalizedModel = normalizeModelId(model);
if (!normalizedModel) return false;
@ -63,6 +93,7 @@ export function isCodexLocalFastModeSupported(model: string | null | undefined):
export const models = [
// DEFAULT_CODEX_LOCAL_MODEL is gpt-5.6-sol, so it doubles as the first (default) 5.6 entry.
{ id: DEFAULT_CODEX_LOCAL_MODEL, label: DEFAULT_CODEX_LOCAL_MODEL },
{ id: "gpt-6-astra", label: "gpt-6-astra" },
{ id: "gpt-5.6-terra", label: "gpt-5.6-terra" },
{ id: "gpt-5.6-luna", label: "gpt-5.6-luna" },
{ id: "gpt-5.4", label: "gpt-5.4" },
@ -85,10 +116,10 @@ Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to stdin prompt at runtime
- model (string, optional): Codex model id
- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high|xhigh) passed via -c model_reasoning_effort=...
- modelReasoningEffort (string, optional): reasoning effort override passed via -c model_reasoning_effort=...; GPT-6 Astra supports low|medium|high|xhigh|max|ultra
- promptTemplate (string, optional): run prompt template
- search (boolean, optional): run codex with --search
- fastMode (boolean, optional): enable Codex Fast mode; supported on GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and passed through for manual model IDs
- fastMode (boolean, optional): enable Codex Fast mode; supported on GPT-6 Astra, GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and passed through for manual model IDs
- dangerouslyBypassApprovalsAndSandbox (boolean, optional): run with bypass flag
- command (string, optional): defaults to "codex"
- extraArgs (string[], optional): additional CLI args
@ -119,7 +150,7 @@ Notes:
- Paperclip injects desired local skills into the effective CODEX_HOME/skills/ directory at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory. For new and updated agents, Paperclip assigns an isolated managed home at ~/.paperclip/instances/<id>/companies/<companyId>/agents/<agentId>/codex-home/skills/; when CODEX_HOME is explicitly overridden in adapter config, that override is used instead.
- New and updated codex_local agents persist an empty OPENAI_API_KEY override by default so a host-level OPENAI_API_KEY cannot leak into Codex runs through process inheritance. Explicit CODEX_HOME overrides must not point at the shared company codex-home, $CODEX_HOME, or ~/.codex.
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
- Fast mode is supported on GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`.
- Fast mode is supported on GPT-6 Astra, GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`.
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
- Codex ACP is the preferred auto lane when Node >=24.11.0 and the Codex ACP server are available. It reuses shared ACP prompt/runtime guidance, selected skill materialization into CODEX_HOME/skills, model/reasoning/fast-mode session config, and existing quota-window reporting. Auto selection falls back to CLI when ACP prerequisites are unavailable; explicit engine="acp" fails loudly.
`;

View File

@ -489,6 +489,19 @@ describe("codex_local ACP lane", () => {
});
});
it("forwards GPT-6 Astra controls to the ACPX Codex target", () => {
expect(buildCodexAcpConfig({
engine: "acp",
model: "gpt-6-astra",
modelReasoningEffort: "ultra",
fastMode: true,
})).toMatchObject({
model: "gpt-6-astra",
modelReasoningEffort: "ultra",
fastMode: true,
});
});
it("normalizes the legacy bare gpt-5.6 alias to gpt-5.6-sol", () => {
expect(buildCodexAcpConfig({ engine: "acp", model: "gpt-5.6" })).toMatchObject({
model: "gpt-5.6-sol",

View File

@ -2,6 +2,31 @@ import { describe, expect, it } from "vitest";
import { buildCodexExecArgs } from "./codex-args.js";
describe("buildCodexExecArgs", () => {
it("forwards GPT-6 Astra, its ultra reasoning effort, and fast mode", () => {
const result = buildCodexExecArgs({
model: "gpt-6-astra",
modelReasoningEffort: "ultra",
fastMode: true,
});
expect(result.model).toBe("gpt-6-astra");
expect(result.fastModeApplied).toBe(true);
expect(result.fastModeIgnoredReason).toBeNull();
expect(result.args).toEqual([
"exec",
"--json",
"--model",
"gpt-6-astra",
"-c",
'model_reasoning_effort="ultra"',
"-c",
'service_tier="fast"',
"-c",
"features.fast_mode=true",
"-",
]);
});
it("rewrites the legacy bare gpt-5.6 alias to gpt-5.6-sol and applies fast mode", () => {
const result = buildCodexExecArgs({
model: "gpt-5.6",
@ -111,7 +136,7 @@ describe("buildCodexExecArgs", () => {
expect(result.fastModeRequested).toBe(true);
expect(result.fastModeApplied).toBe(false);
expect(result.fastModeIgnoredReason).toContain(
"currently only supported on gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, gpt-5.4 or manually configured model IDs",
"currently only supported on gpt-6-astra, gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, gpt-5.4 or manually configured model IDs",
);
expect(result.args).toEqual([
"exec",

View File

@ -63,6 +63,22 @@ describe("buildCodexLocalConfig", () => {
});
});
it("persists the exact GPT-6 Astra model and supported controls", () => {
const config = buildCodexLocalConfig(
makeValues({
model: "gpt-6-astra",
thinkingEffort: "ultra",
fastMode: true,
}),
);
expect(config).toMatchObject({
model: "gpt-6-astra",
modelReasoningEffort: "ultra",
fastMode: true,
});
});
it("omits model when the operator leaves it blank", () => {
const config = buildCodexLocalConfig(makeValues({ model: "" }));

View File

@ -259,6 +259,7 @@ async function renderForm(
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const onSave = vi.fn();
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
@ -274,7 +275,7 @@ async function renderForm(
<AgentConfigForm
mode="edit"
agent={makeAgent(agentOverrides)}
onSave={vi.fn()}
onSave={onSave}
hidePromptTemplate
content={options.content}
showAdapterTypeField={false}
@ -287,7 +288,7 @@ async function renderForm(
});
await flushReact();
return { container, root };
return { container, root, onSave };
}
async function renderCreateForm(
@ -724,6 +725,84 @@ describe("AgentConfigForm environment selector", () => {
expect(result.container.querySelector("select")).toBeNull();
});
it("renders GPT-6 Astra and its model-specific reasoning efforts", async () => {
mockAgentsApi.adapterModels.mockResolvedValue([
{ id: "gpt-5.6-sol", label: "gpt-5.6-sol" },
{ id: "gpt-6-astra", label: "gpt-6-astra" },
]);
const result = await renderForm(
[makeEnvironment({ id: "local-1", name: "Local", driver: "local" })],
{
adapterConfig: {
model: "gpt-6-astra",
modelReasoningEffort: "ultra",
},
},
);
roots.push(result.root);
expect(result.container.textContent).toContain("gpt-6-astra");
const effortButton = Array.from(result.container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Ultra");
expect(effortButton).not.toBeUndefined();
await act(async () => {
effortButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const effortChoices = Array.from(document.body.querySelectorAll("button"))
.map((button) => button.textContent?.replace(/\s+/g, "").trim());
expect(effortChoices).toContain("Maxmax");
expect(effortChoices).toContain("Ultraultra");
expect(effortChoices).not.toContain("Minimalminimal");
});
it("removes a legacy incompatible effort when the model changes to Astra", async () => {
mockAgentsApi.adapterModels.mockResolvedValue([
{ id: "gpt-5.6-sol", label: "gpt-5.6-sol" },
{ id: "gpt-6-astra", label: "gpt-6-astra" },
]);
const result = await renderForm(
[makeEnvironment({ id: "local-1", name: "Local", driver: "local" })],
{
adapterConfig: {
model: "gpt-5.6-sol",
reasoningEffort: "minimal",
},
},
);
roots.push(result.root);
const modelButton = Array.from(result.container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "gpt-5.6-sol");
expect(modelButton).not.toBeUndefined();
await act(async () => {
modelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const astraOption = Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "gpt-6-astra");
expect(astraOption).not.toBeUndefined();
await act(async () => {
astraOption!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const saveButton = Array.from(result.container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Save");
expect(saveButton).not.toBeUndefined();
await act(async () => {
saveButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(result.onSave).toHaveBeenCalledWith({
adapterConfig: { model: "gpt-6-astra" },
replaceAdapterConfig: true,
});
});
it("keeps secret access out of the main Configuration content", async () => {
const result = await renderForm([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),

View File

@ -83,6 +83,7 @@ import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
import { buildAgentUpdatePatch, omitUndefinedEntries, type AgentConfigOverlay } from "../lib/agent-config-patch";
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
import { resolveForcedKubernetesEnvironment } from "../lib/forced-kubernetes-environment";
import { codexReasoningEffortOptions } from "../lib/codex-reasoning-effort";
/* ---- Create mode values ---- */
@ -200,15 +201,6 @@ function formatArgList(value: unknown): string {
return typeof value === "string" ? value : "";
}
const codexThinkingEffortOptions = [
{ id: "", label: "Auto" },
{ id: "minimal", label: "Minimal" },
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "xhigh", label: "X-High" },
] as const;
const openCodeThinkingEffortOptions = [
{ id: "", label: "Auto" },
{ id: "minimal", label: "Minimal" },
@ -1094,7 +1086,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
: "effort";
const thinkingEffortOptions =
adapterType === "codex_local"
? codexThinkingEffortOptions
? codexReasoningEffortOptions(currentModelId, "Auto").map((option) => ({
id: option.value,
label: option.label,
}))
: adapterType === "cursor"
? cursorModeOptions
: adapterType === "opencode_local"
@ -1564,11 +1559,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
<ModelDropdown
models={models}
value={currentModelId}
onChange={(v) =>
isCreate
? set!({ model: v })
: mark("adapterConfig", "model", v || undefined)
}
onChange={(v) => {
const supportedEfforts = codexReasoningEffortOptions(v, "Auto");
const clearUnsupportedEffort = adapterType === "codex_local"
&& Boolean(currentThinkingEffort)
&& !supportedEfforts.some((option) => option.value === currentThinkingEffort);
if (isCreate) {
set!({
model: v,
...(clearUnsupportedEffort ? { thinkingEffort: "" } : {}),
});
return;
}
mark("adapterConfig", "model", v || undefined);
if (clearUnsupportedEffort) {
mark("adapterConfig", thinkingEffortKey, undefined);
mark("adapterConfig", "reasoningEffort", undefined);
}
}}
open={modelOpen}
onOpenChange={setModelOpen}
allowDefault={adapterType !== "opencode_local"}

View File

@ -2151,7 +2151,7 @@ describe("IssueProperties", () => {
},
]);
mockAgentsApi.adapterModels.mockResolvedValue([
{ id: "gpt-5.5", label: "GPT-5.5" },
{ id: "gpt-6-astra", label: "gpt-6-astra" },
{ id: "gpt-5.4", label: "GPT-5.4" },
]);
@ -2178,7 +2178,7 @@ describe("IssueProperties", () => {
let modelButton: HTMLButtonElement | undefined;
await waitForAssertion(() => {
modelButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("GPT-5.5"));
.find((button) => button.textContent?.includes("gpt-6-astra"));
expect(modelButton).not.toBeUndefined();
});
@ -2189,7 +2189,7 @@ describe("IssueProperties", () => {
expect(onUpdate).toHaveBeenCalledWith({
assigneeAdapterOverrides: {
adapterConfig: {
model: "gpt-5.5",
model: "gpt-6-astra",
modelReasoningEffort: "high",
},
},
@ -2198,6 +2198,67 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("keeps Astra-only task efforts when the task inherits the agent model", async () => {
const onUpdate = vi.fn();
mockAgentsApi.list.mockResolvedValue([
{
id: "agent-1",
name: "Senior Product Engineer",
role: "engineer",
title: null,
status: "active",
adapterType: "codex_local",
adapterConfig: { model: "gpt-6-astra" },
icon: null,
},
]);
mockAgentsApi.adapterModels.mockResolvedValue([
{ id: "gpt-6-astra", label: "gpt-6-astra" },
{ id: "gpt-5.4", label: "GPT-5.4" },
]);
const root = renderProperties(container, {
issue: createIssue({
assigneeAgentId: "agent-1",
assigneeAdapterOverrides: {
adapterConfig: { modelReasoningEffort: "ultra" },
},
}),
childIssues: [],
onUpdate,
});
await flush();
await flush();
expect(container.textContent).toContain("Ultra");
expect(container.textContent).toContain("Max");
expect(container.textContent).not.toContain("Minimal");
const defaultModelButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Default model"));
expect(defaultModelButton).not.toBeUndefined();
await act(async () => {
defaultModelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const defaultModelOption = Array.from(document.body.querySelectorAll("button"))
.filter((button) => button.textContent?.trim() === "Default model")
.at(-1);
expect(defaultModelOption).not.toBeUndefined();
await act(async () => {
defaultModelOption!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({
assigneeAdapterOverrides: {
adapterConfig: { modelReasoningEffort: "ultra" },
},
});
act(() => root.unmount());
});
it("clears existing assignee adapter overrides from the properties pane", async () => {
const onUpdate = vi.fn();
mockAgentsApi.list.mockResolvedValue([

View File

@ -546,6 +546,49 @@ describe("NewIssueDialog", () => {
act(() => root.unmount());
});
it("shows Astra-only efforts when a task inherits the agent model", async () => {
dialogState.newIssueDefaults = {
title: "Use inherited Astra",
assigneeAgentId: "agent-1",
};
mockAgentsApi.list.mockResolvedValue([
{
id: "agent-1",
name: "CodexCoder",
status: "active",
adapterType: "codex_local",
adapterConfig: { model: "gpt-6-astra" },
runtimeConfig: {},
permissions: {},
},
]);
const { root } = renderDialog(container);
await waitForAssertion(() => {
expect(container.textContent).toContain("Codex options");
});
const codexOptionsButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Codex options"));
expect(codexOptionsButton).not.toBeUndefined();
await act(async () => {
codexOptionsButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const customLane = Array.from(container.querySelectorAll('button[role="radio"]'))
.find((button) => button.textContent?.trim() === "Custom");
expect(customLane).not.toBeUndefined();
await act(async () => {
customLane!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("Ultra");
expect(container.textContent).toContain("Max");
expect(container.textContent).not.toContain("Minimal");
act(() => root.unmount());
});
it("warns when the selected assignee is a paused imported agent", async () => {
dialogState.newIssueDefaults = {
title: "Compare onboarding flows",

View File

@ -81,6 +81,7 @@ import { InlineBanner } from "./InlineBanner";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { getTrustPreset } from "../lib/trust-policy-ui";
import { ReusableExecutionWorkspaceSelect } from "./ReusableExecutionWorkspaceSelect";
import { codexReasoningEffortOptions } from "../lib/codex-reasoning-effort";
const DRAFT_KEY = "paperclip:issue-draft";
const DEBOUNCE_MS = 800;
@ -184,14 +185,6 @@ const ISSUE_THINKING_EFFORT_OPTIONS = {
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
],
codex_local: [
{ value: "", label: "Default" },
{ value: "minimal", label: "Minimal" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "X-High" },
],
opencode_local: [
{ value: "", label: "Default" },
{ value: "minimal", label: "Minimal" },
@ -594,6 +587,11 @@ export function NewIssueDialog() {
[agents, selectedAssigneeAgentId],
);
const assigneeAdapterType = selectedAssigneeAgent?.adapterType ?? null;
const assigneePrimaryModel = isRecord(selectedAssigneeAgent?.adapterConfig)
&& typeof selectedAssigneeAgent.adapterConfig.model === "string"
? selectedAssigneeAgent.adapterConfig.model
: "";
const effectiveAssigneeModel = assigneeModelOverride || assigneePrimaryModel;
const supportsAssigneeOverrides = Boolean(
assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType),
);
@ -938,7 +936,7 @@ export function NewIssueDialog() {
}
const validThinkingValues =
assigneeAdapterType === "codex_local"
? ISSUE_THINKING_EFFORT_OPTIONS.codex_local
? codexReasoningEffortOptions(effectiveAssigneeModel)
: assigneeAdapterType === "opencode_local"
? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
@ -948,6 +946,7 @@ export function NewIssueDialog() {
}, [
supportsAssigneeOverrides,
assigneeAdapterType,
effectiveAssigneeModel,
assigneeThinkingEffort,
]);
@ -1194,7 +1193,7 @@ export function NewIssueDialog() {
: "Agent options";
const thinkingEffortOptions =
assigneeAdapterType === "codex_local"
? ISSUE_THINKING_EFFORT_OPTIONS.codex_local
? codexReasoningEffortOptions(effectiveAssigneeModel)
: assigneeAdapterType === "opencode_local"
? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;

View File

@ -34,7 +34,7 @@ export const help: Record<string, string> = {
dangerouslySkipPermissions: "Run unattended by auto-approving adapter permission prompts when supported.",
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
search: "Enable Codex web search capability during runs.",
fastMode: "Enable Codex Fast mode. This burns credits/tokens much faster and is supported on GPT-5.6, GPT-5.5, GPT-5.4, and manual Codex model IDs.",
fastMode: "Enable Codex Fast mode. This burns credits/tokens much faster and is supported on GPT-6 Astra, GPT-5.6, GPT-5.5, GPT-5.4, and manual Codex model IDs.",
workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.",
workspaceBaseRef: "Base git ref used when creating a worktree branch. Leave blank to use the resolved workspace ref or HEAD.",
workspaceBranchTemplate: "Template for naming derived branches. Supports {{issue.identifier}}, {{issue.title}}, {{agent.name}}, {{project.id}}, {{workspace.repoRef}}, and {{slug}}.",

View File

@ -730,6 +730,10 @@ export function IssueProperties({
const assigneeOverrideAdapterConfig = asRecord(assigneeAdapterOverrides?.adapterConfig);
const assigneeOverrideModel =
typeof assigneeOverrideAdapterConfig.model === "string" ? assigneeOverrideAdapterConfig.model : "";
const assigneePrimaryAdapterConfig = asRecord(assignee?.adapterConfig);
const assigneePrimaryModel =
typeof assigneePrimaryAdapterConfig.model === "string" ? assigneePrimaryAdapterConfig.model : "";
const effectiveAssigneeModel = assigneeOverrideModel || assigneePrimaryModel;
const assigneeOverrideThinkingEffort = thinkingEffortValueFor(
assigneeAdapterType,
assigneeOverrideAdapterConfig,
@ -790,6 +794,24 @@ export function IssueProperties({
}
updateAssigneeAdapterOverrides(buildAssigneeOverrideWithConfig(nextConfig));
};
const updateAssigneeOverrideModel = (nextModel: string) => {
const nextConfig: Record<string, unknown> = {
...assigneeOverrideAdapterConfig,
model: nextModel || undefined,
};
if (
assigneeAdapterType === "codex_local"
&& assigneeOverrideThinkingEffort
&& !thinkingEffortOptionsFor(assigneeAdapterType, nextModel || assigneePrimaryModel).some(
(option) => option.value === assigneeOverrideThinkingEffort,
)
) {
delete nextConfig.modelReasoningEffort;
delete nextConfig.reasoningEffort;
delete nextConfig.effort;
}
updateAssigneeAdapterOverrides(buildAssigneeOverrideWithConfig(nextConfig));
};
const setAssigneeOverrideLane = (lane: IssueModelLane) => {
if (lane === "primary") {
updateAssigneeAdapterOverrides(null);
@ -855,13 +877,13 @@ export function IssueProperties({
noneLabel="Default model"
searchPlaceholder="Search models..."
emptyMessage="No models found."
onChange={(model) => updateAssigneeOverrideConfig({ model: model || undefined })}
onChange={updateAssigneeOverrideModel}
/>
</div>
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">Thinking effort</div>
<div className="flex items-center gap-1.5 flex-wrap">
{thinkingEffortOptionsFor(assigneeAdapterType).map((option) => (
{thinkingEffortOptionsFor(assigneeAdapterType, effectiveAssigneeModel).map((option) => (
<button
key={option.value || "default"}
className={cn(

View File

@ -2,6 +2,7 @@ import type { AdapterModel } from "../../api/agents";
import type { Issue, Project } from "@paperclipai/shared";
import { extractProviderIdWithFallback } from "../../lib/model-utils";
import type { IssueModelLane } from "../../lib/issue-assignee-overrides";
import { codexReasoningEffortOptions } from "../../lib/codex-reasoning-effort";
export function defaultProjectWorkspaceIdForProject(project: {
workspaces?: Array<{ id: string; isPrimary: boolean }>;
@ -59,14 +60,6 @@ export const ISSUE_THINKING_EFFORT_OPTIONS = {
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
],
codex_local: [
{ value: "", label: "Default" },
{ value: "minimal", label: "Minimal" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "X-High" },
],
opencode_local: [
{ value: "", label: "Default" },
{ value: "minimal", label: "Minimal" },
@ -90,8 +83,11 @@ export function compactRecord(record: Record<string, unknown>) {
);
}
export function thinkingEffortOptionsFor(adapterType: string | null | undefined) {
if (adapterType === "codex_local") return ISSUE_THINKING_EFFORT_OPTIONS.codex_local;
export function thinkingEffortOptionsFor(
adapterType: string | null | undefined,
model?: string | null,
) {
if (adapterType === "codex_local") return codexReasoningEffortOptions(model);
if (adapterType === "opencode_local") return ISSUE_THINKING_EFFORT_OPTIONS.opencode_local;
return ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
}

View File

@ -0,0 +1,29 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { codexReasoningEffortOptions } from "./codex-reasoning-effort";
describe("codexReasoningEffortOptions", () => {
it("exposes only the supported GPT-6 Astra reasoning efforts", () => {
expect(codexReasoningEffortOptions("gpt-6-astra")).toEqual([
{ value: "", label: "Default" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "X-High" },
{ value: "max", label: "Max" },
{ value: "ultra", label: "Ultra" },
]);
});
it("preserves the existing choices for other and manual models", () => {
expect(codexReasoningEffortOptions("gpt-5.6-sol").map((option) => option.value)).toEqual([
"",
"minimal",
"low",
"medium",
"high",
"xhigh",
]);
});
});

View File

@ -0,0 +1,27 @@
import {
codexLocalReasoningEffortsForModel,
type CodexLocalReasoningEffort,
} from "@paperclipai/adapter-codex-local";
const CODEX_REASONING_EFFORT_LABELS: Record<CodexLocalReasoningEffort, string> = {
minimal: "Minimal",
low: "Low",
medium: "Medium",
high: "High",
xhigh: "X-High",
max: "Max",
ultra: "Ultra",
};
export function codexReasoningEffortOptions(
model: string | null | undefined,
defaultLabel = "Default",
) {
return [
{ value: "", label: defaultLabel },
...codexLocalReasoningEffortsForModel(model).map((value) => ({
value,
label: CODEX_REASONING_EFFORT_LABELS[value],
})),
];
}

View File

@ -82,4 +82,21 @@ describe("buildAssigneeAdapterOverrides", () => {
adapterConfig: { variant: "max" },
});
});
it("persists an exact GPT-6 Astra task override", () => {
expect(
buildAssigneeAdapterOverrides({
adapterType: "codex_local",
lane: "custom",
modelOverride: "gpt-6-astra",
thinkingEffortOverride: "ultra",
chrome: false,
}),
).toEqual({
adapterConfig: {
model: "gpt-6-astra",
modelReasoningEffort: "ultra",
},
});
});
});