diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index bfede406dfe..cd669abdfd0 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -115,6 +115,8 @@ pending card. Use **Composer → Model and effort picker** to review harness-specific model choices. Codex uses the curated adapter catalog unless the instance declares `PAPERCLIP_ADAPTER_MODELS`; general OpenAI API models are not Codex choices. +The Paperclip Runner Codex profile shows the same known model effort levels. +Its selected effort is saved with the run and sent to Codex for each turn. The Storybook visual regression suite uses external PNG baselines instead of committed screenshots: diff --git a/packages/paperclip-runner/src/backends/codex-native-backend.ts b/packages/paperclip-runner/src/backends/codex-native-backend.ts index 6f8efb8e15a..2c4aeceee18 100644 --- a/packages/paperclip-runner/src/backends/codex-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-native-backend.ts @@ -145,6 +145,9 @@ function createTransportBackedNativeSessionBackend( return new HarnessDriverBackend( new CodexAppServerDriver({ ...(input.provider.model ? { model: input.provider.model } : {}), + ...(input.provider.kind === "codex" && "reasoningEffort" in input.provider && input.provider.reasoningEffort + ? { reasoningEffort: input.provider.reasoningEffort } + : {}), // Runnerd owns provider permissions for non-Codex facades. Their // Codex-compatible surface must never open a second approval channel. approvalPolicy: diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index 65997242524..a2d3a30c8c5 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -120,6 +120,21 @@ describe("NativeExecutionInputV1", () => { schema: "paperclip.native-execution-input.v4", provider: { kind: "codex", approvalPolicy: "on-request" }, }); + const withEffort = parseNativeExecutionInput({ + ...current, + schema: NATIVE_EXECUTION_INPUT_SCHEMA, + provider: { kind: "codex", model: "gpt-6-astra", approvalPolicy: "on-request", reasoningEffort: "ultra" }, + }); + expect(withEffort.provider).toMatchObject({ kind: "codex", reasoningEffort: "ultra" }); + expect(parseNativeExecutionInput(withEffort)).toEqual(withEffort); + expect(() => parseNativeExecutionInput({ + ...withEffort, + provider: { kind: "codex", model: "gpt-6-astra", approvalPolicy: "on-request", reasoningEffort: "impossible" }, + })).toThrow("reasoningEffort"); + expect(() => parseNativeExecutionInput({ + ...current, + provider: { kind: "codex", model: null, approvalPolicy: "on-request", reasoningEffort: "ultra" }, + })).toThrow("input.provider"); expect(() => parseNativeExecutionInput({ ...parsed, schema: "paperclip.native-execution-input.v4", diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index 2d1ec54a8b2..9d089fde854 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -140,6 +140,10 @@ export type NativeProviderConfigV4 = profile: NativeAcpxProfileSnapshot; }; +export type NativeProviderConfigV5 = + | Exclude + | (Extract & { reasoningEffort?: string }); + export interface NativeExecutionInputV1 { schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V1; binding: { @@ -217,8 +221,9 @@ export interface NativeCompletionSources { criteria: Array<{ id: string; source: NativeCompletionSource }>; } -export interface NativeExecutionInputV5 extends Omit { +export interface NativeExecutionInputV5 extends Omit { schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA; + provider: NativeProviderConfigV5; completionSources?: NativeCompletionSources; } @@ -447,7 +452,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput : provider.kind === "acpx" ? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile"] : provider.kind === "codex" && isV4 - ? ["kind", "model", "approvalPolicy"] + ? ["kind", "model", "approvalPolicy", ...(isV5 ? ["reasoningEffort"] : [])] : provider.kind === "opencode" && isV4 ? ["kind", "model", "permissionMode"] : ["kind", "model"], @@ -468,7 +473,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput if (provider.kind === "opencode" && (providerModel === null || !providerModel.includes("/"))) { throw new NativeExecutionInputError("input.provider.model is required for opencode in provider/model form"); } - let parsedProvider: NativeProviderConfig; + let parsedProvider: NativeProviderConfig | NativeProviderConfigV5; if (provider.kind === "claude_managed") { if (providerModel === null) { throw new NativeExecutionInputError("input.provider.model is required for claude_managed"); @@ -640,12 +645,18 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput if (isV4 && provider.approvalPolicy !== "never" && provider.approvalPolicy !== "on-request" && provider.approvalPolicy !== "untrusted") { throw new NativeExecutionInputError("input.provider.approvalPolicy must be never, on-request, or untrusted"); } + if (isV5 && provider.reasoningEffort !== undefined && !["minimal", "low", "medium", "high", "xhigh", "max", "ultra"].includes(String(provider.reasoningEffort))) { + throw new NativeExecutionInputError("input.provider.reasoningEffort is unsupported"); + } parsedProvider = { kind: "codex", model: providerModel, ...(isV4 ? { approvalPolicy: provider.approvalPolicy as NativeCodexApprovalPolicy } : {}), + ...(isV5 && provider.reasoningEffort !== undefined + ? { reasoningEffort: text(provider.reasoningEffort, "input.provider.reasoningEffort") } + : {}), }; } if (!Array.isArray(contract.criteria) || contract.criteria.length === 0) { @@ -761,6 +772,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput return { ...withPermissions, schema: NATIVE_EXECUTION_INPUT_SCHEMA, + provider: parsedProvider as NativeProviderConfigV5, ...(input.completionSources !== undefined ? { completionSources: parseCompletionSources(input.completionSources) } : {}), }; } diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts index 0fba00ebad7..da221eff7fe 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts @@ -930,6 +930,7 @@ export class CodexAppServerDriver implements HarnessDriver { goalCapability: this.#goalCapability, dynamicTools: this.#providerDynamicTools(), skillInputs: this.#options.skillInputs, + reasoningEffort: this.#options.reasoningEffort, dynamicToolHandler: this.#options.dynamicToolHandler, completionFeedback: this.#options.completionFeedback, }); diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts index 36480ed9f01..bc000a79251 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts @@ -1436,6 +1436,18 @@ describe("Codex app-server Codex driver", () => { }); }); + it("sends the selected reasoning effort with the Codex turn", async () => { + const transport = new FakeCodexTransport(); + const session = await makeDriver([transport], { model: "gpt-6-astra", reasoningEffort: "ultra" }).openSession({ + runId: "run-effort", + normalizedSessionId: "session-effort", + workingDirectory: TEST_WORKING_DIRECTORY, + }); + await session.startTurn({ message: { role: "user", text: "Continue" } }); + expect(transport.calls.find((call) => call.method === "turn/start")?.params).toMatchObject({ effort: "ultra" }); + await session.close({ reason: "test complete" }); + }); + it("fails closed when the installed app-server does not confirm plan mode", async () => { const transport = new FakeCodexTransport(); transport.confirmCollaborationMode = false; diff --git a/packages/paperclip-runner/src/drivers/codex/codex-driver-types.ts b/packages/paperclip-runner/src/drivers/codex/codex-driver-types.ts index 12e6265b6ee..77303d77be4 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-driver-types.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-driver-types.ts @@ -17,6 +17,8 @@ export interface CodexAppServerDriverOptions { taskEnvelope: CodexTaskEnvelope; /** Explicit provider model selected by the persisted native execution. */ model?: string; + /** Per-run reasoning effort sent with each Codex turn, including resumed turns. */ + reasoningEffort?: string; approvalPolicy?: "never" | "on-request" | "untrusted"; baseInstructions?: string; includeSkillInstructions?: boolean; diff --git a/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts b/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts index 5aecc4dd8ab..a7843d516d4 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts @@ -221,6 +221,7 @@ export class CodexHarnessSession try { response = await this.transport.request("turn/start", { threadId: this.opened.threadId, + ...(this.reasoningEffort ? { effort: this.reasoningEffort } : {}), cwd: this.opened.context.workingDirectory, permissions: text(record(record(this.opened.context.sandbox).permissionProfile).id) || (requestedMode === "plan" diff --git a/packages/paperclip-runner/src/drivers/codex/codex-session-state.ts b/packages/paperclip-runner/src/drivers/codex/codex-session-state.ts index 3664ce23edb..318ad4cc7b5 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-session-state.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-session-state.ts @@ -101,6 +101,7 @@ export class CodexSessionState { readonly goalReasonCode: string | null; readonly goalReason: string | null; readonly skillInputs: NonNullable; + readonly reasoningEffort: string | undefined; readonly dynamicTools: readonly Readonly>[]; readonly completionFeedback: CodexAppServerDriverOptions["completionFeedback"]; readonly dynamicToolHandler: CodexAppServerDriverOptions["dynamicToolHandler"]; @@ -174,6 +175,7 @@ export class CodexSessionState { goalReasonCode: string | null; goalReason: string | null; skillInputs?: CodexAppServerDriverOptions["skillInputs"]; + reasoningEffort?: string; dynamicTools: readonly Readonly>[]; completionFeedback?: CodexAppServerDriverOptions["completionFeedback"]; dynamicToolHandler?: CodexAppServerDriverOptions["dynamicToolHandler"]; @@ -198,6 +200,7 @@ export class CodexSessionState { this.goalReasonCode = input.goalReasonCode; this.goalReason = input.goalReason; this.skillInputs = structuredClone(input.skillInputs ?? []); + this.reasoningEffort = input.reasoningEffort; this.dynamicTools = input.dynamicTools; this.dynamicToolHandler = input.dynamicToolHandler; this.completionFeedback = input.completionFeedback; diff --git a/server/src/services/heartbeat-runner-provider-config.test.ts b/server/src/services/heartbeat-runner-provider-config.test.ts index 2b589af21aa..da0a675adbe 100644 --- a/server/src/services/heartbeat-runner-provider-config.test.ts +++ b/server/src/services/heartbeat-runner-provider-config.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { assertAgentCoreProfileRecoveryBinding, assertManagedProfileRecoveryBinding, + projectPaperclipRunnerTaskConfig, resolvePaperclipRunnerNativeProviderInput, } from "./native-runtime/provider-profile.js"; @@ -44,6 +45,45 @@ describe("Paperclip Runner native provider configuration", () => { ).toThrow("codexPermissionMode set to never"); }); + it("projects Codex effort only when the selected model supports it", () => { + expect(resolvePaperclipRunnerNativeProviderInput({ + backend: "codex_app_server", + adapterConfig: { provider: "codex", model: "gpt-6-astra", modelReasoningEffort: "ultra" }, + })).toMatchObject({ provider: "codex", model: "gpt-6-astra", codexReasoningEffort: "ultra" }); + expect(() => resolvePaperclipRunnerNativeProviderInput({ + backend: "codex_app_server", + adapterConfig: { provider: "codex", model: "gpt-6-luna", modelReasoningEffort: "ultra" }, + })).toThrow("reasoning effort is not supported"); + }); + + it("uses only task model and effort overrides without changing the Runner provider", () => { + const base = { provider: "codex", model: "gpt-6-astra", modelReasoningEffort: "ultra", codexPermissionMode: "never" }; + const projected = projectPaperclipRunnerTaskConfig("codex_app_server", base, { + provider: "opencode", model: "gpt-6-luna", managedProfileId: "other-profile", + }); + expect(projected).toEqual({ provider: "codex", model: "gpt-6-luna", codexPermissionMode: "never" }); + expect(resolvePaperclipRunnerNativeProviderInput({ + backend: "codex_app_server", adapterConfig: projected, + })).toEqual({ provider: "codex", model: "gpt-6-luna", codexApprovalPolicy: "never" }); + expect(projectPaperclipRunnerTaskConfig("codex_app_server", base, { modelReasoningEffort: "high" })) + .toMatchObject({ provider: "codex", model: "gpt-6-astra", modelReasoningEffort: "high" }); + }); + + it("uses an OpenCode task model without changing the Runner provider", () => { + const projected = projectPaperclipRunnerTaskConfig( + "opencode_server", + { provider: "opencode", model: "openrouter/qwen/qwen3-coder-next", opencodePermissionMode: "deny" }, + { provider: "codex", model: "openrouter/deepseek/deepseek-v4-flash-0731", modelReasoningEffort: "ultra" }, + ); + expect(projected).toEqual({ + provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731", opencodePermissionMode: "deny", + }); + expect(resolvePaperclipRunnerNativeProviderInput({ backend: "opencode_server", adapterConfig: projected })) + .toEqual({ + provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731", opencodePermissionMode: "deny", + }); + }); + it("requires persisted Claude recovery to use the qualified identity and current profile secret", () => { const stored = { id: "00000000-0000-4000-8000-000000000001", diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 31226e6fe4b..5202327ddf3 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -253,6 +253,7 @@ import { import { assertAgentCoreProfileRecoveryBinding, assertManagedProfileRecoveryBinding, + projectPaperclipRunnerTaskConfig, resolvePaperclipRunnerNativeProviderInput, } from "./native-runtime/provider-profile.js"; import { @@ -23771,7 +23772,14 @@ export function heartbeatService( : null, ...resolvePaperclipRunnerNativeProviderInput({ backend: nativeRuntimeResolution.profile.backend, - adapterConfig: agent.adapterConfig, + adapterConfig: nativeRuntimeResolution.profile.backend === "codex_app_server" + || nativeRuntimeResolution.profile.backend === "opencode_server" + ? projectPaperclipRunnerTaskConfig( + nativeRuntimeResolution.profile.backend, + agent.adapterConfig, + issueAssigneeOverrides?.adapterConfig, + ) + : agent.adapterConfig, managedProfile, agentCoreProfile, }), diff --git a/server/src/services/native-runtime/native-execution-input.test.ts b/server/src/services/native-runtime/native-execution-input.test.ts index 79d93e14ad5..9df5a0f51fe 100644 --- a/server/src/services/native-runtime/native-execution-input.test.ts +++ b/server/src/services/native-runtime/native-execution-input.test.ts @@ -18,11 +18,14 @@ describe("LCA-05 explicit native work mode", () => { taskPrompt: text, workspace: { id: "50000000-0000-4000-8000-000000000005", cwd: "/workspace", repoUrl: null, repoRef: null, branchName: null }, normalizedSessionId: null, + provider: "codex", + codexReasoningEffort: "high", planningContext: workMode === "planning" ? { documentId: null, baseRevisionId: null, baseRevisionNumber: 0, markdown: "", sha256: "a".repeat(64), reviewContext: {} } : null, completionContract: { id: "70000000-0000-4000-8000-000000000007", sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1", contract: { revision: "1", objective: "Deliver the requested work", criteria: [{ id: "output", requirement: "Deliver the requested work" }] } }, runtimeContext: nativeRuntimeContextFixture(), }); expect(input.task.workMode).toBe(workMode); + expect(input.provider).toMatchObject({ kind: "codex", reasoningEffort: "high" }); expect(input.executionMode).toBe(workMode === "planning" ? "plan" : "default"); expect(input.task.title).toBe(text); } diff --git a/server/src/services/native-runtime/native-execution-input.ts b/server/src/services/native-runtime/native-execution-input.ts index 3ec83f06617..27f7c9f2c36 100644 --- a/server/src/services/native-runtime/native-execution-input.ts +++ b/server/src/services/native-runtime/native-execution-input.ts @@ -66,6 +66,7 @@ export function buildNativeExecutionInput(input: { provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx"; acpxAgent?: NativeAcpxAgent; codexApprovalPolicy?: NativeCodexApprovalPolicy; + codexReasoningEffort?: string; opencodePermissionMode?: NativeOpenCodePermissionMode; acpxPermissionMode?: NativeAcpxPermissionMode; model?: string | null; @@ -281,6 +282,7 @@ export function buildNativeExecutionInput(input: { kind: "codex", model: input.model ?? null, approvalPolicy: input.codexApprovalPolicy ?? "never", + ...(input.codexReasoningEffort ? { reasoningEffort: input.codexReasoningEffort } : {}), }, completionContract: { id: input.completionContract.id, diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts index 2a6f6ed12d9..371c04ac593 100644 --- a/server/src/services/native-runtime/provider-profile.ts +++ b/server/src/services/native-runtime/provider-profile.ts @@ -5,6 +5,10 @@ import { resolvePaperclipRunnerPermissionMode, type PaperclipRunnerProvider, } from "@paperclipai/adapter-utils"; +import { + codexLocalReasoningEffortsForModel, + isCodexLocalKnownModel, +} from "@paperclipai/adapter-codex-local"; import { AGENTCORE_QUALIFIED_MODEL, CLAUDE_MANAGED_QUALIFIED_MODEL, @@ -63,6 +67,7 @@ export type PaperclipRunnerNativeProviderInput = provider: "codex"; model: string | null; codexApprovalPolicy: "never" | "on-request" | "untrusted"; + codexReasoningEffort?: string; } | { provider: "opencode"; @@ -138,6 +143,29 @@ function optionalString(value: unknown): string | null { : null; } +/** A task may change a local Runner model (and Codex effort), but not provider identity. */ +export function projectPaperclipRunnerTaskConfig( + backend: "codex_app_server" | "opencode_server", + agentConfig: unknown, + taskOverrides: unknown, +): Record { + const base = asRecord(agentConfig); + const task = asRecord(taskOverrides); + const config = { ...base }; + const model = optionalString(task.model); + const effortKey = backend === "codex_app_server" + ? ["modelReasoningEffort", "reasoningEffort", "effort"].find((key) => key in task) + : undefined; + if (backend === "codex_app_server" && ((model !== null && model !== optionalString(base.model)) || effortKey !== undefined)) { + delete config.modelReasoningEffort; + delete config.reasoningEffort; + delete config.effort; + } + if (model !== null) config.model = model; + if (effortKey !== undefined) config.modelReasoningEffort = task[effortKey]; + return config; +} + function positiveNumberOrNull( value: unknown, code: string, @@ -650,6 +678,18 @@ export function resolvePaperclipRunnerNativeProviderInput(input: { }, }; } + const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort; + const allowedEfforts = ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]; + if (effort !== undefined && effort !== null && effort !== "" && ( + typeof effort !== "string" || !allowedEfforts.includes(effort) + || (profile.model && isCodexLocalKnownModel(profile.model) + && !codexLocalReasoningEffortsForModel(profile.model).includes(effort as "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra")) + )) { + throw new PaperclipRunnerProviderProfileError( + "paperclip_runner_codex_effort_invalid", + "Paperclip Runner Codex reasoning effort is not supported for this model.", + ); + } return { provider: "codex", model: profile.model, @@ -657,5 +697,6 @@ export function resolvePaperclipRunnerNativeProviderInput(input: { "codex", config.codexPermissionMode, ) as "never" | "on-request" | "untrusted", + ...(typeof effort === "string" && effort ? { codexReasoningEffort: effort } : {}), }; } diff --git a/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx b/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx index d16e52a42c7..f3b6025126a 100644 --- a/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx +++ b/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx @@ -136,7 +136,8 @@ export function ComposerRunSettingsPicker({ const filteredModels = models.filter((item) => `${item.label} ${item.id}`.toLowerCase().includes(query.toLowerCase())); const exactMatch = models.some((item) => item.id.toLowerCase() === query.toLowerCase()); - const needsProvider = agent && ["opencode_local", "pi_local", "kimi_local"].includes(agent.adapterType); + const needsProvider = agent && (["opencode_local", "pi_local", "kimi_local"].includes(agent.adapterType) + || (agent.adapterType === "paperclip_runner" && provider === "opencode")); const manualValid = query.length > 0 && !/\s/.test(query) && (!needsProvider || /^[^/]+\/.+[^/]$/.test(query)) && (provider !== "openrouter" || /^openrouter\/[^/]+\/.+[^/]$/.test(query)); diff --git a/ui/src/components/task-chat/composer-run-settings.test.ts b/ui/src/components/task-chat/composer-run-settings.test.ts index 8f57ee20b7a..7e55eb5e7ba 100644 --- a/ui/src/components/task-chat/composer-run-settings.test.ts +++ b/ui/src/components/task-chat/composer-run-settings.test.ts @@ -12,6 +12,8 @@ const agent = (adapterType: Agent["adapterType"], provider?: string) => ({ describe("composer run settings", () => { it("shows only effort levels known for the selected harness and model", () => { expect(composerEfforts(agent("codex_local"), "gpt-6-astra", [])).toContain("ultra"); + expect(composerEfforts(agent("paperclip_runner", "codex"), "gpt-6-astra", [])).toContain("ultra"); + expect(composerEfforts(agent("paperclip_runner", "opencode"), "gpt-6-astra", [])).toEqual([]); expect(composerEfforts(agent("codex_local"), "custom-private-model", [])).toEqual([]); expect(composerEfforts(agent("opencode_local"), "openrouter/x/y", ["openrouter/x/y"])).toEqual([]); expect(composerEfforts(agent("kimi_local"), "kimi-code/k3", ["kimi-code/k3"])).toEqual(["low", "high", "max"]); @@ -32,5 +34,8 @@ describe("composer run settings", () => { expect(readComposerRunSettings({ adapterConfig: { reasoningEffort: "xhigh" } }, "codex_local").effort).toBe("xhigh"); expect(mergeComposerRunSettings(previous, "codex_local", { model: "gpt-6-astra", effort: "ultra", fast: true }, true)) .toEqual({ adapterConfig: { model: "gpt-6-astra", modelReasoningEffort: "ultra", fastMode: true } }); + const runner = mergeComposerRunSettings(null, "paperclip_runner", { model: "gpt-6-astra", effort: "ultra", fast: false }); + expect(runner).toEqual({ adapterConfig: { model: "gpt-6-astra", modelReasoningEffort: "ultra" } }); + expect(readComposerRunSettings(runner, "paperclip_runner").effort).toBe("ultra"); }); }); diff --git a/ui/src/components/task-chat/composer-run-settings.ts b/ui/src/components/task-chat/composer-run-settings.ts index 7e1da94a42b..d24a2c414af 100644 --- a/ui/src/components/task-chat/composer-run-settings.ts +++ b/ui/src/components/task-chat/composer-run-settings.ts @@ -35,7 +35,7 @@ export function composerCatalogProvider(agent: Agent | undefined): string | unde export function composerEfforts(agent: Agent | undefined, model: string, catalogIds: readonly string[]): readonly string[] { if (!agent || !model) return []; - if (agent.adapterType === "codex_local") { + if (agent.adapterType === "codex_local" || (agent.adapterType === "paperclip_runner" && composerCatalogProvider(agent) === "codex")) { return isCodexLocalKnownModel(model) ? codexLocalReasoningEffortsForModel(model) : []; } if (!catalogIds.includes(model)) return []; @@ -53,7 +53,7 @@ export function readComposerRunSettings(overrides: IssueAssigneeAdapterOverrides const config = overrides?.adapterConfig ?? {}; const effortKey = composerEffortKey(adapterType); const effortValue = effortKey && (config[effortKey] - ?? (adapterType === "codex_local" ? config.reasoningEffort ?? config.effort : undefined)); + ?? (adapterType === "codex_local" || adapterType === "paperclip_runner" ? config.reasoningEffort ?? config.effort : undefined)); return { model: typeof config.model === "string" ? config.model : null, effort: typeof effortValue === "string" ? effortValue : null, @@ -62,7 +62,7 @@ export function readComposerRunSettings(overrides: IssueAssigneeAdapterOverrides } function composerEffortKey(adapterType: string | undefined): string | null { - if (adapterType === "codex_local") return "modelReasoningEffort"; + if (adapterType === "codex_local" || adapterType === "paperclip_runner") return "modelReasoningEffort"; if (adapterType === "claude_local" || adapterType === "kimi_local") return "effort"; if (adapterType === "pi_local") return "thinking"; if (adapterType === "opencode_local") return "variant"; diff --git a/ui/storybook/prototypes/composer-model-picker/fixtures.ts b/ui/storybook/prototypes/composer-model-picker/fixtures.ts index 0e7a01136cd..e76cc91253f 100644 --- a/ui/storybook/prototypes/composer-model-picker/fixtures.ts +++ b/ui/storybook/prototypes/composer-model-picker/fixtures.ts @@ -90,7 +90,7 @@ export function effortChoices(agent: ComposerAgent, model: string): readonly str } export function fastModeAvailable(agent: ComposerAgent, model: string): boolean { - return (agent.adapterType === "codex_local" || agent.id === "runner") + return agent.adapterType === "codex_local" && isCodexLocalKnownModel(model) && isCodexLocalFastModeSupported(model); } diff --git a/ui/storybook/stories/composer-model-picker.stories.tsx b/ui/storybook/stories/composer-model-picker.stories.tsx index ec421f5faf8..7038aaa022f 100644 --- a/ui/storybook/stories/composer-model-picker.stories.tsx +++ b/ui/storybook/stories/composer-model-picker.stories.tsx @@ -139,6 +139,10 @@ export const RunnerCodexProfile: Story = { name: "16 · Runner · Codex profile", args: { agentId: "runner", initialPanel: "models" }, }; +export const RunnerCodexEffort: Story = { + name: "16b · Runner · Codex effort", + args: { agentId: "runner", initialPanel: "settings", initialModel: "gpt-6-astra", initialEffort: "high" }, +}; export const GrokModelOnly: Story = { name: "17 · Grok · model only", args: { agentId: "grok", initialPanel: "settings" }, @@ -250,6 +254,16 @@ export const ProductionComposer: Story = { }, }; +export const ProductionRunnerCodexEffort: Story = { + name: "23a · App Runner picker with Codex effort", + render: () => , + play: async ({ canvasElement }) => { + const screen = within(canvasElement.ownerDocument.body); + await expect(screen.getByRole("slider", { name: "Effort" })).toBeVisible(); + await expect(screen.getByTestId("selected-effort")).toHaveTextContent("High"); + }, +}; + export const ProductionMobileComposer: Story = { name: "23b · App picker on mobile", render: () => , @@ -262,6 +276,17 @@ export const ProductionMobileComposer: Story = { }, }; +export const ProductionMobileRunnerCodexEffort: Story = { + name: "23c · App Runner Codex effort on mobile", + render: () => , + globals: { viewport: { value: "mobile1", isRotated: false } }, + play: async ({ canvasElement }) => { + const screen = within(canvasElement.ownerDocument.body); + await expect(screen.getByTestId("composer-mobile-dialog")).toBeVisible(); + await expect(screen.getByRole("slider", { name: "Effort" })).toBeVisible(); + }, +}; + export const IntermediateWidthPlan: Story = { name: "24 · Narrow desktop with Plan and open picker", args: { compact: true, initialMode: "planning", initialPanel: "settings" },