diff --git a/.storybook/mocks/orpc.ts b/.storybook/mocks/orpc.ts index db27e94eae..7e52732465 100644 --- a/.storybook/mocks/orpc.ts +++ b/.storybook/mocks/orpc.ts @@ -21,6 +21,10 @@ import { type SubagentAiDefaults, type TaskSettings, } from "@/common/types/tasks"; +import { + normalizeModeAiDefaults, + type ModeAiDefaults, +} from "@/common/types/modeAiDefaults"; import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue"; import { isWorkspaceArchived } from "@/common/utils/archive"; @@ -57,6 +61,8 @@ export interface MockORPCClientOptions { workspaces?: FrontendWorkspaceMetadata[]; /** Initial task settings for config.getConfig (e.g., Settings → Tasks section) */ taskSettings?: Partial; + /** Initial mode AI defaults for config.getConfig (e.g., Settings → Modes section) */ + modeAiDefaults?: ModeAiDefaults; /** Initial per-subagent AI defaults for config.getConfig (e.g., Settings → Tasks section) */ subagentAiDefaults?: SubagentAiDefaults; /** Per-workspace chat callback. Return messages to emit, or use the callback for streaming. */ @@ -140,6 +146,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl mcpOverrides = new Map(), mcpTestResults = new Map(), taskSettings: initialTaskSettings, + modeAiDefaults: initialModeAiDefaults, subagentAiDefaults: initialSubagentAiDefaults, } = options; @@ -158,6 +165,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }; const workspaceMap = new Map(workspaces.map((w) => [w.id, w])); + let modeAiDefaults = normalizeModeAiDefaults(initialModeAiDefaults ?? {}); let taskSettings = normalizeTaskSettings(initialTaskSettings ?? DEFAULT_TASK_SETTINGS); let subagentAiDefaults = normalizeSubagentAiDefaults(initialSubagentAiDefaults ?? {}); @@ -193,7 +201,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl setSshHost: async () => undefined, }, config: { - getConfig: async () => ({ taskSettings, subagentAiDefaults }), + getConfig: async () => ({ taskSettings, subagentAiDefaults, modeAiDefaults }), saveConfig: async (input: { taskSettings: unknown; subagentAiDefaults?: unknown }) => { taskSettings = normalizeTaskSettings(input.taskSettings); if (input.subagentAiDefaults !== undefined) { @@ -201,6 +209,10 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl } return undefined; }, + updateModeAiDefaults: async (input: { modeAiDefaults: unknown }) => { + modeAiDefaults = normalizeModeAiDefaults(input.modeAiDefaults); + return undefined; + }, }, providers: { list: async () => providersList, diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 3267245f95..0c4cc2858c 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -26,13 +26,16 @@ import { ThemeProvider, useTheme, type ThemeMode } from "./contexts/ThemeContext import { CommandPalette } from "./components/CommandPalette"; import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources"; +import type { UIMode } from "@/common/types/mode"; import type { ThinkingLevel } from "@/common/types/thinking"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents"; import { + getModeKey, + getModelKey, getThinkingLevelByModelKey, getThinkingLevelKey, - getModelKey, + getWorkspaceAISettingsByModeKey, } from "@/common/constants/storage"; import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels"; import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; @@ -319,10 +322,33 @@ function AppInner() { // ThinkingProvider will pick this up via its listener updatePersistedState(key, effective); + type WorkspaceAISettingsByModeCache = Partial< + Record + >; + + const mode = readPersistedState(getModeKey(workspaceId), "exec"); + + updatePersistedState( + getWorkspaceAISettingsByModeKey(workspaceId), + (prev) => { + const record: WorkspaceAISettingsByModeCache = + prev && typeof prev === "object" ? prev : {}; + return { + ...record, + [mode]: { model, thinkingLevel: effective }, + }; + }, + {} + ); + // Persist to backend so the palette change follows the workspace across devices. if (api) { api.workspace - .updateAISettings({ workspaceId, aiSettings: { model, thinkingLevel: effective } }) + .updateModeAISettings({ + workspaceId, + mode, + aiSettings: { model, thinkingLevel: effective }, + }) .catch(() => { // Best-effort only. }); diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index e0fb846838..3ae07201d9 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -34,6 +34,7 @@ import { import { BashOutputCollapsedIndicator } from "./tools/BashOutputCollapsedIndicator"; import { hasInterruptedStream } from "@/browser/utils/messages/retryEligibility"; import { ThinkingProvider } from "@/browser/contexts/ThinkingContext"; +import { WorkspaceModeAISync } from "@/browser/components/WorkspaceModeAISync"; import { ModeProvider } from "@/browser/contexts/ModeContext"; import { ProviderOptionsProvider } from "@/browser/contexts/ProviderOptionsContext"; @@ -838,6 +839,7 @@ export const AIView: React.FC = (props) => { return ( + diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 65ecb3cc34..c953140174 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -13,7 +13,11 @@ import type { Toast } from "../ChatInputToast"; import { ChatInputToast } from "../ChatInputToast"; import { createCommandToast, createErrorToast } from "../ChatInputToasts"; import { parseCommand } from "@/browser/utils/slashCommands/parser"; -import { usePersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { + readPersistedState, + usePersistedState, + updatePersistedState, +} from "@/browser/hooks/usePersistedState"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { useMode } from "@/browser/contexts/ModeContext"; @@ -26,8 +30,11 @@ import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; import { getModelKey, + getThinkingLevelKey, + getWorkspaceAISettingsByModeKey, getInputKey, getInputImagesKey, + MODE_AI_DEFAULTS_KEY, VIM_ENABLED_KEY, getProjectScopeId, getPendingScopeId, @@ -73,7 +80,8 @@ import { processImageFiles, } from "@/browser/utils/imageHandling"; -import type { ThinkingLevel } from "@/common/types/thinking"; +import type { ModeAiDefaults } from "@/common/types/modeAiDefaults"; +import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking"; import type { MuxFrontendMetadata } from "@/common/types/message"; import { prepareUserMessageForSend } from "@/common/types/message"; import { MODEL_ABBREVIATION_EXAMPLES } from "@/common/constants/knownModels"; @@ -269,6 +277,14 @@ const ChatInputInner: React.FC = (props) => { defaultModel, setDefaultModel, } = useModelsFromSettings(); + + const [modeAiDefaults] = usePersistedState( + MODE_AI_DEFAULTS_KEY, + {}, + { + listener: true, + } + ); const commandListId = useId(); const telemetry = useTelemetry(); const [vimEnabled, setVimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { @@ -339,26 +355,49 @@ const ChatInputInner: React.FC = (props) => { const setPreferredModel = useCallback( (model: string) => { + type WorkspaceAISettingsByModeCache = Partial< + Record<"plan" | "exec", { model: string; thinkingLevel: ThinkingLevel }> + >; + const canonicalModel = migrateGatewayModel(model); ensureModelInSettings(canonicalModel); // Ensure model exists in Settings updatePersistedState(storageKeys.modelKey, canonicalModel); // Update workspace or project-specific - // Workspace variant: persist to backend for cross-device consistency. - if (!api || variant !== "workspace" || !workspaceId) { + if (variant !== "workspace" || !workspaceId) { return; } const effectiveThinkingLevel = enforceThinkingPolicy(canonicalModel, thinkingLevel); + + updatePersistedState( + getWorkspaceAISettingsByModeKey(workspaceId), + (prev) => { + const record: WorkspaceAISettingsByModeCache = + prev && typeof prev === "object" ? prev : {}; + return { + ...record, + [mode]: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel }, + }; + }, + {} + ); + + // Workspace variant: persist to backend for cross-device consistency. + if (!api) { + return; + } + api.workspace - .updateAISettings({ + .updateModeAISettings({ workspaceId, + mode, aiSettings: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel }, }) .catch(() => { // Best-effort only. If offline or backend is old, sendMessage will persist. }); }, - [api, storageKeys.modelKey, ensureModelInSettings, thinkingLevel, variant, workspaceId] + [api, mode, storageKeys.modelKey, ensureModelInSettings, thinkingLevel, variant, workspaceId] ); const deferredModel = useDeferredValue(preferredModel); const deferredInput = useDeferredValue(input); @@ -421,23 +460,41 @@ const ChatInputInner: React.FC = (props) => { const hasReviews = attachedReviews.length > 0; const canSend = (hasTypedText || hasImages || hasReviews) && !disabled && !isSendInFlight; - // When entering creation mode, initialize the project-scoped model to the - // default so previous manual picks don't bleed into new creation flows. - // Only runs once per creation session (not when defaultModel changes, which - // would clobber the user's intentional model selection). - const creationModelInitialized = useRef(null); + const creationProjectPath = variant === "creation" ? props.projectPath : ""; + + // Creation variant: keep the project-scoped model/thinking in sync with global per-mode defaults + // so switching Plan/Exec uses the configured defaults (and respects "inherit" semantics). useEffect(() => { - if (variant === "creation" && defaultModel) { - // Only initialize once per project scope - if (creationModelInitialized.current !== storageKeys.modelKey) { - creationModelInitialized.current = storageKeys.modelKey; - updatePersistedState(storageKeys.modelKey, defaultModel); - } - } else if (variant !== "creation") { - // Reset when leaving creation mode so re-entering triggers initialization - creationModelInitialized.current = null; + if (variant !== "creation") { + return; + } + + const scopeId = getProjectScopeId(creationProjectPath); + const modelKey = getModelKey(scopeId); + const thinkingKey = getThinkingLevelKey(scopeId); + + const fallbackModel = defaultModel; + + const existingModel = readPersistedState(modelKey, fallbackModel); + const candidateModel = modeAiDefaults[mode]?.modelString ?? existingModel; + const resolvedModel = + typeof candidateModel === "string" && candidateModel.trim().length > 0 + ? candidateModel + : fallbackModel; + + const existingThinking = readPersistedState(thinkingKey, "off"); + const candidateThinking = modeAiDefaults[mode]?.thinkingLevel ?? existingThinking ?? "off"; + const resolvedThinking = coerceThinkingLevel(candidateThinking) ?? "off"; + const effectiveThinking = enforceThinkingPolicy(resolvedModel, resolvedThinking); + + if (existingModel !== resolvedModel) { + updatePersistedState(modelKey, resolvedModel); + } + + if (existingThinking !== effectiveThinking) { + updatePersistedState(thinkingKey, effectiveThinking); } - }, [variant, defaultModel, storageKeys.modelKey]); + }, [creationProjectPath, defaultModel, mode, modeAiDefaults, variant]); // Expose ChatInput auto-focus completion for Storybook/tests. const chatInputSectionRef = useRef(null); diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index 0d86fa700d..314b029889 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -13,6 +13,7 @@ import { getModelKey, getModeKey, getThinkingLevelKey, + getWorkspaceAISettingsByModeKey, getPendingScopeId, getProjectScopeId, } from "@/common/constants/storage"; @@ -54,6 +55,27 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void if (projectThinkingLevel !== null) { updatePersistedState(getThinkingLevelKey(workspaceId), projectThinkingLevel); } + + if (projectModel) { + const effectiveMode: UIMode = projectMode ?? "exec"; + const effectiveThinking: ThinkingLevel = projectThinkingLevel ?? "off"; + + updatePersistedState< + Partial> + >( + getWorkspaceAISettingsByModeKey(workspaceId), + (prev) => { + const record = prev && typeof prev === "object" ? prev : {}; + return { + ...(record as Partial< + Record<"plan" | "exec", { model: string; thinkingLevel: ThinkingLevel }> + >), + [effectiveMode]: { model: projectModel, thinkingLevel: effectiveThinking }, + }; + }, + {} + ); + } } interface UseCreationWorkspaceReturn { @@ -205,17 +227,32 @@ export function useCreationWorkspace({ // Best-effort: persist the initial AI settings to the backend immediately so this workspace // is portable across devices even before the first stream starts. - api.workspace - .updateAISettings({ - workspaceId: metadata.id, - aiSettings: { - model: settings.model, - thinkingLevel: settings.thinkingLevel, - }, - }) - .catch(() => { - // Ignore (offline / older backend). sendMessage will persist as a fallback. - }); + try { + api.workspace + .updateModeAISettings({ + workspaceId: metadata.id, + mode: settings.mode, + aiSettings: { + model: settings.model, + thinkingLevel: settings.thinkingLevel, + }, + }) + .catch(() => { + // Ignore (offline / older backend). sendMessage will persist as a fallback. + }); + } catch { + api.workspace + .updateAISettings({ + workspaceId: metadata.id, + aiSettings: { + model: settings.model, + thinkingLevel: settings.thinkingLevel, + }, + }) + .catch(() => { + // Ignore (offline / older backend). sendMessage will persist as a fallback. + }); + } // Sync preferences immediately (before switching) syncCreationPreferences(projectPath, metadata.id); if (projectPath) { @@ -259,6 +296,7 @@ export function useCreationWorkspace({ projectScopeId, onWorkspaceCreated, getRuntimeString, + settings.mode, settings.model, settings.thinkingLevel, settings.trunkBranch, diff --git a/src/browser/components/Settings/SettingsModal.tsx b/src/browser/components/Settings/SettingsModal.tsx index 757e7361f6..236215c0aa 100644 --- a/src/browser/components/Settings/SettingsModal.tsx +++ b/src/browser/components/Settings/SettingsModal.tsx @@ -1,10 +1,11 @@ import React from "react"; -import { Settings, Key, Cpu, X, Briefcase, FlaskConical, Bot } from "lucide-react"; +import { Settings, Key, Cpu, X, Briefcase, FlaskConical, Bot, Layers } from "lucide-react"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { Dialog, DialogContent, DialogTitle, VisuallyHidden } from "@/browser/components/ui/dialog"; import { GeneralSection } from "./sections/GeneralSection"; import { TasksSection } from "./sections/TasksSection"; import { ProvidersSection } from "./sections/ProvidersSection"; +import { ModesSection } from "./sections/ModesSection"; import { ModelsSection } from "./sections/ModelsSection"; import { Button } from "@/browser/components/ui/button"; import { ProjectSettingsSection } from "./sections/ProjectSettingsSection"; @@ -36,6 +37,12 @@ const SECTIONS: SettingsSection[] = [ icon: , component: ProjectSettingsSection, }, + { + id: "modes", + label: "Modes", + icon: , + component: ModesSection, + }, { id: "models", label: "Models", diff --git a/src/browser/components/Settings/sections/ModesSection.tsx b/src/browser/components/Settings/sections/ModesSection.tsx new file mode 100644 index 0000000000..834c8847ff --- /dev/null +++ b/src/browser/components/Settings/sections/ModesSection.tsx @@ -0,0 +1,266 @@ +import React, { useEffect, useRef, useState } from "react"; +import { useAPI } from "@/browser/contexts/API"; +import { ModelSelector } from "@/browser/components/ModelSelector"; +import { useModelsFromSettings } from "@/browser/hooks/useModelsFromSettings"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/browser/components/ui/select"; +import type { ThinkingLevel } from "@/common/types/thinking"; +import { + normalizeModeAiDefaults, + type ModeAiDefaults, + type ModeAiDefaultsEntry, +} from "@/common/types/modeAiDefaults"; +import { enforceThinkingPolicy, getThinkingPolicyForModel } from "@/common/utils/thinking/policy"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { MODE_AI_DEFAULTS_KEY } from "@/common/constants/storage"; + +const INHERIT = "__inherit__"; +const ALL_THINKING_LEVELS = ["off", "low", "medium", "high", "xhigh"] as const; + +const MODE_ORDER = [ + { id: "plan", label: "Plan" }, + { id: "exec", label: "Exec" }, + { id: "compact", label: "Compact" }, +] as const; + +type ModeId = (typeof MODE_ORDER)[number]["id"]; + +function updateModeDefaultEntry( + previous: ModeAiDefaults, + mode: ModeId, + update: (entry: ModeAiDefaultsEntry) => void +): ModeAiDefaults { + const next = { ...previous }; + const existing = next[mode] ?? {}; + const updated: ModeAiDefaultsEntry = { ...existing }; + update(updated); + + if (updated.modelString && updated.thinkingLevel) { + updated.thinkingLevel = enforceThinkingPolicy(updated.modelString, updated.thinkingLevel); + } + + if (!updated.modelString && !updated.thinkingLevel) { + delete next[mode]; + } else { + next[mode] = updated; + } + + return next; +} + +export function ModesSection() { + const { api } = useAPI(); + const { models, hiddenModels } = useModelsFromSettings(); + + const [modeAiDefaults, setModeAiDefaults] = useState({}); + const [loaded, setLoaded] = useState(false); + const [loadFailed, setLoadFailed] = useState(false); + const [saveError, setSaveError] = useState(null); + + const saveTimerRef = useRef | null>(null); + const savingRef = useRef(false); + const pendingSaveRef = useRef(null); + + useEffect(() => { + if (!api) return; + + setLoaded(false); + setLoadFailed(false); + setSaveError(null); + + void api.config + .getConfig() + .then((cfg) => { + const normalized = normalizeModeAiDefaults(cfg.modeAiDefaults ?? {}); + setModeAiDefaults(normalized); + // Keep a local cache for non-react readers (compaction handler, sync, etc.) + updatePersistedState(MODE_AI_DEFAULTS_KEY, normalized); + setLoadFailed(false); + setLoaded(true); + }) + .catch((error: unknown) => { + setSaveError(error instanceof Error ? error.message : String(error)); + setLoadFailed(true); + setLoaded(true); + }); + }, [api]); + + useEffect(() => { + if (!api) return; + if (!loaded) return; + if (loadFailed) return; + + pendingSaveRef.current = modeAiDefaults; + updatePersistedState(MODE_AI_DEFAULTS_KEY, modeAiDefaults); + + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + + saveTimerRef.current = setTimeout(() => { + const flush = () => { + if (savingRef.current) return; + if (!api) return; + + const payload = pendingSaveRef.current; + if (!payload) return; + + pendingSaveRef.current = null; + savingRef.current = true; + void api.config + .updateModeAiDefaults({ modeAiDefaults: payload }) + .catch((error: unknown) => { + setSaveError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => { + savingRef.current = false; + flush(); + }); + }; + + flush(); + }, 400); + + return () => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + }; + }, [api, loaded, loadFailed, modeAiDefaults]); + + // Flush any pending debounced save on unmount so changes aren't lost. + useEffect(() => { + if (!api) return; + if (!loaded) return; + if (loadFailed) return; + + return () => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + + if (savingRef.current) return; + const payload = pendingSaveRef.current; + if (!payload) return; + + pendingSaveRef.current = null; + savingRef.current = true; + void api.config + .updateModeAiDefaults({ modeAiDefaults: payload }) + .catch(() => undefined) + .finally(() => { + savingRef.current = false; + }); + }; + }, [api, loaded, loadFailed]); + + const setModeModel = (mode: ModeId, value: string) => { + setModeAiDefaults((prev) => + updateModeDefaultEntry(prev, mode, (updated) => { + if (value === INHERIT) { + delete updated.modelString; + } else { + updated.modelString = value; + } + }) + ); + }; + + const setModeThinking = (mode: ModeId, value: string) => { + setModeAiDefaults((prev) => + updateModeDefaultEntry(prev, mode, (updated) => { + if (value === INHERIT) { + delete updated.thinkingLevel; + return; + } + + updated.thinkingLevel = value as ThinkingLevel; + }) + ); + }; + + return ( +
+
+

Mode Defaults

+
+ Defaults apply globally. Changing model/reasoning in a workspace creates a workspace + override. +
+ + {saveError &&
{saveError}
} +
+ +
+ {MODE_ORDER.map((m) => { + const entry = modeAiDefaults[m.id]; + const modelValue = entry?.modelString ?? INHERIT; + const thinkingValue = entry?.thinkingLevel ?? INHERIT; + const allowedThinkingLevels = + modelValue !== INHERIT ? getThinkingPolicyForModel(modelValue) : ALL_THINKING_LEVELS; + + return ( +
+
{m.label}
+ +
+
+
Model
+
+ setModeModel(m.id, value)} + models={models} + hiddenModels={hiddenModels} + /> + {modelValue !== INHERIT ? ( + + ) : null} +
+
+ +
+
Reasoning
+ +
+
+
+ ); + })} +
+
+ ); +} diff --git a/src/browser/components/WorkspaceModeAISync.tsx b/src/browser/components/WorkspaceModeAISync.tsx new file mode 100644 index 0000000000..fc52ee2af0 --- /dev/null +++ b/src/browser/components/WorkspaceModeAISync.tsx @@ -0,0 +1,73 @@ +import { useEffect } from "react"; +import { useMode } from "@/browser/contexts/ModeContext"; +import { + readPersistedState, + updatePersistedState, + usePersistedState, +} from "@/browser/hooks/usePersistedState"; +import { + getModelKey, + getThinkingLevelKey, + getWorkspaceAISettingsByModeKey, + MODE_AI_DEFAULTS_KEY, +} from "@/common/constants/storage"; +import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; +import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; +import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking"; +import type { ModeAiDefaults } from "@/common/types/modeAiDefaults"; + +type WorkspaceAISettingsByModeCache = Partial< + Record<"plan" | "exec", { model: string; thinkingLevel: ThinkingLevel }> +>; + +export function WorkspaceModeAISync(props: { workspaceId: string }): null { + const workspaceId = props.workspaceId; + const [mode] = useMode(); + + const [modeAiDefaults] = usePersistedState( + MODE_AI_DEFAULTS_KEY, + {}, + { + listener: true, + } + ); + const [workspaceByMode] = usePersistedState( + getWorkspaceAISettingsByModeKey(workspaceId), + {}, + { listener: true } + ); + + useEffect(() => { + const fallbackModel = getDefaultModel(); + const modelKey = getModelKey(workspaceId); + const thinkingKey = getThinkingLevelKey(workspaceId); + + const existingModel = readPersistedState(modelKey, fallbackModel); + const candidateModel = + workspaceByMode[mode]?.model ?? modeAiDefaults[mode]?.modelString ?? existingModel; + const resolvedModel = + typeof candidateModel === "string" && candidateModel.trim().length > 0 + ? candidateModel + : fallbackModel; + + const existingThinking = readPersistedState(thinkingKey, "off"); + const candidateThinking = + workspaceByMode[mode]?.thinkingLevel ?? + modeAiDefaults[mode]?.thinkingLevel ?? + existingThinking ?? + "off"; + const resolvedThinking = coerceThinkingLevel(candidateThinking) ?? "off"; + + const effectiveThinking = enforceThinkingPolicy(resolvedModel, resolvedThinking); + + if (existingModel !== resolvedModel) { + updatePersistedState(modelKey, resolvedModel); + } + + if (existingThinking !== effectiveThinking) { + updatePersistedState(thinkingKey, effectiveThinking); + } + }, [mode, modeAiDefaults, workspaceByMode, workspaceId]); + + return null; +} diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 90f5ee5821..ce700a25bc 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import React, { createContext, useContext, useEffect, useMemo, useCallback } from "react"; +import type { UIMode } from "@/common/types/mode"; import type { ThinkingLevel } from "@/common/types/thinking"; import { readPersistedState, @@ -9,8 +10,10 @@ import { import { getModelKey, getProjectScopeId, + getModeKey, getThinkingLevelByModelKey, getThinkingLevelKey, + getWorkspaceAISettingsByModeKey, GLOBAL_SCOPE_ID, } from "@/common/constants/storage"; import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; @@ -79,13 +82,37 @@ export const ThinkingProvider: React.FC = (props) => { setThinkingLevelInternal(effective); // Workspace variant: persist to backend so settings follow the workspace across devices. - if (!props.workspaceId || !api) { + if (!props.workspaceId) { + return; + } + + type WorkspaceAISettingsByModeCache = Partial< + Record + >; + + const mode = readPersistedState(getModeKey(scopeId), "exec"); + + updatePersistedState( + getWorkspaceAISettingsByModeKey(props.workspaceId), + (prev) => { + const record: WorkspaceAISettingsByModeCache = + prev && typeof prev === "object" ? prev : {}; + return { + ...record, + [mode]: { model, thinkingLevel: effective }, + }; + }, + {} + ); + + if (!api) { return; } api.workspace - .updateAISettings({ + .updateModeAISettings({ workspaceId: props.workspaceId, + mode, aiSettings: { model, thinkingLevel: effective }, }) .catch(() => { diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index f32fd45fe5..5a4c196d3f 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -10,13 +10,17 @@ import { type SetStateAction, } from "react"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { UIMode } from "@/common/types/mode"; import type { ThinkingLevel } from "@/common/types/thinking"; import type { WorkspaceSelection } from "@/browser/components/ProjectSidebar"; import type { RuntimeConfig } from "@/common/types/runtime"; import { deleteWorkspaceStorage, + getModeKey, getModelKey, getThinkingLevelKey, + getWorkspaceAISettingsByModeKey, + MODE_AI_DEFAULTS_KEY, SELECTED_WORKSPACE_KEY, } from "@/common/constants/storage"; import { useAPI } from "@/browser/contexts/API"; @@ -29,6 +33,7 @@ import { useProjectContext } from "@/browser/contexts/ProjectContext"; import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; import { isExperimentEnabled } from "@/browser/hooks/useExperiments"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import { normalizeModeAiDefaults } from "@/common/types/modeAiDefaults"; import { isWorkspaceArchived } from "@/common/utils/archive"; /** @@ -37,27 +42,62 @@ import { isWorkspaceArchived } from "@/common/utils/archive"; * This keeps a workspace's model/thinking consistent across devices/browsers. */ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadata): void { - const ai = metadata.aiSettings; - if (!ai) { + type WorkspaceAISettingsByModeCache = Partial< + Record + >; + + const workspaceId = metadata.id; + + const aiByMode = + metadata.aiSettingsByMode ?? + (metadata.aiSettings + ? { + plan: metadata.aiSettings, + exec: metadata.aiSettings, + } + : undefined); + + if (!aiByMode) { return; } - // Seed model selection. - if (typeof ai.model === "string" && ai.model.length > 0) { - const modelKey = getModelKey(metadata.id); - const existingModel = readPersistedState(modelKey, undefined); - if (existingModel !== ai.model) { - updatePersistedState(modelKey, ai.model); - } + // Merge backend values into a per-workspace per-mode cache. + const byModeKey = getWorkspaceAISettingsByModeKey(workspaceId); + const existingByMode = readPersistedState(byModeKey, {}); + const nextByMode: WorkspaceAISettingsByModeCache = { ...existingByMode }; + + for (const mode of ["plan", "exec"] as const) { + const entry = aiByMode[mode]; + if (!entry) continue; + if (typeof entry.model !== "string" || entry.model.length === 0) continue; + + nextByMode[mode] = { + model: entry.model, + thinkingLevel: entry.thinkingLevel, + }; } - // Seed thinking level. - if (ai.thinkingLevel) { - const thinkingKey = getThinkingLevelKey(metadata.id); - const existingThinking = readPersistedState(thinkingKey, undefined); - if (existingThinking !== ai.thinkingLevel) { - updatePersistedState(thinkingKey, ai.thinkingLevel); - } + if (JSON.stringify(existingByMode) !== JSON.stringify(nextByMode)) { + updatePersistedState(byModeKey, nextByMode); + } + + // Seed the active mode into the existing keys to avoid UI flash. + const activeMode = readPersistedState(getModeKey(workspaceId), "exec"); + const active = nextByMode[activeMode] ?? nextByMode.exec ?? nextByMode.plan; + if (!active) { + return; + } + + const modelKey = getModelKey(workspaceId); + const existingModel = readPersistedState(modelKey, undefined); + if (existingModel !== active.model) { + updatePersistedState(modelKey, active.model); + } + + const thinkingKey = getThinkingLevelKey(workspaceId); + const existingThinking = readPersistedState(thinkingKey, undefined); + if (existingThinking !== active.thinkingLevel) { + updatePersistedState(thinkingKey, active.thinkingLevel); } } @@ -137,6 +177,23 @@ interface WorkspaceProviderProps { export function WorkspaceProvider(props: WorkspaceProviderProps) { const { api } = useAPI(); + + // Cache global mode defaults so non-react code paths (compaction, etc.) can read them. + useEffect(() => { + if (!api?.config?.getConfig) return; + + void api.config + .getConfig() + .then((cfg) => { + updatePersistedState( + MODE_AI_DEFAULTS_KEY, + normalizeModeAiDefaults(cfg.modeAiDefaults ?? {}) + ); + }) + .catch(() => { + // Best-effort only. + }); + }, [api]); // Get project refresh function from ProjectContext const { refreshProjects } = useProjectContext(); diff --git a/src/browser/hooks/usePersistedState.ts b/src/browser/hooks/usePersistedState.ts index 97878389a1..b9d85194e6 100644 --- a/src/browser/hooks/usePersistedState.ts +++ b/src/browser/hooks/usePersistedState.ts @@ -78,6 +78,34 @@ export function readPersistedState(key: string, defaultValue: T): T { } } +/** + * Read a persisted string value from localStorage. + * + * Unlike readPersistedState(), this tolerates values that were written as raw + * strings (not JSON) by legacy code. + */ +export function readPersistedString(key: string): string | undefined { + if (typeof window === "undefined" || !window.localStorage) { + return undefined; + } + + const storedValue = window.localStorage.getItem(key); + if (storedValue === null || storedValue === "undefined") { + return undefined; + } + + try { + const parsed: unknown = JSON.parse(storedValue); + if (typeof parsed === "string") { + return parsed; + } + } catch { + // Fall through to raw string. + } + + return storedValue; +} + /** * Update a persisted state value from outside the hook. * This is useful when you need to update state from a different component/context diff --git a/src/browser/hooks/useTelemetry.ts b/src/browser/hooks/useTelemetry.ts index 139f66f234..a29910761a 100644 --- a/src/browser/hooks/useTelemetry.ts +++ b/src/browser/hooks/useTelemetry.ts @@ -11,6 +11,7 @@ import { trackErrorOccurred, trackExperimentOverridden, } from "@/common/telemetry"; +import type { AgentMode } from "@/common/types/mode"; import type { ErrorContext, TelemetryRuntimeType, @@ -53,7 +54,7 @@ export function useTelemetry() { ( workspaceId: string, model: string, - mode: string, + mode: AgentMode, messageLength: number, runtimeType: TelemetryRuntimeType, thinkingLevel: TelemetryThinkingLevel diff --git a/src/browser/stories/App.settings.stories.tsx b/src/browser/stories/App.settings.stories.tsx index e41321aef4..67f0d0416d 100644 --- a/src/browser/stories/App.settings.stories.tsx +++ b/src/browser/stories/App.settings.stories.tsx @@ -3,8 +3,10 @@ * * Shows different sections and states of the Settings modal: * - General (theme toggle) + * - Agents (task parallelism / nesting) * - Providers (API key configuration) * - Models (custom model management) + * - Modes (per-mode default model / reasoning) * - Experiments * * NOTE: Projects/MCP stories live in App.mcp.stories.tsx @@ -19,6 +21,7 @@ import { selectWorkspace } from "./storyHelpers"; import { createMockORPCClient } from "../../../.storybook/mocks/orpc"; import { within, userEvent, waitFor } from "@storybook/test"; import { getExperimentKey, EXPERIMENT_IDS } from "@/common/constants/experiments"; +import type { ModeAiDefaults } from "@/common/types/modeAiDefaults"; import type { TaskSettings } from "@/common/types/tasks"; export default { @@ -34,6 +37,7 @@ export default { function setupSettingsStory(options: { providersConfig?: Record; providersList?: string[]; + modeAiDefaults?: ModeAiDefaults; taskSettings?: Partial; /** Pre-set experiment states in localStorage before render */ experiments?: Partial>; @@ -54,6 +58,7 @@ function setupSettingsStory(options: { projects: groupWorkspacesByProject(workspaces), workspaces, providersConfig: options.providersConfig ?? {}, + modeAiDefaults: options.modeAiDefaults, providersList: options.providersList ?? ["anthropic", "openai", "xai"], taskSettings: options.taskSettings, }); @@ -229,6 +234,35 @@ export const ModelsConfigured: AppStory = { }, }; +/** Modes section - global default model/reasoning per mode */ +export const Modes: AppStory = { + render: () => ( + + setupSettingsStory({ + modeAiDefaults: { + plan: { modelString: "anthropic:claude-sonnet-4-5", thinkingLevel: "medium" }, + exec: { modelString: "openai:gpt-5.2", thinkingLevel: "xhigh" }, + compact: { modelString: "openai:gpt-5.2-pro", thinkingLevel: "high" }, + }, + }) + } + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openSettingsToSection(canvasElement, "modes"); + + const body = within(canvasElement.ownerDocument.body); + const dialog = await body.findByRole("dialog"); + const modal = within(dialog); + + await modal.findByText(/Mode Defaults/i); + await modal.findByText(/^Plan$/i); + await modal.findByText(/^Exec$/i); + await modal.findByText(/^Compact$/i); + }, +}; + /** Experiments section - shows available experiments */ export const Experiments: AppStory = { render: () => setupSettingsStory({})} />, diff --git a/src/browser/utils/messages/compactionModelPreference.ts b/src/browser/utils/messages/compactionModelPreference.ts index e35fc15125..83922eb9ec 100644 --- a/src/browser/utils/messages/compactionModelPreference.ts +++ b/src/browser/utils/messages/compactionModelPreference.ts @@ -1,31 +1,38 @@ /** * Compaction model preference management - * - * Handles the sticky global preference for which model to use during compaction. */ -import { PREFERRED_COMPACTION_MODEL_KEY } from "@/common/constants/storage"; +import { readPersistedState, readPersistedString } from "@/browser/hooks/usePersistedState"; +import { MODE_AI_DEFAULTS_KEY, PREFERRED_COMPACTION_MODEL_KEY } from "@/common/constants/storage"; +import type { ModeAiDefaults } from "@/common/types/modeAiDefaults"; // Re-export for convenience - validation used in /compact handler export { isValidModelFormat } from "@/common/utils/ai/models"; /** - * Resolve the effective compaction model to use for compaction. + * Resolve the effective compaction model to use. * - * @param requestedModel - Model specified in /compact -m flag (if any) - * @returns The model to use for compaction, or undefined to use workspace default + * Priority: + * 1) /compact -m flag + * 2) Global mode default for compact + * 3) Legacy global preference (preferredCompactionModel) + * 4) undefined (caller falls back to workspace default) */ export function resolveCompactionModel(requestedModel: string | undefined): string | undefined { - if (requestedModel) { + if (typeof requestedModel === "string" && requestedModel.trim().length > 0) { return requestedModel; } - // No model specified, check if user has a saved preference - const savedModel = localStorage.getItem(PREFERRED_COMPACTION_MODEL_KEY); - if (savedModel) { - return savedModel; + const modeAiDefaults = readPersistedState(MODE_AI_DEFAULTS_KEY, {}); + const compactModel = modeAiDefaults.compact?.modelString; + if (typeof compactModel === "string" && compactModel.trim().length > 0) { + return compactModel; + } + + const legacyModel = readPersistedString(PREFERRED_COMPACTION_MODEL_KEY); + if (typeof legacyModel === "string" && legacyModel.trim().length > 0) { + return legacyModel; } - // No preference saved, return undefined to use workspace default return undefined; } diff --git a/src/browser/utils/messages/compactionOptions.test.ts b/src/browser/utils/messages/compactionOptions.test.ts index a6a257ec35..f6273af4fb 100644 --- a/src/browser/utils/messages/compactionOptions.test.ts +++ b/src/browser/utils/messages/compactionOptions.test.ts @@ -32,20 +32,20 @@ describe("applyCompactionOverrides", () => { expect(result.model).toBe(KNOWN_MODELS.HAIKU.id); }); - it("preserves workspace thinking level for all models", () => { - // Test Anthropic model + it("enforces thinking policy for the compaction model", () => { + // Test Anthropic model (supports medium) const anthropicData: CompactionRequestData = { model: KNOWN_MODELS.HAIKU.id, }; const anthropicResult = applyCompactionOverrides(baseOptions, anthropicData); expect(anthropicResult.thinkingLevel).toBe("medium"); - // Test OpenAI model + // Test OpenAI model (gpt-5-pro only supports high) const openaiData: CompactionRequestData = { model: "openai:gpt-5-pro", }; const openaiResult = applyCompactionOverrides(baseOptions, openaiData); - expect(openaiResult.thinkingLevel).toBe("medium"); + expect(openaiResult.thinkingLevel).toBe("high"); }); it("applies maxOutputTokens override", () => { diff --git a/src/browser/utils/messages/compactionOptions.ts b/src/browser/utils/messages/compactionOptions.ts index b7efdbf77c..7e0f544516 100644 --- a/src/browser/utils/messages/compactionOptions.ts +++ b/src/browser/utils/messages/compactionOptions.ts @@ -5,9 +5,14 @@ * Used by both ChatInput (initial send) and useResumeManager (resume after interruption). */ +import { readPersistedState } from "@/browser/hooks/usePersistedState"; +import { toGatewayModel } from "@/browser/hooks/useGatewayModels"; +import { MODE_AI_DEFAULTS_KEY } from "@/common/constants/storage"; import type { SendMessageOptions } from "@/common/orpc/types"; import type { CompactionRequestData } from "@/common/types/message"; -import { toGatewayModel } from "@/browser/hooks/useGatewayModels"; +import type { ModeAiDefaults } from "@/common/types/modeAiDefaults"; +import { coerceThinkingLevel } from "@/common/types/thinking"; +import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; /** * Apply compaction-specific option overrides to base options. @@ -24,15 +29,20 @@ export function applyCompactionOverrides( baseOptions: SendMessageOptions, compactData: CompactionRequestData ): SendMessageOptions { - // Use custom model if specified, otherwise use workspace default - // Apply gateway transformation - compactData.model is raw, baseOptions.model is already transformed + // Apply gateway transformation - compactData.model is raw, baseOptions.model is already transformed. const compactionModel = compactData.model ? toGatewayModel(compactData.model) : baseOptions.model; + const modeAiDefaults = readPersistedState(MODE_AI_DEFAULTS_KEY, {}); + const preferredThinking = modeAiDefaults.compact?.thinkingLevel; + + const requestedThinking = + coerceThinkingLevel(preferredThinking ?? baseOptions.thinkingLevel) ?? "off"; + const thinkingLevel = enforceThinkingPolicy(compactionModel, requestedThinking); + return { ...baseOptions, model: compactionModel, - // Keep workspace default thinking level - all models support thinking now that tools are disabled - thinkingLevel: baseOptions.thinkingLevel, + thinkingLevel, maxOutputTokens: compactData.maxOutputTokens, mode: "compact" as const, // Disable all tools during compaction - regex .* matches all tool names diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 6c9591f2ce..cafd5d85ec 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -65,6 +65,14 @@ export function getThinkingLevelKey(scopeId: string): string { return `thinkingLevel:${scopeId}`; } +/** + * Get the localStorage key for per-mode workspace AI overrides cache. + * Format: "workspaceAiSettingsByMode:{workspaceId}" + */ +export function getWorkspaceAISettingsByModeKey(workspaceId: string): string { + return `workspaceAiSettingsByMode:${workspaceId}`; +} + /** * LEGACY: Get the localStorage key for thinking level preference per model (global). * Format: "thinkingLevel:model:{modelName}" @@ -165,6 +173,12 @@ export function getLastSshHostKey(projectPath: string): string { */ export const PREFERRED_COMPACTION_MODEL_KEY = "preferredCompactionModel"; +/** + * Get the localStorage key for cached mode AI defaults (global). + * Format: "modeAiDefaults" + */ +export const MODE_AI_DEFAULTS_KEY = "modeAiDefaults"; + /** * Get the localStorage key for vim mode preference (global) * Format: "vimEnabled" @@ -322,6 +336,7 @@ export function getAutoCompactionThresholdKey(model: string): string { * List of workspace-scoped key functions that should be copied on fork and deleted on removal */ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> = [ + getWorkspaceAISettingsByModeKey, getModelKey, getInputKey, getInputImagesKey, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index c4ec20ed83..0d748cb4fb 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1,4 +1,5 @@ import { eventIterator } from "@orpc/server"; +import { UIModeSchema } from "../../types/mode"; import { z } from "zod"; import { ChatStatsSchema, SessionUsageFileSchema } from "./chatStats"; import { SendMessageErrorSchema } from "./errors"; @@ -255,6 +256,14 @@ export const workspace = { input: z.object({ workspaceId: z.string(), title: z.string() }), output: ResultSchema(z.void(), z.string()), }, + updateModeAISettings: { + input: z.object({ + workspaceId: z.string(), + mode: UIModeSchema, + aiSettings: WorkspaceAISettingsSchema, + }), + output: ResultSchema(z.void(), z.string()), + }, updateAISettings: { input: z.object({ workspaceId: z.string(), @@ -650,6 +659,20 @@ const SubagentAiDefaultsEntrySchema = z }) .strict(); +const ModeAiDefaultsEntrySchema = z + .object({ + modelString: z.string().min(1).optional(), + thinkingLevel: z.enum(["off", "low", "medium", "high", "xhigh"]).optional(), + }) + .strict(); + +const ModeAiDefaultsSchema = z + .object({ + plan: ModeAiDefaultsEntrySchema.optional(), + exec: ModeAiDefaultsEntrySchema.optional(), + compact: ModeAiDefaultsEntrySchema.optional(), + }) + .strict(); const SubagentAiDefaultsSchema = z.record(z.string().min(1), SubagentAiDefaultsEntrySchema); export const config = { @@ -661,6 +684,7 @@ export const config = { maxTaskNestingDepth: z.number().int(), }), subagentAiDefaults: SubagentAiDefaultsSchema, + modeAiDefaults: ModeAiDefaultsSchema, }), }, saveConfig: { @@ -673,6 +697,12 @@ export const config = { }), output: z.void(), }, + updateModeAiDefaults: { + input: z.object({ + modeAiDefaults: ModeAiDefaultsSchema, + }), + output: z.void(), + }, }; // Splash screens diff --git a/src/common/orpc/schemas/message.ts b/src/common/orpc/schemas/message.ts index 930d503caa..764c8f6e8a 100644 --- a/src/common/orpc/schemas/message.ts +++ b/src/common/orpc/schemas/message.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { AgentModeSchema } from "../../types/mode"; import { StreamErrorTypeSchema } from "./errors"; export const ImagePartSchema = z.object({ @@ -110,7 +111,7 @@ export const MuxMessageSchema = z.object({ // Compaction source: "user" (manual), "idle" (auto), or legacy boolean (true) compacted: z.union([z.literal("user"), z.literal("idle"), z.boolean()]).optional(), toolPolicy: z.any().optional(), - mode: z.string().optional(), + mode: AgentModeSchema.optional().catch(undefined), partial: z.boolean().optional(), synthetic: z.boolean().optional(), error: z.string().optional(), diff --git a/src/common/orpc/schemas/project.ts b/src/common/orpc/schemas/project.ts index 82281a71dc..b712b57305 100644 --- a/src/common/orpc/schemas/project.ts +++ b/src/common/orpc/schemas/project.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { RuntimeConfigSchema } from "./runtime"; import { WorkspaceMCPOverridesSchema } from "./mcp"; -import { WorkspaceAISettingsSchema } from "./workspaceAiSettings"; +import { WorkspaceAISettingsByModeSchema, WorkspaceAISettingsSchema } from "./workspaceAiSettings"; const ThinkingLevelSchema = z.enum(["off", "low", "medium", "high", "xhigh"]); @@ -26,6 +26,9 @@ export const WorkspaceConfigSchema = z.object({ runtimeConfig: RuntimeConfigSchema.optional().meta({ description: "Runtime configuration (local vs SSH) - optional, defaults to local", }), + aiSettingsByMode: WorkspaceAISettingsByModeSchema.optional().meta({ + description: "Per-mode workspace-scoped AI settings (plan/exec)", + }), aiSettings: WorkspaceAISettingsSchema.optional().meta({ description: "Workspace-scoped AI settings (model + thinking level)", }), diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index c7db956103..bd2f428de9 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { AgentModeSchema } from "../../types/mode"; import { ChatUsageDisplaySchema } from "./chatStats"; import { StreamErrorTypeSchema } from "./errors"; import { @@ -43,7 +44,7 @@ export const StreamStartEventSchema = z.object({ startTime: z.number().meta({ description: "Backend timestamp when stream started (Date.now())", }), - mode: z.string().optional().meta({ + mode: AgentModeSchema.optional().catch(undefined).meta({ description: "Agent mode for this stream", }), }); @@ -372,7 +373,7 @@ export const SendMessageOptionsSchema = z.object({ additionalSystemInstructions: z.string().optional(), maxOutputTokens: z.number().optional(), providerOptions: MuxProviderOptionsSchema.optional(), - mode: z.string().optional(), + mode: AgentModeSchema.optional().catch(undefined), muxMetadata: z.any().optional(), // Black box experiments: ExperimentsSchema.optional(), }); diff --git a/src/common/orpc/schemas/telemetry.ts b/src/common/orpc/schemas/telemetry.ts index 903d03b5ea..f635fcc723 100644 --- a/src/common/orpc/schemas/telemetry.ts +++ b/src/common/orpc/schemas/telemetry.ts @@ -6,6 +6,7 @@ */ import { z } from "zod"; +import { AgentModeSchema } from "../../types/mode"; // Error context enum (matches payload.ts) const ErrorContextSchema = z.enum([ @@ -64,7 +65,7 @@ const WorkspaceSwitchedPropertiesSchema = z.object({ const MessageSentPropertiesSchema = z.object({ workspaceId: z.string(), model: z.string(), - mode: z.string(), + mode: AgentModeSchema.catch("exec"), message_length_b2: z.number(), runtimeType: TelemetryRuntimeTypeSchema, frontendPlatform: FrontendPlatformInfoSchema, @@ -83,7 +84,7 @@ const TelemetryMCPTransportModeSchema = z.enum([ const MCPContextInjectedPropertiesSchema = z.object({ workspaceId: z.string(), model: z.string(), - mode: z.string(), + mode: AgentModeSchema.catch("exec"), runtimeType: TelemetryRuntimeTypeSchema, mcp_server_enabled_count: z.number(), @@ -134,7 +135,7 @@ const StatsTabOpenedPropertiesSchema = z.object({ const StreamTimingComputedPropertiesSchema = z.object({ model: z.string(), - mode: z.string(), + mode: AgentModeSchema.catch("exec"), duration_b2: z.number(), ttft_ms_b2: z.number(), tool_ms_b2: z.number(), diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index d6dfdbb13c..19f47e0700 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { RuntimeConfigSchema } from "./runtime"; -import { WorkspaceAISettingsSchema } from "./workspaceAiSettings"; +import { WorkspaceAISettingsByModeSchema, WorkspaceAISettingsSchema } from "./workspaceAiSettings"; const ThinkingLevelSchema = z.enum(["off", "low", "medium", "high", "xhigh"]); @@ -26,6 +26,9 @@ export const WorkspaceMetadataSchema = z.object({ description: "ISO 8601 timestamp of when workspace was created (optional for backward compatibility)", }), + aiSettingsByMode: WorkspaceAISettingsByModeSchema.optional().meta({ + description: "Per-mode AI settings (plan/exec) persisted in config", + }), runtimeConfig: RuntimeConfigSchema.meta({ description: "Runtime configuration for this workspace (always set, defaults to local on load)", }), diff --git a/src/common/orpc/schemas/workspaceAiSettings.ts b/src/common/orpc/schemas/workspaceAiSettings.ts index 275c7734ea..8b7695813d 100644 --- a/src/common/orpc/schemas/workspaceAiSettings.ts +++ b/src/common/orpc/schemas/workspaceAiSettings.ts @@ -7,9 +7,23 @@ import { z } from "zod"; * - `model` must be canonical "provider:model" (NOT mux-gateway:provider/model). * - `thinkingLevel` is workspace-scoped (saved per workspace, not per-model). */ + export const WorkspaceAISettingsSchema = z.object({ model: z.string().meta({ description: 'Canonical model id in the form "provider:model"' }), thinkingLevel: z.enum(["off", "low", "medium", "high", "xhigh"]).meta({ description: "Thinking/reasoning effort level", }), }); + +/** + * Per-mode workspace AI overrides. + * + * Notes: + * - Only includes UI modes (plan/exec). Compact is intentionally excluded. + */ +export const WorkspaceAISettingsByModeSchema = z + .object({ + plan: WorkspaceAISettingsSchema.optional(), + exec: WorkspaceAISettingsSchema.optional(), + }) + .strict(); diff --git a/src/common/orpc/schemas/workspaceStats.ts b/src/common/orpc/schemas/workspaceStats.ts index 37e7efb368..221c890161 100644 --- a/src/common/orpc/schemas/workspaceStats.ts +++ b/src/common/orpc/schemas/workspaceStats.ts @@ -1,7 +1,8 @@ import { z } from "zod"; +import { AgentModeSchema } from "../../types/mode"; -// Mode is a string to support any mode value (plan, exec, compact, etc.) -const ModeSchema = z.string(); +// Mode is an enum, but we defensively drop unknown values when replaying old history. +const ModeSchema = AgentModeSchema.optional().catch(undefined); export const TimingAnomalySchema = z.enum([ "negative_duration", @@ -14,7 +15,7 @@ export const TimingAnomalySchema = z.enum([ export const ActiveStreamStatsSchema = z.object({ messageId: z.string(), model: z.string(), - mode: ModeSchema.optional(), + mode: ModeSchema, elapsedMs: z.number(), ttftMs: z.number().nullable(), @@ -37,7 +38,7 @@ export const ActiveStreamStatsSchema = z.object({ export const CompletedStreamStatsSchema = z.object({ messageId: z.string(), model: z.string(), - mode: ModeSchema.optional(), + mode: ModeSchema, totalDurationMs: z.number(), ttftMs: z.number().nullable(), @@ -54,7 +55,7 @@ export const CompletedStreamStatsSchema = z.object({ export const ModelTimingStatsSchema = z.object({ model: z.string(), - mode: ModeSchema.optional(), + mode: ModeSchema, totalDurationMs: z.number(), totalToolExecutionMs: z.number(), diff --git a/src/common/telemetry/payload.ts b/src/common/telemetry/payload.ts index ae188504e5..f2865cef86 100644 --- a/src/common/telemetry/payload.ts +++ b/src/common/telemetry/payload.ts @@ -20,6 +20,8 @@ * code only needs to provide event-specific properties. */ +import type { AgentMode } from "@/common/types/mode"; + /** * Base properties included with all telemetry events * These are added by the backend, not the frontend @@ -97,7 +99,7 @@ export interface MessageSentPayload { /** Full model identifier (e.g., 'anthropic/claude-3-5-sonnet-20241022') */ model: string; /** UI mode (e.g., 'plan', 'exec', 'edit') */ - mode: string; + mode: AgentMode; /** Message length rounded to nearest power of 2 (e.g., 128, 256, 512, 1024) */ message_length_b2: number; /** Runtime type for the workspace */ @@ -119,7 +121,7 @@ export interface MCPContextInjectedPayload { /** Full model identifier */ model: string; /** UI mode */ - mode: string; + mode: AgentMode; /** Runtime type for the workspace */ runtimeType: TelemetryRuntimeType; @@ -196,7 +198,7 @@ export interface StatsTabOpenedPayload { */ export interface StreamTimingComputedPayload { model: string; - mode: string; + mode: AgentMode; duration_b2: number; ttft_ms_b2: number; tool_ms_b2: number; diff --git a/src/common/telemetry/tracking.ts b/src/common/telemetry/tracking.ts index fa0c44e446..2cddba8b6a 100644 --- a/src/common/telemetry/tracking.ts +++ b/src/common/telemetry/tracking.ts @@ -8,6 +8,7 @@ import { trackEvent } from "./client"; import { roundToBase2 } from "./utils"; +import type { AgentMode } from "@/common/types/mode"; import type { TelemetryRuntimeType, TelemetryThinkingLevel, @@ -67,7 +68,7 @@ export function trackWorkspaceSwitched(fromWorkspaceId: string, toWorkspaceId: s export function trackMessageSent( workspaceId: string, model: string, - mode: string, + mode: AgentMode, messageLength: number, runtimeType: TelemetryRuntimeType, thinkingLevel: TelemetryThinkingLevel diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 996a24eb02..c4f1f4af39 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -3,6 +3,7 @@ import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import type { StreamErrorType } from "./errors"; import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; import type { ImagePart, MuxToolPartSchema } from "@/common/orpc/schemas"; +import type { AgentMode } from "@/common/types/mode"; import type { z } from "zod"; import { type ReviewNoteData, formatReviewForModel } from "./review"; @@ -126,7 +127,7 @@ export interface MuxMetadata { // Readers should use helper: isCompacted = compacted !== undefined && compacted !== false compacted?: "user" | "idle" | boolean; toolPolicy?: ToolPolicy; // Tool policy active when this message was sent (user messages only) - mode?: string; // The mode active when this message was sent (assistant messages only) - plan/exec today, custom modes in future + mode?: AgentMode; // The mode active when this message was sent (assistant messages only) cmuxMetadata?: MuxFrontendMetadata; // Frontend-defined metadata, backend treats as black-box muxMetadata?: MuxFrontendMetadata; // Frontend-defined metadata, backend treats as black-box } diff --git a/src/common/types/mode.ts b/src/common/types/mode.ts index 85fb8d7980..95fbd4b9d2 100644 --- a/src/common/types/mode.ts +++ b/src/common/types/mode.ts @@ -4,5 +4,16 @@ import { z } from "zod"; * UI Mode types */ -export const UIModeSchema = z.enum(["plan", "exec"]); +export const UI_MODE_VALUES = ["plan", "exec"] as const; +export const UIModeSchema = z.enum(UI_MODE_VALUES); export type UIMode = z.infer; + +/** + * Agent mode types + * + * Includes non-UI modes like "compact" used for history compaction. + */ + +export const AGENT_MODE_VALUES = [...UI_MODE_VALUES, "compact"] as const; +export const AgentModeSchema = z.enum(AGENT_MODE_VALUES); +export type AgentMode = z.infer; diff --git a/src/common/types/modeAiDefaults.ts b/src/common/types/modeAiDefaults.ts new file mode 100644 index 0000000000..745fb13f12 --- /dev/null +++ b/src/common/types/modeAiDefaults.ts @@ -0,0 +1,41 @@ +import { AGENT_MODE_VALUES, type AgentMode } from "./mode"; +import { coerceThinkingLevel, type ThinkingLevel } from "./thinking"; + +export interface ModeAiDefaultsEntry { + modelString?: string; + thinkingLevel?: ThinkingLevel; +} + +export type ModeAiDefaults = Partial>; + +const AGENT_MODE_SET = new Set(AGENT_MODE_VALUES); + +export function normalizeModeAiDefaults(raw: unknown): ModeAiDefaults { + const record = raw && typeof raw === "object" ? (raw as Record) : ({} as const); + + const result: ModeAiDefaults = {}; + + for (const [modeRaw, entryRaw] of Object.entries(record)) { + const mode = modeRaw.trim().toLowerCase(); + if (!mode) continue; + if (!AGENT_MODE_SET.has(mode)) continue; + if (!entryRaw || typeof entryRaw !== "object") continue; + + const entry = entryRaw as Record; + + const modelString = + typeof entry.modelString === "string" && entry.modelString.trim().length > 0 + ? entry.modelString.trim() + : undefined; + + const thinkingLevel = coerceThinkingLevel(entry.thinkingLevel); + + if (!modelString && !thinkingLevel) { + continue; + } + + result[mode as AgentMode] = { modelString, thinkingLevel }; + } + + return result; +} diff --git a/src/common/types/project.ts b/src/common/types/project.ts index 87b72fba70..418aacec46 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -6,6 +6,7 @@ import type { z } from "zod"; import type { ProjectConfigSchema, WorkspaceConfigSchema } from "../orpc/schemas"; import type { TaskSettings, SubagentAiDefaults } from "./tasks"; +import type { ModeAiDefaults } from "./modeAiDefaults"; export type Workspace = z.infer; @@ -44,4 +45,6 @@ export interface ProjectsConfig { taskSettings?: TaskSettings; /** Per-subagent default model + thinking overrides. Missing values inherit from the parent workspace. */ subagentAiDefaults?: SubagentAiDefaults; + /** Default model + thinking overrides per mode (plan/exec/compact). */ + modeAiDefaults?: ModeAiDefaults; } diff --git a/src/node/config.ts b/src/node/config.ts index 84cf38cd7b..cefe599e0a 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -17,6 +17,7 @@ import { normalizeSubagentAiDefaults, normalizeTaskSettings, } from "@/common/types/tasks"; +import { normalizeModeAiDefaults } from "@/common/types/modeAiDefaults"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; import { getMuxHome } from "@/common/constants/paths"; @@ -97,6 +98,7 @@ export class Config { featureFlagOverrides?: Record; taskSettings?: unknown; subagentAiDefaults?: unknown; + modeAiDefaults?: unknown; }; // Config is stored as array of [path, config] pairs @@ -119,6 +121,7 @@ export class Config { viewedSplashScreens: parsed.viewedSplashScreens, taskSettings: normalizeTaskSettings(parsed.taskSettings), subagentAiDefaults: normalizeSubagentAiDefaults(parsed.subagentAiDefaults), + modeAiDefaults: normalizeModeAiDefaults(parsed.modeAiDefaults), featureFlagOverrides: parsed.featureFlagOverrides, }; } @@ -132,6 +135,7 @@ export class Config { projects: new Map(), taskSettings: DEFAULT_TASK_SETTINGS, subagentAiDefaults: {}, + modeAiDefaults: {}, }; } @@ -151,6 +155,7 @@ export class Config { featureFlagOverrides?: ProjectsConfig["featureFlagOverrides"]; taskSettings?: ProjectsConfig["taskSettings"]; subagentAiDefaults?: ProjectsConfig["subagentAiDefaults"]; + modeAiDefaults?: ProjectsConfig["modeAiDefaults"]; } = { projects: Array.from(config.projects.entries()), taskSettings: config.taskSettings ?? DEFAULT_TASK_SETTINGS, @@ -179,6 +184,9 @@ export class Config { if (config.viewedSplashScreens) { data.viewedSplashScreens = config.viewedSplashScreens; } + if (config.modeAiDefaults && Object.keys(config.modeAiDefaults).length > 0) { + data.modeAiDefaults = config.modeAiDefaults; + } if (config.subagentAiDefaults && Object.keys(config.subagentAiDefaults).length > 0) { data.subagentAiDefaults = config.subagentAiDefaults; } @@ -406,6 +414,7 @@ export class Config { // GUARANTEE: All workspaces must have runtimeConfig (apply default if missing) runtimeConfig: workspace.runtimeConfig ?? DEFAULT_RUNTIME_CONFIG, aiSettings: workspace.aiSettings, + aiSettingsByMode: workspace.aiSettingsByMode, parentWorkspaceId: workspace.parentWorkspaceId, agentType: workspace.agentType, taskStatus: workspace.taskStatus, @@ -456,6 +465,7 @@ export class Config { metadata.runtimeConfig ??= DEFAULT_RUNTIME_CONFIG; // Preserve any config-only fields that may not exist in legacy metadata.json + metadata.aiSettingsByMode ??= workspace.aiSettingsByMode; metadata.aiSettings ??= workspace.aiSettings; // Preserve tree/task metadata when present in config (metadata.json won't have it) @@ -494,6 +504,7 @@ export class Config { // GUARANTEE: All workspaces must have runtimeConfig runtimeConfig: DEFAULT_RUNTIME_CONFIG, aiSettings: workspace.aiSettings, + aiSettingsByMode: workspace.aiSettingsByMode, parentWorkspaceId: workspace.parentWorkspaceId, agentType: workspace.agentType, taskStatus: workspace.taskStatus, @@ -529,6 +540,7 @@ export class Config { // GUARANTEE: All workspaces must have runtimeConfig (even in error cases) runtimeConfig: DEFAULT_RUNTIME_CONFIG, aiSettings: workspace.aiSettings, + aiSettingsByMode: workspace.aiSettingsByMode, parentWorkspaceId: workspace.parentWorkspaceId, agentType: workspace.agentType, taskStatus: workspace.taskStatus, diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index d21075d0ec..49d83ac223 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -20,6 +20,7 @@ import { readPlanFile } from "@/node/utils/runtime/helpers"; import { secretsToRecord } from "@/common/types/secrets"; import { roundToBase2 } from "@/common/telemetry/utils"; import { createAsyncEventQueue } from "@/common/utils/asyncEventIterator"; +import { normalizeModeAiDefaults } from "@/common/types/modeAiDefaults"; import { DEFAULT_TASK_SETTINGS, normalizeSubagentAiDefaults, @@ -251,8 +252,22 @@ export const router = (authToken?: string) => { return { taskSettings: config.taskSettings ?? DEFAULT_TASK_SETTINGS, subagentAiDefaults: config.subagentAiDefaults ?? {}, + modeAiDefaults: config.modeAiDefaults ?? {}, }; }), + updateModeAiDefaults: t + .input(schemas.config.updateModeAiDefaults.input) + .output(schemas.config.updateModeAiDefaults.output) + .handler(async ({ context, input }) => { + await context.config.editConfig((config) => { + const normalizedDefaults = normalizeModeAiDefaults(input.modeAiDefaults); + return { + ...config, + modeAiDefaults: + Object.keys(normalizedDefaults).length > 0 ? normalizedDefaults : undefined, + }; + }); + }), saveConfig: t .input(schemas.config.saveConfig.input) .output(schemas.config.saveConfig.output) @@ -752,6 +767,16 @@ export const router = (authToken?: string) => { .handler(async ({ context, input }) => { return context.workspaceService.rename(input.workspaceId, input.newName); }), + updateModeAISettings: t + .input(schemas.workspace.updateModeAISettings.input) + .output(schemas.workspace.updateModeAISettings.output) + .handler(async ({ context, input }) => { + return context.workspaceService.updateModeAISettings( + input.workspaceId, + input.mode, + input.aiSettings + ); + }), updateTitle: t .input(schemas.workspace.updateTitle.input) .output(schemas.workspace.updateTitle.output) diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 125da82186..38f508c9fd 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -70,7 +70,7 @@ import { MockScenarioPlayer } from "./mock/mockScenarioPlayer"; import { EnvHttpProxyAgent, type Dispatcher } from "undici"; import { getPlanFilePath } from "@/common/utils/planStorage"; import { getPlanModeInstruction } from "@/common/utils/ui/modeUtils"; -import type { UIMode } from "@/common/types/mode"; +import type { AgentMode, UIMode } from "@/common/types/mode"; import { MUX_APP_ATTRIBUTION_TITLE, MUX_APP_ATTRIBUTION_URL } from "@/constants/appAttribution"; import { readPlanFile } from "@/node/utils/runtime/helpers"; import { getAgentPreset } from "@/node/services/agentPresets"; @@ -1006,7 +1006,7 @@ export class AIService extends EventEmitter { additionalSystemInstructions?: string, maxOutputTokens?: number, muxProviderOptions?: MuxProviderOptions, - mode?: string, + mode?: AgentMode, recordFileState?: (filePath: string, state: FileState) => void, changedFileAttachments?: EditedFileAttachment[], postCompactionAttachments?: PostCompactionAttachment[] | null, @@ -1455,7 +1455,7 @@ export class AIService extends EventEmitter { properties: { workspaceId, model: modelString, - mode: mode ?? uiMode ?? "unknown", + mode: mode ?? uiMode ?? "exec", runtimeType: getRuntimeTypeForTelemetry(metadata.runtimeConfig), mcp_server_enabled_count: effectiveMcpStats.enabledServerCount, diff --git a/src/node/services/sessionTimingService.ts b/src/node/services/sessionTimingService.ts index 6ebcdf510a..3dc7504576 100644 --- a/src/node/services/sessionTimingService.ts +++ b/src/node/services/sessionTimingService.ts @@ -6,6 +6,7 @@ import writeFileAtomic from "write-file-atomic"; import type { Config } from "@/node/config"; import { workspaceFileLocks } from "@/node/utils/concurrency/workspaceFileLocks"; import { normalizeGatewayModel } from "@/common/utils/ai/models"; +import type { AgentMode } from "@/common/types/mode"; import { ActiveStreamStatsSchema, CompletedStreamStatsSchema, @@ -48,7 +49,7 @@ interface ActiveStreamState { workspaceId: string; messageId: string; model: string; - mode?: string; + mode?: AgentMode; startTimeMs: number; firstTokenTimeMs: number | null; @@ -64,7 +65,7 @@ interface ActiveStreamState { lastEventTimestampMs: number; } -function getModelKey(model: string, mode: string | undefined): string { +function getModelKey(model: string, mode: AgentMode | undefined): string { return mode ? `${model}:${mode}` : model; } @@ -354,7 +355,7 @@ export class SessionTimingService { event: "stream_timing_computed", properties: { model: completed.model, - mode: completed.mode ?? "unknown", + mode: completed.mode ?? "exec", duration_b2: roundToBase2(durationSecs), ttft_ms_b2: completed.ttftMs !== null ? roundToBase2(completed.ttftMs) : 0, tool_ms_b2: roundToBase2(completed.toolExecutionMs), diff --git a/src/node/services/tools/code_execution.integration.test.ts b/src/node/services/tools/code_execution.integration.test.ts index 52ff96d6c6..c0d259db4f 100644 --- a/src/node/services/tools/code_execution.integration.test.ts +++ b/src/node/services/tools/code_execution.integration.test.ts @@ -42,84 +42,92 @@ describe("code_execution integration tests", () => { }); describe("file_read through sandbox", () => { - it("reads a real file via mux.file_read()", async () => { - // Create a real file - const testContent = "hello from integration test\nline two\nline three"; - await fs.writeFile(path.join(testDir, "test.txt"), testContent); - - // Create real file_read tool - const fileReadTool = createFileReadTool(toolConfig); - const tools: Record = { file_read: fileReadTool }; - - // Track events - const events: PTCEvent[] = []; - const codeExecutionTool = await createCodeExecutionTool( - runtimeFactory, - new ToolBridge(tools), - (e) => events.push(e) - ); - - // Execute code that reads the file - const code = ` + it( + "reads a real file via mux.file_read()", + async () => { + // Create a real file + const testContent = "hello from integration test\nline two\nline three"; + await fs.writeFile(path.join(testDir, "test.txt"), testContent); + + // Create real file_read tool + const fileReadTool = createFileReadTool(toolConfig); + const tools: Record = { file_read: fileReadTool }; + + // Track events + const events: PTCEvent[] = []; + const codeExecutionTool = await createCodeExecutionTool( + runtimeFactory, + new ToolBridge(tools), + (e) => events.push(e) + ); + + // Execute code that reads the file + const code = ` const result = mux.file_read({ filePath: "test.txt" }); return result; `; - const result = (await codeExecutionTool.execute!( - { code }, - mockToolCallOptions - )) as PTCExecutionResult; + const result = (await codeExecutionTool.execute!( + { code }, + mockToolCallOptions + )) as PTCExecutionResult; - // Verify the result contains the file content - expect(result).toBeDefined(); - expect(result.success).toBe(true); + // Verify the result contains the file content + expect(result).toBeDefined(); + expect(result.success).toBe(true); - // The result should be the file_read response - const fileReadResult = result.result as { - success: boolean; - content?: string; - lines_read?: number; - }; - expect(fileReadResult.success).toBe(true); - expect(fileReadResult.content).toContain("hello from integration test"); - expect(fileReadResult.lines_read).toBe(3); - - // Verify tool call event was emitted (toolName includes mux. prefix from registerObject) - const toolCallEndEvents = events.filter( - (e): e is PTCToolCallEndEvent => e.type === "tool-call-end" - ); - expect(toolCallEndEvents.length).toBe(1); - expect(toolCallEndEvents[0].toolName).toBe("mux.file_read"); - expect(toolCallEndEvents[0].error).toBeUndefined(); - }); - - it("handles file not found gracefully", async () => { - const fileReadTool = createFileReadTool(toolConfig); - const tools: Record = { file_read: fileReadTool }; - - const codeExecutionTool = await createCodeExecutionTool( - runtimeFactory, - new ToolBridge(tools) - ); - - const code = ` + // The result should be the file_read response + const fileReadResult = result.result as { + success: boolean; + content?: string; + lines_read?: number; + }; + expect(fileReadResult.success).toBe(true); + expect(fileReadResult.content).toContain("hello from integration test"); + expect(fileReadResult.lines_read).toBe(3); + + // Verify tool call event was emitted (toolName includes mux. prefix from registerObject) + const toolCallEndEvents = events.filter( + (e): e is PTCToolCallEndEvent => e.type === "tool-call-end" + ); + expect(toolCallEndEvents.length).toBe(1); + expect(toolCallEndEvents[0].toolName).toBe("mux.file_read"); + expect(toolCallEndEvents[0].error).toBeUndefined(); + }, + { timeout: 20_000 } + ); + + it( + "handles file not found gracefully", + async () => { + const fileReadTool = createFileReadTool(toolConfig); + const tools: Record = { file_read: fileReadTool }; + + const codeExecutionTool = await createCodeExecutionTool( + runtimeFactory, + new ToolBridge(tools) + ); + + const code = ` const result = mux.file_read({ filePath: "nonexistent.txt" }); return result; `; - const result = (await codeExecutionTool.execute!( - { code }, - mockToolCallOptions - )) as PTCExecutionResult; - - expect(result.success).toBe(true); - - // file_read returns success: false for missing files, not an exception - const fileReadResult = result.result as { success: boolean; error?: string }; - expect(fileReadResult.success).toBe(false); - // Error contains ENOENT or stat failure message - expect(fileReadResult.error).toMatch(/ENOENT|stat/i); - }); + const result = (await codeExecutionTool.execute!( + { code }, + mockToolCallOptions + )) as PTCExecutionResult; + + expect(result.success).toBe(true); + + // file_read returns success: false for missing files, not an exception + const fileReadResult = result.result as { success: boolean; error?: string }; + expect(fileReadResult.success).toBe(false); + // Error contains ENOENT or stat failure message + expect(fileReadResult.error).toMatch(/ENOENT|stat/i); + }, + { timeout: 20_000 } + ); }); describe("bash through sandbox", () => { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 7c03a579f5..c245c219ac 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -44,6 +44,7 @@ import { AskUserQuestionToolArgsSchema, AskUserQuestionToolResultSchema, } from "@/common/utils/tools/toolDefinitions"; +import type { UIMode } from "@/common/types/mode"; import type { MuxMessage } from "@/common/types/message"; import type { RuntimeConfig } from "@/common/types/runtime"; import { hasSrcBaseDir, getSrcBaseDir, isSSHRuntime } from "@/common/types/runtime"; @@ -1071,16 +1072,23 @@ export class WorkspaceService extends EventEmitter { options: SendMessageOptions | undefined, context: "send" | "resume" ): Promise { - // Skip for compaction - it may use a different model and shouldn't override user preference + // Skip for compaction - it may use a different model and shouldn't override user preference. const isCompaction = options?.mode === "compact"; if (isCompaction) return; const extractedSettings = this.extractWorkspaceAISettingsFromSendOptions(options); if (!extractedSettings) return; - const persistResult = await this.persistWorkspaceAISettings(workspaceId, extractedSettings, { - emitMetadata: false, - }); + const mode: UIMode = options?.mode === "plan" ? "plan" : "exec"; + + const persistResult = await this.persistWorkspaceAISettingsForMode( + workspaceId, + mode, + extractedSettings, + { + emitMetadata: false, + } + ); if (!persistResult.success) { log.debug(`Failed to persist workspace AI settings from ${context} options`, { workspaceId, @@ -1089,8 +1097,9 @@ export class WorkspaceService extends EventEmitter { } } - private async persistWorkspaceAISettings( + private async persistWorkspaceAISettingsForMode( workspaceId: string, + mode: UIMode, aiSettings: WorkspaceAISettings, options?: { emitMetadata?: boolean } ): Promise> { @@ -1114,14 +1123,82 @@ export class WorkspaceService extends EventEmitter { return Err("Workspace not found"); } - const prev = workspaceEntryWithFallback.aiSettings; + const prev = workspaceEntryWithFallback.aiSettingsByMode?.[mode]; const changed = prev?.model !== aiSettings.model || prev?.thinkingLevel !== aiSettings.thinkingLevel; if (!changed) { return Ok(false); } + workspaceEntryWithFallback.aiSettingsByMode = { + ...(workspaceEntryWithFallback.aiSettingsByMode ?? {}), + [mode]: aiSettings, + }; + + // Keep the legacy field in sync for older clients (prefer exec). + workspaceEntryWithFallback.aiSettings = + workspaceEntryWithFallback.aiSettingsByMode.exec ?? + workspaceEntryWithFallback.aiSettingsByMode.plan ?? + aiSettings; + + await this.config.saveConfig(config); + + if (options?.emitMetadata !== false) { + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const updatedMetadata = allMetadata.find((m) => m.id === workspaceId) ?? null; + + const session = this.sessions.get(workspaceId); + if (session) { + session.emitMetadata(updatedMetadata); + } else { + this.emit("metadata", { workspaceId, metadata: updatedMetadata }); + } + } + + return Ok(true); + } + + private async persistWorkspaceAISettings( + workspaceId: string, + aiSettings: WorkspaceAISettings, + options?: { emitMetadata?: boolean } + ): Promise> { + const found = this.config.findWorkspace(workspaceId); + if (!found) { + return Err("Workspace not found"); + } + + const { projectPath, workspacePath } = found; + + const config = this.config.loadConfigOrDefault(); + const projectConfig = config.projects.get(projectPath); + if (!projectConfig) { + return Err(`Project not found: ${projectPath}`); + } + + const workspaceEntry = projectConfig.workspaces.find((w) => w.id === workspaceId); + const workspaceEntryWithFallback = + workspaceEntry ?? projectConfig.workspaces.find((w) => w.path === workspacePath); + if (!workspaceEntryWithFallback) { + return Err("Workspace not found"); + } + + const prevLegacy = workspaceEntryWithFallback.aiSettings; + const prevByMode = workspaceEntryWithFallback.aiSettingsByMode; + + const changed = + prevLegacy?.model !== aiSettings.model || + prevLegacy?.thinkingLevel !== aiSettings.thinkingLevel || + prevByMode?.plan?.model !== aiSettings.model || + prevByMode?.plan?.thinkingLevel !== aiSettings.thinkingLevel || + prevByMode?.exec?.model !== aiSettings.model || + prevByMode?.exec?.thinkingLevel !== aiSettings.thinkingLevel; + if (!changed) { + return Ok(false); + } + workspaceEntryWithFallback.aiSettings = aiSettings; + workspaceEntryWithFallback.aiSettingsByMode = { plan: aiSettings, exec: aiSettings }; await this.config.saveConfig(config); if (options?.emitMetadata !== false) { @@ -1163,6 +1240,36 @@ export class WorkspaceService extends EventEmitter { } } + async updateModeAISettings( + workspaceId: string, + mode: UIMode, + aiSettings: WorkspaceAISettings + ): Promise> { + try { + const normalized = this.normalizeWorkspaceAISettings(aiSettings); + if (!normalized.success) { + return Err(normalized.error); + } + + const persistResult = await this.persistWorkspaceAISettingsForMode( + workspaceId, + mode, + normalized.data, + { + emitMetadata: true, + } + ); + if (!persistResult.success) { + return Err(persistResult.error); + } + + return Ok(undefined); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Failed to update workspace AI settings: ${message}`); + } + } + async fork( sourceWorkspaceId: string, newName: string