Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 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
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
23 changes: 11 additions & 12 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import {
clearPendingWorkspaceAiSettings,
markPendingWorkspaceAiSettings,
resolveEffectiveComposerModel,
updateWorkspaceAgentAISettings,
} from "@/browser/utils/workspaceAiSettingsSync";
import { AuthTokenModal } from "@/browser/components/AuthTokenModal/AuthTokenModal";

Expand Down Expand Up @@ -580,12 +581,11 @@ function AppInner() {
reasoningMode,
});

api.workspace
.updateAgentAISettings({
workspaceId,
agentId: normalizedAgentId,
aiSettings: { model, thinkingLevel: normalized, reasoningMode },
})
updateWorkspaceAgentAISettings(api, {
workspaceId,
agentId: normalizedAgentId,
aiSettings: { model, thinkingLevel: normalized, reasoningMode },
})
.then((result) => {
if (!result.success) {
clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId);
Expand Down Expand Up @@ -661,12 +661,11 @@ function AppInner() {
reasoningMode: next,
});

api.workspace
.updateAgentAISettings({
workspaceId,
agentId: normalizedAgentId,
aiSettings: { model, thinkingLevel, reasoningMode: next },
})
updateWorkspaceAgentAISettings(api, {
workspaceId,
agentId: normalizedAgentId,
aiSettings: { model, thinkingLevel, reasoningMode: next },
})
.then((result) => {
if (!result.success) {
clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId);
Expand Down
198 changes: 182 additions & 16 deletions src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { act, cleanup, render, waitFor } from "@testing-library/react";
import { installDom } from "../../../../tests/ui/dom";

import { AgentProvider } from "@/browser/contexts/AgentContext";
import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition";
import { consumeWorkspaceModelChange } from "@/browser/utils/modelChange";
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import {
Expand All @@ -27,14 +28,43 @@ const noop = () => {
// intentional noop for tests
};

function SyncHarness(props: { workspaceId: string; agentId: string }) {
const DEFAULT_AGENTS: AgentDefinitionDescriptor[] = [
{
id: "exec",
scope: "built-in",
name: "Exec",
uiSelectable: true,
subagentRunnable: false,
},
{
id: "plan",
scope: "built-in",
name: "Plan",
uiSelectable: true,
subagentRunnable: false,
},
{
id: "auto",
scope: "built-in",
name: "Auto",
uiSelectable: true,
subagentRunnable: false,
},
];

function SyncHarness(props: {
workspaceId: string;
agentId: string;
agents?: AgentDefinitionDescriptor[];
}) {
const agents = props.agents ?? DEFAULT_AGENTS;
return (
<AgentProvider
value={{
agentId: props.agentId,
setAgentId: noop,
currentAgent: undefined,
agents: [],
currentAgent: agents.find((agent) => agent.id === props.agentId),
agents,
loaded: true,
loadFailed: false,
refresh: () => Promise.resolve(),
Expand All @@ -48,8 +78,14 @@ function SyncHarness(props: { workspaceId: string; agentId: string }) {
);
}

function renderSync(props: { workspaceId: string; agentId: string }) {
return render(<SyncHarness workspaceId={props.workspaceId} agentId={props.agentId} />);
function renderSync(props: {
workspaceId: string;
agentId: string;
agents?: AgentDefinitionDescriptor[];
}) {
return render(
<SyncHarness workspaceId={props.workspaceId} agentId={props.agentId} agents={props.agents} />
);
}

describe("WorkspaceModeAISync", () => {
Expand Down Expand Up @@ -95,7 +131,7 @@ describe("WorkspaceModeAISync", () => {
expect(consumeWorkspaceModelChange(workspaceId, planModel)).toBe("agent");
});

test("prefers configured agent defaults over workspace-by-agent overrides", async () => {
test("prefers a hydrated workspace bucket over configured agent defaults", async () => {
const workspaceId = nextWorkspaceId();

const configuredModel = "anthropic:claude-haiku-4-5";
Expand All @@ -116,15 +152,145 @@ 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(workspaceModel);
expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe(workspaceThinking);
});
});

test("ignores workspace-by-agent values when settings are inherit", async () => {
test("preserves a hydrated workspace bucket when descriptors arrive", async () => {
const workspaceId = nextWorkspaceId();
const hydratedModel = "anthropic:claude-sonnet-4-6";
const hydratedThinking = "high";
const definitionModel = "openai:gpt-5.6-sol";
const agents: AgentDefinitionDescriptor[] = [
{
id: "exec",
scope: "built-in",
name: "Exec",
uiSelectable: true,
subagentRunnable: false,
ownAiDefaults: { model: definitionModel, thinkingLevel: "low" },
},
];

updatePersistedState(AGENT_AI_DEFAULTS_KEY, {});
updatePersistedState(getWorkspaceAISettingsByAgentKey(workspaceId), {
exec: { model: hydratedModel, thinkingLevel: hydratedThinking },
});
updatePersistedState(getModelKey(workspaceId), hydratedModel);
updatePersistedState(getThinkingLevelKey(workspaceId), hydratedThinking);

const { rerender } = renderSync({ workspaceId, agentId: "exec", agents: [] });
await waitFor(() => {
expect(readPersistedState(getModelKey(workspaceId), "")).toBe(hydratedModel);
});

rerender(<SyncHarness workspaceId={workspaceId} agentId="exec" agents={agents} />);

await waitFor(() => {
expect(readPersistedState(getModelKey(workspaceId), "")).toBe(hydratedModel);
expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe(hydratedThinking);
});
});
test("applies custom agent definition defaults on an explicit switch", async () => {
const workspaceId = nextWorkspaceId();
const existingModel = "anthropic:claude-sonnet-4-5";
const definitionModel = "openai:gpt-5.6-sol";
const agents: AgentDefinitionDescriptor[] = [
{
id: "plan",
scope: "built-in",
name: "Plan",
uiSelectable: true,
subagentRunnable: false,
},
{
id: "researcher",
scope: "project",
name: "Researcher",
uiSelectable: true,
subagentRunnable: false,
base: "exec",
aiDefaults: { model: definitionModel, thinkingLevel: "high" },
ownAiDefaults: { model: definitionModel, thinkingLevel: "high" },
},
];

updatePersistedState(AGENT_AI_DEFAULTS_KEY, {});
updatePersistedState(getModelKey(workspaceId), existingModel);
updatePersistedState(getThinkingLevelKey(workspaceId), "off");

const { rerender } = renderSync({ workspaceId, agentId: "plan", agents });

const existingModel = "some-legacy-model";
await waitFor(() => {
expect(readPersistedState(getModelKey(workspaceId), "")).toBe(existingModel);
});

rerender(<SyncHarness workspaceId={workspaceId} agentId="researcher" agents={agents} />);

await waitFor(() => {
expect(readPersistedState(getModelKey(workspaceId), "")).toBe(definitionModel);
expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe("high");
});
expect(consumeWorkspaceModelChange(workspaceId, definitionModel)).toBe("agent");
});

test("configured base defaults outrank inherited definition defaults", async () => {
const workspaceId = nextWorkspaceId();
const existingModel = "anthropic:claude-sonnet-4-5";
const agents: AgentDefinitionDescriptor[] = [
{
id: "plan",
scope: "built-in",
name: "Plan",
uiSelectable: true,
subagentRunnable: false,
},
{
id: "exec",
scope: "built-in",
name: "Exec",
uiSelectable: true,
subagentRunnable: false,
aiDefaults: { thinkingLevel: "low" },
ownAiDefaults: { thinkingLevel: "low" },
},
{
id: "researcher",
scope: "project",
name: "Researcher",
uiSelectable: true,
subagentRunnable: false,
base: "exec",
// Effective UI defaults include exec's inherited definition value, but
// the child has no definition default of its own.
aiDefaults: { thinkingLevel: "low" },
},
];

updatePersistedState(AGENT_AI_DEFAULTS_KEY, {
exec: { thinkingLevel: "high" },
});
updatePersistedState(getModelKey(workspaceId), existingModel);
updatePersistedState(getThinkingLevelKey(workspaceId), "off");

const { rerender } = renderSync({ workspaceId, agentId: "plan", agents });
await waitFor(() => {
expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe("off");
});

rerender(<SyncHarness workspaceId={workspaceId} agentId="researcher" agents={agents} />);

await waitFor(() => {
expect(readPersistedState(getModelKey(workspaceId), "")).toBe(existingModel);
expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe("high");
});
});

test("restores a hydrated workspace bucket when settings inherit", async () => {
const workspaceId = nextWorkspaceId();

const existingModel = "anthropic:claude-sonnet-4-5";
const existingThinking = "off";

// Inherit in Settings removes explicit per-agent defaults from AGENT_AI_DEFAULTS_KEY.
Expand All @@ -139,8 +305,8 @@ describe("WorkspaceModeAISync", () => {
renderSync({ workspaceId, agentId: "exec" });

await waitFor(() => {
expect(readPersistedState(getModelKey(workspaceId), "")).toBe(existingModel);
expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe(existingThinking);
expect(readPersistedState(getModelKey(workspaceId), "")).toBe("openai:gpt-5.2");
expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe("medium");
});
});

Expand Down Expand Up @@ -179,10 +345,10 @@ describe("WorkspaceModeAISync", () => {
expect(consumeWorkspaceModelChange(workspaceId, execWorkspaceModel)).toBe("agent");
});

test("ignores same-agent workspace overrides when agent defaults are missing", async () => {
test("restores a hydrated custom-agent bucket during background sync", async () => {
const workspaceId = nextWorkspaceId();

const existingModel = "some-legacy-model";
const existingModel = "anthropic:claude-sonnet-4-5";
const existingThinking = "high";

updatePersistedState(AGENT_AI_DEFAULTS_KEY, {
Expand All @@ -198,8 +364,8 @@ describe("WorkspaceModeAISync", () => {
renderSync({ workspaceId, agentId: "custom" });

await waitFor(() => {
expect(readPersistedState(getModelKey(workspaceId), "")).toBe(existingModel);
expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe(existingThinking);
expect(readPersistedState(getModelKey(workspaceId), "")).toBe("openai:gpt-5.2-pro");
expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe("medium");
});
});

Expand Down
33 changes: 15 additions & 18 deletions src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,8 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null {
prevAgentIdRef.current = normalizedAgentId;
prevWorkspaceIdRef.current = workspaceId;

// Read at call time rather than subscribing: this cache only feeds explicit agent
// switches, yet every model/thinking/pro-mode change rewrites it, so a subscription
// would re-run this effect and re-apply the mode default over the user's own pick.
// Read at call time rather than subscribing: every model/thinking/pro-mode change
// rewrites this cache, so a subscription would re-run the effect on its own updates.
const workspaceByAgent = readPersistedState<WorkspaceAISettingsCache>(
getWorkspaceAISettingsByAgentKey(workspaceId),
{}
Expand All @@ -67,21 +66,19 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null {
const reasoningKey = getReasoningModeKey(workspaceId);
const existingReasoning = readPersistedState<OpenAIReasoningMode>(reasoningKey, "standard");

const { resolvedModel, resolvedThinking, resolvedReasoningMode } =
resolveWorkspaceAiSettingsForAgent({
agentId: normalizedAgentId,
agentAiDefaults,
// Keep deterministic handoff behavior: background sync should trust the
// currently active workspace model, but explicit mode switches should
// restore the selected agent's per-workspace override (if any).
workspaceByAgent,
useWorkspaceByAgentFallback: isExplicitAgentSwitch,
fallbackModel,
existingModel,
existingThinking,
existingReasoningMode: existingReasoning,
agentBaseById: new Map(agents.map((agent) => [agent.id, agent.base])),
});
const resolvedSettings = resolveWorkspaceAiSettingsForAgent({
agentId: normalizedAgentId,
agentAiDefaults,
workspaceByAgent,
fallbackModel,
existingModel,
existingThinking,
existingReasoningMode: existingReasoning,
agents,
mode: isExplicitAgentSwitch ? "explicit-switch" : "background-sync",
});
if (!resolvedSettings) return;
const { resolvedModel, resolvedThinking, resolvedReasoningMode } = resolvedSettings;

if (existingModel !== resolvedModel) {
setWorkspaceModelWithOrigin(
Expand Down
Loading
Loading