Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
dfbedb1
🤖 feat: remember last used model and mode per workspace across clients
ibetitsmike Aug 23, 2026
4103151
🤖 fix: converge agent selection when persistence or follow-up send fails
ibetitsmike Aug 25, 2026
bf11f76
🤖 fix: address round-2 Codex findings on agent-selection sync
ibetitsmike Aug 25, 2026
cd2228a
🤖 fix: gate agent-only switches on the fully resolved dispatch model
ibetitsmike Aug 25, 2026
badc083
🤖 fix: address round-4 Codex findings on switch durability and gating
ibetitsmike Aug 25, 2026
2d9d468
🤖 fix: harden mode-switch rollback and heartbeat pricing probe
ibetitsmike Aug 25, 2026
c53bc5b
🤖 fix: restore backend agent on failed sends, persist ACP mode switch…
ibetitsmike Aug 25, 2026
9793b29
🤖 fix: guard stale post-send agent writes, gate ACP mode-switch dispa…
ibetitsmike Aug 25, 2026
9f54ba0
🤖 fix: honor the failed-send restore result before local rollback
ibetitsmike Aug 25, 2026
efae88b
🤖 fix: persist resolved settings with picker switches, reconcile fail…
ibetitsmike Aug 25, 2026
ddc7902
🤖 fix: resolve legacy agent identity, retry settings with selection, …
ibetitsmike Aug 25, 2026
6e1c8c8
🤖 refactor: drop client-side agent-switch rollback and reconcile comp…
ibetitsmike Aug 25, 2026
d318f2f
revert rejected agent switches locally; hydrate legacy shared setting…
ibetitsmike Aug 26, 2026
dbf12e2
revert typed-rejected plan-action switches; reconcile chained rejecti…
ibetitsmike Aug 26, 2026
4fd9852
resolve legacy agent identity for rejection baselines; overlay legacy…
ibetitsmike Aug 26, 2026
7268a3e
reconcile rejected switches from settle-time metadata; workspace buck…
ibetitsmike Aug 26, 2026
9823360
fix: include agent definition AI defaults when resolving explicit swi…
ibetitsmike Aug 26, 2026
d8de5ea
fix: refresh rollback baseline synchronously with metadata updates
ibetitsmike Aug 26, 2026
022191d
fix: apply agent definition defaults during workspace sync
ibetitsmike Aug 26, 2026
8af10b7
fix: preserve agent settings when forking workspaces
ibetitsmike Aug 26, 2026
becb79c
fix: restore rejected agent settings atomically
ibetitsmike Aug 26, 2026
f6f87f2
fix: resolve browser agent defaults per definition hop
ibetitsmike Aug 26, 2026
e891acd
fix: retain agent switch ordering across overlapping writes
ibetitsmike Aug 26, 2026
fc1f241
🤖 fix: serialize workspace AI settings writes
ibetitsmike Aug 26, 2026
d5f1f42
🤖 fix: preserve agent definition defaults across switches
ibetitsmike Aug 27, 2026
b27cf15
🤖 fix: serialize workspace AI persistence paths
ibetitsmike Aug 27, 2026
026e4eb
Merge remote-tracking branch 'origin/main' into agent_exec_ef0a69ea34
ibetitsmike Aug 27, 2026
fb04366
🤖 fix: preserve creation model after descriptor load
ibetitsmike Aug 27, 2026
a8d89f8
🤖 fix: serialize workspace stream resumes
ibetitsmike Aug 27, 2026
e8c902e
🤖 fix: snapshot latest settings when forking
ibetitsmike Aug 27, 2026
25acf92
🤖 fix: serialize ACP workspace AI writes
ibetitsmike Aug 27, 2026
7f95564
🤖 fix: keep built-in agent switching available
ibetitsmike Aug 27, 2026
c3598ac
🤖 fix: guard switched agent settings from stale metadata
ibetitsmike Aug 27, 2026
2fd2af9
🤖 fix: preserve hidden agent ancestry defaults
ibetitsmike Aug 27, 2026
9cd6bf6
🤖 fix: honor workspace buckets during background sync
ibetitsmike Aug 27, 2026
9620992
refactor: persist workspace selections only on send
ibetitsmike Sep 4, 2026
866d4c7
Merge main and reconcile send-only persistence tests
ibetitsmike Sep 4, 2026
c841243
fix: restore workspace choices only on initial load
ibetitsmike Sep 4, 2026
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
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 @@ -540,8 +536,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 @@ -573,30 +567,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 @@ -614,9 +585,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 @@ -654,32 +623,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
38 changes: 30 additions & 8 deletions src/browser/contexts/WorkspaceContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch";
import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject";
import type { RecursivePartial } from "@/browser/testUtils";
import { readPersistedState } from "@/browser/hooks/usePersistedState";
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import { getProjectRouteId } from "@/common/utils/projectRouteId";
import type { RightSidebarLayoutState } from "@/browser/utils/rightSidebarLayout";

Expand Down Expand Up @@ -520,15 +520,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 @@ -551,19 +558,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