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
56 changes: 56 additions & 0 deletions agent-chat/src/browser-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
type BrowserStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;

/** Storage failures must not interrupt a live session or discard its draft. */
export function createRecoverableStorage(resolve: () => BrowserStorage | undefined): BrowserStorage {
const cached = new Map<string, string | null>();
const pending = new Set<string>();
return {
getItem(key) {
// A rejected write/remove is newer than the value still on disk. A null
// tombstone also prevents a consumed draft from reappearing on remount.
if (pending.has(key)) return cached.get(key) ?? null;
try {
const storage = resolve();
if (storage) {
const value = storage.getItem(key);
cached.set(key, value);
return value;
}
} catch {
// Accessing the storage property itself can throw SecurityError.
}
return cached.get(key) ?? null;
},
setItem(key, value) {
cached.set(key, value);
pending.add(key);
try {
const storage = resolve();
if (storage) {
storage.setItem(key, value);
pending.delete(key);
}
} catch {
// Keep the selected value available to other views in this page.
}
},
removeItem(key) {
cached.set(key, null);
pending.add(key);
try {
const storage = resolve();
if (storage) {
storage.removeItem(key);
pending.delete(key);
}
} catch {
// Keep the tombstone until a later successful mutation of this key.
}
},
};
}

// Resolve lazily inside the guarded operations; evaluating a default argument
// such as `storage = localStorage` can throw before a function's try/catch.
export const preferenceStorage = createRecoverableStorage(() => globalThis.localStorage);
export const draftStorage = createRecoverableStorage(() => globalThis.sessionStorage);
7 changes: 4 additions & 3 deletions agent-chat/src/components/Chat.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { preferenceStorage, draftStorage } from "../browser-storage";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
import { useCtx } from "../context";
import { agentChatText } from "../i18n";
Expand Down Expand Up @@ -122,9 +123,9 @@ export function Chat() {
return;
}
updateStoredProviderOption(provider, "model", model, allProviderOptions[provider] ?? []);
localStorage.setItem("agentui.provider", provider);
localStorage.setItem("agentui.cwd", session.cwd);
sessionStorage.setItem("agentui.draft", text);
preferenceStorage.setItem("agentui.provider", provider);
preferenceStorage.setItem("agentui.cwd", session.cwd);
draftStorage.setItem("agentui.draft", text);
compose();
};

