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
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,8 @@ import {
setTestAgentAiDefaults,
setTestExperiment,
} from "@/browser/testUtils";
import {
getAgentIdKey,
getAutoModelRoutingKey,
getAutoThinkingLevelKey,
getWorkspaceAISettingsByAgentKey,
} from "@/common/constants/storage";
import { getAutoModelRoutingKey, getAutoThinkingLevelKey } from "@/common/constants/storage";
import { setWorkspaceAgentPick, setWorkspaceAiMetadata } from "@/browser/utils/aiSelectionIntent";

import { WorkspaceModeAISync } from "../WorkspaceModeAISync/WorkspaceModeAISync";

Expand Down Expand Up @@ -124,12 +120,12 @@ describe("WorkspaceModeAISync", () => {
});

function renderAt(workspaceId: string, agentId: string) {
updatePersistedState(getAgentIdKey(workspaceId), agentId);
setWorkspaceAgentPick(workspaceId, agentId);
return renderSync({ workspaceId, agentId });
}

function switchTo(rerender: (ui: ReactElement) => void, workspaceId: string, agentId: string) {
updatePersistedState(getAgentIdKey(workspaceId), agentId);
setWorkspaceAgentPick(workspaceId, agentId);
rerender(<SyncHarness workspaceId={workspaceId} agentId={agentId} />);
}

Expand Down Expand Up @@ -165,9 +161,18 @@ describe("WorkspaceModeAISync", () => {
});
cleanup();

const saved = nextWorkspaceId();
setWorkspaceAiMetadata(saved, {
aiSettingsByAgent: { exec: { model: "openai:gpt-5.2", thinkingLevel: "low" } },
});
renderAt(saved, "exec");
expect(readAuto(saved)).toEqual({ model: false, thinkingLevel: false });
cleanup();

// A legacy workspace's single settings bucket applies to every agent.
const legacy = nextWorkspaceId();
updatePersistedState(getWorkspaceAISettingsByAgentKey(legacy), {
exec: { model: "openai:gpt-5.2", thinkingLevel: "low" },
setWorkspaceAiMetadata(legacy, {
aiSettings: { model: "openai:gpt-5.2", thinkingLevel: "low" },
});
renderAt(legacy, "exec");
expect(readAuto(legacy)).toEqual({ model: false, thinkingLevel: false });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,12 @@ import { useEffect, useRef } from "react";
import { useAgent } from "@/browser/contexts/AgentContext";
import { readPersistedState } from "@/browser/hooks/usePersistedState";
import { useAgentAiDefaults } from "@/browser/stores/AppConfigStore";
import {
getAutoRoutingChoiceByAgentKey,
getWorkspaceAISettingsByAgentKey,
} from "@/common/constants/storage";
import { getAutoRoutingChoiceByAgentKey } from "@/common/constants/storage";
import { getSavedAiSettings } from "@/browser/utils/aiSelectionIntent";
import { applyAutoRoutingOutcome, recordWorkspaceModelChange } from "@/browser/utils/modelChange";
import {
resolveAutoRoutingForAgent,
type AutoRoutingChoiceByAgent,
type WorkspaceAISettingsCache,
} from "@/browser/utils/workspaceModeAi";
import { getWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync";
import { useExperimentValue } from "@/browser/hooks/useExperiments";
Expand Down Expand Up @@ -64,10 +61,9 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null {
getAutoRoutingChoiceByAgentKey(workspaceId),
{}
),
workspaceByAgent: readPersistedState<WorkspaceAISettingsCache>(
getWorkspaceAISettingsByAgentKey(workspaceId),
{}
),
workspaceByAgent: {
[normalizedAgentId]: getSavedAiSettings(workspaceId, normalizedAgentId),
},
});
applyAutoRoutingOutcome(workspaceId, autoRoutingOutcome);
}, [agentAiDefaults, agentId, agents, autoRoutingEnabled, workspaceId]);
Expand Down
32 changes: 29 additions & 3 deletions src/browser/contexts/AgentContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { GlobalWindow } from "happy-dom";

import { useWorkspaceStoreRaw as getWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore";
import { CUSTOM_EVENTS } from "@/common/constants/events";
import { getAgentIdKey } from "@/common/constants/storage";
import { setWorkspaceAgentPick } from "@/browser/utils/aiSelectionIntent";
import type { UserPreferences } from "@/common/config/schemas/userPreferences";
import { getAppConfigStore, getUserPreferences } from "@/browser/stores/AppConfigStore";
import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition";
Expand Down Expand Up @@ -311,6 +311,32 @@ describe("AgentContext", () => {
});
});

test("a workspace without a saved agent follows project default agent changes", async () => {
const workspaceId = "ws-project-default-agent";
mockAgentDefinitions = [EXEC_AGENT, PLAN_AGENT, REVIEW_PROJECT_AGENT];
mockWorkspaceMetadata = new Map([[workspaceId, {}]]);
let contextValue: AgentContextValue | undefined;

renderAgentHarness({
projectPath: "/tmp/project",
workspaceId,
preferences: { ai: { projectDefaults: { "/tmp/project": { agentId: "plan" } } } },
onChange: (value) => (contextValue = value),
});
await waitFor(() => {
expect(contextValue?.agentId).toBe("plan");
});

act(() => {
getAppConfigStore().updateOptimistically({
userPreferences: { ai: { projectDefaults: { "/tmp/project": { agentId: "review" } } } },
});
});
await waitFor(() => {
expect(contextValue?.agentId).toBe("review");
});
});

