Files
letta-code/src/tests/settings-manager.test.ts

1272 lines
41 KiB
TypeScript

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<string, unknown[]> = {};
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<string, unknown>;
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<string, unknown>;
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<string, string[]>)?.[
"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<string, unknown>;
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<string, unknown>;
// 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<string, unknown>;
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<string, unknown>)?.LETTA_API_KEY).toBe(
"sk-fallback-test",
);
}
});
});