Skip to content
Merged
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: 1 addition & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ jobs:
# when sharing the monolithic process; keep them in fresh isolated processes.
src/node/services/workflows/WorkflowRunner.test.ts
src/node/services/ptc/quickjsRuntime.test.ts
src/node/services/tools/code_execution.test.ts
src/node/services/sandbox/sandboxHostService.test.ts
src/node/services/agentPlugins/hookService.test.ts
src/node/orpc/router.test.ts
Expand Down
61 changes: 3 additions & 58 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,7 @@ import { getRuntimeTypeForTelemetry } from "@/common/telemetry";
import { useStartWorkspaceCreation } from "./hooks/useStartWorkspaceCreation";
import { useAPI } from "@/browser/contexts/API";
import { requestActiveTurnThinkingLevel } from "@/browser/utils/activeTurnThinking";
import {
clearPendingWorkspaceAiSettings,
markPendingWorkspaceAiSettings,
resolveEffectiveComposerModel,
} from "@/browser/utils/workspaceAiSettingsSync";
import { resolveEffectiveComposerModel } from "@/browser/utils/workspaceAiSettingsSync";
import { AuthTokenModal } from "@/browser/components/AuthTokenModal/AuthTokenModal";

import { ScratchPage } from "@/browser/components/ScratchPage/ScratchPage";
Expand Down Expand Up @@ -541,8 +537,6 @@ function AppInner() {
const normalized = THINKING_LEVELS.includes(level) ? level : "off";
const model = getModelForWorkspace(workspaceId);
const key = getThinkingLevelKey(workspaceId);
// Carry the current pro-mode choice: the backend replaces the agent's
// settings wholesale, so omitting reasoningMode would wipe it.
const reasoningMode = getReasoningModeForWorkspace(workspaceId);

// Use the utility function which handles localStorage and event dispatch
Expand Down Expand Up @@ -574,30 +568,7 @@ function AppInner() {
{}
);

// Persist to backend so the palette change follows the workspace across devices.
if (api) {
markPendingWorkspaceAiSettings(workspaceId, normalizedAgentId, {
model,
thinkingLevel: normalized,
reasoningMode,
});

api.workspace
.updateAgentAISettings({
workspaceId,
agentId: normalizedAgentId,
aiSettings: { model, thinkingLevel: normalized, reasoningMode },
})
.then((result) => {
if (!result.success) {
clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId);
}
})
.catch(() => {
clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId);
// Best-effort only.
});

// Mid-turn change: also apply to the active turn's next model step so
// the palette/keybind path behaves like the selector (ThinkingProvider).
requestActiveTurnThinkingLevel(api, workspaceId, normalized);
Expand All @@ -615,9 +586,7 @@ function AppInner() {
[api, getModelForWorkspace, getReasoningModeForWorkspace]
);

// Palette toggle for the OpenAI pro reasoning mode. Persists like the
// thinking-level palette action: localStorage first (ThinkingProvider listens),
// then best-effort backend sync with the full settings payload.
// Keep palette choices local until a user message sends the full settings.
const toggleReasoningModeFromPalette = useCallback(
(workspaceId: string) => {
if (!workspaceId) {
Expand Down Expand Up @@ -655,32 +624,8 @@ function AppInner() {
},
{}
);

if (api) {
markPendingWorkspaceAiSettings(workspaceId, normalizedAgentId, {
model,
thinkingLevel,
reasoningMode: next,
});

api.workspace
.updateAgentAISettings({
workspaceId,
agentId: normalizedAgentId,
aiSettings: { model, thinkingLevel, reasoningMode: next },
})
.then((result) => {
if (!result.success) {
clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId);
}
})
.catch(() => {
clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId);
// Best-effort only.
});
}
},
[api, getModelForWorkspace, getReasoningModeForWorkspace, getThinkingLevelForWorkspace]
[getModelForWorkspace, getReasoningModeForWorkspace, getThinkingLevelForWorkspace]
);