test("cycle shortcut advances to next agent", async () => {
const projectPath = "/tmp/project";
mockAgentDefinitions = [EXEC_AGENT, PLAN_AGENT];
Expand Down Expand Up @@ -379,7 +405,7 @@ describe("AgentContext", () => {
parentWorkspaceId: "parent-workspace",
agentId: "exec",
});
window.localStorage.setItem(getAgentIdKey(lockedWorkspaceId), JSON.stringify("plan"));
setWorkspaceAgentPick(lockedWorkspaceId, "plan");

let contextValue: AgentContextValue | undefined;
let openPickerEvents = 0;
Expand All @@ -396,7 +422,7 @@ describe("AgentContext", () => {
});

await waitFor(() => {
// Backend-assigned agent overrides stale localStorage in locked workspaces.
// Backend-assigned agent overrides a stale agent pick in locked workspaces.
expect(contextValue?.agentId).toBe("exec");
});

Expand Down
32 changes: 14 additions & 18 deletions src/browser/contexts/WorkspaceContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { useWorkspaceStoreRaw as getWorkspaceStoreRaw } from "@/browser/stores/W
import {
LAST_VISITED_ROUTE_KEY,
SELECTED_WORKSPACE_KEY,
getAgentIdKey,
getDraftScopeId,
getModelKey,
getRightSidebarLayoutKey,
Expand All @@ -24,9 +23,13 @@ import {
import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch";
import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject";
import { createTestApiClient, type TestApiOverrides } from "@/browser/testUtils";
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import { readPersistedState } from "@/browser/hooks/usePersistedState";
import { getProjectRouteId } from "@/common/utils/projectRouteId";
import { getWorkspaceAiMetadata } from "@/browser/utils/aiSelectionIntent";
import {
getWorkspaceAgentId,
getWorkspaceAiMetadata,
setWorkspaceAgentPick,
} from "@/browser/utils/aiSelectionIntent";
import type { RightSidebarLayoutState } from "@/browser/utils/rightSidebarLayout";
import { resetWorkspaceStorageGcForTests } from "@/browser/utils/workspaceStorageGc";
import { resetCreationDraftStorageGcForTests } from "@/browser/utils/creationDraftStorageGc";
Expand Down Expand Up @@ -540,10 +543,10 @@ describe("WorkspaceContext", () => {
}
);

test.each(["unchanged", "mode", "model"])("keeps local choices: %s", async (change) => {
test.each(["unchanged", "mode", "model"])("keeps an unsent agent pick: %s", async (change) => {
const changed = change !== "unchanged";
const nextAgentId = change === "mode" ? "auto" : "plan";
const workspaceId = "ws-agent-main";
const workspaceId = `ws-agent-main-${change}`;
const saved = createWorkspaceMetadata({
id: workspaceId,
agentId: "plan",
Expand All @@ -569,19 +572,16 @@ describe("WorkspaceContext", () => {
})() as unknown as Awaited<ReturnType<APIClient["workspace"]["onMetadata"]>>
),
},
localStorage: {
[getAgentIdKey(workspaceId)]: JSON.stringify("exec"),
},
});

const ctx = await setup();

await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1));
await waitFor(() => expect(emitMetadata).toBeTruthy());
expect(readPersistedState(getAgentIdKey(workspaceId), "")).toBe("plan");
expect(getWorkspaceAgentId(workspaceId)).toBe("plan");

act(() => {
updatePersistedState(getAgentIdKey(workspaceId), "exec");
setWorkspaceAgentPick(workspaceId, "exec");
emitMetadata?.({
workspaceId,
metadata: {
Expand All @@ -602,10 +602,10 @@ describe("WorkspaceContext", () => {
await waitFor(() =>
expect(ctx().workspaceMetadata.get(workspaceId)?.title).toBe("Updated title")
);
expect(readPersistedState(getAgentIdKey(workspaceId), "")).toBe("exec");
expect(getWorkspaceAgentId(workspaceId)).toBe("exec");
});

test("child workspace metadata still seeds the locked backend agent", async () => {
test("child workspace metadata resolves the locked backend agent", async () => {
const workspaceId = "ws-agent-child";

createMockAPI({
Expand All @@ -615,22 +615,18 @@ describe("WorkspaceContext", () => {
createWorkspaceMetadata({
id: workspaceId,
parentWorkspaceId: "ws-parent",
agentId: "exec",
agentType: "plan",
}),
]),
},
localStorage: {
[getAgentIdKey(workspaceId)]: JSON.stringify("exec"),
},
});

const ctx = await setup();

await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1));

expect(readPersistedState<string | undefined>(getAgentIdKey(workspaceId), undefined)).toBe(
"plan"
);
expect(getWorkspaceAgentId(workspaceId)).toBe("plan");
});

test("loads workspace metadata on mount", async () => {
Expand Down
Loading
Loading