diff --git a/src-tauri/build.rs b/src-tauri/build.rs index e0ce0d3ce2..57b15203de 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -155,6 +155,8 @@ fn main() { "jev_classifier_set_key", "jev_classifier_clear_key", "jev_classify", + "app_agent_state_load", + "app_agent_state_set", "title_bar_double_click", "notification_show", "notification_permission_state", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index cba381a270..a15ba65747 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -117,6 +117,8 @@ "allow-jev-classifier-set-key", "allow-jev-classifier-clear-key", "allow-jev-classify", + "allow-app-agent-state-load", + "allow-app-agent-state-set", "allow-title-bar-double-click", "allow-notification-show", diff --git a/src-tauri/src/agent_state.rs b/src-tauri/src/agent_state.rs new file mode 100644 index 0000000000..43b5e4927c --- /dev/null +++ b/src-tauri/src/agent_state.rs @@ -0,0 +1,343 @@ +//! Agent state shared by every program that runs this machine's agents: each +//! agent's settings, the owner's teams, unfinished setups, and the agent types' +//! saved sessions. It lives in `agents/state/`, next to `identities.json`, so +//! the app and the standalone `buzz-agent-host` read and write the same state. +//! +//! One file per key, each replaced whole through a rename, so a crash leaves +//! the last complete copy, and two programs that write different keys never +//! overwrite each other. The `hosts/` locks decide which program runs an agent. +//! +//! A file's name is its key, encoded. A key whose encoded name is too long for +//! the file system gets a fixed-length name instead, `~` and the SHA-256 of the +//! key, and its file holds `{"key": …, "value": …}` so `load` can recover it. +use std::collections::HashMap; +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// The largest value kept, as an upper bound on what `localStorage` held. +const VALUE_LIMIT: usize = 4 * 1024 * 1024; +/// The longest file name macOS, Linux and Windows all allow, in bytes. +const NAME_LIMIT: usize = 255; +const TEMPORARY: &str = ".tmp"; +/// Starts the name of a file that holds its key. `encode` never writes `~`. +const HASHED: char = '~'; + +/// The contents of a file whose name is a hash of its key. +#[derive(Serialize, Deserialize)] +struct Hashed<'a> { + key: std::borrow::Cow<'a, str>, + value: std::borrow::Cow<'a, str>, +} + +pub(crate) struct AgentState { + dir: Result, +} + +impl AgentState { + /// `dir` is `agents/state`. + pub(crate) fn new(dir: Result) -> Self { + Self { dir } + } + + fn dir(&self) -> Result<&Path, String> { + self.dir.as_deref().map_err(Clone::clone) + } + + /// Every key and its value. Unreadable or foreign files are skipped. + pub(crate) fn load(&self) -> Result, String> { + let dir = self.dir()?; + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(HashMap::new()), + Err(_) => return Err("Could not read agent state".into()), + }; + let mut items = HashMap::new(); + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + let key = decode(name); + if key.is_none() && !name.starts_with(HASHED) { + continue; + } + let Ok(text) = fs::read_to_string(entry.path()) else { + continue; + }; + match key { + Some(key) => { + items.insert(key, text); + } + None => { + // Skip a file whose key does not give its name. + if let Ok(saved) = serde_json::from_str::(&text) { + if file_name(&saved.key) == name { + items.insert(saved.key.into_owned(), saved.value.into_owned()); + } + } + } + } + } + Ok(items) + } + + /// Saves `value` under `key`, or removes the key when `value` is `None`. + pub(crate) fn set(&self, key: &str, value: Option<&str>) -> Result<(), String> { + self.write(key, value, false).map(|_| ()) + } + + /// Saves `value` under `key` only if no file holds the key, in one step, so + /// two programs that add the same key at once never replace each other's. + /// Returns what the file already held when it was there. + pub(crate) fn add(&self, key: &str, value: &str) -> Result, String> { + self.write(key, Some(value), true) + } + + fn write( + &self, + key: &str, + value: Option<&str>, + absent: bool, + ) -> Result, String> { + if key.is_empty() { + return Err("Invalid agent state key".into()); + } + let dir = self.dir()?; + let name = file_name(key); + let path = dir.join(&name); + let Some(value) = value else { + return match fs::remove_file(&path) { + Err(error) if error.kind() != ErrorKind::NotFound => { + Err("Could not save agent state".into()) + } + _ => Ok(None), + }; + }; + if value.len() > VALUE_LIMIT { + return Err("Agent state is too large".into()); + } + let hashed; + let contents = if name.starts_with(HASHED) { + hashed = serde_json::to_string(&Hashed { + key: key.into(), + value: value.into(), + }) + .map_err(|_| "Could not save agent state")?; + hashed.as_str() + } else { + value + }; + let saved = (|| { + fs::create_dir_all(dir)?; + let mut file = tempfile::Builder::new() + .suffix(TEMPORARY) + .tempfile_in(dir)?; + std::io::Write::write_all(&mut file, contents.as_bytes())?; + file.as_file().sync_all()?; + if !absent { + file.persist(&path).map_err(|error| error.error)?; + return Ok(None); + } + match file.persist_noclobber(&path) { + Ok(_) => Ok(None), + Err(error) if error.error.kind() == ErrorKind::AlreadyExists => { + let text = fs::read_to_string(&path)?; + if !name.starts_with(HASHED) { + return Ok(Some(text)); + } + let saved = serde_json::from_str::(&text) + .map_err(|error| std::io::Error::new(ErrorKind::InvalidData, error))?; + Ok(Some(saved.value.into_owned())) + } + Err(error) => Err(error.error), + } + })(); + saved.map_err(|_| "Could not save agent state".into()) + } +} + +/// The file `key` is saved in: its encoded name, or `~` and the key's SHA-256 +/// when that name is too long. +pub(crate) fn file_name(key: &str) -> String { + let name = encode(key); + if name.len() <= NAME_LIMIT { + return name; + } + format!("{HASHED}{:x}", Sha256::digest(key.as_bytes())) +} + +/// A file name for `key`: bytes other than `A-Z a-z 0-9 . _ -` become `%XX`. +pub(crate) fn encode(key: &str) -> String { + let mut name = String::with_capacity(key.len()); + for byte in key.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-') { + name.push(byte as char); + } else { + name.push_str(&format!("%{byte:02X}")); + } + } + name +} + +/// The key a file name holds, or `None` for a file that is not one, such as a +/// write in progress. +pub(crate) fn decode(name: &str) -> Option { + if name.is_empty() || name.ends_with(TEMPORARY) || name.starts_with('.') { + return None; + } + let bytes = name.as_bytes(); + let mut key = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' => { + let hex = name.get(index + 1..index + 3)?; + key.push(u8::from_str_radix(hex, 16).ok()?); + index += 3; + } + byte if byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-') => { + key.push(byte); + index += 1; + } + _ => return None, + } + } + String::from_utf8(key).ok() +} + +#[tauri::command] +pub(crate) async fn app_agent_state_load( + state: tauri::State<'_, AgentState>, +) -> Result, String> { + state.load() +} + +/// Saves or removes `value` under `key`. With `absent`, it saves only if no +/// file holds the key, and returns what the file held when one did. +#[tauri::command] +pub(crate) async fn app_agent_state_set( + state: tauri::State<'_, AgentState>, + key: String, + value: Option, + absent: Option, +) -> Result, String> { + match (value, absent.unwrap_or(false)) { + (Some(value), true) => state.add(&key, &value), + (value, _) => state.set(&key, value.as_deref()).map(|_| None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_round_trip() { + for key in [ + "buzz.agents.v1", + "buzz.codex.sessions.v4:wss://relay.example/a:ab", + "a/b c%d", + ] { + let name = encode(key); + assert!(!name.contains('/') && !name.contains(':'), "{name}"); + assert_eq!(decode(&name).as_deref(), Some(key)); + } + assert_eq!(decode("x.tmp"), None); + assert_eq!(decode(".hidden"), None); + assert_eq!(decode("bad%Z"), None); + assert_eq!(decode("has space"), None); + } + + #[test] + fn saves_loads_and_removes() { + let root = tempfile::tempdir().unwrap(); + let state = AgentState::new(Ok(root.path().join("state"))); + assert!(state.load().unwrap().is_empty()); + state.set("one:a", Some("1")).unwrap(); + state.set("two", Some("2")).unwrap(); + state.set("two", Some("22")).unwrap(); + fs::write(root.path().join("state").join("partial.tmp"), "x").unwrap(); + let items = state.load().unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items["one:a"], "1"); + assert_eq!(items["two"], "22"); + state.set("one:a", None).unwrap(); + state.set("missing", None).unwrap(); + assert_eq!(state.load().unwrap().keys().collect::>(), ["two"]); + assert!(state.set("", Some("x")).is_err()); + state.set(&"k".repeat(NAME_LIMIT), Some("x")).unwrap(); + assert!(root + .path() + .join("state") + .join("k".repeat(NAME_LIMIT)) + .exists()); + } + + #[test] + fn adds_a_key_only_where_none_is_saved() { + let root = tempfile::tempdir().unwrap(); + let state = AgentState::new(Ok(root.path().join("state"))); + assert_eq!(state.add("one", "first").unwrap(), None); + // Another program saved it first: it keeps that value, and gets it back. + assert_eq!(state.add("one", "stale").unwrap().as_deref(), Some("first")); + assert_eq!(state.load().unwrap()["one"], "first"); + let long = "k".repeat(NAME_LIMIT + 1); + assert_eq!(state.add(&long, "first").unwrap(), None); + assert_eq!(state.add(&long, "stale").unwrap().as_deref(), Some("first")); + assert_eq!(state.load().unwrap()[&long], "first"); + // No write in progress is left behind. + assert_eq!(fs::read_dir(root.path().join("state")).unwrap().count(), 2); + } + + #[test] + fn saves_keys_whose_names_are_too_long_under_a_hash() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("state"); + let state = AgentState::new(Ok(dir.clone())); + // 251 bytes, as Codex names it for a community on a 95-character host; + // `encode` makes it 263. + let key = format!( + "buzz.codex.sessions.v4:https://agents.production.us-west-2.buzz-workspace.platform.engineering.internal.tools.example.com:{}:{}", + "a".repeat(64), + "b".repeat(64), + ); + assert_eq!(key.len(), 251); + assert!(encode(&key).len() > NAME_LIMIT); + for key in [key.as_str(), &":".repeat(86), &"k".repeat(NAME_LIMIT + 1)] { + state.set(key, Some("{\"a\":1}")).unwrap(); + let name = file_name(key); + assert!(name.starts_with(HASHED) && name.len() == 65, "{name}"); + assert!(dir.join(&name).exists()); + assert_eq!(state.load().unwrap()[key], "{\"a\":1}"); + state.set(key, Some("2")).unwrap(); + assert_eq!(state.load().unwrap()[key], "2"); + state.set(key, None).unwrap(); + assert!(!state.load().unwrap().contains_key(key)); + } + // A hashed file whose key does not give its name is not loaded. + fs::write( + dir.join(format!("{HASHED}{}", "0".repeat(64))), + r#"{"key":"other","value":"x"}"#, + ) + .unwrap(); + fs::write(dir.join(format!("{HASHED}bad")), "not json").unwrap(); + assert!(state.load().unwrap().is_empty()); + } + + #[test] + fn saves_codex_session_keys_of_long_community_hosts() { + let root = tempfile::tempdir().unwrap(); + let state = AgentState::new(Ok(root.path().join("state"))); + // 202 bytes, as Codex names it for a community on a 47-character host. + let key = format!( + "buzz.codex.sessions.v4:https://agents.production.engineering.example.com:{}:{}", + "a".repeat(64), + "b".repeat(64), + ); + assert_eq!(key.len(), 202); + state.set(&key, Some("{}")).unwrap(); + assert_eq!(state.load().unwrap()[&key], "{}"); + } +} diff --git a/src-tauri/src/browser_permissions_tests.rs b/src-tauri/src/browser_permissions_tests.rs index eacf115558..a808f99f36 100644 --- a/src-tauri/src/browser_permissions_tests.rs +++ b/src-tauri/src/browser_permissions_tests.rs @@ -157,6 +157,8 @@ fn native_command_permissions_isolate_webviews() { "jev_classifier_set_key", "jev_classifier_clear_key", "jev_classify", + "app_agent_state_load", + "app_agent_state_set", "title_bar_double_click", "notification_show", #[cfg(target_os = "macos")] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bafccceea3..d33a5c5679 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ use browser::{ browser_action, browser_attach, browser_detach, browser_navigate, browser_set_bounds, browser_status, }; +mod agent_state; mod app_agents; mod deep_links; mod dock; @@ -556,6 +557,8 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen jev::jev_classifier_set_key, jev::jev_classifier_clear_key, jev::jev_classify, + agent_state::app_agent_state_load, + agent_state::app_agent_state_set, title_bar_double_click, notification_show, #[cfg(target_os = "macos")] @@ -685,6 +688,12 @@ pub fn run() { .map_err(|_| "Could not resolve legacy library directory")?; Ok((root, legacy)) })(); + app.manage(agent_state::AgentState::new( + paths + .as_ref() + .map(|(root, _)| root.with_file_name(sign_out::AGENTS).join("state")) + .map_err(Clone::clone), + )); app.manage(app_agents::AppAgentHost::new( paths .as_ref() diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index 75d2ceea34..035260b3b0 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -32,6 +32,7 @@ import type { TeamPort, } from "../../features/agents/service"; import type { OldTeam, PortableAgent } from "../../features/agents/native"; +import { localStore } from "../../features/agents/state"; import type { RelayData } from "../../features/relay/service"; import type { Navigation } from "../../features/navigation/controller"; import type { PageNavigation } from "../../features/navigation/service"; @@ -347,6 +348,7 @@ function setup( }; for (const listener of listeners) listener(); }), + storage: localStore(), } satisfies Agents; const disconnected = { status: "disconnected", generation: 0 }; const relay = { diff --git a/src/bundled/claude-code/defaults.ts b/src/bundled/claude-code/defaults.ts index 095e44698f..b0bea2945a 100644 --- a/src/bundled/claude-code/defaults.ts +++ b/src/bundled/claude-code/defaults.ts @@ -1,3 +1,4 @@ +import { localStore, type AgentStore } from "../../features/agents/store"; import { config, type Config } from "./runtime"; export type ClaudeProviderDefaults = Readonly<{ @@ -13,7 +14,7 @@ export class ClaudeDefaults { private state: ClaudeProviderDefaults; private readonly listeners = new Set<() => void>(); - constructor(private readonly storage: Storage = localStorage) { + constructor(private readonly storage: AgentStore = localStore()) { this.state = this.read(); } @@ -34,9 +35,11 @@ export class ClaudeDefaults { : ""), }; } - save(next: ClaudeProviderDefaults) { + /** Settles once the defaults are saved, and only then uses them; rejects, + * keeping the saved ones, when they could not be saved. */ + async save(next: ClaudeProviderDefaults) { const { model, effort = "" } = next; - this.storage.setItem( + await this.storage.set( STORAGE_KEY, JSON.stringify({ version: 1, model, effort }), ); @@ -46,7 +49,7 @@ export class ClaudeDefaults { private read(): ClaudeProviderDefaults { try { const value: unknown = JSON.parse( - this.storage.getItem(STORAGE_KEY) ?? "null", + this.storage.get(STORAGE_KEY) ?? "null", ); if ( value && diff --git a/src/bundled/claude-code/index.ts b/src/bundled/claude-code/index.ts index 0808755c0d..1850a6ff55 100644 --- a/src/bundled/claude-code/index.ts +++ b/src/bundled/claude-code/index.ts @@ -25,12 +25,12 @@ export const apply: PluginModule["apply"] = (ctx) => { const typeKey = `${ctx.pluginOwner?.id ?? "buzz.claude-code"}/${TYPE_ID}`; const spawn = host.spawn && withScript(host.spawn.bind(host)); const setup = new ClaudeSetup(spawn, (input) => host.request(input)); - const providerDefaults = new ClaudeDefaults(); + const providerDefaults = new ClaudeDefaults(agents.storage); const runtime = new ClaudeRuntime( spawn ? { spawn } : {}, (pubkey) => ownerReads(agents.relay(pubkey), pubkey), (pubkey, signal) => agents.core(pubkey, signal), - localStorage, + agents.storage, (raw) => providerDefaults.resolve(raw), () => agents.tools(), ); diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts index ba97188ac8..5a63ae1b6b 100644 --- a/src/bundled/claude-code/runtime.test.ts +++ b/src/bundled/claude-code/runtime.test.ts @@ -6,6 +6,7 @@ import type { BuzzClient } from "../../buzz-mcp/client"; import { callTool } from "../../buzz-mcp/tools"; import { createAgentMemories } from "../../features/agents/memory"; import { ownerReads } from "../../features/agents/reads"; +import { localStore } from "../../features/agents/state"; import type { Agent, Delivery } from "../../features/agents/service"; import type { Host } from "../../features/host/service"; import type { EventData, RelayEvent } from "../../features/relay/events"; @@ -129,10 +130,11 @@ function setup( snapshot: () => ({ status: "ready", session, viewer: alice }), } as unknown as RelayData; const data = new Map(); - const storage = { + const storage = localStore({ getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => void data.set(key, value), - } as Storage; + removeItem: (key: string) => void data.delete(key), + } as Storage); // The host's copy of `core` memory, which needs no relay read. const core = vi.fn( async ( @@ -258,11 +260,11 @@ function setup( it("names each saved session's channel as the community lists it", () => { const { runtime, data } = setup(); const saved = localSessions( - { + localStore({ getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => void data.set(key, value), removeItem: (key: string) => void data.delete(key), - } as Storage, + } as Storage), self, ); saved.set(channel, { id: "s1", seen: 100, at: 5 }); @@ -1043,7 +1045,7 @@ it("runs a timer as a one-off turn that is never saved", async () => { expect(prompt).toMatch(/\nHandle the work that caused this turn/); // Its process stops once the turn is done, and no session is kept for it. expect(claude?.killed).toBe(true); - expect(data.get("buzz.claude-code.sessions.v1") ?? "{}").toBe("{}"); + expect(data.get(`buzz.claude-code.sessions.v1:${self}`) ?? "{}").toBe("{}"); expect(runtime.sessions(self)).toEqual([]); }); diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index 72c0e303c4..2059bdf676 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -14,6 +14,7 @@ import { } from "../../buzz-mcp/session-tools"; import { fromOwner, workflowOf } from "../../features/agents/owner-attestation"; import type { AgentReads } from "../../features/agents/reads"; +import { localStore, type AgentStore } from "../../features/agents/store"; import type { Agent, AgentHandle, @@ -151,7 +152,7 @@ export class ClaudeRuntime { private readonly reads: (pubkey: string) => AgentReads | undefined, /** The agent's `core` memory as the host keeps it (`Agents.core`). */ private readonly core: Agents["core"], - private readonly storage: Storage = globalThis.localStorage, + private readonly storage: AgentStore = localStore(), private readonly resolveConfig: (raw: unknown) => Config = config, /** The tools plugins add to every session now. */ private readonly added: () => readonly AddedTool[] = () => [], diff --git a/src/bundled/claude-code/sessions.test.ts b/src/bundled/claude-code/sessions.test.ts index 9d16e95dd4..322ea68e8f 100644 --- a/src/bundled/claude-code/sessions.test.ts +++ b/src/bundled/claude-code/sessions.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, it, vi } from "vitest"; +import { fileStore, localStore } from "../../features/agents/state"; import { backgroundTasks, fakeSpawn, @@ -598,12 +599,23 @@ it("starts no spare for a conversation that starts after its agents are gone", a expect(live()).toEqual([]); }); -it("keeps saved sessions per agent in storage", () => { - const data = new Map(); - const storage = { +/** Agent storage over `data`, which saves as it is set. */ +const mapStore = (data: Map) => + localStore({ + get length() { + return data.size; + }, + key: (index: number) => [...data.keys()][index] ?? null, getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => void data.set(key, value), - } as Storage; + removeItem: (key: string) => void data.delete(key), + } as Storage); +/** Waits for the saves already started. */ +const settled = () => new Promise((resolve) => setTimeout(resolve)); + +it("keeps saved sessions per agent in storage", () => { + const data = new Map(); + const storage = mapStore(data); const one = localSessions(storage, "one"); const two = localSessions(storage, "two"); one.set("c/root", { id: "s1", seen: 3, at: 1 }); @@ -613,8 +625,62 @@ it("keeps saved sessions per agent in storage", () => { one.delete("c/root"); expect(one.get("c/root")).toBeUndefined(); expect(two.get("c/root")?.id).toBe("s2"); - data.set("buzz.claude-code.sessions.v1", "not json"); + expect([...data.keys()].sort()).toEqual([ + "buzz.claude-code.sessions.v1:one", + "buzz.claude-code.sessions.v1:two", + ]); + data.set("buzz.claude-code.sessions.v1:one", "not json"); + expect(one.get("c/root")).toBeUndefined(); +}); + +it("reads an agent's sessions from the shared value until it saves its own", async () => { + const data = new Map([ + [ + "buzz.claude-code.sessions.v1", + JSON.stringify({ + one: { "c/root": { id: "s1", seen: 3, at: 1 } }, + two: { "c/root": { id: "s2", seen: 4, at: 2 } }, + }), + ], + ]); + const storage = mapStore(data); + const one = localSessions(storage, "one"); + expect(one.get("c/root")?.id).toBe("s1"); + one.delete("c/root"); expect(one.get("c/root")).toBeUndefined(); + expect(localSessions(storage, "two").get("c/root")?.id).toBe("s2"); + localSessions(storage, "two").set("c/other", { id: "s3", seen: 1, at: 3 }); + await settled(); + expect(data.has("buzz.claude-code.sessions.v1")).toBe(false); + expect(localSessions(storage, "two").get("c/root")?.id).toBe("s2"); +}); + +it("keeps an agent in the shared sessions value until its own is saved", async () => { + const name = "buzz.claude-code.sessions.v1"; + const shared = JSON.stringify({ + one: { "c/root": { id: "s1", seen: 3, at: 1 } }, + }); + const disk = new Map([[name, shared]]); + const save = vi.fn(async (key: string, value: string | null) => { + if (key !== name) throw new Error("disk full"); + if (value === null) disk.delete(key); + else disk.set(key, value); + }); + const storage = fileStore(new Map(disk), save); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + localSessions(storage, "one").set("c/other", { id: "s2", seen: 1, at: 2 }); + await settled(); + error.mockRestore(); + expect(warn).toHaveBeenCalledWith( + "Claude sessions were not saved", + expect.objectContaining({ message: "disk full" }), + ); + warn.mockRestore(); + expect(disk.get(name)).toBe(shared); + // The next start still resumes the saved session. + const restarted = fileStore(new Map(disk), save); + expect(localSessions(restarted, "one").get("c/root")?.id).toBe("s1"); }); it("shares a temporary metadata lookup, releases its process, and refreshes after settings change", async () => { diff --git a/src/bundled/claude-code/sessions.ts b/src/bundled/claude-code/sessions.ts index f4f34c8353..ad8969536e 100644 --- a/src/bundled/claude-code/sessions.ts +++ b/src/bundled/claude-code/sessions.ts @@ -16,6 +16,7 @@ import { type TurnPick, } from "./claude"; import { STANDALONE_DEADLINE_MS } from "../../features/agent-harness/standalone"; +import type { AgentStore } from "../../features/agents/store"; /** A conversation's process stops after this long without a message or its * own activity, such as background work. */ @@ -517,53 +518,78 @@ export class Spare { } } -/** Sessions saved in local storage, by agent and then conversation. */ +/** Where sessions were kept for every agent in one value, before each agent's + * had its own key. */ +export const LEGACY_SESSIONS = "buzz.claude-code.sessions.v1"; + +/** Sessions saved in agent storage, one key per agent, by conversation. Each + * agent's own key lets programs that run different agents share the storage. */ export function localSessions( - storage: Storage, + storage: AgentStore, agent: string, - name = "buzz.claude-code.sessions.v1", + name = LEGACY_SESSIONS, ): SessionStore & { /** Saved sessions, most recently used first. */ list(): [string, SavedSession][]; } { - const readAll = (): Record> => { + const key = `${name}:${agent}`; + const object = (text: string | null) => { try { - const value = JSON.parse(storage.getItem(name) ?? "{}"); + const value = JSON.parse(text ?? "{}"); return value && typeof value === "object" ? value : {}; } catch { return {}; } }; + const read = (): Record => { + const own = storage.get(key); + return own === null + ? (object(storage.get(name))[agent] ?? {}) + : object(own); + }; const update = (change: (sessions: Record) => void) => { - const all = readAll(); - const sessions = { ...(all[agent] ?? {}) }; + const sessions = { ...read() }; change(sessions); const kept = Object.entries(sessions) .sort(([, a], [, b]) => b.at - a.at) .slice(0, SAVED_LIMIT); - all[agent] = Object.fromEntries(kept); - try { - storage.setItem(name, JSON.stringify(all)); - } catch (error) { + const warn = (error: unknown) => console.warn("Claude sessions were not saved", error); + const own = storage.set(key, JSON.stringify(Object.fromEntries(kept))); + // The old shared value drops this agent once its own key is saved: until + // then it holds the only saved copy. + if (!(agent in object(storage.get(name)))) { + void own.catch(warn); + return; } + void own + .then(() => { + const legacy = object(storage.get(name)); + if (!(agent in legacy)) return; + delete legacy[agent]; + return storage.set( + name, + Object.keys(legacy).length ? JSON.stringify(legacy) : null, + ); + }) + .catch(warn); }; return { list: () => - Object.entries(readAll()[agent] ?? {}) + Object.entries(read()) .filter(([, saved]) => typeof saved?.id === "string") .sort(([, a], [, b]) => b.at - a.at), - get: (key) => { - const saved = readAll()[agent]?.[key]; + get: (conversation) => { + const saved = read()[conversation]; return saved && typeof saved.id === "string" ? saved : undefined; }, - set: (key, session) => + set: (conversation, session) => update((sessions) => { - sessions[key] = session; + sessions[conversation] = session; }), - delete: (key) => + delete: (conversation) => update((sessions) => { - delete sessions[key]; + delete sessions[conversation]; }), }; } diff --git a/src/bundled/claude-code/tabs.test.tsx b/src/bundled/claude-code/tabs.test.tsx index 07e22376fd..70e5d00a7a 100644 --- a/src/bundled/claude-code/tabs.test.tsx +++ b/src/bundled/claude-code/tabs.test.tsx @@ -18,6 +18,11 @@ import { StrictMode } from "react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { Agent, AgentType } from "../../features/agents/service"; +import { + fileStore, + localStore, + type AgentStore, +} from "../../features/agents/state"; import type { HostProcessOptions } from "../../features/host/service"; import { ClaudeLogoIcon } from "../../shared/design-system/icons"; import { apply, inject } from "./index"; @@ -108,7 +113,10 @@ function applied({ latest = version, files = {}, catalog = { listed: true }, + storage, }: { + /** Agent storage; this page's localStorage without it. */ + storage?: AgentStore; loggedIn?: boolean; available?: boolean; version?: string; @@ -236,6 +244,7 @@ function applied({ })), }, agents: { + storage, register: (registered: AgentType) => { type = registered; }, @@ -511,11 +520,48 @@ it("edits the model through the Settings dropdown and preserves agent inheritanc expect(save).not.toHaveBeenCalled(); }); -it("preserves native behavior for invalid defaults and keeps explicit agent models", () => { +it("keeps the saved default and shows the error while a default could not be saved", async () => { + let failing = true; + const storage = fileStore(new Map(), async () => { + if (failing) throw new Error("disk full"); + }); + const Control = installed({ storage }).type.provider?.DefaultControl; + if (!Control) throw new Error("Missing default control"); + render(); + const control = await screen.findByRole("button", { + name: /^Claude default model:/, + }); + await waitFor(() => expect(control).toHaveTextContent("Opus 5.5")); + const pick = async () => { + fireEvent.click(control); + fireEvent.click(await screen.findByRole("menuitem", { name: "Claude" })); + fireEvent.click( + await screen.findByRole("menuitemradio", { name: "Fable 5.1" }), + ); + }; + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + await pick(); + expect(await screen.findByRole("alert")).toHaveTextContent("disk full"); + error.mockRestore(); + expect(control).toHaveTextContent("Opus 5.5"); + expect(storage.get("buzz.claude-code.provider-defaults.v1")).toBeNull(); + // The same choice can be saved again. + failing = false; + await pick(); + await waitFor(() => + expect(screen.queryByRole("alert")).not.toBeInTheDocument(), + ); + expect(control).toHaveTextContent("Fable 5.1"); + expect(storage.get("buzz.claude-code.provider-defaults.v1")).toContain( + "fable", + ); +}); + +it("preserves native behavior for invalid defaults and keeps explicit agent models", async () => { localStorage.setItem("buzz.claude-code.provider-defaults.v1", "not json"); - const defaults = new ClaudeDefaults(localStorage); + const defaults = new ClaudeDefaults(localStore()); expect(defaults.resolve(DEFAULT_CONFIG)).toEqual(DEFAULT_CONFIG); - defaults.save({ model: "sonnet" }); + await defaults.save({ model: "sonnet" }); const inherited = { ...DEFAULT_CONFIG }; expect(defaults.resolve(inherited).model).toBe("sonnet"); expect(inherited).toEqual(DEFAULT_CONFIG); @@ -1074,11 +1120,13 @@ it("saves model-specific default effort from the nested menu and inherits it", a await screen.findByRole("menuitemradio", { name: "Sonnet 5.5" }), ); fireEvent.click(await screen.findByRole("menuitemradio", { name: "High" })); - expect(control).toHaveAccessibleName( - "Claude default model: Sonnet 5.5, High", + await waitFor(() => + expect(control).toHaveAccessibleName( + "Claude default model: Sonnet 5.5, High", + ), ); - const reopened = new ClaudeDefaults(localStorage); + const reopened = new ClaudeDefaults(localStore()); expect(reopened.resolve(DEFAULT_CONFIG)).toMatchObject({ model: "sonnet", effort: "high", @@ -1093,7 +1141,7 @@ it("saves model-specific default effort from the nested menu and inherits it", a fireEvent.click( await screen.findByRole("menuitem", { name: "Use Claude default" }), ); - expect(new ClaudeDefaults(localStorage).snapshot().model).toBe(""); + expect(new ClaudeDefaults(localStore()).snapshot().model).toBe(""); }); it("retries failed model discovery from the defaults row", async () => { @@ -1126,7 +1174,7 @@ it("keeps the model inherited when changing the default model's effort", async ( await screen.findByRole("menuitemradio", { name: "Opus 5.5" }), ); fireEvent.click(await screen.findByRole("menuitemradio", { name: "High" })); - expect(new ClaudeDefaults(localStorage).snapshot()).toMatchObject({ + expect(new ClaudeDefaults(localStore()).snapshot()).toMatchObject({ model: "", effort: "high", }); diff --git a/src/bundled/claude-code/tabs.tsx b/src/bundled/claude-code/tabs.tsx index 1cdc34280e..d4d7d463da 100644 --- a/src/bundled/claude-code/tabs.tsx +++ b/src/bundled/claude-code/tabs.tsx @@ -435,12 +435,10 @@ export function createTabs({ effort={saved.effort ?? ""} effortLabel={saved.effort ? effortLabel(saved.effort) : "Automatic"} onEffortChange={(model, effort) => { - try { - defaults.save({ model, effort }); - setError(""); - } catch (reason) { - setError(String(reason)); - } + defaults.save({ model, effort }).then( + () => setError(""), + (reason) => setError(String(reason)), + ); }} value={ saved.model @@ -452,12 +450,10 @@ export function createTabs({ defaultLabel={providerDefaultLabel("", state.models)} onConfigure={onConfigure} onChange={(model) => { - try { - defaults.save({ model }); - setError(""); - } catch (reason) { - setError(String(reason)); - } + defaults.save({ model }).then( + () => setError(""), + (reason) => setError(String(reason)), + ); }} /> diff --git a/src/bundled/codex/index.ts b/src/bundled/codex/index.ts index d212c8b9e2..928d948ebe 100644 --- a/src/bundled/codex/index.ts +++ b/src/bundled/codex/index.ts @@ -22,12 +22,16 @@ export const apply: PluginModule["apply"] = (ctx) => { (async () => { throw new Error("Codex agents run only in the desktop app"); }); - const providerController = new CodexProvider(start, localStorage, setup); + const providerController = new CodexProvider( + start, + ctx.agents.storage, + setup, + ); const runtime = new CodexRuntime( start, (pubkey) => ownerReads(ctx.agents.relay(pubkey), pubkey), (pubkey, signal) => ctx.agents.core(pubkey, signal), - localStorage, + ctx.agents.storage, (raw) => providerController.resolve(raw), () => ctx.agents.tools(), ); diff --git a/src/bundled/codex/live.mjs b/src/bundled/codex/live.mjs index e62d366ad1..72e007720d 100644 --- a/src/bundled/codex/live.mjs +++ b/src/bundled/codex/live.mjs @@ -151,9 +151,21 @@ const events = []; const memories = []; const uploads = []; const storage = new Map(); +// Agent storage (features/agents/store.ts `AgentStore`): each write settles +// once saved, as the app's file-backed store does. const store = { - getItem: (key) => storage.get(key) ?? null, - setItem: (key, value) => storage.set(key, value), + keys: () => [...storage.keys()], + get: (key) => storage.get(key) ?? null, + set: async (key, value) => { + if (value === null) storage.delete(key); + else storage.set(key, value); + }, + add: async (key, value) => { + const held = storage.get(key); + if (held !== undefined) return held; + storage.set(key, value); + return value; + }, }; const scope = "local-acceptance"; const relay = { @@ -341,6 +353,7 @@ try { threadId: saved.threadId, includeTurns: true, }); + // The title, then the conversation's tag (prompt.ts `sessionTag`). assert.match( result.thread.name, /^Buzz #codex-test · thread cccccccc · Codex · buzz:[0-9a-f]{16}$/, diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index fdbb1822a7..f61cd667f3 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -4,6 +4,7 @@ import { bytesToHex } from "nostr-tools/utils"; import { apply } from "./index"; import { describe, afterEach, expect, it, vi } from "vitest"; import { ownerReads } from "../../features/agents/reads"; +import { localStore } from "../../features/agents/state"; import type { Agent, Delivery } from "../../features/agents/service"; import type { RelayData } from "../../features/relay/service"; import type { EventData } from "../../features/relay/events"; @@ -285,12 +286,13 @@ function fixture( }, }; const relay = { snapshot: () => snapshot } as unknown as RelayData; - const store = { + const local = { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => { storage.set(key, value); }, } as Storage; + const store = localStore(local); // The host's copy of `core` memory, which needs no relay read. const core = vi.fn( async ( @@ -422,7 +424,7 @@ function fixture( runtime.run(delivery); let pluginDispose = () => {}; if (plugin) { - vi.stubGlobal("localStorage", store); + vi.stubGlobal("localStorage", local); vi.stubGlobal("navigator", { platform: "MacIntel" }); apply({ host: { spawn }, @@ -1694,7 +1696,10 @@ it("starts no work when the agent stops while its channel is read", async () => ); }, async () => undefined, - { getItem: () => null, setItem: () => {} } as unknown as Storage, + localStore({ + getItem: () => null, + setItem: () => {}, + } as unknown as Storage), ); runtimes.push(runtime); runtime.sync([{ pubkey } as Agent]); diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index c5d49f08b4..75862d9d10 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -1,6 +1,7 @@ import type { WorkOutcome } from "../../features/agents/local-activity"; import { fromOwner, workflowOf } from "../../features/agents/owner-attestation"; import type { AgentReads } from "../../features/agents/reads"; +import { localStore, type AgentStore } from "../../features/agents/store"; import type { Agent, AgentHandle, @@ -185,7 +186,7 @@ export class CodexRuntime { private readonly reads: (pubkey: string) => AgentReads | undefined, /** The agent's `core` memory as the host keeps it (`Agents.core`). */ private readonly core: Agents["core"], - private readonly storage: Storage = localStorage, + private readonly storage: AgentStore = localStore(), private readonly resolveConfig: (raw: unknown) => Config = config, /** The tools plugins add to every thread now. */ private readonly added: () => readonly AddedTool[] = () => [], @@ -385,9 +386,7 @@ export class CodexRuntime { } private load(storageKey: string) { try { - const stored: unknown = JSON.parse( - this.storage.getItem(storageKey) ?? "{}", - ); + const stored: unknown = JSON.parse(this.storage.get(storageKey) ?? "{}"); if (stored && typeof stored === "object" && !Array.isArray(stored)) return Object.fromEntries( Object.entries(stored).filter( @@ -471,16 +470,16 @@ export class CodexRuntime { entry.saved[key] = saved; // Retain a bounded set of bindings; this never deletes Codex's own history. entry.saved = Object.fromEntries(Object.entries(entry.saved).slice(-200)); - try { - this.storage.setItem(entry.storageKey, JSON.stringify(entry.saved)); - } catch (error) { - this.status( - request.agent.pubkey, - key, - "Session could not be saved", - message(error), + void this.storage + .set(entry.storageKey, JSON.stringify(entry.saved)) + .catch((error: unknown) => + this.status( + request.agent.pubkey, + key, + "Session could not be saved", + message(error), + ), ); - } } /** Dates the binding now, when it takes in a message or ends a turn. */ private touch(request: Request, entry: Entry, key: string) { diff --git a/src/bundled/codex/tabs.test.tsx b/src/bundled/codex/tabs.test.tsx index ada3eea018..7d7f23e00d 100644 --- a/src/bundled/codex/tabs.test.tsx +++ b/src/bundled/codex/tabs.test.tsx @@ -18,6 +18,11 @@ import { StrictMode } from "react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { ownerReads } from "../../features/agents/reads"; +import { + fileStore, + localStore, + type AgentStore, +} from "../../features/agents/state"; import type { AgentViewProps } from "../../features/agents/service"; import type { HostProcessOptions } from "../../features/host/service"; import type { RelayData } from "../../features/relay/service"; @@ -58,7 +63,10 @@ function fixture({ turns = () => ({ result: { data: [] } }), threads = [], configured = {}, + storage = localStore(), }: { + /** Agent storage, which keeps the provider defaults. */ + storage?: AgentStore; signedIn?: boolean; installed?: boolean; saved?: Record; @@ -165,7 +173,7 @@ function fixture({ return process; }); const setup = new CodexSetup(spawn); - const provider = new CodexProvider(spawn, localStorage, setup); + const provider = new CodexProvider(spawn, storage, setup); const relay = { snapshot: () => ({ status: "ready", @@ -176,10 +184,10 @@ function fixture({ }, }), } as unknown as RelayData; - const sessions = { + const sessions = localStore({ getItem: () => JSON.stringify(saved), setItem: () => undefined, - } as unknown as Storage; + } as unknown as Storage); const runtime = new CodexRuntime( spawn, (pubkey) => ownerReads(relay, pubkey), @@ -444,6 +452,7 @@ it("keeps provider readiness quiet and shows recovery when needed", async () => await user.click(screen.getByLabelText("Default thinking")); await user.click(await screen.findByRole("option", { name: "High" })); fireEvent.click(screen.getByRole("button", { name: "Save defaults" })); + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()); expect(f.provider.resolve(defaults)).toMatchObject({ model: "", effort: "high", @@ -475,10 +484,63 @@ it("keeps provider readiness quiet and shows recovery when needed", async () => expect(screen.getByRole("button", { name: "Sign in" })).toBeVisible(); expect(screen.queryByText("Connection")).not.toBeInTheDocument(); }); +it("keeps the provider dialog and its draft open while defaults could not be saved", async () => { + let failing = true; + const storage = fileStore(new Map(), async () => { + if (failing) throw new Error("disk full"); + }); + const f = fixture({ storage }); + const onClose = vi.fn(); + render(); + await waitFor(() => + expect(screen.getByLabelText("Default thinking")).toBeEnabled(), + ); + const user = userEvent.setup(); + await user.click(screen.getByLabelText("Default thinking")); + await user.click(await screen.findByRole("option", { name: "High" })); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + fireEvent.click(screen.getByRole("button", { name: "Save defaults" })); + expect(await screen.findByRole("alert")).toHaveTextContent("disk full"); + error.mockRestore(); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Default thinking")).toHaveTextContent("High"); + // Nothing inherits defaults that were not saved. + expect(f.provider.snapshot().defaults.effort).toBe(""); + failing = false; + fireEvent.click(screen.getByRole("button", { name: "Save defaults" })); + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()); + expect(f.provider.resolve(defaults).effort).toBe("high"); + expect(storage.get("buzz.codex.provider-defaults.v1")).toContain("high"); +}); + +it("holds the default thinking while it saves, so the dialog closes on what was saved", async () => { + let release = () => {}; + const storage = fileStore( + new Map(), + () => new Promise((resolve) => (release = resolve)), + ); + const f = fixture({ storage }); + const onClose = vi.fn(); + render(); + await waitFor(() => + expect(screen.getByLabelText("Default thinking")).toBeEnabled(), + ); + const user = userEvent.setup(); + await user.click(screen.getByLabelText("Default thinking")); + await user.click(await screen.findByRole("option", { name: "High" })); + fireEvent.click(screen.getByRole("button", { name: "Save defaults" })); + await waitFor(() => + expect(screen.getByLabelText("Default thinking")).toBeDisabled(), + ); + release(); + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()); + expect(f.provider.resolve(defaults).effort).toBe("high"); +}); + it("applies provider precedence without rewriting records and falls back for incompatible thinking", async () => { const f = fixture(); await f.provider.check(); - f.provider.saveDefaults({ model: "model-a", effort: "high" }); + await f.provider.saveDefaults({ model: "model-a", effort: "high" }); const inherited = { ...defaults }; expect(f.provider.resolve(inherited)).toMatchObject({ model: "model-a", @@ -493,7 +555,7 @@ it("applies provider precedence without rewriting records and falls back for inc f.provider.resolve({ ...defaults, model: "model-b", effort: "high" }), ).toMatchObject({ model: "model-b", effort: "high" }); - f.provider.saveDefaults({ model: "model-b", effort: "medium" }); + await f.provider.saveDefaults({ model: "model-b", effort: "medium" }); render(); await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toHaveTextContent( @@ -508,7 +570,7 @@ it("applies provider precedence without rewriting records and falls back for inc cleanup(); localStorage.setItem("buzz.codex.provider-defaults.v1", "invalid"); - const invalid = new CodexProvider(f.spawn, localStorage); + const invalid = new CodexProvider(f.spawn, localStore()); expect(invalid.resolve(defaults)).toEqual(defaults); }); it("selects a model in the Settings dropdown without changing local Codex preferences", async () => { @@ -527,14 +589,14 @@ it("selects a model in the Settings dropdown without changing local Codex prefer ); fireEvent.click(await screen.findByRole("menuitemradio", { name: "Medium" })); expect( - new CodexProvider(f.spawn, localStorage).resolve(defaults), + new CodexProvider(f.spawn, localStore()).resolve(defaults), ).toMatchObject({ model: "model-b", effort: "medium" }); - expect(control).toHaveTextContent("Medium"); + await waitFor(() => expect(control).toHaveTextContent("Medium")); fireEvent.click(control); fireEvent.click( await screen.findByRole("menuitem", { name: "Use Codex recommended" }), ); - expect(f.provider.snapshot().defaults.model).toBe(""); + await waitFor(() => expect(f.provider.snapshot().defaults.model).toBe("")); expect(configured).toEqual({ model: "model-b", @@ -633,7 +695,7 @@ it("shows a provider model recheck inside a focusable inactive picker", async () it("keeps model-default provider thinking dynamic across save and reopen", async () => { const f = fixture(); await f.provider.check(); - f.provider.saveDefaults({ model: "", effort: "high" }); + await f.provider.saveDefaults({ model: "", effort: "high" }); const onClose = vi.fn(); const view = render(); const user = userEvent.setup(); @@ -1141,7 +1203,7 @@ it("moves the card from Install Codex to Sign in once the install succeeds", asy it("installs and signs in Buzz's copy of Codex from its provider settings", async () => { const f = fixture({ installed: false, signedIn: false }); - const provider = new CodexProvider(f.spawn, localStorage, f.setup); + const provider = new CodexProvider(f.spawn, localStore(), f.setup); const { ProviderDialogBody } = createTabs( provider, f.runtime, @@ -1304,8 +1366,10 @@ it("keeps the recommended model inherited when only effort changes", async () => await screen.findByRole("menuitemradio", { name: "Model A" }), ); fireEvent.click(await screen.findByRole("menuitemradio", { name: "High" })); - expect(f.provider.snapshot().defaults).toMatchObject({ - model: "", - effort: "high", - }); + await waitFor(() => + expect(f.provider.snapshot().defaults).toMatchObject({ + model: "", + effort: "high", + }), + ); }); diff --git a/src/bundled/codex/tabs.tsx b/src/bundled/codex/tabs.tsx index 0a87980c6f..15c1d71131 100644 --- a/src/bundled/codex/tabs.tsx +++ b/src/bundled/codex/tabs.tsx @@ -24,6 +24,7 @@ import type { AgentProviderSnapshot, AgentViewProps, } from "../../features/agents/service"; +import { localStore, type AgentStore } from "../../features/agents/store"; import { PanelSubview } from "../../features/panels/PanelSubview"; import { Button } from "../../shared/design-system/ui/Button"; import { Field } from "../../shared/design-system/ui/Field"; @@ -102,7 +103,7 @@ export class CodexProvider { constructor( private readonly spawn: Spawn, - private readonly storage: Storage = localStorage, + private readonly storage: AgentStore = localStore(), private readonly setup?: CodexSetup, ) { this.state = Object.freeze({ @@ -187,7 +188,9 @@ export class CodexProvider { if (this.abort === abort) this.abort = undefined; } } - saveDefaults(next: ProviderDefaults) { + /** Settles once the defaults are saved, and only then uses them; rejects, + * keeping the saved ones, when they could not be saved. */ + async saveDefaults(next: ProviderDefaults) { const selected = this.model(next.model); const effort = !next.effort || @@ -197,7 +200,10 @@ export class CodexProvider { ? next.effort : (selected?.defaultReasoningEffort ?? ""); const defaults = Object.freeze({ model: next.model, effort }); - this.writeDefaults(defaults); + await this.storage.set( + STORAGE_KEY, + JSON.stringify({ version: 1, ...defaults }), + ); this.set({ defaults }); } resolve(raw: unknown): Config { @@ -230,16 +236,10 @@ export class CodexProvider { private model(model: string) { return catalogModel(model, this.state.data); } - private writeDefaults(defaults: ProviderDefaults) { - this.storage.setItem( - STORAGE_KEY, - JSON.stringify({ version: 1, ...defaults }), - ); - } private readDefaults(): ProviderDefaults { try { const value: unknown = JSON.parse( - this.storage.getItem(STORAGE_KEY) ?? "null", + this.storage.get(STORAGE_KEY) ?? "null", ); if ( value && @@ -441,24 +441,20 @@ export function createTabs( : "Automatic" } onEffortChange={(model, effort) => { - try { - provider.saveDefaults({ model, effort }); - setError(""); - } catch (reason) { - setError(String(reason)); - } + provider.saveDefaults({ model, effort }).then( + () => setError(""), + (reason) => setError(String(reason)), + ); }} value={state.defaults.model} defaultModel={catalogModel("", state.data)?.model} defaultLabel={providerDefaultLabel("", state.data)} onConfigure={onConfigure} onChange={(model) => { - try { - provider.saveDefaults({ model, effort: "" }); - setError(""); - } catch (reason) { - setError(String(reason)); - } + provider.saveDefaults({ model, effort: "" }).then( + () => setError(""), + (reason) => setError(String(reason)), + ); }} /> {error && {error}} @@ -477,6 +473,7 @@ export function createTabs( ); const [draft, setDraft] = useState(state.defaults); const [saveError, setSaveError] = useState(""); + const [savingDefaults, setSavingDefaults] = useState(false); const action = error === NOT_INSTALLED ? ({ step: "install", label: "Install Codex" } as const) @@ -555,14 +552,14 @@ export function createTabs( hidden={state.readiness !== "ready"} onSubmit={(event) => { event.preventDefault(); + if (savingDefaults) return; setSaveError(""); - try { - provider.saveDefaults(draft); - } catch (reason) { - setSaveError(message(reason)); - return; - } - onClose(); + setSavingDefaults(true); + // Closes only once saved; else the draft stays to try again. + provider + .saveDefaults(draft) + .then(onClose, (reason) => setSaveError(message(reason))) + .finally(() => setSavingDefaults(false)); }} >