From 5108d1eda3862309d3aff17739d12eb83efe3d29 Mon Sep 17 00:00:00 2001 From: Shubham Naik Date: Fri, 31 Oct 2025 11:54:39 -0700 Subject: [PATCH] Refactor settings manager (#41) Co-authored-by: Shubham Naik --- .github/workflows/ci.yml | 19 +- src/agent/client.ts | 6 +- src/agent/create.ts | 26 +- src/cli/App.tsx | 4 +- src/headless.ts | 20 +- src/index.ts | 41 ++- src/settings-manager.ts | 385 ++++++++++++++++++++++ src/tests/settings-manager.test.ts | 512 +++++++++++++++++++++++++++++ 8 files changed, 969 insertions(+), 44 deletions(-) create mode 100644 src/settings-manager.ts create mode 100644 src/tests/settings-manager.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e99d711..68098c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,8 +60,25 @@ jobs: - name: CLI version smoke test run: ./letta.js --version || true + - name: Bundle size check + run: | + if [ "$RUNNER_OS" = "Windows" ]; then + SIZE=$(powershell -command "(Get-Item letta.js).length") + else + SIZE=$(stat -f%z ./letta.js 2>/dev/null || stat -c%s ./letta.js 2>/dev/null) + fi + SIZE_MB=$((SIZE / 1024 / 1024)) + echo "Bundle size: $SIZE bytes (~${SIZE_MB}MB)" + # Warn if bundle is larger than 50MB + if [ $SIZE -gt 52428800 ]; then + echo "⚠️ Warning: Bundle size is larger than 50MB" + else + echo "✓ Bundle size is acceptable" + fi + - name: Headless smoke test (API) - if: ${{ github.event_name == 'push' }} + # Only run on push to main or PRs from the same repo (not forks, to protect secrets) + if: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }} env: LETTA_API_KEY: ${{ secrets.LETTA_API_KEY }} run: ./letta.js --prompt "ping" --tools "" --permission-mode plan diff --git a/src/agent/client.ts b/src/agent/client.ts index c1094bb..c0cbcda 100644 --- a/src/agent/client.ts +++ b/src/agent/client.ts @@ -1,8 +1,8 @@ import Letta from "@letta-ai/letta-client"; -import { loadSettings, updateSettings } from "../settings"; +import { settingsManager } from "../settings-manager"; export async function getClient() { - const settings = await loadSettings(); + const settings = settingsManager.getSettings(); const apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY; if (!apiKey) { @@ -34,7 +34,7 @@ export async function getClient() { } if (needsUpdate) { - await updateSettings({ env: updatedEnv }); + settingsManager.updateSettings({ env: updatedEnv }); } return new Letta({ apiKey, baseURL }); diff --git a/src/agent/create.ts b/src/agent/create.ts index f3d44eb..a592a66 100644 --- a/src/agent/create.ts +++ b/src/agent/create.ts @@ -7,11 +7,7 @@ import type { Block, CreateBlock, } from "@letta-ai/letta-client/resources/agents/agents"; -import { - loadProjectSettings, - updateProjectSettings, -} from "../project-settings"; -import { loadSettings, updateSettings } from "../settings"; +import { settingsManager } from "../settings-manager"; import { getToolNames } from "../tools/manager"; import { getClient } from "./client"; import { getDefaultMemoryBlocks } from "./memory"; @@ -56,11 +52,12 @@ export async function createAgent( const defaultMemoryBlocks = await getDefaultMemoryBlocks(); // Load global shared memory blocks from user settings - const settings = await loadSettings(); + const settings = settingsManager.getSettings(); const globalSharedBlockIds = settings.globalSharedBlockIds; // Load project-local shared blocks from project settings - const projectSettings = await loadProjectSettings(); + await settingsManager.loadProjectSettings(); + const projectSettings = settingsManager.getProjectSettings(); const localSharedBlockIds = projectSettings.localSharedBlockIds; // Retrieve existing blocks (both global and local) and match them with defaults @@ -136,7 +133,7 @@ export async function createAgent( // Save newly created global block IDs to user settings if (Object.keys(newGlobalBlockIds).length > 0) { - await updateSettings({ + settingsManager.updateSettings({ globalSharedBlockIds: { ...globalSharedBlockIds, ...newGlobalBlockIds, @@ -146,12 +143,15 @@ export async function createAgent( // Save newly created local block IDs to project settings if (Object.keys(newLocalBlockIds).length > 0) { - await updateProjectSettings(process.cwd(), { - localSharedBlockIds: { - ...localSharedBlockIds, - ...newLocalBlockIds, + settingsManager.updateProjectSettings( + { + localSharedBlockIds: { + ...localSharedBlockIds, + ...newLocalBlockIds, + }, }, - }); + process.cwd(), + ); } // Create agent with all block IDs (existing + newly created) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 9520e7c..4cb924c 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -638,8 +638,8 @@ export default function App({ setTokenStreamingEnabled(newValue); // Save to settings - const { updateSettings } = await import("../settings"); - await updateSettings({ tokenStreaming: newValue }); + const { settingsManager } = await import("../settings-manager"); + settingsManager.updateSettings({ tokenStreaming: newValue }); // Update the same command with final result buffersRef.current.byId.set(cmdId, { diff --git a/src/headless.ts b/src/headless.ts index b3d77c3..8047f12 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -13,11 +13,11 @@ import { SessionStats } from "./agent/stats"; import { createBuffers, toLines } from "./cli/helpers/accumulator"; import { safeJsonParseOr } from "./cli/helpers/safeJsonParse"; import { drainStream } from "./cli/helpers/stream"; -import { loadSettings, updateSettings } from "./settings"; +import { settingsManager } from "./settings-manager"; import { checkToolPermission, executeTool } from "./tools/manager"; export async function handleHeadlessCommand(argv: string[], model?: string) { - const settings = await loadSettings(); + const settings = settingsManager.getSettings(); // Parse CLI args const { values, positionals } = parseArgs({ @@ -79,14 +79,14 @@ export async function handleHeadlessCommand(argv: string[], model?: string) { // Priority 3: Try to resume from project settings (.letta/settings.local.json) if (!agent) { - const { loadProjectSettings } = await import("./settings"); - const projectSettings = await loadProjectSettings(); - if (projectSettings?.lastAgent) { + await settingsManager.loadLocalProjectSettings(); + const localProjectSettings = settingsManager.getLocalProjectSettings(); + if (localProjectSettings?.lastAgent) { try { - agent = await client.agents.retrieve(projectSettings.lastAgent); + agent = await client.agents.retrieve(localProjectSettings.lastAgent); } catch (_error) { console.error( - `Project agent ${projectSettings.lastAgent} not found, creating new one...`, + `Project agent ${localProjectSettings.lastAgent} not found, creating new one...`, ); } } @@ -110,9 +110,9 @@ export async function handleHeadlessCommand(argv: string[], model?: string) { } // Save agent ID to both project and global settings - const { updateProjectSettings } = await import("./settings"); - await updateProjectSettings({ lastAgent: agent.id }); - await updateSettings({ lastAgent: agent.id }); + await settingsManager.loadLocalProjectSettings(); + settingsManager.updateLocalProjectSettings({ lastAgent: agent.id }); + settingsManager.updateSettings({ lastAgent: agent.id }); // Validate output format const outputFormat = diff --git a/src/index.ts b/src/index.ts index 2e0be05..0f3cee4 100755 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents" import { getResumeData, type ResumeData } from "./agent/check-approval"; import { getClient } from "./agent/client"; import { permissionMode } from "./permissions/mode"; -import { loadSettings } from "./settings"; +import { settingsManager } from "./settings-manager"; import { loadTools, upsertToolsToServer } from "./tools/manager"; function printHelp() { @@ -52,8 +52,17 @@ EXAMPLES } async function main() { - // Load settings first (creates default settings file if it doesn't exist) - const settings = await loadSettings(); + // Initialize settings manager (loads settings once into memory) + await settingsManager.initialize(); + const settings = settingsManager.getSettings(); + + // set LETTA_API_KEY from environment if available + if (process.env.LETTA_API_KEY && !settings.env?.LETTA_API_KEY) { + settings.env = settings.env || {}; + settings.env.LETTA_API_KEY = process.env.LETTA_API_KEY; + + settingsManager.updateSettings({ env: settings.env }); + } // Parse command-line arguments (Bun-idiomatic approach using parseArgs) let values: Record; @@ -219,8 +228,6 @@ async function main() { setLoadingState("initializing"); const { createAgent } = await import("./agent/create"); const { getModelUpdateArgs } = await import("./agent/model"); - const { updateSettings, loadProjectSettings, updateProjectSettings } = - await import("./settings"); let agent: AgentState | null = null; @@ -245,14 +252,18 @@ async function main() { // Priority 3: Try to resume from project settings (.letta/settings.local.json) if (!agent) { - const projectSettings = await loadProjectSettings(); - if (projectSettings?.lastAgent) { + await settingsManager.loadLocalProjectSettings(); + const localProjectSettings = + settingsManager.getLocalProjectSettings(); + if (localProjectSettings?.lastAgent) { try { - agent = await client.agents.retrieve(projectSettings.lastAgent); - // console.log(`Resuming project agent ${projectSettings.lastAgent}...`); + agent = await client.agents.retrieve( + localProjectSettings.lastAgent, + ); + // console.log(`Resuming project agent ${localProjectSettings.lastAgent}...`); } catch (error) { console.error( - `Project agent ${projectSettings.lastAgent} not found (error: ${JSON.stringify(error)}), creating new one...`, + `Project agent ${localProjectSettings.lastAgent} not found (error: ${JSON.stringify(error)}), creating new one...`, ); } } @@ -277,15 +288,15 @@ async function main() { } // Save agent ID to both project and global settings - await updateProjectSettings({ lastAgent: agent.id }); - await updateSettings({ lastAgent: agent.id }); + settingsManager.updateLocalProjectSettings({ lastAgent: agent.id }); + settingsManager.updateSettings({ lastAgent: agent.id }); // Check if we're resuming an existing agent - const projectSettings = await loadProjectSettings(); + const localProjectSettings = settingsManager.getLocalProjectSettings(); const isResumingProject = !forceNew && - projectSettings?.lastAgent && - agent.id === projectSettings.lastAgent; + localProjectSettings?.lastAgent && + agent.id === localProjectSettings.lastAgent; const resuming = continueSession || !!agentIdArg || isResumingProject; setIsResumingSession(resuming); diff --git a/src/settings-manager.ts b/src/settings-manager.ts new file mode 100644 index 0000000..c9278ea --- /dev/null +++ b/src/settings-manager.ts @@ -0,0 +1,385 @@ +// src/settings-manager.ts +// In-memory settings manager that loads once and provides sync access + +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { PermissionRules } from "./permissions/types"; +import { exists, mkdir, readFile, writeFile } from "./utils/fs.js"; + +export type UIMode = "simple" | "rich"; + +export interface Settings { + uiMode: UIMode; + lastAgent: string | null; + tokenStreaming: boolean; + globalSharedBlockIds: Record; + permissions?: PermissionRules; + env?: Record; +} + +export interface ProjectSettings { + localSharedBlockIds: Record; +} + +export interface LocalProjectSettings { + lastAgent: string | null; + permissions?: PermissionRules; +} + +const DEFAULT_SETTINGS: Settings = { + uiMode: "simple", + lastAgent: null, + tokenStreaming: false, + globalSharedBlockIds: {}, +}; + +const DEFAULT_PROJECT_SETTINGS: ProjectSettings = { + localSharedBlockIds: {}, +}; + +const DEFAULT_LOCAL_PROJECT_SETTINGS: LocalProjectSettings = { + lastAgent: null, +}; + +class SettingsManager { + private settings: Settings | null = null; + private projectSettings: Map = new Map(); + private localProjectSettings: Map = new Map(); + private initialized = false; + + /** + * Initialize the settings manager (loads from disk) + * Should be called once at app startup + */ + async initialize(): Promise { + if (this.initialized) return; + + const settingsPath = this.getSettingsPath(); + + try { + // Check if settings file exists + if (!exists(settingsPath)) { + // Create default settings file + this.settings = { ...DEFAULT_SETTINGS }; + await this.persistSettings(); + } else { + // Read and parse settings + const content = await readFile(settingsPath); + const loadedSettings = JSON.parse(content) as Settings; + // Merge with defaults in case new fields were added + this.settings = { ...DEFAULT_SETTINGS, ...loadedSettings }; + } + + this.initialized = true; + } catch (error) { + console.error("Error loading settings, using defaults:", error); + this.settings = { ...DEFAULT_SETTINGS }; + this.initialized = true; + } + } + + /** + * Get all settings (synchronous, from memory) + */ + getSettings(): Settings { + if (!this.initialized || !this.settings) { + throw new Error( + "Settings not initialized. Call settingsManager.initialize() first.", + ); + } + return { ...this.settings }; + } + + /** + * Get a specific setting value (synchronous) + */ + getSetting(key: K): Settings[K] { + return this.getSettings()[key]; + } + + /** + * Update settings (synchronous in-memory, async persist) + */ + updateSettings(updates: Partial): void { + if (!this.initialized || !this.settings) { + throw new Error( + "Settings not initialized. Call settingsManager.initialize() first.", + ); + } + + this.settings = { ...this.settings, ...updates }; + + // Persist asynchronously (fire and forget, errors logged) + this.persistSettings().catch((error) => { + console.error("Failed to persist settings:", error); + }); + } + + /** + * Load project settings for a specific directory + */ + async loadProjectSettings( + workingDirectory: string = process.cwd(), + ): Promise { + // Check cache first + const cached = this.projectSettings.get(workingDirectory); + if (cached) { + return { ...cached }; + } + + const settingsPath = this.getProjectSettingsPath(workingDirectory); + + try { + if (!exists(settingsPath)) { + const defaults = { ...DEFAULT_PROJECT_SETTINGS }; + this.projectSettings.set(workingDirectory, defaults); + return defaults; + } + + const content = await readFile(settingsPath); + const rawSettings = JSON.parse(content) as Record; + + const projectSettings: ProjectSettings = { + localSharedBlockIds: + (rawSettings.localSharedBlockIds as Record) ?? {}, + }; + + this.projectSettings.set(workingDirectory, projectSettings); + return { ...projectSettings }; + } catch (error) { + console.error("Error loading project settings, using defaults:", error); + const defaults = { ...DEFAULT_PROJECT_SETTINGS }; + this.projectSettings.set(workingDirectory, defaults); + return defaults; + } + } + + /** + * Get project settings (synchronous, from memory) + */ + getProjectSettings( + workingDirectory: string = process.cwd(), + ): ProjectSettings { + const cached = this.projectSettings.get(workingDirectory); + if (!cached) { + throw new Error( + `Project settings for ${workingDirectory} not loaded. Call loadProjectSettings() first.`, + ); + } + return { ...cached }; + } + + /** + * Update project settings (synchronous in-memory, async persist) + */ + updateProjectSettings( + updates: Partial, + workingDirectory: string = process.cwd(), + ): void { + const current = this.projectSettings.get(workingDirectory); + if (!current) { + throw new Error( + `Project settings for ${workingDirectory} not loaded. Call loadProjectSettings() first.`, + ); + } + + const updated = { ...current, ...updates }; + this.projectSettings.set(workingDirectory, updated); + + // Persist asynchronously + this.persistProjectSettings(workingDirectory).catch((error) => { + console.error("Failed to persist project settings:", error); + }); + } + + /** + * Persist settings to disk (private helper) + */ + private async persistSettings(): Promise { + if (!this.settings) return; + + const settingsPath = this.getSettingsPath(); + const dirPath = join(homedir(), ".letta"); + + try { + if (!exists(dirPath)) { + await mkdir(dirPath, { recursive: true }); + } + await writeFile(settingsPath, JSON.stringify(this.settings, null, 2)); + } catch (error) { + console.error("Error saving settings:", error); + throw error; + } + } + + /** + * Persist project settings to disk (private helper) + */ + private async persistProjectSettings( + workingDirectory: string, + ): Promise { + const settings = this.projectSettings.get(workingDirectory); + if (!settings) return; + + const settingsPath = this.getProjectSettingsPath(workingDirectory); + const dirPath = join(workingDirectory, ".letta"); + + try { + // Read existing settings (might have permissions, etc.) + let existingSettings: Record = {}; + if (exists(settingsPath)) { + const content = await readFile(settingsPath); + existingSettings = JSON.parse(content) as Record; + } + + // Create directory if needed + if (!exists(dirPath)) { + await mkdir(dirPath, { recursive: true }); + } + + // Merge updates with existing settings + const newSettings = { + ...existingSettings, + ...settings, + }; + + await writeFile(settingsPath, JSON.stringify(newSettings, null, 2)); + } catch (error) { + console.error("Error saving project settings:", error); + throw error; + } + } + + private getSettingsPath(): string { + return join(homedir(), ".letta", "settings.json"); + } + + private getProjectSettingsPath(workingDirectory: string): string { + return join(workingDirectory, ".letta", "settings.json"); + } + + private getLocalProjectSettingsPath(workingDirectory: string): string { + return join(workingDirectory, ".letta", "settings.local.json"); + } + + /** + * Load local project settings (.letta/settings.local.json) + */ + async loadLocalProjectSettings( + workingDirectory: string = process.cwd(), + ): Promise { + // Check cache first + const cached = this.localProjectSettings.get(workingDirectory); + if (cached) { + return { ...cached }; + } + + const settingsPath = this.getLocalProjectSettingsPath(workingDirectory); + + try { + if (!exists(settingsPath)) { + const defaults = { ...DEFAULT_LOCAL_PROJECT_SETTINGS }; + this.localProjectSettings.set(workingDirectory, defaults); + return defaults; + } + + const content = await readFile(settingsPath); + const localSettings = JSON.parse(content) as LocalProjectSettings; + + this.localProjectSettings.set(workingDirectory, localSettings); + return { ...localSettings }; + } catch (error) { + console.error( + "Error loading local project settings, using defaults:", + error, + ); + const defaults = { ...DEFAULT_LOCAL_PROJECT_SETTINGS }; + this.localProjectSettings.set(workingDirectory, defaults); + return defaults; + } + } + + /** + * Get local project settings (synchronous, from memory) + */ + getLocalProjectSettings( + workingDirectory: string = process.cwd(), + ): LocalProjectSettings { + const cached = this.localProjectSettings.get(workingDirectory); + if (!cached) { + throw new Error( + `Local project settings for ${workingDirectory} not loaded. Call loadLocalProjectSettings() first.`, + ); + } + return { ...cached }; + } + + /** + * Update local project settings (synchronous in-memory, async persist) + */ + updateLocalProjectSettings( + updates: Partial, + workingDirectory: string = process.cwd(), + ): void { + const current = this.localProjectSettings.get(workingDirectory); + if (!current) { + throw new Error( + `Local project settings for ${workingDirectory} not loaded. Call loadLocalProjectSettings() first.`, + ); + } + + const updated = { ...current, ...updates }; + this.localProjectSettings.set(workingDirectory, updated); + + // Persist asynchronously + this.persistLocalProjectSettings(workingDirectory).catch((error) => { + console.error("Failed to persist local project settings:", error); + }); + } + + /** + * Persist local project settings to disk (private helper) + */ + private async persistLocalProjectSettings( + workingDirectory: string, + ): Promise { + const settings = this.localProjectSettings.get(workingDirectory); + if (!settings) return; + + const settingsPath = this.getLocalProjectSettingsPath(workingDirectory); + const dirPath = join(workingDirectory, ".letta"); + + try { + // Create directory if needed + if (!exists(dirPath)) { + await mkdir(dirPath, { recursive: true }); + } + + await writeFile(settingsPath, JSON.stringify(settings, null, 2)); + } catch (error) { + console.error("Error saving local project settings:", error); + throw error; + } + } + + /** + * Reset the manager (mainly for testing) + */ + reset(): void { + this.settings = null; + this.projectSettings.clear(); + this.localProjectSettings.clear(); + this.initialized = false; + } +} + +// Singleton instance - use globalThis to ensure only one instance across the entire bundle +declare global { + var __lettaSettingsManager: SettingsManager | undefined; +} + +if (!globalThis.__lettaSettingsManager) { + globalThis.__lettaSettingsManager = new SettingsManager(); +} + +export const settingsManager = globalThis.__lettaSettingsManager; diff --git a/src/tests/settings-manager.test.ts b/src/tests/settings-manager.test.ts new file mode 100644 index 0000000..8c7ee22 --- /dev/null +++ b/src/tests/settings-manager.test.ts @@ -0,0 +1,512 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { settingsManager } from "../settings-manager"; + +// Store original HOME to restore after tests +const originalHome = process.env.HOME; +let testHomeDir: string; +let testProjectDir: string; + +beforeEach(async () => { + // Reset settings manager FIRST before changing HOME + settingsManager.reset(); + + // Create temporary directories for testing + testHomeDir = await mkdtemp(join(tmpdir(), "letta-test-home-")); + testProjectDir = await mkdtemp(join(tmpdir(), "letta-test-project-")); + + // Override HOME for tests (must be done BEFORE initialize is called) + process.env.HOME = testHomeDir; +}); + +afterEach(async () => { + // Clean up test directories + await rm(testHomeDir, { recursive: true, force: true }); + await rm(testProjectDir, { recursive: true, force: true }); + + // Restore original HOME + process.env.HOME = originalHome; + + // Reset settings manager after each test + settingsManager.reset(); +}); + +// ============================================================================ +// Initialization Tests +// ============================================================================ + +describe("Settings Manager - Initialization", () => { + test("Initialize makes settings accessible", async () => { + await settingsManager.initialize(); + + // Settings should be accessible immediately after initialization + const settings = settingsManager.getSettings(); + expect(settings).toBeDefined(); + expect(settings.uiMode).toBeDefined(); + expect(typeof settings.tokenStreaming).toBe("boolean"); + expect(settings.globalSharedBlockIds).toBeDefined(); + expect(typeof settings.globalSharedBlockIds).toBe("object"); + }); + + test("Initialize loads existing settings from disk", async () => { + // First initialize and set some settings + await settingsManager.initialize(); + settingsManager.updateSettings({ + uiMode: "rich", + tokenStreaming: true, + lastAgent: "agent-123", + }); + + // Wait for persist to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Reset and re-initialize + settingsManager.reset(); + await settingsManager.initialize(); + + const settings = settingsManager.getSettings(); + expect(settings.uiMode).toBe("rich"); + expect(settings.tokenStreaming).toBe(true); + expect(settings.lastAgent).toBe("agent-123"); + }); + + test("Initialize only runs once", async () => { + await settingsManager.initialize(); + const settings1 = settingsManager.getSettings(); + + // Call initialize again + await settingsManager.initialize(); + const settings2 = settingsManager.getSettings(); + + // Should be same instance + expect(settings1).toEqual(settings2); + }); + + test("Throws error if accessing settings before initialization", () => { + expect(() => settingsManager.getSettings()).toThrow( + "Settings not initialized", + ); + }); +}); + +// ============================================================================ +// Global Settings Tests +// ============================================================================ + +describe("Settings Manager - Global Settings", () => { + beforeEach(async () => { + await settingsManager.initialize(); + }); + + test("Get settings returns a copy", () => { + const settings1 = settingsManager.getSettings(); + const settings2 = settingsManager.getSettings(); + + expect(settings1).toEqual(settings2); + expect(settings1).not.toBe(settings2); // Different object instances + }); + + test("Get specific setting", () => { + settingsManager.updateSettings({ uiMode: "rich" }); + + const uiMode = settingsManager.getSetting("uiMode"); + expect(uiMode).toBe("rich"); + }); + + test("Update single setting", () => { + // Verify initial state first + const initialSettings = settingsManager.getSettings(); + const initialUiMode = initialSettings.uiMode; + + settingsManager.updateSettings({ tokenStreaming: true }); + + const settings = settingsManager.getSettings(); + expect(settings.tokenStreaming).toBe(true); + expect(settings.uiMode).toBe(initialUiMode); // Other settings unchanged + }); + + test("Update multiple settings", () => { + settingsManager.updateSettings({ + uiMode: "rich", + tokenStreaming: true, + lastAgent: "agent-456", + }); + + const settings = settingsManager.getSettings(); + expect(settings.uiMode).toBe("rich"); + expect(settings.tokenStreaming).toBe(true); + expect(settings.lastAgent).toBe("agent-456"); + }); + + test("Update global shared block IDs", () => { + settingsManager.updateSettings({ + globalSharedBlockIds: { + persona: "block-1", + human: "block-2", + }, + }); + + const settings = settingsManager.getSettings(); + expect(settings.globalSharedBlockIds).toEqual({ + persona: "block-1", + human: "block-2", + }); + }); + + test("Update env variables", () => { + settingsManager.updateSettings({ + env: { + LETTA_API_KEY: "sk-test-123", + CUSTOM_VAR: "value", + }, + }); + + const settings = settingsManager.getSettings(); + expect(settings.env).toEqual({ + LETTA_API_KEY: "sk-test-123", + CUSTOM_VAR: "value", + }); + }); + + test("Settings persist to disk", async () => { + settingsManager.updateSettings({ + uiMode: "rich", + lastAgent: "agent-789", + }); + + // Wait for async persist + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Reset and reload + settingsManager.reset(); + await settingsManager.initialize(); + + const settings = settingsManager.getSettings(); + expect(settings.uiMode).toBe("rich"); + expect(settings.lastAgent).toBe("agent-789"); + }); +}); + +// ============================================================================ +// Project Settings Tests (.letta/settings.json) +// ============================================================================ + +describe("Settings Manager - Project Settings", () => { + beforeEach(async () => { + await settingsManager.initialize(); + }); + + test("Load project settings creates defaults if none exist", async () => { + const projectSettings = + await settingsManager.loadProjectSettings(testProjectDir); + + expect(projectSettings.localSharedBlockIds).toEqual({}); + }); + + test("Get project settings returns cached value", async () => { + await settingsManager.loadProjectSettings(testProjectDir); + + const settings1 = settingsManager.getProjectSettings(testProjectDir); + const settings2 = settingsManager.getProjectSettings(testProjectDir); + + expect(settings1).toEqual(settings2); + expect(settings1).not.toBe(settings2); // Different instances + }); + + test("Update project settings", async () => { + await settingsManager.loadProjectSettings(testProjectDir); + + settingsManager.updateProjectSettings( + { + localSharedBlockIds: { + style: "block-style-1", + project: "block-project-1", + }, + }, + testProjectDir, + ); + + const settings = settingsManager.getProjectSettings(testProjectDir); + expect(settings.localSharedBlockIds).toEqual({ + style: "block-style-1", + project: "block-project-1", + }); + }); + + test("Project settings persist to disk", async () => { + await settingsManager.loadProjectSettings(testProjectDir); + + settingsManager.updateProjectSettings( + { + localSharedBlockIds: { + test: "block-test-1", + }, + }, + testProjectDir, + ); + + // Wait for persist + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Clear cache and reload + settingsManager.reset(); + await settingsManager.initialize(); + const reloaded = await settingsManager.loadProjectSettings(testProjectDir); + + expect(reloaded.localSharedBlockIds).toEqual({ + test: "block-test-1", + }); + }); + + test("Throw error if accessing project settings before loading", async () => { + expect(() => settingsManager.getProjectSettings(testProjectDir)).toThrow( + "Project settings for", + ); + }); +}); + +// ============================================================================ +// Local Project Settings Tests (.letta/settings.local.json) +// ============================================================================ + +describe("Settings Manager - Local Project Settings", () => { + beforeEach(async () => { + await settingsManager.initialize(); + }); + + test("Load local project settings creates defaults if none exist", async () => { + const localSettings = + await settingsManager.loadLocalProjectSettings(testProjectDir); + + expect(localSettings.lastAgent).toBe(null); + }); + + test("Get local project settings returns cached value", async () => { + await settingsManager.loadLocalProjectSettings(testProjectDir); + + const settings1 = settingsManager.getLocalProjectSettings(testProjectDir); + const settings2 = settingsManager.getLocalProjectSettings(testProjectDir); + + expect(settings1).toEqual(settings2); + expect(settings1).not.toBe(settings2); + }); + + test("Update local project settings - last agent", async () => { + await settingsManager.loadLocalProjectSettings(testProjectDir); + + settingsManager.updateLocalProjectSettings( + { lastAgent: "agent-local-1" }, + testProjectDir, + ); + + const settings = settingsManager.getLocalProjectSettings(testProjectDir); + expect(settings.lastAgent).toBe("agent-local-1"); + }); + + test("Update local project settings - permissions", async () => { + await settingsManager.loadLocalProjectSettings(testProjectDir); + + settingsManager.updateLocalProjectSettings( + { + permissions: { + allow: ["Bash(ls:*)"], + deny: ["Read(.env)"], + }, + }, + testProjectDir, + ); + + const settings = settingsManager.getLocalProjectSettings(testProjectDir); + expect(settings.permissions).toEqual({ + allow: ["Bash(ls:*)"], + deny: ["Read(.env)"], + }); + }); + + test("Local project settings persist to disk", async () => { + await settingsManager.loadLocalProjectSettings(testProjectDir); + + settingsManager.updateLocalProjectSettings( + { + lastAgent: "agent-persist-1", + permissions: { + allow: ["Bash(*)"], + }, + }, + testProjectDir, + ); + + // Wait for persist + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Clear cache and reload + settingsManager.reset(); + await settingsManager.initialize(); + const reloaded = + await settingsManager.loadLocalProjectSettings(testProjectDir); + + expect(reloaded.lastAgent).toBe("agent-persist-1"); + expect(reloaded.permissions).toEqual({ + allow: ["Bash(*)"], + }); + }); + + test("Throw error if accessing local project settings before loading", async () => { + expect(() => + settingsManager.getLocalProjectSettings(testProjectDir), + ).toThrow("Local project settings for"); + }); +}); + +// ============================================================================ +// Multiple Projects Tests +// ============================================================================ + +describe("Settings Manager - Multiple Projects", () => { + let testProjectDir2: string; + + beforeEach(async () => { + await settingsManager.initialize(); + testProjectDir2 = await mkdtemp(join(tmpdir(), "letta-test-project2-")); + }); + + afterEach(async () => { + await rm(testProjectDir2, { recursive: true, force: true }); + }); + + test("Can manage settings for multiple projects independently", async () => { + // Load settings for both projects + await settingsManager.loadLocalProjectSettings(testProjectDir); + await settingsManager.loadLocalProjectSettings(testProjectDir2); + + // Update different values + settingsManager.updateLocalProjectSettings( + { lastAgent: "agent-project-1" }, + testProjectDir, + ); + settingsManager.updateLocalProjectSettings( + { lastAgent: "agent-project-2" }, + testProjectDir2, + ); + + // Verify independence + const settings1 = settingsManager.getLocalProjectSettings(testProjectDir); + const settings2 = settingsManager.getLocalProjectSettings(testProjectDir2); + + expect(settings1.lastAgent).toBe("agent-project-1"); + expect(settings2.lastAgent).toBe("agent-project-2"); + }); + + test("Project settings are cached separately", async () => { + await settingsManager.loadProjectSettings(testProjectDir); + await settingsManager.loadProjectSettings(testProjectDir2); + + settingsManager.updateProjectSettings( + { localSharedBlockIds: { test: "block-1" } }, + testProjectDir, + ); + settingsManager.updateProjectSettings( + { localSharedBlockIds: { test: "block-2" } }, + testProjectDir2, + ); + + const settings1 = settingsManager.getProjectSettings(testProjectDir); + const settings2 = settingsManager.getProjectSettings(testProjectDir2); + + expect(settings1.localSharedBlockIds.test).toBe("block-1"); + expect(settings2.localSharedBlockIds.test).toBe("block-2"); + }); +}); + +// ============================================================================ +// Reset Tests +// ============================================================================ + +describe("Settings Manager - Reset", () => { + test("Reset clears all cached data", async () => { + await settingsManager.initialize(); + settingsManager.updateSettings({ lastAgent: "agent-reset-test" }); + + settingsManager.reset(); + + // Should throw error after reset + expect(() => settingsManager.getSettings()).toThrow(); + }); + + test("Can reinitialize after reset", async () => { + await settingsManager.initialize(); + settingsManager.updateSettings({ uiMode: "rich" }); + + // Wait for persist + await new Promise((resolve) => setTimeout(resolve, 100)); + + settingsManager.reset(); + await settingsManager.initialize(); + + const settings = settingsManager.getSettings(); + expect(settings.uiMode).toBe("rich"); + }); +}); + +// ============================================================================ +// Edge Cases and Error Handling +// ============================================================================ + +describe("Settings Manager - Edge Cases", () => { + test("Handles corrupted settings file gracefully", async () => { + // Create corrupted settings file + const { writeFile, mkdir } = await import("../utils/fs.js"); + const settingsDir = join(testHomeDir, ".letta"); + await mkdir(settingsDir, { recursive: true }); + await writeFile(join(settingsDir, "settings.json"), "{ invalid json"); + + // Should fall back to defaults + await settingsManager.initialize(); + const settings = settingsManager.getSettings(); + + // Should have default values (not corrupt) + expect(settings).toBeDefined(); + expect(settings.uiMode).toBeDefined(); + expect(settings.tokenStreaming).toBeDefined(); + expect(typeof settings.tokenStreaming).toBe("boolean"); + }); + + test("Modifying returned settings doesn't affect internal state", async () => { + await settingsManager.initialize(); + settingsManager.updateSettings({ + lastAgent: "agent-123", + globalSharedBlockIds: {}, + }); + + const settings = settingsManager.getSettings(); + settings.lastAgent = "modified-agent"; + settings.globalSharedBlockIds = { modified: "block" }; + + // Internal state should be unchanged + const actualSettings = settingsManager.getSettings(); + expect(actualSettings.lastAgent).toBe("agent-123"); + expect(actualSettings.globalSharedBlockIds).toEqual({}); + }); + + test("Partial updates preserve existing values", async () => { + await settingsManager.initialize(); + + settingsManager.updateSettings({ + uiMode: "rich", + tokenStreaming: true, + lastAgent: "agent-1", + }); + + // Partial update + settingsManager.updateSettings({ + lastAgent: "agent-2", + }); + + const settings = settingsManager.getSettings(); + expect(settings.uiMode).toBe("rich"); // Preserved + expect(settings.tokenStreaming).toBe(true); // Preserved + expect(settings.lastAgent).toBe("agent-2"); // Updated + }); +});