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:
parent
d593463ab6
commit
77312ee2d9
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "" }));
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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],
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue