From 77312ee2d96f18cd1297c0d644fcaf4b86cc5dcf Mon Sep 17 00:00:00 2001 From: Michael Nguyen <13559011+nguyenm7@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:50:04 -1000 Subject: [PATCH] feat(codex): add GPT-6 Astra support (#12851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../adapters/codex-local/src/index.test.ts | 25 +++++- packages/adapters/codex-local/src/index.ts | 37 ++++++++- .../codex-local/src/server/acp.test.ts | 13 +++ .../codex-local/src/server/codex-args.test.ts | 27 +++++- .../codex-local/src/ui/build-config.test.ts | 16 ++++ .../AgentConfigForm.render.test.tsx | 83 ++++++++++++++++++- ui/src/components/AgentConfigForm.tsx | 38 +++++---- ui/src/components/IssueProperties.test.tsx | 67 ++++++++++++++- ui/src/components/NewIssueDialog.test.tsx | 43 ++++++++++ ui/src/components/NewIssueDialog.tsx | 19 ++--- ui/src/components/agent-config-primitives.tsx | 2 +- .../issue-properties/IssueProperties.tsx | 26 +++++- ui/src/components/issue-properties/helpers.ts | 16 ++-- ui/src/lib/codex-reasoning-effort.test.ts | 29 +++++++ ui/src/lib/codex-reasoning-effort.ts | 27 ++++++ ui/src/lib/issue-assignee-overrides.test.ts | 17 ++++ 16 files changed, 436 insertions(+), 49 deletions(-) create mode 100644 ui/src/lib/codex-reasoning-effort.test.ts create mode 100644 ui/src/lib/codex-reasoning-effort.ts diff --git a/packages/adapters/codex-local/src/index.test.ts b/packages/adapters/codex-local/src/index.test.ts index 659d34c61f..c70529e6da 100644 --- a/packages/adapters/codex-local/src/index.test.ts +++ b/packages/adapters/codex-local/src/index.test.ts @@ -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"); diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index 008c1f0309..537c276654 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -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> = { "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//companies//agents//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. `; diff --git a/packages/adapters/codex-local/src/server/acp.test.ts b/packages/adapters/codex-local/src/server/acp.test.ts index d398e4e57d..b9f79d9d4f 100644 --- a/packages/adapters/codex-local/src/server/acp.test.ts +++ b/packages/adapters/codex-local/src/server/acp.test.ts @@ -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", diff --git a/packages/adapters/codex-local/src/server/codex-args.test.ts b/packages/adapters/codex-local/src/server/codex-args.test.ts index 931dea19ba..3777f5e65e 100644 --- a/packages/adapters/codex-local/src/server/codex-args.test.ts +++ b/packages/adapters/codex-local/src/server/codex-args.test.ts @@ -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", diff --git a/packages/adapters/codex-local/src/ui/build-config.test.ts b/packages/adapters/codex-local/src/ui/build-config.test.ts index 760e276f15..c23e407148 100644 --- a/packages/adapters/codex-local/src/ui/build-config.test.ts +++ b/packages/adapters/codex-local/src/ui/build-config.test.ts @@ -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: "" })); diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index f9d0aaba71..fdc6c1697d 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -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( { 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" }), diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 6af6a1e358..11a07b8f22 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -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) { - 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"} diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 66e9327bb1..c012f1d19b 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -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([ diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index 4e0004a753..fcedbb7376 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -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", diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 34bd5c5407..216bd2e820 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -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; diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index e209036db1..673fe45b2c 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -34,7 +34,7 @@ export const help: Record = { 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}}.", diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index d1af86d380..6e43d07208 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -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 = { + ...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} />
Thinking effort
- {thinkingEffortOptionsFor(assigneeAdapterType).map((option) => ( + {thinkingEffortOptionsFor(assigneeAdapterType, effectiveAssigneeModel).map((option) => (