Expand Down
19 changes: 10 additions & 9 deletions agent-chat/src/components/Composer.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { preferenceStorage, draftStorage } from "../browser-storage";
import { useCallback, useMemo, useState } from "react";
import { composerDraftKey, visibleWorkflowHarnesses, type OptionValue } from "../session";
import { useCtx } from "../context";
Expand Down Expand Up @@ -46,12 +47,12 @@ export function Composer() {
clearError,
start,
} = useCtx();
const [provider, setProvider] = useState(() => localStorage.getItem("agentui.provider") || "claude");
const [cwd, setCwd] = useState(() => localStorage.getItem("agentui.cwd") || "");
const [committedCwd, setCommittedCwd] = useState(() => localStorage.getItem("agentui.cwd") || "");
const [provider, setProvider] = useState(() => preferenceStorage.getItem("agentui.provider") || "claude");
const [cwd, setCwd] = useState(() => preferenceStorage.getItem("agentui.cwd") || "");
const [committedCwd, setCommittedCwd] = useState(() => preferenceStorage.getItem("agentui.cwd") || "");
const [prompt, setPrompt] = useState(() => {
const draft = sessionStorage.getItem(composerDraftKey) || "";
sessionStorage.removeItem(composerDraftKey);
const draft = draftStorage.getItem(composerDraftKey) || "";
draftStorage.removeItem(composerDraftKey);
return draft;
});
const [startOptionsByProvider, setStartOptionsByProvider] = useState<Record<string, Record<string, OptionValue>>>(() => ({
Expand Down Expand Up @@ -110,20 +111,20 @@ export function Composer() {
const runCwd = cwd.trim();
const sent = start({ provider, cwd: runCwd, prompt: text, options: sanitizeStartOptions(startOptions, options) });
if (!sent) return;
localStorage.setItem("agentui.provider", provider);
localStorage.setItem("agentui.cwd", runCwd);
preferenceStorage.setItem("agentui.provider", provider);
preferenceStorage.setItem("agentui.cwd", runCwd);
};
const changeCwd = (v: string) => { setCwd(v); };
const commitCwd = (v: string) => {
const next = v.trim();
if (!next) return;
setCommittedCwd(next);
localStorage.setItem("agentui.cwd", next);
preferenceStorage.setItem("agentui.cwd", next);
};
const changeProvider = (v: string) => {
setProvider(v);
setStartOptionsByProvider((all) => all[v] ? all : { ...all, [v]: readProviderOptions(v) });
localStorage.setItem("agentui.provider", v);
preferenceStorage.setItem("agentui.provider", v);
};
const changeProviderModel = (nextProvider: string, model: string) => {
changeProvider(nextProvider);
Expand Down
5 changes: 3 additions & 2 deletions agent-chat/src/hooks/useCatalogs.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { preferenceStorage } from "../browser-storage";
import { useEffect } from "react";
import type { CommandGroup, Provider, SessionOption } from "../session";

Expand Down Expand Up @@ -98,7 +99,7 @@ export function useCwdValidation(
if (!checked || checked.ok || !defaultCwd) return;
setCwd(defaultCwd);
setCommittedCwd(defaultCwd);
localStorage.setItem("agentui.cwd", defaultCwd);
preferenceStorage.setItem("agentui.cwd", defaultCwd);
}, [cwd, cwdChecks, defaultCwd, setCommittedCwd, setCwd]);
}

Expand All @@ -112,6 +113,6 @@ export function useCwdErrorFallback(
if (!message.includes("working directory does not exist") || !defaultCwd) return;
setCwd(defaultCwd);
setCommittedCwd(defaultCwd);
localStorage.setItem("agentui.cwd", defaultCwd);
preferenceStorage.setItem("agentui.cwd", defaultCwd);
}, [defaultCwd, message, setCommittedCwd, setCwd]);
}
13 changes: 7 additions & 6 deletions agent-chat/src/options-store.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { preferenceStorage } from "./browser-storage";
import type { OptionValue, SessionOption } from "./session";

export interface ProviderOptionStore {
Expand Down Expand Up @@ -74,23 +75,23 @@ export function flatOptionsFromStore(store: ProviderOptionStore): Record<string,
return out;
}

