Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
98 changes: 98 additions & 0 deletions src/browser/contexts/AgentContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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 +213,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 +270,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 +389,77 @@ 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");
});

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");
});
});

test("shortcut actions do not override a locked workspace agent", async () => {
const projectPath = "/tmp/project";
const lockedWorkspaceId = "locked-workspace";
Expand Down
61 changes: 52 additions & 9 deletions src/browser/contexts/AgentContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import {
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 +124,59 @@ 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;

// 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) {
setAgentIdRaw((current) => (current === nextAgentId ? previousAgentId : current));
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
}
};

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) {
rollback();
}
Comment thread
ibetitsmike marked this conversation as resolved.
})
.catch(rollback);
},
[globalDefaultAgentId, isProjectScope, setAgentIdRaw]
[api, globalDefaultAgentId, isCurrentAgentLocked, isProjectScope, setAgentIdRaw, workspaceId]
);

const [agents, setAgents] = useState<AgentDefinitionDescriptor[]>([]);
Expand Down Expand Up @@ -230,11 +276,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
72 changes: 62 additions & 10 deletions src/browser/contexts/WorkspaceContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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 { markPendingWorkspaceAgentId } from "@/browser/utils/workspaceAiSettingsSync";
import { getProjectRouteId } from "@/common/utils/projectRouteId";
import type { RightSidebarLayoutState } from "@/browser/utils/rightSidebarLayout";

Expand Down Expand Up @@ -520,8 +521,30 @@ describe("WorkspaceContext", () => {
"xhigh"
);
});
test("stale metadata does not override a main workspace agent selection", async () => {
test("backend agentId seeds a main workspace agent selection", async () => {
const workspaceId = "ws-agent-main";

createMockAPI({
workspace: {
list: () =>
Promise.resolve([createWorkspaceMetadata({ id: workspaceId, agentId: "plan" })]),
},
localStorage: {
// Backend value wins over a stale local selection from another client.
[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"
);
});

test("stale metadata does not clobber a pending local agent switch", async () => {
const workspaceId = "ws-agent-pending";
let emitMetadata:
| ((event: { workspaceId: string; metadata: FrontendWorkspaceMetadata | null }) => void)
| null = null;
Expand All @@ -532,13 +555,16 @@ describe("WorkspaceContext", () => {
onMetadata: () =>
Promise.resolve(
(async function* () {
const event = await new Promise<{
workspaceId: string;
metadata: FrontendWorkspaceMetadata | null;
}>((resolve) => {
emitMetadata = resolve;
});
yield event;
while (true) {
const event = await new Promise<{
workspaceId: string;
metadata: FrontendWorkspaceMetadata | null;
}>((resolve) => {
emitMetadata = resolve;
});
emitMetadata = null;
yield event;
}
})() as unknown as Awaited<ReturnType<APIClient["workspace"]["onMetadata"]>>
),
},
Expand All @@ -547,23 +573,49 @@ describe("WorkspaceContext", () => {
},
});

// Simulate a local mode switch whose backend write hasn't echoed yet.
markPendingWorkspaceAgentId(workspaceId, "exec");

const ctx = await setup();

await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(1));
await waitFor(() => expect(emitMetadata).toBeTruthy());
expect(ctx().workspaceMetadata.get(workspaceId)?.agentId).toBeUndefined();

// A stale broadcast carrying the previous agent must not revert the switch.
act(() => {
emitMetadata?.({
workspaceId,
metadata: createWorkspaceMetadata({ id: workspaceId, agentId: "plan" }),
});
});

await waitFor(() => expect(ctx().workspaceMetadata.get(workspaceId)?.agentId).toBe("plan"));
expect(readPersistedState<string | undefined>(getAgentIdKey(workspaceId), undefined)).toBe(
"exec"
);

// The backend echo of the pending value clears the guard...
await waitFor(() => expect(emitMetadata).toBeTruthy());
act(() => {
emitMetadata?.({
workspaceId,
metadata: createWorkspaceMetadata({ id: workspaceId, agentId: "exec" }),
});
});
await waitFor(() => expect(ctx().workspaceMetadata.get(workspaceId)?.agentId).toBe("exec"));

// ...so later backend updates apply again.
await waitFor(() => expect(emitMetadata).toBeTruthy());
act(() => {
emitMetadata?.({
workspaceId,
metadata: createWorkspaceMetadata({ id: workspaceId, agentId: "plan" }),
});
});
await waitFor(() =>
expect(readPersistedState<string | undefined>(getAgentIdKey(workspaceId), undefined)).toBe(
"plan"
)
);
});

test("child workspace metadata still seeds the locked backend agent", async () => {
Expand Down
29 changes: 17 additions & 12 deletions src/browser/contexts/WorkspaceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ import {
import { normalizeAgentAiDefaults } from "@/common/types/agentAiDefaults";
import { isWorkspaceArchived } from "@/common/utils/archive";
import { reassignPinnedTimestamps } from "@/common/utils/pin";
import { shouldApplyWorkspaceAiSettingsFromBackend } from "@/browser/utils/workspaceAiSettingsSync";
import {
shouldApplyWorkspaceAgentIdFromBackend,
shouldApplyWorkspaceAiSettingsFromBackend,
} from "@/browser/utils/workspaceAiSettingsSync";
import { isAbortError } from "@/browser/utils/isAbortError";
import { findAdjacentWorkspaceId } from "@/browser/utils/ui/workspaceDomNav";
import { useRouter } from "@/browser/contexts/RouterContext";
Expand Down Expand Up @@ -161,12 +164,6 @@ function migrateLocalGatewayPrefsToBackend(
}
}

function shouldSeedWorkspaceAgentIdFromBackend(metadata: FrontendWorkspaceMetadata): boolean {
// Main workspaces own their live agent selection in localStorage. Child/task
// workspaces are backend-defined and locked, so they must re-seed from metadata.
return metadata.parentWorkspaceId != null;
}

/**
* Seed per-workspace localStorage from backend workspace metadata.
*
Expand All @@ -183,13 +180,21 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat

const workspaceId = metadata.id;

// Seed the active agent from backend metadata so the last used mode follows
// the workspace across clients. Child/task workspaces are backend-defined and
// locked, so they always re-seed; main workspaces persist local mode changes
// to the backend and are protected from stale broadcasts by the pending-echo
// guard (shouldApplyWorkspaceAgentIdFromBackend).
const metadataAgentId = resolvePersistedAgentId(metadata, "");
if (shouldSeedWorkspaceAgentIdFromBackend(metadata) && metadataAgentId.length > 0) {
const key = getAgentIdKey(workspaceId);
if (metadataAgentId.length > 0) {
const normalized = normalizeAgentId(metadataAgentId);
Comment thread
ibetitsmike marked this conversation as resolved.
const existing = readPersistedState<string | undefined>(key, undefined);
if (existing !== normalized) {
updatePersistedState(key, normalized);
const isLockedChildWorkspace = metadata.parentWorkspaceId != null;
if (isLockedChildWorkspace || shouldApplyWorkspaceAgentIdFromBackend(workspaceId, normalized)) {
Comment thread
ibetitsmike marked this conversation as resolved.
Comment thread
ibetitsmike marked this conversation as resolved.
const key = getAgentIdKey(workspaceId);
const existing = readPersistedState<string | undefined>(key, undefined);
if (existing !== normalized) {
updatePersistedState(key, normalized);
}
}
}

Expand Down
Loading
Loading