Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 5 additions & 15 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ import {
} from "@/common/utils/subProjects";
import {
THINKING_LEVELS,
coerceOpenAIReasoningMode,
type OpenAIReasoningMode,
type ThinkingLevel,
} from "@/common/types/thinking";
Expand All @@ -74,7 +73,6 @@ import {
getAgentIdKey,
getAgentsInitNudgeKey,
getProjectScopeId,
getReasoningModeKey,
getWorkspaceAISettingsByAgentKey,
getWorkspaceLastReadKey,
EXPANDED_PROJECTS_KEY,
Expand Down Expand Up @@ -517,6 +515,11 @@ function AppInner() {
workspaceId ? getAiSelectionForWorkspace(workspaceId).thinkingLevel : "off",
[getAiSelectionForWorkspace]
);
const getReasoningModeForWorkspace = useCallback(
(workspaceId: string): OpenAIReasoningMode =>
workspaceId ? getAiSelectionForWorkspace(workspaceId).reasoningMode : "standard",
[getAiSelectionForWorkspace]
);

// Pro mode is Responses-only; the palette command hides under chatCompletions
// and on non-passthrough routes (mirroring the send path's header gating).
Expand All @@ -531,18 +534,6 @@ function AppInner() {
[routing]
);

const getReasoningModeForWorkspace = useCallback((workspaceId: string): OpenAIReasoningMode => {
if (!workspaceId) {
return "standard";
}
const stored = readPersistedState<OpenAIReasoningMode | null>(
getReasoningModeKey(workspaceId),
null
);
// Coerce untrusted persisted values so corrupt entries self-heal to "standard".
return coerceOpenAIReasoningMode(stored) ?? "standard";
}, []);

// Palette picks stay in memory until a user message sends them.
const setThinkingLevelFromPalette = useCallback(
(workspaceId: string, level: ThinkingLevel) => {
Expand Down Expand Up @@ -613,7 +604,6 @@ function AppInner() {
const model = getModelForWorkspace(workspaceId);
const thinkingLevel = getThinkingLevelForWorkspace(workspaceId);

updatePersistedState(getReasoningModeKey(workspaceId), next);
markAiSelectionIntent(workspaceId, "reasoningMode", next);

type WorkspaceAISettingsByAgentCache = Partial<
Expand Down
47 changes: 6 additions & 41 deletions src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
import { useEffect, useRef } from "react";
import { useAgent } from "@/browser/contexts/AgentContext";
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import { readPersistedState } from "@/browser/hooks/usePersistedState";
import { useAgentAiDefaults } from "@/browser/stores/AppConfigStore";
import {
getAutoRoutingChoiceByAgentKey,
getReasoningModeKey,
getWorkspaceAISettingsByAgentKey,
} from "@/common/constants/storage";
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
import { applyAutoRoutingOutcome, recordWorkspaceModelChange } from "@/browser/utils/modelChange";
import {
resolveAutoRoutingForAgent,
resolveWorkspaceAiSettingsForAgent,
type AutoRoutingChoiceByAgent,
type WorkspaceAISettingsCache,
} from "@/browser/utils/workspaceModeAi";
import { getWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync";
import { useExperimentValue } from "@/browser/hooks/useExperiments";
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
import type { OpenAIReasoningMode } from "@/common/types/thinking";
import { normalizeAgentId } from "@/common/utils/agentIds";

export function WorkspaceModeAISync(props: { workspaceId: string }): null {
Expand All @@ -35,8 +31,6 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null {
const prevWorkspaceIdRef = useRef<string | null>(null);

useEffect(() => {
const fallbackModel = getDefaultModel();

const normalizedAgentId = normalizeAgentId(agentId, "exec");
const previousAgentId = prevAgentIdRef.current;

Expand All @@ -49,44 +43,17 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null {
prevAgentIdRef.current = normalizedAgentId;
prevWorkspaceIdRef.current = workspaceId;

// Read at call time rather than subscribing: this cache only feeds explicit agent
// switches, yet every model/thinking/pro-mode change rewrites it, so a subscription
// would re-run this effect and re-apply the mode default over the user's own pick.
const workspaceByAgent = readPersistedState<WorkspaceAISettingsCache>(
getWorkspaceAISettingsByAgentKey(workspaceId),
{}
);

const reasoningKey = getReasoningModeKey(workspaceId);
const existingReasoning = readPersistedState<OpenAIReasoningMode>(reasoningKey, "standard");

const agentBaseById = new Map(agents.map((agent) => [agent.id, agent.base]));
const selection = getWorkspaceAiSelection(workspaceId, normalizedAgentId, agentBaseById);
if (isExplicitAgentSwitch) {
// Each agent resolves its own model, so the switch itself is the explicit model change.
recordWorkspaceModelChange(
workspaceId,
selection.model,
getWorkspaceAiSelection(workspaceId, normalizedAgentId, agentBaseById).model,
"agent",
getWorkspaceAiSelection(workspaceId, previousAgentId, agentBaseById).model
);
}

// The resolver owns model and thinking; only reasoning comes from here.
const { resolvedReasoningMode } = resolveWorkspaceAiSettingsForAgent({
agentId: normalizedAgentId,
agentAiDefaults,
// Keep deterministic handoff behavior: background sync should trust the
// currently active workspace settings, but explicit mode switches should
// restore the selected agent's per-workspace override (if any).
workspaceByAgent,
useWorkspaceByAgentFallback: isExplicitAgentSwitch,
fallbackModel,
existingModel: selection.model,
existingThinking: selection.thinkingLevel,
existingReasoningMode: existingReasoning,
agentBaseById,
});
const autoRoutingOutcome = resolveAutoRoutingForAgent({
agentId: normalizedAgentId,
agentAiDefaults,
Expand All @@ -97,13 +64,11 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null {
getAutoRoutingChoiceByAgentKey(workspaceId),
{}
),
workspaceByAgent,
workspaceByAgent: readPersistedState<WorkspaceAISettingsCache>(
getWorkspaceAISettingsByAgentKey(workspaceId),
{}
),
});

if (existingReasoning !== resolvedReasoningMode) {
updatePersistedState(reasoningKey, resolvedReasoningMode);
}

applyAutoRoutingOutcome(workspaceId, autoRoutingOutcome);
}, [agentAiDefaults, agentId, agents, autoRoutingEnabled, workspaceId]);

Expand Down
26 changes: 14 additions & 12 deletions src/browser/contexts/ThinkingContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@ import {
} from "@/browser/contexts/WorkspaceContext";
import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import type { ThinkingLevel } from "@/common/types/thinking";
import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking";
import {
getAutoThinkingLevelKey,
getProjectScopeId,
getReasoningModeKey,
getWorkspaceAISettingsByAgentKey,
} from "@/common/constants/storage";
import { useReasoningMode } from "@/browser/hooks/useReasoningMode";
Expand Down Expand Up @@ -354,7 +353,7 @@ describe("ThinkingContext", () => {

test("setting thinking uses metadata model before global default", async () => {
const workspaceId = "ws-set-thinking-metadata-model";
updatePersistedState(getReasoningModeKey(workspaceId), "pro");
markAiSelectionIntent(workspaceId, "reasoningMode", "pro");
const updateAgentAISettings = mock<
(args: WorkspaceUpdateAgentAISettingsArgs) => Promise<WorkspaceUpdateAgentAISettingsResult>
>(() =>
Expand Down Expand Up @@ -480,21 +479,24 @@ describe("ThinkingContext", () => {
expect(updateAgentAISettings).not.toHaveBeenCalled();
});

test("self-heals corrupt persisted reasoningMode to standard but keeps valid pro", async () => {
// Corrupt persisted values (e.g. from a future downgrade) must coerce to
test("self-heals a corrupt saved reasoningMode to standard but keeps valid pro", async () => {
// Corrupt saved values (e.g. from a future downgrade) must coerce to
// "standard" instead of flowing into SendMessageOptionsSchema and bricking sends.
const cases = [
{ workspaceId: "ws-reasoning-corrupt", persisted: "ultra", expected: "standard" },
{ workspaceId: "ws-reasoning-valid", persisted: "pro", expected: "pro" },
{ workspaceId: "ws-reasoning-corrupt", saved: "ultra", expected: "standard" },
{ workspaceId: "ws-reasoning-valid", saved: "pro", expected: "pro" },
];

for (const testCase of cases) {
const metadata = createWorkspaceMetadata({ id: testCase.workspaceId });
const metadata = createWorkspaceMetadata({
id: testCase.workspaceId,
aiSettings: {
model: "openai:gpt-5.5",
thinkingLevel: "high",
reasoningMode: testCase.saved as OpenAIReasoningMode,
},
});
setWorkspaceMetadata(metadata);
window.localStorage.setItem(
getReasoningModeKey(testCase.workspaceId),
JSON.stringify(testCase.persisted)
);

const view = renderWithWorkspaceMetadata({
workspaceId: testCase.workspaceId,
Expand Down
68 changes: 18 additions & 50 deletions src/browser/contexts/ThinkingContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@ import {
type OpenAIReasoningMode,
type ThinkingLevel,
} from "@/common/types/thinking";
import {
readPersistedState,
updatePersistedState,
usePersistedState,
} from "@/browser/hooks/usePersistedState";
import { updatePersistedState, usePersistedState } from "@/browser/hooks/usePersistedState";
import {
getProjectScopeId,
getReasoningModeKey,
Expand All @@ -24,12 +20,8 @@ import { useMinThinkingLevels } from "@/browser/hooks/useMinThinkingLevels";
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
import { useAPI } from "@/browser/contexts/API";
import { requestActiveTurnThinkingLevel } from "@/browser/utils/activeTurnThinking";
import {
getWorkspaceAiSettingsFromMetadata,
useWorkspaceAiSelection,
} from "@/browser/utils/workspaceAiSettingsSync";
import { useWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync";
import { useOptionalAgent } from "@/browser/contexts/AgentContext";
import { useOptionalWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
import { KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds";
import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults";
import { markAiSelectionIntent } from "@/browser/utils/aiSelectionIntent";
Expand Down Expand Up @@ -62,7 +54,6 @@ function getScopeId(workspaceId: string | undefined, projectPath: string | undef

export const ThinkingProvider: React.FC<ThinkingProviderProps> = (props) => {
const { api } = useAPI();
const workspaceContext = useOptionalWorkspaceContext();
const { getMinimum } = useMinThinkingLevels();
// Resolve mapped aliases so keybind stepping walks the target model's ladder.
const { config: providersConfig } = useProvidersConfig();
Expand All @@ -79,30 +70,22 @@ export const ThinkingProvider: React.FC<ThinkingProviderProps> = (props) => {
);
const scopedModel = useScopedAiDefault(defaultsScopeId, "model");
const scopedThinkingLevel = useScopedAiDefault(defaultsScopeId, "thinkingLevel");
const [scopedReasoningMode, setScopedReasoningMode] =
usePersistedState<OpenAIReasoningMode | null>(getReasoningModeKey(defaultsScopeId), null, {
listener: true,
});

// normalizeSelectedModel (not normalizeToCanonical): explicit gateway identities must
// survive; thinking policy lookups resolve gateway-scoped strings themselves.
const model =
workspaceId != null ? selection.model : normalizeSelectedModel(scopedModel ?? defaultModel);
const thinkingLevel =
workspaceId != null ? selection.thinkingLevel : (scopedThinkingLevel ?? THINKING_LEVEL_OFF);
const metadataAgentId = readScopedAiDefault(scopeId, "agentId") ?? WORKSPACE_DEFAULTS.agentId;
const metadataSettings = getWorkspaceAiSettingsFromMetadata(
props.workspaceId ? workspaceContext?.workspaceMetadata.get(props.workspaceId) : undefined,
metadataAgentId
);

// Workspace-scoped OpenAI pro reasoning mode. Null = no explicit user choice yet;
// absent everywhere means "standard" (the API default).
const reasoningKey = getReasoningModeKey(scopeId);
const [persistedReasoningMode, setReasoningModeInternal] =
usePersistedState<OpenAIReasoningMode | null>(reasoningKey, null, { listener: true });
// Coerce untrusted persisted values (corrupt entries or a future downgrade) so
// bad state self-heals to "standard" instead of failing SendMessageOptionsSchema
// validation and bricking sends until storage is cleared.
// Coerce untrusted persisted values so bad state self-heals to "standard".
const reasoningMode =
coerceOpenAIReasoningMode(persistedReasoningMode) ??
coerceOpenAIReasoningMode(metadataSettings.reasoningMode) ??
"standard";
workspaceId != null
? selection.reasoningMode
: (coerceOpenAIReasoningMode(scopedReasoningMode) ?? "standard");

// Keep picker choices local until a user message sends the full settings.
const persistAgentAiSettings = useCallback(
Expand Down Expand Up @@ -145,18 +128,6 @@ export const ThinkingProvider: React.FC<ThinkingProviderProps> = (props) => {
[props.workspaceId, scopeId]
);

// Read the sibling setting at call time (not from the render closure) so
// rapid interleaved updates cannot persist a stale counterpart value.
const getCurrentReasoningMode = useCallback(
(): OpenAIReasoningMode =>
coerceOpenAIReasoningMode(
readPersistedState<OpenAIReasoningMode | null>(reasoningKey, null)
) ??
coerceOpenAIReasoningMode(metadataSettings.reasoningMode) ??
"standard",
[metadataSettings.reasoningMode, reasoningKey]
);

// A workspace pick stays in memory until a user message sends it.
const setThinkingLevel = useCallback(
(level: ThinkingLevel) => {
Expand All @@ -169,28 +140,25 @@ export const ThinkingProvider: React.FC<ThinkingProviderProps> = (props) => {
// A concrete pick (selector row or keybind step) leaves thinking Auto,
// mirroring setWorkspaceModelWithOrigin for the model dimension.
setAutoRoutingChoice(scopeId, "thinkingLevel", false);
persistAgentAiSettings({
model,
thinkingLevel: level,
reasoningMode: getCurrentReasoningMode(),
});
persistAgentAiSettings({ model, thinkingLevel: level, reasoningMode });
// Mid-turn change: also request the new level for the active turn's next model step.
if (workspaceId != null) {
requestActiveTurnThinkingLevel(api, workspaceId, level);
}
},
[api, getCurrentReasoningMode, model, persistAgentAiSettings, scopeId, workspaceId]
[api, model, persistAgentAiSettings, reasoningMode, scopeId, workspaceId]
);

const setReasoningMode = useCallback(
(mode: OpenAIReasoningMode) => {
setReasoningModeInternal(mode);
if (props.workspaceId) {
markAiSelectionIntent(props.workspaceId, "reasoningMode", mode);
if (workspaceId != null) {
markAiSelectionIntent(workspaceId, "reasoningMode", mode);
Comment thread
ibetitsmike marked this conversation as resolved.
} else {
setScopedReasoningMode(mode);
Comment thread
ibetitsmike marked this conversation as resolved.
}
persistAgentAiSettings({ model, thinkingLevel, reasoningMode: mode });
},
[model, persistAgentAiSettings, props.workspaceId, setReasoningModeInternal, thinkingLevel]
[model, persistAgentAiSettings, setScopedReasoningMode, thinkingLevel, workspaceId]
);

// Global keybinds for adjusting the thinking level.
Expand Down
18 changes: 1 addition & 17 deletions src/browser/features/ChatInput/useCreationWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,7 @@ import type {
} from "@/common/types/runtime";
import type { RuntimeChoice } from "@/browser/utils/runtimeUi";
import { buildRuntimeConfig, RUNTIME_MODE } from "@/common/types/runtime";
import {
coerceOpenAIReasoningMode,
type OpenAIReasoningMode,
type ThinkingLevel,
} from "@/common/types/thinking";
import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking";
import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings";
import {
getAutoRoutingKey,
Expand All @@ -25,7 +21,6 @@ import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePer
import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions";
import {
getAgentIdKey,
getReasoningModeKey,
getWorkspaceAISettingsByAgentKey,
getPendingScopeId,
getDraftScopeId,
Expand Down Expand Up @@ -178,16 +173,6 @@ function syncCreationPreferences(
recordAutoRoutingChoiceForAgent(workspaceId, effectiveAgentId, routingChoice);
}

// Carry the creation-time pro reasoning-mode choice into the new workspace's
// scope so it survives the project→workspace transition.
// Coerced so a corrupt persisted value is dropped instead of copied forward.
const projectReasoningMode = coerceOpenAIReasoningMode(
readPersistedState<OpenAIReasoningMode | null>(getReasoningModeKey(projectScopeId), null)
);
if (projectReasoningMode != null) {
updatePersistedState(getReasoningModeKey(workspaceId), projectReasoningMode);
}

const projectModel = projectDefaults?.model;
if (projectModel) {
const effectiveThinking: ThinkingLevel = projectDefaults?.thinkingLevel ?? "off";
Expand All @@ -207,7 +192,6 @@ function syncCreationPreferences(
[effectiveAgentId]: {
model: projectModel,
thinkingLevel: effectiveThinking,
Comment thread
ibetitsmike marked this conversation as resolved.
...(projectReasoningMode != null ? { reasoningMode: projectReasoningMode } : {}),
},
};
},
Expand Down
Loading
Loading