Refactor settings manager (#41)
Co-authored-by: Shubham Naik <shub@memgpt.ai>
This commit is contained in:
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
@@ -60,8 +60,25 @@ jobs:
|
|||||||
- name: CLI version smoke test
|
- name: CLI version smoke test
|
||||||
run: ./letta.js --version || true
|
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)
|
- 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:
|
env:
|
||||||
LETTA_API_KEY: ${{ secrets.LETTA_API_KEY }}
|
LETTA_API_KEY: ${{ secrets.LETTA_API_KEY }}
|
||||||
run: ./letta.js --prompt "ping" --tools "" --permission-mode plan
|
run: ./letta.js --prompt "ping" --tools "" --permission-mode plan
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Letta from "@letta-ai/letta-client";
|
import Letta from "@letta-ai/letta-client";
|
||||||
import { loadSettings, updateSettings } from "../settings";
|
import { settingsManager } from "../settings-manager";
|
||||||
|
|
||||||
export async function getClient() {
|
export async function getClient() {
|
||||||
const settings = await loadSettings();
|
const settings = settingsManager.getSettings();
|
||||||
|
|
||||||
const apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY;
|
const apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -34,7 +34,7 @@ export async function getClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
await updateSettings({ env: updatedEnv });
|
settingsManager.updateSettings({ env: updatedEnv });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Letta({ apiKey, baseURL });
|
return new Letta({ apiKey, baseURL });
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ import type {
|
|||||||
Block,
|
Block,
|
||||||
CreateBlock,
|
CreateBlock,
|
||||||
} from "@letta-ai/letta-client/resources/agents/agents";
|
} from "@letta-ai/letta-client/resources/agents/agents";
|
||||||
import {
|
import { settingsManager } from "../settings-manager";
|
||||||
loadProjectSettings,
|
|
||||||
updateProjectSettings,
|
|
||||||
} from "../project-settings";
|
|
||||||
import { loadSettings, updateSettings } from "../settings";
|
|
||||||
import { getToolNames } from "../tools/manager";
|
import { getToolNames } from "../tools/manager";
|
||||||
import { getClient } from "./client";
|
import { getClient } from "./client";
|
||||||
import { getDefaultMemoryBlocks } from "./memory";
|
import { getDefaultMemoryBlocks } from "./memory";
|
||||||
@@ -56,11 +52,12 @@ export async function createAgent(
|
|||||||
const defaultMemoryBlocks = await getDefaultMemoryBlocks();
|
const defaultMemoryBlocks = await getDefaultMemoryBlocks();
|
||||||
|
|
||||||
// Load global shared memory blocks from user settings
|
// Load global shared memory blocks from user settings
|
||||||
const settings = await loadSettings();
|
const settings = settingsManager.getSettings();
|
||||||
const globalSharedBlockIds = settings.globalSharedBlockIds;
|
const globalSharedBlockIds = settings.globalSharedBlockIds;
|
||||||
|
|
||||||
// Load project-local shared blocks from project settings
|
// Load project-local shared blocks from project settings
|
||||||
const projectSettings = await loadProjectSettings();
|
await settingsManager.loadProjectSettings();
|
||||||
|
const projectSettings = settingsManager.getProjectSettings();
|
||||||
const localSharedBlockIds = projectSettings.localSharedBlockIds;
|
const localSharedBlockIds = projectSettings.localSharedBlockIds;
|
||||||
|
|
||||||
// Retrieve existing blocks (both global and local) and match them with defaults
|
// 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
|
// Save newly created global block IDs to user settings
|
||||||
if (Object.keys(newGlobalBlockIds).length > 0) {
|
if (Object.keys(newGlobalBlockIds).length > 0) {
|
||||||
await updateSettings({
|
settingsManager.updateSettings({
|
||||||
globalSharedBlockIds: {
|
globalSharedBlockIds: {
|
||||||
...globalSharedBlockIds,
|
...globalSharedBlockIds,
|
||||||
...newGlobalBlockIds,
|
...newGlobalBlockIds,
|
||||||
@@ -146,12 +143,15 @@ export async function createAgent(
|
|||||||
|
|
||||||
// Save newly created local block IDs to project settings
|
// Save newly created local block IDs to project settings
|
||||||
if (Object.keys(newLocalBlockIds).length > 0) {
|
if (Object.keys(newLocalBlockIds).length > 0) {
|
||||||
await updateProjectSettings(process.cwd(), {
|
settingsManager.updateProjectSettings(
|
||||||
localSharedBlockIds: {
|
{
|
||||||
...localSharedBlockIds,
|
localSharedBlockIds: {
|
||||||
...newLocalBlockIds,
|
...localSharedBlockIds,
|
||||||
|
...newLocalBlockIds,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
process.cwd(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create agent with all block IDs (existing + newly created)
|
// Create agent with all block IDs (existing + newly created)
|
||||||
|
|||||||
@@ -638,8 +638,8 @@ export default function App({
|
|||||||
setTokenStreamingEnabled(newValue);
|
setTokenStreamingEnabled(newValue);
|
||||||
|
|
||||||
// Save to settings
|
// Save to settings
|
||||||
const { updateSettings } = await import("../settings");
|
const { settingsManager } = await import("../settings-manager");
|
||||||
await updateSettings({ tokenStreaming: newValue });
|
settingsManager.updateSettings({ tokenStreaming: newValue });
|
||||||
|
|
||||||
// Update the same command with final result
|
// Update the same command with final result
|
||||||
buffersRef.current.byId.set(cmdId, {
|
buffersRef.current.byId.set(cmdId, {
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ import { SessionStats } from "./agent/stats";
|
|||||||
import { createBuffers, toLines } from "./cli/helpers/accumulator";
|
import { createBuffers, toLines } from "./cli/helpers/accumulator";
|
||||||
import { safeJsonParseOr } from "./cli/helpers/safeJsonParse";
|
import { safeJsonParseOr } from "./cli/helpers/safeJsonParse";
|
||||||
import { drainStream } from "./cli/helpers/stream";
|
import { drainStream } from "./cli/helpers/stream";
|
||||||
import { loadSettings, updateSettings } from "./settings";
|
import { settingsManager } from "./settings-manager";
|
||||||
import { checkToolPermission, executeTool } from "./tools/manager";
|
import { checkToolPermission, executeTool } from "./tools/manager";
|
||||||
|
|
||||||
export async function handleHeadlessCommand(argv: string[], model?: string) {
|
export async function handleHeadlessCommand(argv: string[], model?: string) {
|
||||||
const settings = await loadSettings();
|
const settings = settingsManager.getSettings();
|
||||||
|
|
||||||
// Parse CLI args
|
// Parse CLI args
|
||||||
const { values, positionals } = parseArgs({
|
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)
|
// Priority 3: Try to resume from project settings (.letta/settings.local.json)
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
const { loadProjectSettings } = await import("./settings");
|
await settingsManager.loadLocalProjectSettings();
|
||||||
const projectSettings = await loadProjectSettings();
|
const localProjectSettings = settingsManager.getLocalProjectSettings();
|
||||||
if (projectSettings?.lastAgent) {
|
if (localProjectSettings?.lastAgent) {
|
||||||
try {
|
try {
|
||||||
agent = await client.agents.retrieve(projectSettings.lastAgent);
|
agent = await client.agents.retrieve(localProjectSettings.lastAgent);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
console.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
|
// Save agent ID to both project and global settings
|
||||||
const { updateProjectSettings } = await import("./settings");
|
await settingsManager.loadLocalProjectSettings();
|
||||||
await updateProjectSettings({ lastAgent: agent.id });
|
settingsManager.updateLocalProjectSettings({ lastAgent: agent.id });
|
||||||
await updateSettings({ lastAgent: agent.id });
|
settingsManager.updateSettings({ lastAgent: agent.id });
|
||||||
|
|
||||||
// Validate output format
|
// Validate output format
|
||||||
const outputFormat =
|
const outputFormat =
|
||||||
|
|||||||
41
src/index.ts
41
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 { getResumeData, type ResumeData } from "./agent/check-approval";
|
||||||
import { getClient } from "./agent/client";
|
import { getClient } from "./agent/client";
|
||||||
import { permissionMode } from "./permissions/mode";
|
import { permissionMode } from "./permissions/mode";
|
||||||
import { loadSettings } from "./settings";
|
import { settingsManager } from "./settings-manager";
|
||||||
import { loadTools, upsertToolsToServer } from "./tools/manager";
|
import { loadTools, upsertToolsToServer } from "./tools/manager";
|
||||||
|
|
||||||
function printHelp() {
|
function printHelp() {
|
||||||
@@ -52,8 +52,17 @@ EXAMPLES
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// Load settings first (creates default settings file if it doesn't exist)
|
// Initialize settings manager (loads settings once into memory)
|
||||||
const settings = await loadSettings();
|
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)
|
// Parse command-line arguments (Bun-idiomatic approach using parseArgs)
|
||||||
let values: Record<string, unknown>;
|
let values: Record<string, unknown>;
|
||||||
@@ -219,8 +228,6 @@ async function main() {
|
|||||||
setLoadingState("initializing");
|
setLoadingState("initializing");
|
||||||
const { createAgent } = await import("./agent/create");
|
const { createAgent } = await import("./agent/create");
|
||||||
const { getModelUpdateArgs } = await import("./agent/model");
|
const { getModelUpdateArgs } = await import("./agent/model");
|
||||||
const { updateSettings, loadProjectSettings, updateProjectSettings } =
|
|
||||||
await import("./settings");
|
|
||||||
|
|
||||||
let agent: AgentState | null = null;
|
let agent: AgentState | null = null;
|
||||||
|
|
||||||
@@ -245,14 +252,18 @@ async function main() {
|
|||||||
|
|
||||||
// Priority 3: Try to resume from project settings (.letta/settings.local.json)
|
// Priority 3: Try to resume from project settings (.letta/settings.local.json)
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
const projectSettings = await loadProjectSettings();
|
await settingsManager.loadLocalProjectSettings();
|
||||||
if (projectSettings?.lastAgent) {
|
const localProjectSettings =
|
||||||
|
settingsManager.getLocalProjectSettings();
|
||||||
|
if (localProjectSettings?.lastAgent) {
|
||||||
try {
|
try {
|
||||||
agent = await client.agents.retrieve(projectSettings.lastAgent);
|
agent = await client.agents.retrieve(
|
||||||
// console.log(`Resuming project agent ${projectSettings.lastAgent}...`);
|
localProjectSettings.lastAgent,
|
||||||
|
);
|
||||||
|
// console.log(`Resuming project agent ${localProjectSettings.lastAgent}...`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.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
|
// Save agent ID to both project and global settings
|
||||||
await updateProjectSettings({ lastAgent: agent.id });
|
settingsManager.updateLocalProjectSettings({ lastAgent: agent.id });
|
||||||
await updateSettings({ lastAgent: agent.id });
|
settingsManager.updateSettings({ lastAgent: agent.id });
|
||||||
|
|
||||||
// Check if we're resuming an existing agent
|
// Check if we're resuming an existing agent
|
||||||
const projectSettings = await loadProjectSettings();
|
const localProjectSettings = settingsManager.getLocalProjectSettings();
|
||||||
const isResumingProject =
|
const isResumingProject =
|
||||||
!forceNew &&
|
!forceNew &&
|
||||||
projectSettings?.lastAgent &&
|
localProjectSettings?.lastAgent &&
|
||||||
agent.id === projectSettings.lastAgent;
|
agent.id === localProjectSettings.lastAgent;
|
||||||
const resuming = continueSession || !!agentIdArg || isResumingProject;
|
const resuming = continueSession || !!agentIdArg || isResumingProject;
|
||||||
setIsResumingSession(resuming);
|
setIsResumingSession(resuming);
|
||||||
|
|
||||||
|
|||||||
385
src/settings-manager.ts
Normal file
385
src/settings-manager.ts
Normal file
@@ -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<string, string>;
|
||||||
|
permissions?: PermissionRules;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSettings {
|
||||||
|
localSharedBlockIds: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, ProjectSettings> = new Map();
|
||||||
|
private localProjectSettings: Map<string, LocalProjectSettings> = new Map();
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the settings manager (loads from disk)
|
||||||
|
* Should be called once at app startup
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
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<K extends keyof Settings>(key: K): Settings[K] {
|
||||||
|
return this.getSettings()[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update settings (synchronous in-memory, async persist)
|
||||||
|
*/
|
||||||
|
updateSettings(updates: Partial<Settings>): 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<ProjectSettings> {
|
||||||
|
// 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<string, unknown>;
|
||||||
|
|
||||||
|
const projectSettings: ProjectSettings = {
|
||||||
|
localSharedBlockIds:
|
||||||
|
(rawSettings.localSharedBlockIds as Record<string, string>) ?? {},
|
||||||
|
};
|
||||||
|
|
||||||
|
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<ProjectSettings>,
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, unknown> = {};
|
||||||
|
if (exists(settingsPath)) {
|
||||||
|
const content = await readFile(settingsPath);
|
||||||
|
existingSettings = JSON.parse(content) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<LocalProjectSettings> {
|
||||||
|
// 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<LocalProjectSettings>,
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
512
src/tests/settings-manager.test.ts
Normal file
512
src/tests/settings-manager.test.ts
Normal file
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user