const getFastModeActive = useCallback(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ describe("WorkspaceModeAISync", () => {
expect(consumeWorkspaceModelChange(workspaceId, planModel)).toBe("agent");
});

test("prefers configured agent defaults over workspace-by-agent overrides", async () => {
test("preserves unsent workspace choices over configured agent defaults", async () => {
const workspaceId = nextWorkspaceId();

const configuredModel = "anthropic:claude-haiku-4-5";
Expand All @@ -116,8 +116,8 @@ describe("WorkspaceModeAISync", () => {
renderSync({ workspaceId, agentId: "exec" });

await waitFor(() => {
expect(readPersistedState(getModelKey(workspaceId), "")).toBe(configuredModel);
expect(readPersistedState(getThinkingLevelKey(workspaceId), "high")).toBe(configuredThinking);
expect(readPersistedState(getModelKey(workspaceId), "")).toBe("some-legacy-model");
expect(readPersistedState(getThinkingLevelKey(workspaceId), "high")).toBe("medium");
});
});

Expand Down
22 changes: 5 additions & 17 deletions src/browser/contexts/ThinkingContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ describe("ThinkingContext", () => {

test("setting thinking uses metadata model before global default", async () => {
const workspaceId = "ws-set-thinking-metadata-model";
updatePersistedState(getReasoningModeKey(workspaceId), "pro");
const updateAgentAISettings = mock<
(args: WorkspaceUpdateAgentAISettingsArgs) => Promise<WorkspaceUpdateAgentAISettingsResult>
>(() =>
Expand Down Expand Up @@ -357,24 +358,16 @@ describe("ThinkingContext", () => {
button.click();
});

// setThinkingLevel persists the full settings payload including the current
// reasoningMode (default "standard") so partial writes cannot clobber it.
const expectedSettings = {
model: "metadataModel:abc",
thinkingLevel: "medium" as const,
reasoningMode: "standard" as const,
reasoningMode: "pro" as const,
};
await waitFor(() => {
expect(readWorkspaceAISettingsCache(workspaceId).exec).toEqual(expectedSettings);
}, METADATA_WAIT_OPTIONS);

if (updateAgentAISettings.mock.calls.length > 0) {
expect(updateAgentAISettings).toHaveBeenCalledWith({
workspaceId,
agentId: "exec",
aiSettings: expectedSettings,
});
}
expect(updateAgentAISettings).not.toHaveBeenCalled();
});

test("setting thinking preserves an explicit Coder gateway model identity", async () => {
Expand Down Expand Up @@ -427,6 +420,7 @@ describe("ThinkingContext", () => {
await waitFor(() => {
expect(readWorkspaceAISettingsCache(workspaceId).exec).toEqual(expectedSettings);
}, METADATA_WAIT_OPTIONS);
expect(updateAgentAISettings).not.toHaveBeenCalled();
});

test("self-heals corrupt persisted reasoningMode to standard but keeps valid pro", async () => {
Expand Down Expand Up @@ -634,13 +628,7 @@ describe("ThinkingContext", () => {
expect(readWorkspaceAISettingsCache(workspaceId).exec).toEqual(expectedSettings);
}, METADATA_WAIT_OPTIONS);

if (updateAgentAISettings.mock.calls.length > 0) {
expect(updateAgentAISettings).toHaveBeenCalledWith({
workspaceId,
agentId: "exec",
aiSettings: expectedSettings,
});
}
expect(updateAgentAISettings).not.toHaveBeenCalled();
});

test("requests a mid-turn override for the active workspace turn on slider changes", async () => {
Expand Down
40 changes: 3 additions & 37 deletions src/browser/contexts/ThinkingContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,7 @@ import { useMinThinkingLevels } from "@/browser/hooks/useMinThinkingLevels";
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
import { useAPI } from "@/browser/contexts/API";
import { requestActiveTurnThinkingLevel } from "@/browser/utils/activeTurnThinking";
import {
clearPendingWorkspaceAiSettings,
getWorkspaceAiSettingsFromMetadata,
markPendingWorkspaceAiSettings,
} from "@/browser/utils/workspaceAiSettingsSync";
import { getWorkspaceAiSettingsFromMetadata } from "@/browser/utils/workspaceAiSettingsSync";
import { useOptionalWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
import { KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds";
import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults";
Expand Down Expand Up @@ -133,17 +129,13 @@ export const ThinkingProvider: React.FC<ThinkingProviderProps> = (props) => {
updatePersistedState(thinkingKey, legacy);
}, [defaultModel, scopeId, thinkingKey]);

// Shared persistence for both setters: caches the full per-agent settings and
// pushes them to the backend. updateAgentAISettings replaces the agent's
// settings wholesale, so every payload must carry BOTH thinkingLevel and
// reasoningMode or the omitted one gets wiped on the next sync.
// Keep picker choices local until a user message sends the full settings.
const persistAgentAiSettings = useCallback(
(settings: {
model: string;
thinkingLevel: ThinkingLevel;
reasoningMode: OpenAIReasoningMode;
}) => {
// Workspace variant: persist to backend so settings follow the workspace across devices.
if (!props.workspaceId) {
return;
}
Expand Down Expand Up @@ -174,38 +166,12 @@ export const ThinkingProvider: React.FC<ThinkingProviderProps> = (props) => {
},
{}
);

if (!api) {
return;
}

// Avoid stale backend metadata clobbering newer local preferences when users
// click through levels quickly (tests reproduce this by cycling to xhigh).
markPendingWorkspaceAiSettings(workspaceId, normalizedAgentId, settings);

api.workspace
.updateAgentAISettings({
workspaceId,
agentId: normalizedAgentId,
aiSettings: settings,
})
.then((result) => {
if (!result.success) {
clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId);
}
})
.catch(() => {
clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId);
// Best-effort only. If offline or backend is old, the next sendMessage will persist.
});
},
[api, props.workspaceId, scopeId]
[props.workspaceId, scopeId]
);

// Read the sibling setting at call time (not from the render closure) so
// rapid interleaved updates cannot persist a stale counterpart value.
// Coerced like the render path: a corrupt persisted value must not ride a
// thinking-level change into updateAgentAISettings and fail backend sync.
const getCurrentReasoningMode = useCallback(
(): OpenAIReasoningMode =>
coerceOpenAIReasoningMode(
Expand Down
36 changes: 29 additions & 7 deletions src/browser/contexts/WorkspaceContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -668,15 +668,22 @@ describe("WorkspaceContext", () => {
"xhigh"
);
});
test("stale metadata does not override a main workspace agent selection", async () => {
test.each(["unchanged", "mode", "model"])("keeps local choices: %s", async (change) => {
const changed = change !== "unchanged";
const nextAgentId = change === "mode" ? "auto" : "plan";
const workspaceId = "ws-agent-main";
const saved = createWorkspaceMetadata({
id: workspaceId,
agentId: "plan",
aiSettingsByAgent: { plan: { model: "openai:gpt-5.2", thinkingLevel: "high" } },
});
let emitMetadata:
| ((event: { workspaceId: string; metadata: FrontendWorkspaceMetadata | null }) => void)
| null = null;

createMockAPI({
workspace: {
list: () => Promise.resolve([createWorkspaceMetadata({ id: workspaceId })]),
list: () => Promise.resolve([saved]),
onMetadata: () =>
Promise.resolve(
(async function* () {
Expand All @@ -699,19 +706,34 @@ describe("WorkspaceContext", () => {

await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1));
await waitFor(() => expect(emitMetadata).toBeTruthy());
expect(ctx().workspaceMetadata.get(workspaceId)?.agentId).toBeUndefined();
expect(readPersistedState(getAgentIdKey(workspaceId), "")).toBe("plan");
expect(readPersistedState(getModelKey(workspaceId), "")).toBe("openai:gpt-5.2");

act(() => {
updatePersistedState(getAgentIdKey(workspaceId), "exec");
updatePersistedState(getModelKey(workspaceId), "anthropic:claude-opus-4-6");
emitMetadata?.({
workspaceId,
metadata: createWorkspaceMetadata({ id: workspaceId, agentId: "plan" }),
metadata: {
...saved,
title: "Updated title",
...(changed
? {
agentId: nextAgentId,
aiSettingsByAgent: {
[nextAgentId]: { model: "openai:gpt-5.3-codex", thinkingLevel: "medium" },
},
}
: {}),
},
});
});

await waitFor(() => expect(ctx().workspaceMetadata.get(workspaceId)?.agentId).toBe("plan"));
expect(readPersistedState<string | undefined>(getAgentIdKey(workspaceId), undefined)).toBe(
"exec"
await waitFor(() =>
expect(ctx().workspaceMetadata.get(workspaceId)?.title).toBe("Updated title")
);
expect(readPersistedState(getAgentIdKey(workspaceId), "")).toBe("exec");
expect(readPersistedState(getModelKey(workspaceId), "")).toBe("anthropic:claude-opus-4-6");
});

test("child workspace metadata still seeds the locked backend agent", async () => {
Expand Down
Loading
Loading