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 NguyenandPaperclip authored and GitHub committed 2026-09-04 11:50:04 -10:00
1 parent d593463ab6
commit 77312ee2d9
16 files changed
+436 -49

No files matched your search

@@ -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" }),
+23 -15
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"}
+64 -3
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([
+43
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",
+9 -10
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;
@@ -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}}.",
@@ -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(
+6 -10
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;
}