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 type { CommandHookConfig, HookCommand } from "../hooks/types"; import { settingsManager } from "../settings-manager"; // Type-safe helper to extract command from a hook (tests only use command hooks) function asCommand( hook: HookCommand | undefined, ): CommandHookConfig | undefined { if (hook && hook.type === "command") { return hook as CommandHookConfig; } return undefined; } import { deleteSecureTokens, isKeychainAvailable, keychainAvailablePrecompute, setServiceName, } from "../utils/secrets.js"; // Store original HOME to restore after tests const originalHome = process.env.HOME; let testHomeDir: string; let testProjectDir: string; beforeEach(async () => { // Use a test-specific keychain service name to avoid deleting real credentials setServiceName("letta-code-test"); // Reset settings manager FIRST before changing HOME await 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 () => { // Wait for all pending writes to complete BEFORE restoring HOME // This prevents test writes from leaking into real settings after HOME is restored await settingsManager.reset(); // Clean up test directories await rm(testHomeDir, { recursive: true, force: true }); await rm(testProjectDir, { recursive: true, force: true }); // Restore original HOME AFTER reset completes process.env.HOME = originalHome; // Restore the real service name setServiceName("letta-code"); }); // ============================================================================ // 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(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({ tokenStreaming: true, lastAgent: "agent-123", }); // Wait for persist to complete await new Promise((resolve) => setTimeout(resolve, 100)); // Reset and re-initialize await settingsManager.reset(); await settingsManager.initialize(); const settings = settingsManager.getSettings(); 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", () => { let keychainSupported: boolean = false; beforeEach(async () => { await settingsManager.initialize(); // Check if secrets are available on this system keychainSupported = await isKeychainAvailable(); if (keychainSupported) { // Clean up any existing test tokens await deleteSecureTokens(); } }); afterEach(async () => { if (keychainSupported) { // Clean up after each test await deleteSecureTokens(); } }); 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({ tokenStreaming: true }); const tokenStreaming = settingsManager.getSetting("tokenStreaming"); expect(tokenStreaming).toBe(true); }); test("Update single setting", () => { // Verify initial state first const initialSettings = settingsManager.getSettings(); const initialLastAgent = initialSettings.lastAgent; settingsManager.updateSettings({ tokenStreaming: true }); const settings = settingsManager.getSettings(); expect(settings.tokenStreaming).toBe(true); expect(settings.lastAgent).toBe(initialLastAgent); // Other settings unchanged }); test("Update multiple settings", () => { settingsManager.updateSettings({ tokenStreaming: true, lastAgent: "agent-456", enableSleeptime: true, }); const settings = settingsManager.getSettings(); expect(settings.tokenStreaming).toBe(true); expect(settings.lastAgent).toBe("agent-456"); expect(settings.enableSleeptime).toBe(true); }); 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(); // LETTA_API_KEY should not be in settings file (moved to keychain) expect(settings.env).toEqual({ CUSTOM_VAR: "value", }); }); test.skipIf(!keychainAvailablePrecompute)( "Get settings with secure tokens (async method)", async () => { // This test verifies the async method that includes keychain tokens settingsManager.updateSettings({ env: { LETTA_API_KEY: "sk-test-async-123", CUSTOM_VAR: "async-value", }, refreshToken: "rt-test-refresh", tokenExpiresAt: Date.now() + 3600000, }); const settingsWithTokens = await settingsManager.getSettingsWithSecureTokens(); // Should include the environment variables and other settings expect(settingsWithTokens.env?.CUSTOM_VAR).toBe("async-value"); expect(typeof settingsWithTokens.tokenExpiresAt).toBe("number"); }, ); test("LETTA_BASE_URL should not be cached in settings", () => { // This test verifies that LETTA_BASE_URL is NOT persisted to settings // It should only come from environment variables settingsManager.updateSettings({ env: { LETTA_API_KEY: "sk-test-123", // LETTA_BASE_URL should not be included here }, }); const settings = settingsManager.getSettings(); expect(settings.env?.LETTA_BASE_URL).toBeUndefined(); }); test("Settings persist to disk", async () => { settingsManager.updateSettings({ tokenStreaming: true, lastAgent: "agent-789", }); // Wait for async persist await new Promise((resolve) => setTimeout(resolve, 100)); // Reset and reload await settingsManager.reset(); await settingsManager.initialize(); const settings = settingsManager.getSettings(); expect(settings.tokenStreaming).toBe(true); 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 await 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", ); }); test("When cwd is HOME, project settings resolve to defaults (no global collision)", async () => { await settingsManager.initialize(); // Seed a global statusLine config in ~/.letta/settings.json settingsManager.updateSettings({ statusLine: { command: "echo global-status" }, }); await new Promise((resolve) => setTimeout(resolve, 100)); const projectSettings = await settingsManager.loadProjectSettings(testHomeDir); expect(projectSettings.localSharedBlockIds).toEqual({}); expect(projectSettings.statusLine).toBeUndefined(); }); test("When cwd is HOME, project hook/statusLine updates route to global settings", async () => { await settingsManager.initialize(); // Load project settings for HOME (will be defaults due to collision guard) await settingsManager.loadProjectSettings(testHomeDir); settingsManager.updateProjectSettings( { statusLine: { command: "echo routed-status" }, hooks: { Notification: [ { hooks: [{ type: "command", command: "echo routed-hook" }], }, ], }, }, testHomeDir, ); await new Promise((resolve) => setTimeout(resolve, 100)); const globalSettings = settingsManager.getSettings(); expect(globalSettings.statusLine?.command).toBe("echo routed-status"); expect( asCommand(globalSettings.hooks?.Notification?.[0]?.hooks[0])?.command, ).toBe("echo routed-hook"); // Ensure project-only field is not written into global file by this route expect(globalSettings).not.toHaveProperty("localSharedBlockIds"); }); }); // ============================================================================ // 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 await 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" }); await settingsManager.reset(); // Should throw error after reset expect(() => settingsManager.getSettings()).toThrow(); }); test("Can reinitialize after reset", async () => { await settingsManager.initialize(); settingsManager.updateSettings({ tokenStreaming: true }); // Wait for persist await new Promise((resolve) => setTimeout(resolve, 100)); await settingsManager.reset(); await settingsManager.initialize(); const settings = settingsManager.getSettings(); expect(settings.tokenStreaming).toBe(true); }); test("Reset clears managedKeys so stale keys don't leak into next session", async () => { const { writeFile, mkdir } = await import("../utils/fs.js"); const settingsDir = join(testHomeDir, ".letta"); await mkdir(settingsDir, { recursive: true }); // First session: write a setting that will be tracked in managedKeys await settingsManager.initialize(); settingsManager.updateSettings({ lastAgent: "agent-first-session" }); await new Promise((resolve) => setTimeout(resolve, 100)); await settingsManager.reset(); // Second session: write a completely fresh file with a different key await writeFile( join(settingsDir, "settings.json"), JSON.stringify({ tokenStreaming: true }), ); await settingsManager.initialize(); // After re-init, managedKeys should only contain keys from the new file. // Persisting should write tokenStreaming but NOT ghost-write lastAgent from // the previous session's managedKeys. settingsManager.updateSettings({ enableSleeptime: false }); await new Promise((resolve) => setTimeout(resolve, 100)); await settingsManager.reset(); await settingsManager.initialize(); const settings = settingsManager.getSettings(); expect(settings.tokenStreaming).toBe(true); // lastAgent was only set in the first session — should not reappear expect(settings.lastAgent).toBeNull(); }); }); // ============================================================================ // Hooks Configuration Tests // ============================================================================ describe("Settings Manager - Hooks", () => { beforeEach(async () => { await settingsManager.initialize(); }); test("Update hooks configuration in global settings", async () => { settingsManager.updateSettings({ hooks: { PreToolUse: [ { matcher: "Bash", hooks: [{ type: "command", command: "echo test" }], }, ], }, }); const settings = settingsManager.getSettings(); expect(settings.hooks).toBeDefined(); expect(settings.hooks?.PreToolUse).toHaveLength(1); expect(settings.hooks?.PreToolUse?.[0]?.matcher).toBe("Bash"); }); test("Hooks configuration persists to disk", async () => { settingsManager.updateSettings({ hooks: { // Tool event with HookMatcher[] PreToolUse: [ { matcher: "*", hooks: [{ type: "command", command: "echo persisted" }], }, ], // Simple event with SimpleHookMatcher[] SessionStart: [ { hooks: [{ type: "command", command: "echo session" }] }, ], }, }); // Wait for async persist await new Promise((resolve) => setTimeout(resolve, 100)); // Reset and reload await settingsManager.reset(); await settingsManager.initialize(); const settings = settingsManager.getSettings(); expect(settings.hooks?.PreToolUse).toHaveLength(1); expect(asCommand(settings.hooks?.PreToolUse?.[0]?.hooks[0])?.command).toBe( "echo persisted", ); expect(settings.hooks?.SessionStart).toHaveLength(1); }); test("Update hooks in local project settings with patterns", async () => { await settingsManager.loadLocalProjectSettings(testProjectDir); settingsManager.updateLocalProjectSettings( { hooks: { PostToolUse: [ { matcher: "Write|Edit", hooks: [{ type: "command", command: "echo post-tool" }], }, ], }, }, testProjectDir, ); const localSettings = settingsManager.getLocalProjectSettings(testProjectDir); expect(localSettings.hooks?.PostToolUse).toHaveLength(1); expect(localSettings.hooks?.PostToolUse?.[0]?.matcher).toBe("Write|Edit"); }); test("Update hooks in local project settings", async () => { await settingsManager.loadLocalProjectSettings(testProjectDir); settingsManager.updateLocalProjectSettings( { hooks: { // Simple event uses SimpleHookMatcher[] (hooks wrapper) UserPromptSubmit: [ { hooks: [{ type: "command", command: "echo local-hook" }] }, ], }, }, testProjectDir, ); const localSettings = settingsManager.getLocalProjectSettings(testProjectDir); expect(localSettings.hooks?.UserPromptSubmit).toHaveLength(1); }); test("Local project hooks persist to disk", async () => { await settingsManager.loadLocalProjectSettings(testProjectDir); settingsManager.updateLocalProjectSettings( { hooks: { // Simple event uses SimpleHookMatcher[] (hooks wrapper) Stop: [{ hooks: [{ type: "command", command: "echo stop-hook" }] }], }, }, testProjectDir, ); // Wait for persist await new Promise((resolve) => setTimeout(resolve, 100)); // Clear cache and reload await settingsManager.reset(); await settingsManager.initialize(); const reloaded = await settingsManager.loadLocalProjectSettings(testProjectDir); expect(reloaded.hooks?.Stop).toHaveLength(1); // Simple event hooks are in SimpleHookMatcher format with hooks array expect(asCommand(reloaded.hooks?.Stop?.[0]?.hooks[0])?.command).toBe( "echo stop-hook", ); }); test("All 10 hook event types can be configured", async () => { const allHookEvents = [ "PreToolUse", "PostToolUse", "PermissionRequest", "UserPromptSubmit", "Notification", "Stop", "SubagentStop", "PreCompact", "SessionStart", "SessionEnd", ] as const; const hooksConfig: Record = {}; for (const event of allHookEvents) { hooksConfig[event] = [ { matcher: "*", hooks: [{ type: "command", command: `echo ${event}` }], }, ]; } settingsManager.updateSettings({ hooks: hooksConfig as never, }); const settings = settingsManager.getSettings(); for (const event of allHookEvents) { expect(settings.hooks?.[event]).toHaveLength(1); } }); test("Partial hooks update preserves other hooks", async () => { settingsManager.updateSettings({ hooks: { PreToolUse: [ { matcher: "*", hooks: [{ type: "command", command: "echo pre" }] }, ], PostToolUse: [ { matcher: "*", hooks: [{ type: "command", command: "echo post" }] }, ], }, }); // Update only PreToolUse settingsManager.updateSettings({ hooks: { PreToolUse: [ { matcher: "Bash", hooks: [{ type: "command", command: "echo updated" }], }, ], }, }); const settings = settingsManager.getSettings(); // PreToolUse should be updated (replaced) expect(settings.hooks?.PreToolUse?.[0]?.matcher).toBe("Bash"); // Note: This test documents current behavior - hooks object is replaced entirely }); }); // ============================================================================ // 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.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({ tokenStreaming: true, lastAgent: "agent-1", enableSleeptime: true, }); // Partial update settingsManager.updateSettings({ lastAgent: "agent-2", }); const settings = settingsManager.getSettings(); expect(settings.tokenStreaming).toBe(true); // Preserved expect(settings.enableSleeptime).toBe(true); // Preserved expect(settings.lastAgent).toBe("agent-2"); // Updated }); }); // ============================================================================ // Agents Array Migration Tests // ============================================================================ describe("Settings Manager - Agents Array Migration", () => { const originalSubagentRole = process.env.LETTA_CODE_AGENT_ROLE; afterEach(() => { if (originalSubagentRole === undefined) { delete process.env.LETTA_CODE_AGENT_ROLE; } else { process.env.LETTA_CODE_AGENT_ROLE = originalSubagentRole; } }); test.skipIf(!keychainAvailablePrecompute)( "Subagent process skips token migration to secrets", async () => { const { writeFile, mkdir } = await import("../utils/fs.js"); const settingsDir = join(testHomeDir, ".letta"); await mkdir(settingsDir, { recursive: true }); await writeFile( join(settingsDir, "settings.json"), JSON.stringify({ refreshToken: "rt-subagent-should-stay", env: { LETTA_API_KEY: "sk-subagent-should-stay", }, }), ); process.env.LETTA_CODE_AGENT_ROLE = "subagent"; await settingsManager.initialize(); const settings = settingsManager.getSettings(); expect(settings.refreshToken).toBe("rt-subagent-should-stay"); expect(settings.env?.LETTA_API_KEY).toBe("sk-subagent-should-stay"); }, ); test("Migrates from pinnedAgents (oldest legacy format)", async () => { // Setup: Write old format to disk const { writeFile, mkdir } = await import("../utils/fs.js"); const settingsDir = join(testHomeDir, ".letta"); await mkdir(settingsDir, { recursive: true }); await writeFile( join(settingsDir, "settings.json"), JSON.stringify({ pinnedAgents: ["agent-old-1", "agent-old-2"], tokenStreaming: true, }), ); await settingsManager.initialize(); const settings = settingsManager.getSettings(); // Should have migrated to agents array expect(settings.agents).toBeDefined(); expect(settings.agents).toHaveLength(2); expect(settings.agents?.[0]).toEqual({ agentId: "agent-old-1", pinned: true, }); expect(settings.agents?.[1]).toEqual({ agentId: "agent-old-2", pinned: true, }); // Legacy field should still exist for downgrade compat expect(settings.pinnedAgents).toEqual(["agent-old-1", "agent-old-2"]); }); test("Migrates from pinnedAgentsByServer (newer legacy format)", async () => { const { writeFile, mkdir } = await import("../utils/fs.js"); const settingsDir = join(testHomeDir, ".letta"); await mkdir(settingsDir, { recursive: true }); await writeFile( join(settingsDir, "settings.json"), JSON.stringify({ pinnedAgentsByServer: { "api.letta.com": ["agent-cloud-1"], "localhost:8283": ["agent-local-1", "agent-local-2"], }, }), ); await settingsManager.initialize(); const settings = settingsManager.getSettings(); expect(settings.agents).toHaveLength(3); // Cloud agents have no baseUrl (or undefined) expect(settings.agents).toContainEqual({ agentId: "agent-cloud-1", pinned: true, }); // Local agents have baseUrl expect(settings.agents).toContainEqual({ agentId: "agent-local-1", baseUrl: "localhost:8283", pinned: true, }); expect(settings.agents).toContainEqual({ agentId: "agent-local-2", baseUrl: "localhost:8283", pinned: true, }); }); test("Migrates from both legacy formats (deduplicated)", async () => { const { writeFile, mkdir } = await import("../utils/fs.js"); const settingsDir = join(testHomeDir, ".letta"); await mkdir(settingsDir, { recursive: true }); await writeFile( join(settingsDir, "settings.json"), JSON.stringify({ pinnedAgents: ["agent-1", "agent-2"], // Old old format pinnedAgentsByServer: { "api.letta.com": ["agent-1", "agent-3"], // agent-1 is duplicate }, }), ); await settingsManager.initialize(); const settings = settingsManager.getSettings(); // Should have 3 agents (agent-1 deduped) expect(settings.agents).toHaveLength(3); const agentIds = settings.agents?.map((a) => a.agentId); expect(agentIds).toContain("agent-1"); expect(agentIds).toContain("agent-2"); expect(agentIds).toContain("agent-3"); }); test("Already migrated settings are not re-migrated", async () => { const { writeFile, mkdir } = await import("../utils/fs.js"); const settingsDir = join(testHomeDir, ".letta"); await mkdir(settingsDir, { recursive: true }); await writeFile( join(settingsDir, "settings.json"), JSON.stringify({ agents: [{ agentId: "agent-new", pinned: true, memfs: true }], pinnedAgentsByServer: { "api.letta.com": ["agent-old"], // Should be ignored since agents exists }, }), ); await settingsManager.initialize(); const settings = settingsManager.getSettings(); // Should only have the new format agent expect(settings.agents).toHaveLength(1); expect(settings.agents?.[0]?.agentId).toBe("agent-new"); expect(settings.agents?.[0]?.memfs).toBe(true); }); test("isMemfsEnabled returns false for agents without memfs flag", async () => { await settingsManager.initialize(); // Manually set up agents array settingsManager.updateSettings({ agents: [ { agentId: "agent-with-memfs", pinned: true, memfs: true }, { agentId: "agent-without-memfs", pinned: true }, ], }); expect(settingsManager.isMemfsEnabled("agent-with-memfs")).toBe(true); expect(settingsManager.isMemfsEnabled("agent-without-memfs")).toBe(false); expect(settingsManager.isMemfsEnabled("agent-unknown")).toBe(false); }); test("setMemfsEnabled adds/removes memfs flag", async () => { await settingsManager.initialize(); settingsManager.setMemfsEnabled("agent-test", true); expect(settingsManager.isMemfsEnabled("agent-test")).toBe(true); settingsManager.setMemfsEnabled("agent-test", false); expect(settingsManager.isMemfsEnabled("agent-test")).toBe(false); }); test("setMemfsEnabled persists to disk", async () => { await settingsManager.initialize(); settingsManager.setMemfsEnabled("agent-persist-test", true); // Wait for async persist await new Promise((resolve) => setTimeout(resolve, 100)); // Reset and reload await settingsManager.reset(); await settingsManager.initialize(); expect(settingsManager.isMemfsEnabled("agent-persist-test")).toBe(true); }); }); describe("Settings Manager - Toolset Preferences", () => { test("getToolsetPreference defaults to auto", async () => { await settingsManager.initialize(); expect(settingsManager.getToolsetPreference("agent-unset")).toBe("auto"); }); test("setToolsetPreference stores and clears manual override", async () => { await settingsManager.initialize(); settingsManager.setToolsetPreference("agent-toolset", "codex"); expect(settingsManager.getToolsetPreference("agent-toolset")).toBe("codex"); settingsManager.setToolsetPreference("agent-toolset", "auto"); expect(settingsManager.getToolsetPreference("agent-toolset")).toBe("auto"); }); test("setToolsetPreference persists to disk", async () => { await settingsManager.initialize(); settingsManager.setToolsetPreference("agent-toolset-persist", "gemini"); // Wait for async persist await new Promise((resolve) => setTimeout(resolve, 100)); await settingsManager.reset(); await settingsManager.initialize(); expect(settingsManager.getToolsetPreference("agent-toolset-persist")).toBe( "gemini", ); }); }); // ============================================================================ // Managed Keys / Settings Preservation Tests // ============================================================================ describe("Settings Manager - Managed Keys Preservation", () => { test("Unknown top-level keys in the file are preserved across writes", async () => { const { writeFile, mkdir } = await import("../utils/fs.js"); const settingsDir = join(testHomeDir, ".letta"); await mkdir(settingsDir, { recursive: true }); // Simulate a user manually adding a key that Letta Code doesn't know about await writeFile( join(settingsDir, "settings.json"), JSON.stringify({ tokenStreaming: true, myCustomFlag: "keep-me", }), ); await settingsManager.initialize(); // Update an unrelated setting — should not clobber myCustomFlag settingsManager.updateSettings({ lastAgent: "agent-abc" }); await new Promise((resolve) => setTimeout(resolve, 100)); // Read the raw file to confirm myCustomFlag survived const { readFile } = await import("../utils/fs.js"); const raw = JSON.parse( await readFile(join(settingsDir, "settings.json")), ) as Record; expect(raw.myCustomFlag).toBe("keep-me"); expect(raw.tokenStreaming).toBe(true); expect(raw.lastAgent).toBe("agent-abc"); }); test("External updates to managed keys are preserved when this process didn't change them", async () => { const { writeFile, readFile, mkdir } = await import("../utils/fs.js"); const settingsDir = join(testHomeDir, ".letta"); const settingsPath = join(settingsDir, "settings.json"); await mkdir(settingsDir, { recursive: true }); await writeFile( settingsPath, JSON.stringify({ tokenStreaming: true, pinnedAgents: ["agent-a"], pinnedAgentsByServer: { "api.letta.com": ["agent-a"], }, }), ); await settingsManager.initialize(); // Simulate another process appending a new global pin while this process // is still running with stale in-memory settings. const externallyUpdated = JSON.parse( await readFile(settingsPath), ) as Record; const pinnedByServer = (externallyUpdated.pinnedAgentsByServer as Record< string, string[] >) || { "api.letta.com": [] }; pinnedByServer["api.letta.com"] = [ ...(pinnedByServer["api.letta.com"] || []), "agent-b", ]; externallyUpdated.pinnedAgentsByServer = pinnedByServer; const pinned = (externallyUpdated.pinnedAgents as string[]) || []; externallyUpdated.pinnedAgents = [...pinned, "agent-b"]; await writeFile(settingsPath, JSON.stringify(externallyUpdated)); // Trigger an unrelated write from this process. settingsManager.updateSettings({ lastAgent: "agent-current" }); await settingsManager.flush(); const raw = JSON.parse(await readFile(settingsPath)) as Record< string, unknown >; expect(raw.lastAgent).toBe("agent-current"); expect((raw.pinnedAgents as string[]) || []).toContain("agent-b"); expect( (raw.pinnedAgentsByServer as Record)?.[ "api.letta.com" ] || [], ).toContain("agent-b"); }); test("External deletion of managed keys is preserved when this process didn't change them", async () => { const { writeFile, readFile, mkdir } = await import("../utils/fs.js"); const settingsDir = join(testHomeDir, ".letta"); const settingsPath = join(settingsDir, "settings.json"); await mkdir(settingsDir, { recursive: true }); await writeFile( settingsPath, JSON.stringify({ tokenStreaming: true, pinnedAgents: ["agent-a"], pinnedAgentsByServer: { "api.letta.com": ["agent-a"], }, }), ); await settingsManager.initialize(); // Simulate another process removing managed pin keys. const externallyUpdated = JSON.parse( await readFile(settingsPath), ) as Record; delete externallyUpdated.pinnedAgents; delete externallyUpdated.pinnedAgentsByServer; await writeFile(settingsPath, JSON.stringify(externallyUpdated)); // Trigger an unrelated write from this process. settingsManager.updateSettings({ lastAgent: "agent-current" }); await settingsManager.flush(); const raw = JSON.parse(await readFile(settingsPath)) as Record< string, unknown >; expect(raw.lastAgent).toBe("agent-current"); expect("pinnedAgents" in raw).toBe(false); expect("pinnedAgentsByServer" in raw).toBe(false); }); test("No-keychain fallback persists refreshToken and LETTA_API_KEY to file", async () => { // On machines with a keychain, tokens go to the keychain, not the file. // On machines without a keychain, tokens must fall back to the file. // Both paths are exercised here depending on the environment. const secretsAvail = await isKeychainAvailable(); if (secretsAvail) { // On machines with a keychain, tokens go to the keychain, not the file. // Test the fallback indirectly: if secrets are available the tokens // should NOT be in the file (they're in the keychain). await settingsManager.initialize(); settingsManager.updateSettings({ refreshToken: "rt-keychain-test" }); await new Promise((resolve) => setTimeout(resolve, 100)); const { readFile } = await import("../utils/fs.js"); const settingsDir = join(testHomeDir, ".letta"); const raw = JSON.parse( await readFile(join(settingsDir, "settings.json")), ) as Record; // With keychain available, refreshToken goes to keychain not file expect(raw.refreshToken).toBeUndefined(); } else { // No keychain: tokens fall back to the settings file and must be persisted await settingsManager.initialize(); settingsManager.updateSettings({ refreshToken: "rt-fallback-test", env: { LETTA_API_KEY: "sk-fallback-test" }, }); await new Promise((resolve) => setTimeout(resolve, 100)); const { readFile } = await import("../utils/fs.js"); const settingsDir = join(testHomeDir, ".letta"); const raw = JSON.parse( await readFile(join(settingsDir, "settings.json")), ) as Record; expect(raw.refreshToken).toBe("rt-fallback-test"); // LETTA_API_KEY also falls back to the file when keychain is unavailable expect((raw.env as Record)?.LETTA_API_KEY).toBe( "sk-fallback-test", ); } }); });