Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
168 changes: 167 additions & 1 deletion src/browser/contexts/AgentContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ import { GlobalWindow } from "happy-dom";

import { useWorkspaceStoreRaw as getWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore";
import { CUSTOM_EVENTS } from "@/common/constants/events";
import { GLOBAL_SCOPE_ID, getAgentIdKey, getProjectScopeId } from "@/common/constants/storage";
import {
GLOBAL_SCOPE_ID,
getAgentIdKey,
getModelKey,
getProjectScopeId,
getThinkingLevelKey,
} from "@/common/constants/storage";
import { requireTestModule } from "@/browser/testUtils";
import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
Expand All @@ -22,6 +28,19 @@ import type * as WorkspaceContextModule from "./WorkspaceContext";

let mockAgentDefinitions: AgentDefinitionDescriptor[] = [];
let mockWorkspaceMetadata = new Map<string, { parentWorkspaceId?: string; agentId?: string }>();
let updateAgentAISettingsCalls: Array<{
workspaceId: string;
agentId: string;
aiSettings: { model: string } | null;
persistSelectedAgentId?: boolean | null;
}> = [];
interface UpdateAgentAISettingsResult {
success: boolean;
error?: string;
data?: undefined;
}
let deferUpdateAgentAISettings = false;
let resolveUpdateAgentAISettings: ((result: UpdateAgentAISettingsResult) => void) | null = null;

let APIProvider!: typeof APIModule.APIProvider;
let RouterProvider!: typeof RouterContextModule.RouterProvider;
Expand Down Expand Up @@ -200,6 +219,17 @@ function createApiClient(): APIClient {
},
truncateHistory: () => Promise.resolve({ success: true as const, data: undefined }),
interruptStream: () => Promise.resolve({ success: true as const, data: undefined }),
updateAgentAISettings: (
input: (typeof updateAgentAISettingsCalls)[number]
): Promise<UpdateAgentAISettingsResult> => {
updateAgentAISettingsCalls.push(input);
if (deferUpdateAgentAISettings) {
return new Promise((resolve) => {
resolveUpdateAgentAISettings = resolve;
});
}
return Promise.resolve({ success: true, data: undefined });
},
},
projects: {
list: () => Promise.resolve([]),
Expand Down Expand Up @@ -246,6 +276,9 @@ describe("AgentContext", () => {
isolatedModuleDir = await importIsolatedAgentModules();
mockAgentDefinitions = [];
mockWorkspaceMetadata = new Map();
updateAgentAISettingsCalls = [];
deferUpdateAgentAISettings = false;
resolveUpdateAgentAISettings = null;

originalWindow = globalThis.window;
originalDocument = globalThis.document;
Expand Down Expand Up @@ -362,6 +395,139 @@ describe("AgentContext", () => {
});
});

test("workspace agent selection persists to the backend", async () => {
const projectPath = "/tmp/project";
const workspaceId = "main-workspace";
mockAgentDefinitions = [EXEC_AGENT, PLAN_AGENT];
mockWorkspaceMetadata.set(workspaceId, {});
window.localStorage.setItem(getAgentIdKey(workspaceId), JSON.stringify("exec"));

let contextValue: AgentContextValue | undefined;

renderAgentHarness({
workspaceId,
projectPath,
onChange: (value) => (contextValue = value),
});

await waitFor(() => {
expect(contextValue?.agentId).toBe("exec");
});

contextValue?.setAgentId("plan");

await waitFor(() => {
expect(contextValue?.agentId).toBe("plan");
});
expect(updateAgentAISettingsCalls).toEqual([
{ workspaceId, agentId: "plan", aiSettings: null, persistSelectedAgentId: true },
]);

// Re-selecting the current agent is a no-op and must not hit the backend.
contextValue?.setAgentId("plan");
expect(updateAgentAISettingsCalls).toHaveLength(1);
});

test("failed persistence rolls back the local agent selection", async () => {
const projectPath = "/tmp/project";
const workspaceId = "main-workspace";
mockAgentDefinitions = [EXEC_AGENT, PLAN_AGENT];
mockWorkspaceMetadata.set(workspaceId, {});
window.localStorage.setItem(getAgentIdKey(workspaceId), JSON.stringify("exec"));
deferUpdateAgentAISettings = true;

let contextValue: AgentContextValue | undefined;

renderAgentHarness({
workspaceId,
projectPath,
onChange: (value) => (contextValue = value),
});

await waitFor(() => {
expect(contextValue?.agentId).toBe("exec");
});

const toasts: Array<{ workspaceId: string; message: string }> = [];
const toastListener = (event: Event) =>
toasts.push((event as CustomEvent<{ workspaceId: string; message: string }>).detail);
window.addEventListener(CUSTOM_EVENTS.AGENT_SWITCH_ERROR_TOAST, toastListener);

try {
contextValue?.setAgentId("plan");

// Optimistic switch happens immediately...
await waitFor(() => {
expect(contextValue?.agentId).toBe("plan");
});
await waitFor(() => {
expect(resolveUpdateAgentAISettings).not.toBeNull();
});

// ...then the backend rejects the write and the selection rolls back.
resolveUpdateAgentAISettings?.({ success: false, error: "offline" });

await waitFor(() => {
expect(contextValue?.agentId).toBe("exec");
});
// The rejection surfaces to the user instead of silently snapping back.
expect(toasts).toEqual([{ workspaceId, message: "offline" }]);
} finally {
window.removeEventListener(CUSTOM_EVENTS.AGENT_SWITCH_ERROR_TOAST, toastListener);
}
});

test("failed persistence restores the pre-switch model settings with the agent", async () => {
const projectPath = "/tmp/project";
const workspaceId = "main-workspace";
mockAgentDefinitions = [EXEC_AGENT, PLAN_AGENT];
mockWorkspaceMetadata.set(workspaceId, {});
window.localStorage.setItem(getAgentIdKey(workspaceId), JSON.stringify("exec"));
window.localStorage.setItem(getModelKey(workspaceId), JSON.stringify("openai:exec-model"));
window.localStorage.setItem(getThinkingLevelKey(workspaceId), JSON.stringify("high"));
deferUpdateAgentAISettings = true;

let contextValue: AgentContextValue | undefined;

renderAgentHarness({
workspaceId,
projectPath,
onChange: (value) => (contextValue = value),
});

await waitFor(() => {
expect(contextValue?.agentId).toBe("exec");
});

contextValue?.setAgentId("plan");

await waitFor(() => {
expect(contextValue?.agentId).toBe("plan");
});

// WorkspaceModeAISync reacts to the optimistic switch by applying the
// target agent's settings before the backend write settles.
window.localStorage.setItem(getModelKey(workspaceId), JSON.stringify("openai:plan-model"));
window.localStorage.setItem(getThinkingLevelKey(workspaceId), JSON.stringify("off"));

await waitFor(() => {
expect(resolveUpdateAgentAISettings).not.toBeNull();
});
resolveUpdateAgentAISettings?.({ success: false, error: "offline" });

// Rollback restores the previous agent AND its resolved settings, so the
// sync effect's fallback cannot leave exec on plan's just-applied model.
await waitFor(() => {
expect(contextValue?.agentId).toBe("exec");
});
expect(window.localStorage.getItem(getModelKey(workspaceId))).toBe(
JSON.stringify("openai:exec-model")
);
expect(window.localStorage.getItem(getThinkingLevelKey(workspaceId))).toBe(
JSON.stringify("high")
);
});

test("shortcut actions do not override a locked workspace agent", async () => {
const projectPath = "/tmp/project";
const lockedWorkspaceId = "locked-workspace";
Expand Down
119 changes: 109 additions & 10 deletions src/browser/contexts/AgentContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,33 @@ import {

import { useAPI } from "@/browser/contexts/API";
import { useWorkspaceMetadata } from "@/browser/contexts/WorkspaceContext";
import { usePersistedState } from "@/browser/hooks/usePersistedState";
import {
readPersistedState,
updatePersistedState,
usePersistedState,
} from "@/browser/hooks/usePersistedState";
import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events";
import { matchesKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
import {
getAgentIdKey,
getModelKey,
getProjectScopeId,
getDisableWorkspaceAgentsKey,
getReasoningModeKey,
getThinkingLevelKey,
GLOBAL_SCOPE_ID,
} from "@/common/constants/storage";
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange";
import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking";
import { getErrorMessage } from "@/common/utils/errors";
import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition";
import { sortAgentsStable } from "@/browser/utils/agents";
import { normalizeAgentId, resolveRemovedBuiltinAgentId } from "@/common/utils/agentIds";
import {
clearPendingWorkspaceAgentId,
markPendingWorkspaceAgentId,
} from "@/browser/utils/workspaceAiSettingsSync";
import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults";

export interface AgentContextValue {
Expand Down Expand Up @@ -120,17 +135,104 @@ function AgentProviderWithState(props: {
}
}, [disableWorkspaceAgents, setDisableWorkspaceAgents]);

// Child/subagent workspaces keep the backend-assigned agent; their selection
// is locked, so local changes must never be written back.
const isCurrentAgentLocked = currentMeta?.parentWorkspaceId != null;

const workspaceId = props.workspaceId;
const setAgentId: Dispatch<SetStateAction<string>> = useCallback(
(value) => {
// usePersistedState runs the updater synchronously, so `next` is
// available right after the call.
let next: string | null = null;
let previous: string | null = null;
setAgentIdRaw((prev) => {
const explicitPrevAgentId =
typeof prev === "string" && prev.trim().length > 0 ? prev : globalDefaultAgentId;
const previousAgentId = coerceAgentId(isProjectScope ? explicitPrevAgentId : prev);
const next = typeof value === "function" ? value(previousAgentId) : value;
return coerceAgentId(next);
previous = coerceAgentId(isProjectScope ? explicitPrevAgentId : prev);
next = coerceAgentId(typeof value === "function" ? value(previous) : value);
return next;
});

// Persist workspace mode changes so the selection is remembered
// per-workspace across clients, not just in this client's localStorage.
if (!api || !workspaceId || isCurrentAgentLocked || next == null || next === previous) {
return;
}
const nextAgentId: string = next;
const previousAgentId: string | null = previous;

// Snapshot the resolved settings before WorkspaceModeAISync reacts to
// the optimistic switch: it replaces model/thinking/reasoning for the
// target agent, and on rollback its fallback for a previous agent with
// no bucket or configured defaults would be the target agent's newly
// applied settings rather than these.
const modelKey = getModelKey(workspaceId);
const thinkingKey = getThinkingLevelKey(workspaceId);
const reasoningKey = getReasoningModeKey(workspaceId);
const previousModel = readPersistedState<string>(modelKey, getDefaultModel());
const previousThinking = readPersistedState<ThinkingLevel>(thinkingKey, "off");
const previousReasoning = readPersistedState<OpenAIReasoningMode>(reasoningKey, "standard");

// Optimistic local update above; on persistence failure roll the local
// selection back (unless it changed again meanwhile) so this client
// cannot silently diverge from the backend-authoritative agent.
const rollback = () => {
clearPendingWorkspaceAgentId(workspaceId, nextAgentId);
if (previousAgentId == null) {
return;
}
// scopeId === workspaceId here: persistence only runs workspace-scoped.
const current = readPersistedState<string | null>(getAgentIdKey(workspaceId), null);
if (current !== nextAgentId) {
return;
}
// Restore settings before the agent id so the sync effect's rollback
// run reads them as the existing (fallback) values.
setWorkspaceModelWithOrigin(workspaceId, previousModel, "sync");
updatePersistedState(thinkingKey, previousThinking);
updatePersistedState(reasoningKey, previousReasoning);
setAgentIdRaw(previousAgentId);
};

// The picker closes on selection, so a rejected switch would otherwise
// just snap back with no explanation (e.g. budgeted-goal pricing gate).
const notifySwitchRejected = (message: string) => {
window.dispatchEvent(
createCustomEvent(CUSTOM_EVENTS.AGENT_SWITCH_ERROR_TOAST, {
workspaceId,
message:
message.trim().length > 0 ? message : `Failed to switch to the ${nextAgentId} agent.`,
})
);
};

markPendingWorkspaceAgentId(workspaceId, nextAgentId);
Comment thread
ibetitsmike marked this conversation as resolved.
api.workspace
.updateAgentAISettings({
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
workspaceId,
agentId: nextAgentId,
aiSettings: null,
persistSelectedAgentId: true,
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
})
.then((result) => {
if (result.success) {
// A no-op write (backend already on this agent) emits no metadata
// echo, so release the guard deterministically. For changed writes
// the echo is ordered after any stale broadcast, so releasing on
// the response cannot strand a stale value.
clearPendingWorkspaceAgentId(workspaceId, nextAgentId);
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
return;
}
Comment thread
ibetitsmike marked this conversation as resolved.
notifySwitchRejected(typeof result.error === "string" ? result.error : "");
rollback();
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
})
.catch((error) => {
notifySwitchRejected(getErrorMessage(error));
rollback();
});
},
[globalDefaultAgentId, isProjectScope, setAgentIdRaw]
[api, globalDefaultAgentId, isCurrentAgentLocked, isProjectScope, setAgentIdRaw, workspaceId]
);

const [agents, setAgents] = useState<AgentDefinitionDescriptor[]>([]);
Expand Down Expand Up @@ -230,11 +332,8 @@ function AgentProviderWithState(props: {
}
}, [fetchAgents, props.projectPath, props.workspaceId, disableWorkspaceAgents]);

// Project-scoped providers should inherit the global default agent until a
// project-scoped preference is explicitly set. Child/subagent workspaces keep
// the backend-assigned agent so local persisted overrides cannot drift.
const isCurrentAgentLocked = currentMeta?.parentWorkspaceId != null;

// Project-scoped providers inherit the global default agent until a
// project-scoped preference is explicitly set.
// For locked workspaces, use the backend-assigned agent β€” persisted localStorage
// may contain a stale selection from before locking, and the picker is disabled
// so there's no in-UI recovery path.
Expand Down
Loading
Loading