export function readStoredProviderOptions(provider: string, storage: StorageLike = localStorage): Record<string, OptionValue> {
export function readStoredProviderOptions(provider: string, storage: StorageLike = preferenceStorage): Record<string, OptionValue> {
try {
return flatOptionsFromStore(normalizeProviderOptionStore(JSON.parse(storage.getItem(key(provider)) || "null")));
} catch {
return {};
}
}

export function readProviderOptionStore(provider: string, storage: StorageLike = localStorage): ProviderOptionStore {
export function readProviderOptionStore(provider: string, storage: StorageLike = preferenceStorage): ProviderOptionStore {
try {
return normalizeProviderOptionStore(JSON.parse(storage.getItem(key(provider)) || "null"));
} catch {
return emptyStore();
}
}

export function writeProviderOptionStore(provider: string, store: ProviderOptionStore, storage: StorageLike = localStorage) {
export function writeProviderOptionStore(provider: string, store: ProviderOptionStore, storage: StorageLike = preferenceStorage) {
storage.setItem(key(provider), JSON.stringify(store));
}

Expand All @@ -104,7 +105,7 @@ export function updateStoredProviderOption(
id: string,
value: OptionValue,
options: SessionOption[],
storage: StorageLike = localStorage,
storage: StorageLike = preferenceStorage,
): Record<string, OptionValue> {
const store = readProviderOptionStore(provider, storage);
const activeModel = id === "model" && typeof value === "string" ? value : optionModel(options, store.model);
Expand All @@ -124,7 +125,7 @@ export function writeStoredProviderOptions(
provider: string,
values: Record<string, OptionValue>,
options: SessionOption[] = [],
storage: StorageLike = localStorage,
storage: StorageLike = preferenceStorage,
): Record<string, OptionValue> {
const store = readProviderOptionStore(provider, storage);
const model = typeof values.model === "string" && values.model ? values.model : optionModel(options, store.model);
Expand All @@ -141,7 +142,7 @@ export function writeStoredProviderOptions(
return flatOptionsFromStore(store);
}

export function persistOptionsSnapshot(provider: string, options: SessionOption[], storage: StorageLike = localStorage) {
export function persistOptionsSnapshot(provider: string, options: SessionOption[], storage: StorageLike = preferenceStorage) {
const values: Record<string, OptionValue> = {};
for (const option of options) values[option.id] = option.value;
writeStoredProviderOptions(provider, values, options, storage);
Expand Down
3 changes: 2 additions & 1 deletion agent-chat/src/session.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { draftStorage } from "./browser-storage";
// Client-side session state: one WebSocket, one session per page.
import { useCallback, useEffect, useRef, useState } from "react";
import { applyThemeVars } from "./theme";
Expand Down Expand Up @@ -403,7 +404,7 @@ export function useSession(): SessionState {
if (!pending) return;
clearPendingStartTimeout();
pendingStartRef.current = null;
restoreComposerDraft(sessionStorage, pending.prompt);
restoreComposerDraft(draftStorage, pending.prompt);
history.replaceState(null, "", appPath("/"));
document.title = "cmux agent";
sessionIdRef.current = null;
Expand Down
99 changes: 99 additions & 0 deletions agent-chat/test/browser-storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { expect, test } from "bun:test";
import { createRecoverableStorage, draftStorage, preferenceStorage } from "../src/browser-storage";

class MemoryStorage {
values = new Map<string, string>();
getItem(key: string) { return this.values.get(key) ?? null; }
setItem(key: string, value: string) { this.values.set(key, value); }
removeItem(key: string) { this.values.delete(key); }
}

test("unavailable storage preserves a draft and consumes it once", () => {
const storage = createRecoverableStorage(() => { throw new DOMException("Denied", "SecurityError"); });
expect(storage.getItem("draft")).toBeNull();
storage.setItem("draft", "retry this exact prompt");
expect(storage.getItem("draft")).toBe("retry this exact prompt");
storage.removeItem("draft");
expect(storage.getItem("draft")).toBeNull();
});

test("healthy storage observes updates from another view", () => {
const backend = new MemoryStorage();
const storage = createRecoverableStorage(() => backend);
storage.setItem("provider", "claude");
expect(backend.getItem("provider")).toBe("claude");
backend.setItem("provider", "codex");
expect(storage.getItem("provider")).toBe("codex");
storage.removeItem("provider");
expect(backend.getItem("provider")).toBeNull();
});

test("a later denied read retains the last known preference", () => {
const backend = new MemoryStorage();
backend.setItem("cwd", "/work/project");
let available = true;
const storage = createRecoverableStorage(() => available ? backend : undefined);
expect(storage.getItem("cwd")).toBe("/work/project");
available = false;
expect(storage.getItem("cwd")).toBe("/work/project");
});

test("failed writes win over stale disk data until a successful mutation", () => {
const backend = new MemoryStorage();
backend.setItem("draft", "old prompt");
let writable = false;
const storage = createRecoverableStorage(() => ({
getItem: (key) => backend.getItem(key),
setItem: (key, value) => {
if (!writable) throw new DOMException("Full", "QuotaExceededError");
backend.setItem(key, value);
},
removeItem: (key) => backend.removeItem(key),
}));
storage.setItem("draft", "recovered prompt");
expect(storage.getItem("draft")).toBe("recovered prompt");
expect(backend.getItem("draft")).toBe("old prompt");
writable = true;
storage.setItem("draft", "next prompt");
expect(backend.getItem("draft")).toBe("next prompt");
backend.setItem("draft", "another view's prompt");
expect(storage.getItem("draft")).toBe("another view's prompt");
});

test("failed removal does not resurrect a consumed draft", () => {
const backend = new MemoryStorage();
backend.setItem("draft", "already consumed");
const storage = createRecoverableStorage(() => ({
getItem: (key) => backend.getItem(key),
setItem: (key, value) => backend.setItem(key, value),
removeItem() { throw new DOMException("Denied", "SecurityError"); },
}));
expect(storage.getItem("draft")).toBe("already consumed");
storage.removeItem("draft");
expect(storage.getItem("draft")).toBeNull();
expect(storage.getItem("draft")).toBeNull();
expect(backend.getItem("draft")).toBe("already consumed");
});

test("local preferences and session drafts keep separate fallbacks", () => {
const previous = Object.getOwnPropertyDescriptors(globalThis);
try {
for (const name of ["localStorage", "sessionStorage"]) {
Object.defineProperty(globalThis, name, {
configurable: true,
get() { throw new DOMException("Denied", "SecurityError"); },
});
}
preferenceStorage.setItem("storage-test", "preference");
draftStorage.setItem("storage-test", "draft");
expect(preferenceStorage.getItem("storage-test")).toBe("preference");
expect(draftStorage.getItem("storage-test")).toBe("draft");
} finally {
preferenceStorage.removeItem("storage-test");
draftStorage.removeItem("storage-test");
for (const name of ["localStorage", "sessionStorage"]) {
if (previous[name]) Object.defineProperty(globalThis, name, previous[name]);
else Reflect.deleteProperty(globalThis, name);
}
}
});
37 changes: 37 additions & 0 deletions agent-chat/test/options-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,40 @@ if (read.model !== "m1" || read.effort !== "high" || read.permissionMode !== "pl
}

console.log("options store assertions passed");

// Embedded browser storage can reject either property access or a write.
// These are defaults used by the live composer, not injected healthy storage.
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
try {
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
get() { throw new DOMException("Storage access denied", "SecurityError"); },
});
if (Object.keys(readStoredProviderOptions("denied-storage")).length) {
throw new Error("denied storage should start with empty options");
}
updateStoredProviderOption("denied-storage", "permissionMode", "plan", options("m1"));
if (readStoredProviderOptions("denied-storage").permissionMode !== "plan") {
throw new Error("permission selection should survive denied storage within the page");
}

const fullStorage = {
getItem() { return JSON.stringify({ version: 2, model: "m1", harness: {}, models: { m1: { effort: "high" } } }); },
setItem() { throw new DOMException("Storage is full", "QuotaExceededError"); },
removeItem() { throw new DOMException("Storage is full", "QuotaExceededError"); },
};
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: fullStorage });
const selected = updateStoredProviderOption("full-storage", "permissionMode", "plan", options("m1"));
if (selected.effort !== "high" || selected.permissionMode !== "plan") {
throw new Error("a rejected preference write should preserve both existing and selected options");
}
const restored = readStoredProviderOptions("full-storage");
if (restored.effort !== "high" || restored.permissionMode !== "plan") {
throw new Error("a remounted composer should read the selection from memory when disk writes fail");
}
} finally {
if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
}

console.log("options store storage-failure assertions passed");
24 changes: 23 additions & 1 deletion agent-chat/test/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,34 @@ Object.defineProperty(globalThis, "location", {

const { composerDraftKey, consumeOptimisticUserEcho, foldEvent, latestRouting, restoreComposerDraft, shouldAcceptHandoffResponse } = await import("../src/session");
const { latestRouteStatus, normalizeRouteStatus, routeHealthForPhase } = await import("../route-status");
const { draftStorage } = await import("../src/browser-storage");

const writes: Record<string, string> = {};
restoreComposerDraft({ setItem: (key: string, value: string) => { writes[key] = value; } }, "retry this exact prompt");

if (writes[composerDraftKey] !== "retry this exact prompt") {
throw new Error(`pre-session start failure did not preserve composer draft: ${JSON.stringify(writes)}`);
throw new Error(`pre-session start failure did not preserve composer draft: ${JSON.stringify(writes)}`);
}

const originalSessionStorage = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage");
try {
Object.defineProperty(globalThis, "sessionStorage", {
configurable: true,
get() { throw new DOMException("Storage access denied", "SecurityError"); },
});
// The failed-start path writes here before resetting the view, then the
// remounted composer reads and consumes the draft through the same store.
restoreComposerDraft(draftStorage, "recover this exact prompt after a failed start");
if (draftStorage.getItem(composerDraftKey) !== "recover this exact prompt after a failed start") {
throw new Error("a rejected session storage write lost the failed-start prompt");
}
draftStorage.removeItem(composerDraftKey);
if (draftStorage.getItem(composerDraftKey) !== null) {
throw new Error("a consumed failed-start prompt should not reappear");
}
} finally {
if (originalSessionStorage) Object.defineProperty(globalThis, "sessionStorage", originalSessionStorage);
else Reflect.deleteProperty(globalThis, "sessionStorage");
}

const repeated = [
Expand Down
Loading