diff --git a/src/renderer/components/thread/ThreadDraftView.test.tsx b/src/renderer/components/thread/ThreadDraftView.test.tsx index 917788fa9..74f69d8d2 100644 --- a/src/renderer/components/thread/ThreadDraftView.test.tsx +++ b/src/renderer/components/thread/ThreadDraftView.test.tsx @@ -1,4 +1,10 @@ -import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; +import { + Children, + isValidElement, + type ComponentProps, + type ReactElement, + type ReactNode, +} from "react"; import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithI18n as render } from "@/renderer/testUtils/i18n"; @@ -52,6 +58,19 @@ const project: Project = { createdAt: "2026-03-28T00:00:00.000Z", }; +const legacyCodexProject: Project = { + ...project, + lastDraftConfig: { + agentKind: "codex", + model: "gpt-5.4", + effort: "high", + mode: "agent", + approvalPolicy: "on-request", + approvalsReviewer: "auto_review", + sandboxMode: "workspace-write", + }, +}; + const remoteProject: Project = { ...project, id: "remote-project", @@ -125,6 +144,22 @@ const dualModeCodexStatus: AgentStatus = { }, }; +const contextualCodexStatus: AgentStatus = { + ...dualModeCodexStatus, + capabilities: { + ...dualModeCodexStatus.capabilities, + contextSizes: [ + { id: "272k", label: "272k" }, + { id: "400k", label: "400k" }, + { id: "1m", label: "1M" }, + ], + modelContextSizes: { + "gpt-5.4": ["272k", "400k", "1m"], + }, + defaultContextSize: "272k", + }, +}; + const geminiStatus: AgentStatus = { kind: "gemini", label: "Gemini", @@ -293,6 +328,23 @@ const acpGenericStatus: AgentStatus = { }, }; +function StoreBackedThreadDraftView(props: { + onStart: ComponentProps["onStart"]; +}) { + const storedProject = useAppStore((state) => + state.projects.find((candidate) => candidate.id === project.id), + ); + if (!storedProject) return null; + return ( + + ); +} + function classNameIncludes(element: HTMLElement, value: string): boolean { return typeof element.className === "string" && element.className.includes(value); } @@ -1098,6 +1150,123 @@ describe("ThreadDraftView", () => { }); }); + it("inherits the saved Codex context window when the project draft predates it", async () => { + const onStart = vi.fn<(input: unknown) => void>(); + useSharedSettings.setState({ sharedSettingsHydrated: false, providerConfigs: {} }); + useAppStore.setState({ projects: [legacyCodexProject] }); + + render(); + + await waitFor(() => { + const props = composerSpy.mock.lastCall?.[0] as { + controls: Array<{ kind?: string; contextValue?: string }>; + }; + const effortContext = props.controls.find((control) => control.kind === "effort-context"); + expect(effortContext?.contextValue).toBe("272k"); + }); + + const initialProps = composerSpy.mock.lastCall?.[0] as { + controls: Array<{ kind?: string; onEffortChange?: (value: string) => void }>; + }; + const initialEffortContext = initialProps.controls.find( + (control) => control.kind === "effort-context", + ); + act(() => initialEffortContext?.onEffortChange?.("xhigh")); + + act(() => { + useSharedSettings.setState({ + providerConfigs: { + codex: { + model: "gpt-5.4", + effort: "medium", + contextSize: "400k", + mode: "agent", + approvalPolicy: "on-request", + approvalsReviewer: "auto_review", + sandboxMode: "workspace-write", + }, + }, + sharedSettingsHydrated: true, + }); + }); + + await waitFor(() => { + const props = composerSpy.mock.lastCall?.[0] as { + controls: Array<{ kind?: string; contextValue?: string; effortValue?: string }>; + }; + const effortContext = props.controls.find((control) => control.kind === "effort-context"); + expect(effortContext?.contextValue).toBe("400k"); + expect(effortContext?.effortValue).toBe("xhigh"); + }); + + fireEvent.click(screen.getByText("set-prompt")); + fireEvent.click(screen.getByText("submit")); + + expect(onStart).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ contextSize: "400k", effort: "xhigh" }), + }), + ); + }); + + it("keeps an explicit Codex context choice made before settings hydrate", async () => { + const onStart = vi.fn<(input: unknown) => void>(); + useSharedSettings.setState({ sharedSettingsHydrated: false, providerConfigs: {} }); + useAppStore.setState({ projects: [legacyCodexProject] }); + + render(); + + await waitFor(() => { + const props = composerSpy.mock.lastCall?.[0] as { + controls: Array<{ kind?: string; contextValue?: string }>; + }; + const effortContext = props.controls.find((control) => control.kind === "effort-context"); + expect(effortContext?.contextValue).toBe("272k"); + }); + + const initialProps = composerSpy.mock.lastCall?.[0] as { + controls: Array<{ kind?: string; onContextChange?: (value: string) => void }>; + }; + const initialEffortContext = initialProps.controls.find( + (control) => control.kind === "effort-context", + ); + act(() => initialEffortContext?.onContextChange?.("1m")); + + act(() => { + useSharedSettings.setState({ + providerConfigs: { + codex: { + model: "gpt-5.4", + effort: "medium", + contextSize: "400k", + mode: "agent", + approvalPolicy: "on-request", + approvalsReviewer: "auto_review", + sandboxMode: "workspace-write", + }, + }, + sharedSettingsHydrated: true, + }); + }); + + await waitFor(() => { + const props = composerSpy.mock.lastCall?.[0] as { + controls: Array<{ kind?: string; contextValue?: string }>; + }; + const effortContext = props.controls.find((control) => control.kind === "effort-context"); + expect(effortContext?.contextValue).toBe("1m"); + }); + + fireEvent.click(screen.getByText("set-prompt")); + fireEvent.click(screen.getByText("submit")); + + expect(onStart).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ contextSize: "1m" }), + }), + ); + }); + it("submits an explicit Fast-off selection in the launch config", async () => { const onStart = vi.fn<(input: unknown) => void>(); diff --git a/src/renderer/components/thread/ThreadDraftView.tsx b/src/renderer/components/thread/ThreadDraftView.tsx index ae198a6d7..ab91f9d50 100644 --- a/src/renderer/components/thread/ThreadDraftView.tsx +++ b/src/renderer/components/thread/ThreadDraftView.tsx @@ -225,7 +225,17 @@ export function ThreadDraftView(props: { installedAgents.find((status) => status.kind === effectiveAgentKind) ?? installedAgents[0]; const [model, setModel] = useState(""); const [effort, setEffort] = useState(""); - const [contextSize, setContextSize] = useState(undefined); + const [contextSize, setContextSize] = useState(() => { + if ( + lastDraftConfig && + lastDraftConfig.agentKind === preferredAgentKind && + lastDraftConfig.contextSize + ) { + return lastDraftConfig.contextSize; + } + if (!preferredAgentKind || isHomeScope) return undefined; + return useSharedSettings.getState().providerConfigs[preferredAgentKind]?.contextSize; + }); const [fast, setFast] = useState(false); const [thinking, setThinking] = useState(false); const [mode, setMode] = useState<"agent" | "plan" | "autopilot">("agent"); @@ -315,7 +325,9 @@ export function ThreadDraftView(props: { const setProviderConfig = useSharedSettings((s) => s.setProviderConfig); const effectiveAgentKindRef = useRef(effectiveAgentKind); const providerConfigsRef = useRef>({}); + const initialLastDraftConfigRef = useRef(lastDraftConfig); const hasLocalConfigEditRef = useRef(false); + const hasLocalContextEditRef = useRef(false); effectiveAgentKindRef.current = effectiveAgentKind; // Spread is required: the effects below mutate `providerConfigsRef.current[kind]` // in place to keep effort/model selections in sync mid-render. Assigning the @@ -505,22 +517,63 @@ export function ThreadDraftView(props: { ]); useEffect(() => { - if (isHomeScope || !sharedSettingsHydrated || hasLocalConfigEditRef.current) { + if (isHomeScope || !sharedSettingsHydrated) { return; } if (!selectedAgentForConfig || !effectiveAgentKind) { return; } - if (lastDraftConfig?.agentKind === effectiveAgentKind && lastDraftConfig.model.trim()) { + const initialLastDraftConfig = initialLastDraftConfigRef.current; + const hasInitialProjectDraft = + initialLastDraftConfig?.agentKind === effectiveAgentKind && + Boolean(initialLastDraftConfig.model.trim()); + const hasInitialContext = hasInitialProjectDraft && Boolean(initialLastDraftConfig.contextSize); + const shouldInheritContext = !hasInitialContext && !hasLocalContextEditRef.current; + if (hasLocalConfigEditRef.current && !shouldInheritContext) { + return; + } + if (hasInitialContext) { return; } - const saved = useSharedSettings.getState().providerConfigs[effectiveAgentKind]; - if (!saved) { + const providerConfigs = useSharedSettings.getState().providerConfigs; + const providerConfig = providerConfigs[effectiveAgentKind]; + if (!providerConfig) { + return; + } + providerConfigsRef.current = { ...providerConfigs }; + + if (hasLocalConfigEditRef.current) { + const nextContext = resolveContextSizeValue( + selectedAgentForConfig, + model, + providerConfig.contextSize, + ); + if (nextContext === contextSize) { + return; + } + setContextSize(nextContext); + updateProjectDraftConfig(project.id, { + agentKind: effectiveAgentKind, + model, + effort, + ...(nextContext ? { contextSize: nextContext } : {}), + ...(supportsUsableFastMode(selectedAgentForConfig.capabilities, model) ? { fast } : {}), + ...(selectedAgentForConfig.capabilities.thinkingModels?.includes(model) + ? { thinking } + : {}), + mode, + approvalPolicy, + approvalsReviewer, + sandboxMode, + worktreeMode: effectiveWorktreeMode, + }); return; } - providerConfigsRef.current = { ...useSharedSettings.getState().providerConfigs }; + const saved = hasInitialProjectDraft + ? resolveSavedProviderDraftConfig(effectiveAgentKind, initialLastDraftConfig, providerConfigs) + : providerConfig; const resolved = resolveProviderDraftConfig(selectedAgentForConfig, saved); const nextModel = resolved.model; const nextEffort = resolved.effort ?? ""; @@ -558,15 +611,16 @@ export function ThreadDraftView(props: { lastAppliedAgentKindRef.current = effectiveAgentKind; if ( - saved.model !== nextModel || - saved.effort !== nextEffort || - saved.contextSize !== nextContext || - saved.fast !== nextFast || - saved.thinking !== nextThinking || - saved.mode !== nextMode || - saved.approvalPolicy !== nextApproval || - saved.approvalsReviewer !== nextReviewer || - saved.sandboxMode !== nextSandbox + !hasInitialProjectDraft && + (providerConfig.model !== nextModel || + providerConfig.effort !== nextEffort || + providerConfig.contextSize !== nextContext || + providerConfig.fast !== nextFast || + providerConfig.thinking !== nextThinking || + providerConfig.mode !== nextMode || + providerConfig.approvalPolicy !== nextApproval || + providerConfig.approvalsReviewer !== nextReviewer || + providerConfig.sandboxMode !== nextSandbox) ) { providerConfigsRef.current[effectiveAgentKind] = resolved; setProviderConfig(effectiveAgentKind, resolved); @@ -669,6 +723,9 @@ export function ThreadDraftView(props: { } if (!selectedAgentForConfig) return; hasLocalConfigEditRef.current = true; + if ("contextSize" in patch) { + hasLocalContextEditRef.current = true; + } const resolved = resolveProviderDraftConfig(selectedAgentForConfig, { model: patch.model ?? model, effort: patch.effort ?? effort, diff --git a/src/renderer/components/thread/threadDraftViewHelpers.test.ts b/src/renderer/components/thread/threadDraftViewHelpers.test.ts index d3e6b263d..68ada24c2 100644 --- a/src/renderer/components/thread/threadDraftViewHelpers.test.ts +++ b/src/renderer/components/thread/threadDraftViewHelpers.test.ts @@ -5,6 +5,7 @@ import type { AgentCapability, AgentStatus } from "@/shared/contracts"; import { resolveFastValue, resolveProviderDraftConfig, + resolveSavedProviderDraftConfig, resolveThinkingValue, } from "./threadDraftViewHelpers"; @@ -99,3 +100,37 @@ describe("resolveThinkingValue", () => { expect(resolveThinkingValue(agentWith({ thinkingModels: ["plain"] }), "plain")).toBe(false); }); }); + +describe("resolveSavedProviderDraftConfig", () => { + it("fills an omitted context window from the app-wide provider preset", () => { + const resolved = resolveSavedProviderDraftConfig( + "codex", + { agentKind: "codex", model: "gpt-5.6-sol", effort: "high" }, + { + codex: { + model: "gpt-5.6-sol", + contextSize: "400k", + effort: "medium", + fast: false, + }, + }, + ); + + expect(resolved).toMatchObject({ + model: "gpt-5.6-sol", + effort: "high", + contextSize: "400k", + }); + expect(resolved?.fast).toBeUndefined(); + }); + + it("keeps an explicit last-draft context size over the provider preset", () => { + expect( + resolveSavedProviderDraftConfig( + "codex", + { agentKind: "codex", model: "gpt-5.6-sol", contextSize: "1m" }, + { codex: { model: "gpt-5.6-sol", contextSize: "400k" } }, + ), + ).toMatchObject({ contextSize: "1m" }); + }); +}); diff --git a/src/renderer/components/thread/threadDraftViewHelpers.ts b/src/renderer/components/thread/threadDraftViewHelpers.ts index 8da2a523c..f8d1e3ed2 100644 --- a/src/renderer/components/thread/threadDraftViewHelpers.ts +++ b/src/renderer/components/thread/threadDraftViewHelpers.ts @@ -34,11 +34,16 @@ export function resolveSavedProviderDraftConfig( lastDraftConfig: ProjectDraftConfig | undefined, providerConfigs: Record, ): Partial | undefined { + const providerConfig = providerConfigs[agentKind]; if (lastDraftConfig?.agentKind === agentKind && lastDraftConfig.model.trim()) { - return lastDraftConfig; + // Older project drafts predate context-window persistence. Preserve their + // other choices while filling only that missing field from the provider preset. + return !lastDraftConfig.contextSize && providerConfig?.contextSize + ? { ...lastDraftConfig, contextSize: providerConfig.contextSize } + : lastDraftConfig; } - return providerConfigs[agentKind]; + return providerConfig; } export function resolveModelValue(agent: AgentStatus, preferred?: string): string { diff --git a/src/supervisor/agents/codex/serverPool.test.ts b/src/supervisor/agents/codex/serverPool.test.ts index e7a4c449f..f29300fee 100644 --- a/src/supervisor/agents/codex/serverPool.test.ts +++ b/src/supervisor/agents/codex/serverPool.test.ts @@ -106,6 +106,23 @@ describe("Codex app-server pool", () => { expect(mocks.terminateChildProcessTree).toHaveBeenCalledOnce(); }); + it("reuses one process when thread context windows differ", async () => { + const first = await acquireCodexAppServer({ + ...input("local-a", browserServer("local-a")), + config: { model: "gpt-5.6-sol", contextSize: "400k" }, + }); + const second = await acquireCodexAppServer({ + ...input("local-b", browserServer("local-b")), + config: { model: "gpt-5.6-sol", contextSize: "272k" }, + }); + + expect(mocks.spawn).toHaveBeenCalledOnce(); + expect(second.connection).toBe(first.connection); + + first.dispose(); + second.dispose(); + }); + it("keeps the shared process alive while two of three thread leases remain", async () => { const first = await acquireCodexAppServer(input("local-a", browserServer("local-a"))); const second = await acquireCodexAppServer(input("local-b", browserServer("local-b")));