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
1 change: 0 additions & 1 deletion src/browser/features/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -833,7 +833,6 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
draftId: props.pendingDraftId,
userModel: preferredModel,
agentBaseById: new Map(agents.map((agent) => [agent.id, agent.base])),
autoRoutingEnabled: autoModelRoutingEnabled,
}
: {
// Dummy values for workspace variant (never used)
Expand Down
108 changes: 47 additions & 61 deletions src/browser/features/ChatInput/useCreationWorkspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { defaultCreationDraftScope, getDraftStore } from "@/browser/stores/Draft
import { APIProvider, type APIClient } from "@/browser/contexts/API";
import { createTestApiClient } from "@/browser/testUtils";
import { getAppConfigStore } from "@/browser/stores/AppConfigStore";
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
import {
getAutoRoutingPick,
resetAiSelectionIntentForTests,
setAutoRoutingPick,
} from "@/browser/utils/aiSelectionIntent";
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
import type { UserPreferences } from "@/common/config/schemas/userPreferences";
import * as ProjectContextModule from "@/browser/contexts/ProjectContext";
import * as RouterContextModule from "@/browser/contexts/RouterContext";
Expand All @@ -17,7 +17,6 @@ import * as DraftWorkspaceSettingsModule from "@/browser/hooks/useDraftWorkspace
import * as ChatCommandsModule from "@/browser/utils/chatCommands";
import type { ProjectConfig } from "@/common/types/project";
import {
getAutoRoutingChoiceByAgentKey,
getPendingDraftSkillDiscoveryKey,
getPendingScopeId,
getPendingWorkspaceSendErrorKey,
Expand Down Expand Up @@ -683,10 +682,7 @@ describe("useCreationWorkspace", () => {
mockProjectConfigMap = new Map([[TEST_PROJECT_PATH, { workspaces: [], trusted: true }]]);
persistedPreferences = {};
setPreferences(undefined);
getAppConfigStore().updateOptimistically({
agentAiDefaults: undefined,
experiments: undefined,
});
getAppConfigStore().updateOptimistically({ agentAiDefaults: undefined });
readPersistedStateCalls.length = 0;
updatePersistedStateCalls.length = 0;
draftSettingsInvocations = [];
Expand All @@ -705,9 +701,10 @@ describe("useCreationWorkspace", () => {
});

afterEach(async () => {
resetAiSelectionIntentForTests();
getAppConfigStore().updateOptimistically({ experiments: undefined });
getDraftStore().setClient(null);
cleanup();
resetAiSelectionIntentForTests();
restorePersistedStateMocks?.();
restorePersistedStateMocks = null;
await restoreUseCreationWorkspaceModuleMocks();
Expand Down Expand Up @@ -1566,7 +1563,6 @@ describe("useCreationWorkspace", () => {
projectPath: TEST_PROJECT_PATH,
onWorkspaceCreated: mock((metadata: FrontendWorkspaceMetadata) => metadata),
message: "/goal ship the feature",
autoRoutingEnabled: true,
});
await waitFor(() => expect(getHook().branches).toEqual([FALLBACK_BRANCH]));

Expand Down Expand Up @@ -2062,61 +2058,52 @@ describe("useCreationWorkspace", () => {
expect(sendMessageMock.mock.calls[0]?.[0]?.options?.agentId).toBe("exec");
});

test.each([true, false])(
"records only creation routing picks that differ from the agent's Auto default (experiment %p)",
async (autoRoutingEnabled) => {
setupWindow({
listBranches: mock(
(): Promise<BranchListResult> =>
Promise.resolve({ branches: ["main"], recommendedTrunk: "main" })
),
sendMessage: mock(
(_args: WorkspaceSendMessageArgs): Promise<WorkspaceSendMessageResult> =>
Promise.resolve({ success: true as const, data: {} })
),
create: mock(
(_args: WorkspaceCreateArgs): Promise<WorkspaceCreateResult> =>
Promise.resolve({ success: true, metadata: TEST_METADATA } as WorkspaceCreateResult)
),
});
test("the first send carries the creation composer's Auto picks to the new workspace", async () => {
const sendMessageMock = mock(
(_args: WorkspaceSendMessageArgs): Promise<WorkspaceSendMessageResult> =>
Promise.resolve({ success: true as const, data: {} })
);
setupWindow({
listBranches: mock(
(): Promise<BranchListResult> =>
Promise.resolve({ branches: ["main"], recommendedTrunk: "main" })
),
sendMessage: sendMessageMock,
create: mock(
(_args: WorkspaceCreateArgs): Promise<WorkspaceCreateResult> =>
Promise.resolve({ success: true, metadata: TEST_METADATA } as WorkspaceCreateResult)
),
});

const projectScopeId = getProjectScopeId(TEST_PROJECT_PATH);
getAppConfigStore().updateOptimistically({
agentAiDefaults: { exec: { autoModelRouting: true } },
});
setPreferences({
ai: { projectDefaults: { [TEST_PROJECT_PATH]: { agentId: "exec", model: "gpt-4" } } },
});
// Model Auto came from the default; thinking Auto was picked in the creation composer.
setAutoRoutingPick(projectScopeId, "exec", "thinkingLevel", true);
draftSettingsState = createDraftSettingsHarness({ agentId: "exec" });

const getHook = renderUseCreationWorkspace({
projectPath: TEST_PROJECT_PATH,
onWorkspaceCreated: mock((metadata: FrontendWorkspaceMetadata) => metadata),
message: "launch workspace",
autoRoutingEnabled,
});
await waitFor(() => expect(getHook().branches).toEqual(["main"]));
getAppConfigStore().updateOptimistically({
agentAiDefaults: { exec: { autoModelRouting: true } },
experiments: { [EXPERIMENT_IDS.AUTO_MODEL_ROUTING]: true },
});
setPreferences({
ai: { projectDefaults: { [TEST_PROJECT_PATH]: { agentId: "exec", model: "gpt-4" } } },
});
// Model Auto comes from the default; thinking Auto was picked in the creation composer.
setAutoRoutingPick(getProjectScopeId(TEST_PROJECT_PATH), "exec", "thinkingLevel", true);
draftSettingsState = createDraftSettingsHarness({ agentId: "exec" });

await act(async () => {
await getHook().handleSend("launch workspace");
});
const getHook = renderUseCreationWorkspace({
projectPath: TEST_PROJECT_PATH,
onWorkspaceCreated: mock((metadata: FrontendWorkspaceMetadata) => metadata),
message: "launch workspace",
});
await waitFor(() => expect(getHook().branches).toEqual(["main"]));

// The workspace shows both creation choices until a send saves them.
const workspaceAuto = (dimension: "model" | "thinkingLevel") =>
getAutoRoutingPick(TEST_WORKSPACE_ID, "exec", dimension);
expect([workspaceAuto("model"), workspaceAuto("thinkingLevel")]).toEqual(
autoRoutingEnabled ? [true, true] : [undefined, undefined]
);
const recordedChoices = updatePersistedStateCalls
.filter(([key]) => key === getAutoRoutingChoiceByAgentKey(TEST_WORKSPACE_ID))
.map(([, updater]) => (updater as (prev: unknown) => unknown)({}));
expect(recordedChoices).toEqual(
autoRoutingEnabled ? [{ exec: { thinkingLevel: true } }] : []
);
}
);
await act(async () => {
await getHook().handleSend("launch workspace");
});

const options = sendMessageMock.mock.calls[0]?.[0]?.options;
expect(options?.autoModelRouting).toBe(true);
expect(options?.autoThinkingLevel).toBe(true);
// The workspace shows both creation choices until a send saves them.
expect(getAutoRoutingPick(TEST_WORKSPACE_ID, "exec", "model")).toBe(true);
expect(getAutoRoutingPick(TEST_WORKSPACE_ID, "exec", "thinkingLevel")).toBe(true);
});

test("handleSend returns failure when sendMessage fails and clears draft", async () => {
const listBranchesMock = mock(
Expand Down Expand Up @@ -2600,7 +2587,6 @@ interface HookOptions {
}
) => void;
agentBaseById?: ReadonlyMap<string, string | undefined>;
autoRoutingEnabled?: boolean;
message?: string;
draftId?: string | null;
}
Expand Down
72 changes: 10 additions & 62 deletions src/browser/features/ChatInput/useCreationWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,8 @@ import type {
import type { RuntimeChoice } from "@/browser/utils/runtimeUi";
import { buildRuntimeConfig, RUNTIME_MODE } from "@/common/types/runtime";
import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings";
import { recordAutoRoutingChoiceForAgent } from "@/browser/utils/modelChange";
import { setAutoRoutingPick, type AutoRoutingDimension } from "@/browser/utils/aiSelectionIntent";
import { setAutoRoutingPick } from "@/browser/utils/aiSelectionIntent";
import { getAutoRouting } from "@/browser/utils/workspaceAiSettingsSync";
import { resolveConfiguredAiDefaults } from "@/browser/utils/workspaceModeAi";
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions";
import {
Expand All @@ -29,6 +27,7 @@ import {
getUserPreferences,
updateUserPreferences,
} from "@/browser/stores/AppConfigStore";
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
import type { SendMessageError } from "@/common/types/errors";
import { useOptionalWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
import { useRouter } from "@/browser/contexts/RouterContext";
Expand Down Expand Up @@ -81,9 +80,8 @@ import {
import { normalizeModelInput } from "@/common/utils/ai/normalizeModelInput";
import { resolveDevcontainerSelection } from "@/browser/utils/devcontainerSelection";
import { getErrorMessage } from "@/common/utils/errors";
import { normalizeAgentId, resolveRemovedBuiltinAgentId } from "@/common/utils/agentIds";
import { resolveRemovedBuiltinAgentId } from "@/common/utils/agentIds";
import { workspaceStore } from "@/browser/stores/WorkspaceStore";
import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults";

export type CreationSendResult = { success: true } | { success: false; error?: SendMessageError };
export type CreationInitialSlashCommand = Extract<ParsedCommand, { type: "goal-set" }> & {
Expand All @@ -107,7 +105,6 @@ interface UseCreationWorkspaceOptions {
/** User's currently selected model (for name generation fallback) */
userModel?: string;
agentBaseById?: ReadonlyMap<string, string | undefined>;
autoRoutingEnabled?: boolean;
}

// Project defaults can still name a removed built-in agent, and only user actions write
Expand All @@ -122,53 +119,14 @@ function resolveCreationAgentId(
: agentId;
}

function syncCreationPreferences(
projectPath: string,
workspaceId: string,
agentBaseById: ReadonlyMap<string, string | undefined> | undefined,
autoRoutingEnabled: boolean
): void {
const projectScopeId = getProjectScopeId(projectPath);
const aiPreferences = getUserPreferences().ai;
const projectDefaults = aiPreferences?.projectDefaults?.[projectPath];

const projectAgentId = projectDefaults?.agentId;
const globalDefaultAgentId = aiPreferences?.globalDefaults?.agentId ?? WORKSPACE_DEFAULTS.agentId;
const effectiveAgentId = resolveCreationAgentId(
typeof projectAgentId === "string" && projectAgentId.trim().length > 0
? normalizeAgentId(projectAgentId, WORKSPACE_DEFAULTS.agentId)
: normalizeAgentId(globalDefaultAgentId, WORKSPACE_DEFAULTS.agentId),
agentBaseById
);

// Preserve only creation choices that differ from configured defaults; recording
// defaults would prevent later Settings changes from taking effect.
const configuredDefaults = resolveConfiguredAiDefaults(
effectiveAgentId,
getAppConfigStore().getSnapshot()?.agentAiDefaults ?? {},
agentBaseById
);
function syncCreationPreferences(projectPath: string, workspaceId: string, agentId: string): void {
// Without the experiment the composer offers no Auto, so the creation scope has no choice.
if (autoRoutingEnabled) {
const routingChoice: Partial<Record<AutoRoutingDimension, boolean>> = {};
for (const [dimension, configuredAuto] of [
["model", configuredDefaults.autoModelRouting === true],
["thinkingLevel", configuredDefaults.autoThinkingLevel === true],
] as const) {
const creationAuto = getAutoRouting(
projectScopeId,
dimension,
effectiveAgentId,
agentBaseById
);
const experiments = getAppConfigStore().getSnapshot()?.experiments;
if (experiments?.[EXPERIMENT_IDS.AUTO_MODEL_ROUTING] === true) {
for (const dimension of ["model", "thinkingLevel"] as const) {
// The workspace shows the creation choice until a send saves it.
setAutoRoutingPick(workspaceId, effectiveAgentId, dimension, creationAuto);
if (creationAuto !== configuredAuto) {
routingChoice[dimension] = creationAuto;
}
}
if (Object.keys(routingChoice).length > 0) {
recordAutoRoutingChoiceForAgent(workspaceId, effectiveAgentId, routingChoice);
const creationAuto = getAutoRouting(getProjectScopeId(projectPath), dimension, agentId);
setAutoRoutingPick(workspaceId, agentId, dimension, creationAuto);
}
}

Expand Down Expand Up @@ -298,7 +256,6 @@ export function useCreationWorkspace({
draftId,
userModel,
agentBaseById,
autoRoutingEnabled = false,
}: UseCreationWorkspaceOptions): UseCreationWorkspaceReturn {
const workspaceContext = useOptionalWorkspaceContext();
const promoteWorkspaceDraft = workspaceContext?.promoteWorkspaceDraft;
Expand All @@ -316,9 +273,6 @@ export function useCreationWorkspace({

// Keep router state fresh synchronously so auto-navigation checks don't lag behind route changes.
latestRouteRef.current = { currentWorkspaceId, currentProjectId, pendingDraftId };
// Read through a ref so a per-render agent map does not destabilize handleSend.
const agentBaseByIdRef = useRef(agentBaseById);
agentBaseByIdRef.current = agentBaseById;
const { api } = useAPI();
const { getProjectConfig, refreshProjects, loading: projectsLoading } = useProjectContext();
const { config: providersConfig } = useProvidersConfig();
Expand Down Expand Up @@ -690,12 +644,7 @@ export function useCreationWorkspace({
};

// Sync preferences before switching (keeps workspace settings consistent).
syncCreationPreferences(
projectPath,
metadata.id,
agentBaseByIdRef.current,
autoRoutingEnabled
);
syncCreationPreferences(projectPath, metadata.id, creationAgentId);

// Switch to the workspace immediately after creation unless the user navigated away
// from the draft that initiated the creation (avoid yanking focus to the new workspace).
Expand Down Expand Up @@ -1007,7 +956,6 @@ export function useCreationWorkspace({
workspaceNameState.autoGenerate,
message,
subProjectPath,
autoRoutingEnabled,
draftId,
promoteWorkspaceDraft,
deleteWorkspaceDraft,
Expand Down
4 changes: 0 additions & 4 deletions src/browser/hooks/persistedStateBudget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ import * as storageModule from "@/common/constants/storage";
import {
GLOBAL_SCOPE_ID,
PERSISTED_KEY_REGISTRY,
getAutoModelRoutingKey,
getAutoThinkingLevelKey,
getDisableWorkspaceAgentsKey,
getDraftScopeId,
getModelKey,
Expand Down Expand Up @@ -58,8 +56,6 @@ const PROJECT_SCOPED_WORKSPACE_KEYS = [
getThinkingLevelKey,
getReasoningModeKey,
getDisableWorkspaceAgentsKey,
getAutoModelRoutingKey,
getAutoThinkingLevelKey,
];

function projectPath(index: number): string {
Expand Down
3 changes: 3 additions & 0 deletions src/browser/utils/legacyLocalStorageCleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ describe("removeDroppedCacheKeys", () => {
"agentId:0123456789",
"agentId:__project__/repo",
"workspaceAiSettingsByAgent:0123456789",
"autoModelRouting:0123456789",
"autoThinkingLevel:__project__/repo",
"autoRoutingChoiceByAgent:0123456789",
];
for (const key of [...droppedKeys, ...keptKeys]) {
window.localStorage.setItem(key, JSON.stringify({ value: key }));
Expand Down
3 changes: 3 additions & 0 deletions src/browser/utils/legacyLocalStorageCleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ const DROPPED_CACHE_KEY_PREFIXES = [
"experiment:",
"agentId:",
"workspaceAiSettingsByAgent:",
"autoModelRouting:",
"autoThinkingLevel:",
"autoRoutingChoiceByAgent:",
] as const;

/**
Expand Down
25 changes: 1 addition & 24 deletions src/browser/utils/modelChange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,10 @@ import {
import { getAppConfigStore } from "@/browser/stores/AppConfigStore";
import {
consumeWorkspaceModelChange,
recordAutoRoutingChoiceForAgent,
setAutoRoutingChoice,
setWorkspaceModelWithOrigin,
} from "@/browser/utils/modelChange";
import {
AUTO_ROUTING_CHOICE_BY_AGENT_MAX_CHARS,
getAutoRoutingChoiceByAgentKey,
getProjectScopeId,
} from "@/common/constants/storage";
import { getProjectScopeId } from "@/common/constants/storage";
import { getAutoRouting } from "@/browser/utils/workspaceAiSettingsSync";

let workspaceCounter = 0;
Expand Down Expand Up @@ -160,22 +155,4 @@ describe("modelChange", () => {
// A persisted rewrite to the passthrough alias must still consume the entry.
expect(consumeWorkspaceModelChange(workspaceId, gatewayAlias)).toBe("user");
});

// Choices accumulate one entry per agent. Past the key budget the value would only live in
// memory, so after a reload the newest explicit choices would be lost.
test("keeps the newest per-agent routing choices on disk as agents accumulate", () => {
const workspaceId = nextWorkspaceId();
const agentIds = Array.from({ length: 12 }, (_, index) =>
`custom-agent-${index}`.padEnd(64, "x")
);
for (const agentId of agentIds) {
recordAutoRoutingChoiceForAgent(workspaceId, agentId, { model: true, thinkingLevel: false });
}

const stored = localStorage.getItem(getAutoRoutingChoiceByAgentKey(workspaceId))!;
expect(stored.length).toBeLessThanOrEqual(AUTO_ROUTING_CHOICE_BY_AGENT_MAX_CHARS);
expect(JSON.parse(stored)).toMatchObject({
[agentIds[agentIds.length - 1]]: { model: true, thinkingLevel: false },
});
});
});
Loading
Loading