diff --git a/src/agent/defaults.ts b/src/agent/defaults.ts index 0ce5134..f0e9534 100644 --- a/src/agent/defaults.ts +++ b/src/agent/defaults.ts @@ -31,7 +31,7 @@ const INCOGNITO_DESCRIPTION = */ export const DEFAULT_AGENT_CONFIGS: Record = { memo: { - name: "Memo", + name: "Letta Code", description: MEMO_DESCRIPTION, // Uses default memory blocks and tools (full stateful config) // Override persona block with Memo-specific personality @@ -90,6 +90,11 @@ export async function ensureDefaultAgents( const { agent } = await createAgent(DEFAULT_AGENT_CONFIGS.memo); await addTagToAgent(client, agent.id, MEMO_TAG); settingsManager.pinGlobal(agent.id); + + // Enable memfs by default on Letta Cloud + const { enableMemfsIfCloud } = await import("./memoryFilesystem"); + await enableMemfsIfCloud(agent.id); + return agent; } catch (err) { // Re-throw so caller can handle/exit appropriately diff --git a/src/agent/memoryFilesystem.ts b/src/agent/memoryFilesystem.ts index fbcc47c..95e90a2 100644 --- a/src/agent/memoryFilesystem.ts +++ b/src/agent/memoryFilesystem.ts @@ -215,3 +215,21 @@ export async function applyMemfsFlags( pullSummary, }; } + +/** + * Enable memfs for a newly created agent if on Letta Cloud. + * Non-fatal: logs a warning on failure. Skips on self-hosted. + */ +export async function enableMemfsIfCloud(agentId: string): Promise { + const { getServerUrl } = await import("./client"); + const serverUrl = getServerUrl(); + if (!serverUrl.includes("api.letta.com")) return; + + try { + await applyMemfsFlags(agentId, true, undefined); + } catch (error) { + console.warn( + `Warning: Could not enable memfs for new agent: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/src/agent/resolve-startup-agent.ts b/src/agent/resolve-startup-agent.ts new file mode 100644 index 0000000..cdfa107 --- /dev/null +++ b/src/agent/resolve-startup-agent.ts @@ -0,0 +1,88 @@ +/** + * Pure startup agent resolution logic. + * + * Encodes the decision tree for which agent to use when `letta` starts: + * local LRU → global LRU → selector → create default + * + * Extracted from index.ts/headless.ts so it can be unit-tested without + * React effects or real network calls. + */ + +export type StartupTarget = + | { action: "resume"; agentId: string; conversationId?: string } + | { action: "select" } + | { action: "create" }; + +export interface StartupResolutionInput { + /** Agent ID from local project LRU (via getLocalLastAgentId) */ + localAgentId: string | null; + /** Conversation ID from local project LRU */ + localConversationId: string | null; + /** Whether the local agent still exists on the server */ + localAgentExists: boolean; + + /** Agent ID from global LRU (via getGlobalLastAgentId) */ + globalAgentId: string | null; + /** Whether the global agent still exists on the server */ + globalAgentExists: boolean; + + /** Number of merged pinned agents (local + global) */ + mergedPinnedCount: number; + + /** --new-agent flag: skip all resume logic, create fresh */ + forceNew: boolean; + + /** Self-hosted server with no available default model */ + needsModelPicker: boolean; +} + +/** + * Determine which agent to start with based on available context. + * + * Decision tree: + * 1. forceNew → create + * 2. local LRU valid → resume (with local conversation) + * 3. global LRU valid → resume (no conversation — project-scoped) + * 4. needsModelPicker → select + * 5. pinned agents exist → select + * 6. nothing → create + */ +export function resolveStartupTarget( + input: StartupResolutionInput, +): StartupTarget { + // --new-agent always creates + if (input.forceNew) { + return { action: "create" }; + } + + // Step 1: Local project LRU + if (input.localAgentId && input.localAgentExists) { + return { + action: "resume", + agentId: input.localAgentId, + conversationId: input.localConversationId ?? undefined, + }; + } + + // Step 2: Global LRU (directory-switching fallback) + // Do NOT restore global conversation — keep conversations project-scoped + if (input.globalAgentId && input.globalAgentExists) { + return { + action: "resume", + agentId: input.globalAgentId, + }; + } + + // Step 3: Self-hosted model picker + if (input.needsModelPicker) { + return { action: "select" }; + } + + // Step 4: Show selector if any pinned agents exist + if (input.mergedPinnedCount > 0) { + return { action: "select" }; + } + + // Step 5: True fresh user — create default agent + return { action: "create" }; +} diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 102fe06..8157baf 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -5136,6 +5136,12 @@ export default function App({ // Create the new agent const { agent } = await createAgent(name); + // Enable memfs by default on Letta Cloud for new agents + const { enableMemfsIfCloud } = await import( + "../agent/memoryFilesystem" + ); + await enableMemfsIfCloud(agent.id); + // Update project settings with new agent await updateProjectSettings({ lastAgent: agent.id }); diff --git a/src/headless.ts b/src/headless.ts index ec858b8..7513ee2 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -82,8 +82,6 @@ export async function handleHeadlessCommand( skillsDirectory?: string, noSkills?: boolean, ) { - const settings = settingsManager.getSettings(); - // Parse CLI args // Include all flags from index.ts to prevent them from being treated as positionals const { values, positionals } = parseArgs({ @@ -601,47 +599,57 @@ export async function handleHeadlessCommand( }; const result = await createAgent(createOptions); agent = result.agent; + + // Enable memfs by default on Letta Cloud for new agents + const { enableMemfsIfCloud } = await import("./agent/memoryFilesystem"); + await enableMemfsIfCloud(agent.id); } // Priority 4: Try to resume from project settings (.letta/settings.local.json) + // Store local conversation ID for use in conversation resolution below + let resolvedLocalConvId: string | null = null; if (!agent) { await settingsManager.loadLocalProjectSettings(); - const localProjectSettings = settingsManager.getLocalProjectSettings(); - if (localProjectSettings?.lastAgent) { + const localAgentId = settingsManager.getLocalLastAgentId(process.cwd()); + if (localAgentId) { try { - agent = await client.agents.retrieve(localProjectSettings.lastAgent); + agent = await client.agents.retrieve(localAgentId); + // Store local conversation for downstream resolution + const localSession = settingsManager.getLocalLastSession(process.cwd()); + resolvedLocalConvId = localSession?.conversationId ?? null; } catch (_error) { // Local LRU agent doesn't exist - log and continue - console.error( - `Unable to locate agent ${localProjectSettings.lastAgent} in .letta/`, - ); + console.error(`Unable to locate agent ${localAgentId} in .letta/`); } } } - // Priority 5: Try to reuse global lastAgent if --continue flag is passed - if (!agent && shouldContinue) { - if (settings.lastAgent) { + // Priority 5: Try to reuse global LRU (covers directory-switching case) + // Do NOT restore global conversation — use default (project-scoped conversations) + if (!agent) { + const globalAgentId = settingsManager.getGlobalLastAgentId(); + if (globalAgentId) { try { - agent = await client.agents.retrieve(settings.lastAgent); + agent = await client.agents.retrieve(globalAgentId); } catch (_error) { // Global LRU agent doesn't exist } } - // --continue requires an LRU agent to exist - if (!agent) { - console.error("No recent session found in .letta/ or ~/.letta."); - console.error("Run 'letta' to get started."); - process.exit(1); - } } - // Priority 6: Fresh user with no LRU - create Memo (same as interactive mode) + // Priority 6: --continue with no agent found → error + if (!agent && shouldContinue) { + console.error("No recent session found in .letta/ or ~/.letta."); + console.error("Run 'letta' to get started."); + process.exit(1); + } + + // Priority 7: Fresh user with no LRU - create default agent if (!agent) { const { ensureDefaultAgents } = await import("./agent/defaults"); - const memoAgent = await ensureDefaultAgents(client); - if (memoAgent) { - agent = memoAgent; + const defaultAgent = await ensureDefaultAgents(client); + if (defaultAgent) { + agent = defaultAgent; } } @@ -786,8 +794,21 @@ export async function handleHeadlessCommand( isolated_block_labels: isolatedBlockLabels, }); conversationId = conversation.id; + } else if (resolvedLocalConvId) { + // Resumed from local LRU — restore the local conversation + if (resolvedLocalConvId === "default") { + conversationId = "default"; + } else { + try { + await client.conversations.retrieve(resolvedLocalConvId); + conversationId = resolvedLocalConvId; + } catch { + // Local conversation no longer exists — fall back to default + conversationId = "default"; + } + } } else { - // Default (including --new-agent, --agent): use the agent's "default" conversation + // Default (including --new-agent, --agent, global LRU fallback): use "default" conversation conversationId = "default"; } markMilestone("HEADLESS_CONVERSATION_READY"); diff --git a/src/index.ts b/src/index.ts index 9c9ff74..0127774 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1147,7 +1147,6 @@ async function main(): Promise { // Load settings await settingsManager.loadLocalProjectSettings(); const localSettings = settingsManager.getLocalProjectSettings(); - const globalPinned = settingsManager.getGlobalPinnedAgents(); const client = await getClient(); // For self-hosted servers, pre-fetch available models @@ -1330,65 +1329,91 @@ async function main(): Promise { // ===================================================================== // DEFAULT PATH: No special flags - // Check local LRU, then selector, then defaults + // Check local LRU → global LRU → selector → create default // ===================================================================== - // Check if user would see selector (fresh dir, no bypass flags) - const wouldShowSelector = - !localSettings.lastAgent && !forceNew && !agentIdArg && !fromAfFile; + // Short-circuit: flags handled by init() skip resolution entirely + if (forceNew || agentIdArg || fromAfFile) { + setLoadingState("assembling"); + return; + } - if ( - wouldShowSelector && - globalPinned.length === 0 && - !needsModelPicker - ) { - // New user with no pinned agents - create a fresh Memo agent - // NOTE: Always creates a new agent (no server-side tag lookup) to avoid - // picking up agents created by other users on shared orgs. - // Skip if needsModelPicker is true - let user select a model first. - const { ensureDefaultAgents } = await import("./agent/defaults"); + // Step 1: Check local project LRU (session helpers centralize legacy fallback) + const localAgentId = settingsManager.getLocalLastAgentId(process.cwd()); + let localAgentExists = false; + if (localAgentId) { try { - const memoAgent = await ensureDefaultAgents(client); - if (memoAgent) { - setSelectedGlobalAgentId(memoAgent.id); - setLoadingState("assembling"); - return; - } - // If memoAgent is null (createDefaultAgents disabled), fall through - } catch (err) { - console.error( - `Failed to create default agents: ${err instanceof Error ? err.message : String(err)}`, + await client.agents.retrieve(localAgentId); + localAgentExists = true; + } catch { + setFailedAgentMessage( + `Unable to locate recently used agent ${localAgentId}`, ); - process.exit(1); } } - // If there's a local LRU, use it directly (takes priority over model picker) - if (localSettings.lastAgent) { + // Step 2: Check global LRU (covers directory-switching case) + const globalAgentId = settingsManager.getGlobalLastAgentId(); + let globalAgentExists = false; + if (globalAgentId && globalAgentId !== localAgentId) { try { - await client.agents.retrieve(localSettings.lastAgent); + await client.agents.retrieve(globalAgentId); + globalAgentExists = true; + } catch { + // Global agent doesn't exist either + } + } else if (globalAgentId && globalAgentId === localAgentId) { + globalAgentExists = localAgentExists; + } + + // Step 3: Resolve startup target using pure decision logic + const mergedPinned = settingsManager.getMergedPinnedAgents( + process.cwd(), + ); + const { resolveStartupTarget } = await import( + "./agent/resolve-startup-agent" + ); + const target = resolveStartupTarget({ + localAgentId, + localConversationId: null, // DEFAULT PATH always uses default conv + localAgentExists, + globalAgentId, + globalAgentExists, + mergedPinnedCount: mergedPinned.length, + forceNew: false, // forceNew short-circuited above + needsModelPicker, + }); + + switch (target.action) { + case "resume": + setSelectedGlobalAgentId(target.agentId); + // Don't set selectedConversationId — DEFAULT PATH uses default conv. + // Conversation restoration is handled by --continue path instead. setLoadingState("assembling"); return; - } catch { - // LRU agent doesn't exist, show message and fall through to selector - setFailedAgentMessage( - `Unable to locate recently used agent ${localSettings.lastAgent}`, - ); + case "select": + setLoadingState("selecting_global"); + return; + case "create": { + const { ensureDefaultAgents } = await import("./agent/defaults"); + try { + const defaultAgent = await ensureDefaultAgents(client); + if (defaultAgent) { + setSelectedGlobalAgentId(defaultAgent.id); + setLoadingState("assembling"); + return; + } + // If null (createDefaultAgents disabled), fall through + } catch (err) { + console.error( + `Failed to create default agent: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + break; } } - // On self-hosted with unavailable default model, show selector to pick a model - if (needsModelPicker) { - setLoadingState("selecting_global"); - return; - } - - // Show selector if there are pinned agents to choose from - if (wouldShowSelector && globalPinned.length > 0) { - setLoadingState("selecting_global"); - return; - } - setLoadingState("assembling"); } checkAndStart(); @@ -1613,6 +1638,12 @@ async function main(): Promise { }); agent = result.agent; setAgentProvenance(result.provenance); + + // Enable memfs by default on Letta Cloud for new agents + const { enableMemfsIfCloud } = await import( + "./agent/memoryFilesystem" + ); + await enableMemfsIfCloud(agent.id); } // Priority 4: Try to resume from project settings LRU (.letta/settings.local.json) diff --git a/src/tests/nux-agent-resolution.test.ts b/src/tests/nux-agent-resolution.test.ts new file mode 100644 index 0000000..c830d7a --- /dev/null +++ b/src/tests/nux-agent-resolution.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from "bun:test"; +import { + resolveStartupTarget, + type StartupResolutionInput, +} from "../agent/resolve-startup-agent"; + +/** + * Unit tests for the NUX (new user experience) agent resolution logic. + * + * Core invariant: switching directories with a valid global LRU + * should NOT create a new agent — it should resume the global agent. + */ + +function makeInput( + overrides: Partial = {}, +): StartupResolutionInput { + return { + localAgentId: null, + localConversationId: null, + localAgentExists: false, + globalAgentId: null, + globalAgentExists: false, + mergedPinnedCount: 0, + forceNew: false, + needsModelPicker: false, + ...overrides, + }; +} + +describe("resolveStartupTarget", () => { + test("fresh dir + valid global LRU → resumes global agent", () => { + const result = resolveStartupTarget( + makeInput({ + globalAgentId: "agent-global-123", + globalAgentExists: true, + }), + ); + expect(result).toEqual({ + action: "resume", + agentId: "agent-global-123", + }); + }); + + test("fresh dir + invalid global LRU + has pinned → select", () => { + const result = resolveStartupTarget( + makeInput({ + globalAgentId: "agent-global-deleted", + globalAgentExists: false, + mergedPinnedCount: 3, + }), + ); + expect(result).toEqual({ action: "select" }); + }); + + test("fresh dir + invalid global LRU + no pinned → create", () => { + const result = resolveStartupTarget( + makeInput({ + globalAgentId: "agent-global-deleted", + globalAgentExists: false, + mergedPinnedCount: 0, + }), + ); + expect(result).toEqual({ action: "create" }); + }); + + test("dir with local LRU + valid agent → resumes local with conversation", () => { + const result = resolveStartupTarget( + makeInput({ + localAgentId: "agent-local-456", + localConversationId: "conv-local-789", + localAgentExists: true, + globalAgentId: "agent-global-123", + globalAgentExists: true, + }), + ); + expect(result).toEqual({ + action: "resume", + agentId: "agent-local-456", + conversationId: "conv-local-789", + }); + }); + + test("dir with local LRU + invalid agent + valid global → resumes global (no conv)", () => { + const result = resolveStartupTarget( + makeInput({ + localAgentId: "agent-local-deleted", + localConversationId: "conv-local-789", + localAgentExists: false, + globalAgentId: "agent-global-123", + globalAgentExists: true, + }), + ); + expect(result).toEqual({ + action: "resume", + agentId: "agent-global-123", + }); + }); + + test("true fresh user (no local, no global, no pinned) → create", () => { + const result = resolveStartupTarget(makeInput()); + expect(result).toEqual({ action: "create" }); + }); + + test("no LRU but pinned agents exist → select", () => { + const result = resolveStartupTarget( + makeInput({ + mergedPinnedCount: 2, + }), + ); + expect(result).toEqual({ action: "select" }); + }); + + test("forceNew = true → create (even with valid LRU)", () => { + const result = resolveStartupTarget( + makeInput({ + localAgentId: "agent-local-456", + localAgentExists: true, + globalAgentId: "agent-global-123", + globalAgentExists: true, + forceNew: true, + }), + ); + expect(result).toEqual({ action: "create" }); + }); + + test("needsModelPicker + no valid agents → select (not create)", () => { + const result = resolveStartupTarget( + makeInput({ + needsModelPicker: true, + }), + ); + expect(result).toEqual({ action: "select" }); + }); + + test("needsModelPicker takes priority over pinned selector", () => { + const result = resolveStartupTarget( + makeInput({ + needsModelPicker: true, + mergedPinnedCount: 5, + }), + ); + expect(result).toEqual({ action: "select" }); + }); + + test("local LRU with null conversation → resumes without conversation", () => { + const result = resolveStartupTarget( + makeInput({ + localAgentId: "agent-local-456", + localConversationId: null, + localAgentExists: true, + }), + ); + expect(result).toEqual({ + action: "resume", + agentId: "agent-local-456", + }); + }); + + test("global LRU never restores conversation (project-scoped)", () => { + // Even if global session had a conversation, resolveStartupTarget + // should NOT include it — conversations are project-scoped + const result = resolveStartupTarget( + makeInput({ + globalAgentId: "agent-global-123", + globalAgentExists: true, + }), + ); + expect(result).toEqual({ + action: "resume", + agentId: "agent-global-123", + }); + // Verify no conversationId key (not even undefined) + expect("conversationId" in result).toBe(false); + }); + + test("same local/global ID invalid + no pinned → create", () => { + const result = resolveStartupTarget( + makeInput({ + localAgentId: "agent-same", + localAgentExists: false, + globalAgentId: "agent-same", + globalAgentExists: false, + mergedPinnedCount: 0, + }), + ); + expect(result).toEqual({ action: "create" }); + }); + + test("same local/global ID invalid + pinned → select", () => { + const result = resolveStartupTarget( + makeInput({ + localAgentId: "agent-same", + localAgentExists: false, + globalAgentId: "agent-same", + globalAgentExists: false, + mergedPinnedCount: 1, + }), + ); + expect(result).toEqual({ action: "select" }); + }); +}); diff --git a/src/tests/startup-flow.test.ts b/src/tests/startup-flow.test.ts index 61aa840..729c31d 100644 --- a/src/tests/startup-flow.test.ts +++ b/src/tests/startup-flow.test.ts @@ -124,3 +124,20 @@ describe("Startup Flow - Flag Conflicts", () => { ); }); }); + +describe("Startup Flow - Smoke", () => { + test("--name conflicts with --new-agent", async () => { + const result = await runCli(["--name", "MyAgent", "--new-agent"], { + expectExit: 1, + }); + expect(result.stderr).toContain("--name cannot be used with --new"); + }); + + test("--new-agent headless parses and reaches credential check", async () => { + const result = await runCli(["--new-agent", "-p", "Say OK"], { + expectExit: 1, + }); + expect(result.stderr).toContain("Missing LETTA_API_KEY"); + expect(result.stderr).not.toContain("No recent session found"); + }); +}); diff --git a/src/tests/startup-resolution-files.test.ts b/src/tests/startup-resolution-files.test.ts new file mode 100644 index 0000000..74b21b6 --- /dev/null +++ b/src/tests/startup-resolution-files.test.ts @@ -0,0 +1,290 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { resolveStartupTarget } from "../agent/resolve-startup-agent"; +import { settingsManager } from "../settings-manager"; + +const originalHome = process.env.HOME; +const originalCwd = process.cwd(); + +let testHomeDir: string; +let testProjectDir: string; + +async function writeJson(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf-8"); +} + +async function writeGlobalSettings(settings: Record) { + await writeJson(join(testHomeDir, ".letta", "settings.json"), settings); +} + +async function writeLocalSettings(settings: Record) { + await writeJson( + join(testProjectDir, ".letta", "settings.local.json"), + settings, + ); +} + +async function resolveFromSettings(options?: { + existingAgentIds?: string[]; + includeLocalConversation?: boolean; + forceNew?: boolean; + needsModelPicker?: boolean; +}) { + const existing = new Set(options?.existingAgentIds ?? []); + + await settingsManager.initialize(); + await settingsManager.loadLocalProjectSettings(testProjectDir); + + const localAgentId = settingsManager.getLocalLastAgentId(testProjectDir); + const localSession = settingsManager.getLocalLastSession(testProjectDir); + const globalAgentId = settingsManager.getGlobalLastAgentId(); + + const localAgentExists = localAgentId ? existing.has(localAgentId) : false; + const globalAgentExists = globalAgentId ? existing.has(globalAgentId) : false; + const mergedPinnedCount = + settingsManager.getMergedPinnedAgents(testProjectDir).length; + + return resolveStartupTarget({ + localAgentId, + localConversationId: options?.includeLocalConversation + ? (localSession?.conversationId ?? null) + : null, + localAgentExists, + globalAgentId, + globalAgentExists, + mergedPinnedCount, + forceNew: options?.forceNew ?? false, + needsModelPicker: options?.needsModelPicker ?? false, + }); +} + +beforeEach(async () => { + await settingsManager.reset(); + testHomeDir = await mkdtemp(join(tmpdir(), "letta-startup-home-")); + testProjectDir = await mkdtemp(join(tmpdir(), "letta-startup-project-")); + process.env.HOME = testHomeDir; + process.chdir(testProjectDir); +}); + +afterEach(async () => { + await settingsManager.reset(); + process.chdir(originalCwd); + process.env.HOME = originalHome; + await rm(testHomeDir, { recursive: true, force: true }); + await rm(testProjectDir, { recursive: true, force: true }); +}); + +describe("startup resolution from settings files", () => { + test("no local/global settings files => create", async () => { + const target = await resolveFromSettings(); + expect(target).toEqual({ action: "create" }); + }); + + test("fresh dir + valid global session => resume global agent", async () => { + await writeGlobalSettings({ + sessionsByServer: { + "api.letta.com": { + agentId: "agent-global", + conversationId: "conv-global", + }, + }, + }); + + const target = await resolveFromSettings({ + existingAgentIds: ["agent-global"], + }); + + expect(target).toEqual({ + action: "resume", + agentId: "agent-global", + }); + }); + + test("local session + valid local agent => resume local agent", async () => { + await writeLocalSettings({ + sessionsByServer: { + "api.letta.com": { + agentId: "agent-local", + conversationId: "conv-local", + }, + }, + }); + + const target = await resolveFromSettings({ + existingAgentIds: ["agent-local"], + }); + + expect(target).toEqual({ + action: "resume", + agentId: "agent-local", + }); + }); + + test("headless parity mode: local session can carry local conversation", async () => { + await writeLocalSettings({ + sessionsByServer: { + "api.letta.com": { + agentId: "agent-local", + conversationId: "conv-local", + }, + }, + }); + + const target = await resolveFromSettings({ + existingAgentIds: ["agent-local"], + includeLocalConversation: true, + }); + + expect(target).toEqual({ + action: "resume", + agentId: "agent-local", + conversationId: "conv-local", + }); + }); + + test("invalid local + valid global => fallback resume global", async () => { + await writeLocalSettings({ + sessionsByServer: { + "api.letta.com": { + agentId: "agent-local-missing", + conversationId: "conv-local", + }, + }, + }); + await writeGlobalSettings({ + sessionsByServer: { + "api.letta.com": { + agentId: "agent-global", + conversationId: "conv-global", + }, + }, + }); + + const target = await resolveFromSettings({ + existingAgentIds: ["agent-global"], + }); + + expect(target).toEqual({ + action: "resume", + agentId: "agent-global", + }); + }); + + test("invalid local/global + global pinned => select", async () => { + await writeLocalSettings({ + sessionsByServer: { + "api.letta.com": { + agentId: "agent-local-missing", + conversationId: "conv-local", + }, + }, + }); + await writeGlobalSettings({ + sessionsByServer: { + "api.letta.com": { + agentId: "agent-global-missing", + conversationId: "conv-global", + }, + }, + pinnedAgentsByServer: { + "api.letta.com": ["agent-pinned-global"], + }, + }); + + const target = await resolveFromSettings(); + expect(target).toEqual({ action: "select" }); + }); + + test("invalid local/global + local pinned only => select", async () => { + await writeLocalSettings({ + sessionsByServer: { + "api.letta.com": { + agentId: "agent-local-missing", + conversationId: "conv-local", + }, + }, + pinnedAgentsByServer: { + "api.letta.com": ["agent-pinned-local"], + }, + }); + + const target = await resolveFromSettings(); + expect(target).toEqual({ action: "select" }); + }); + + test("no valid sessions + no pinned + needsModelPicker => select", async () => { + const target = await resolveFromSettings({ needsModelPicker: true }); + expect(target).toEqual({ action: "select" }); + }); + + test("forceNew always creates", async () => { + await writeLocalSettings({ + sessionsByServer: { + "api.letta.com": { + agentId: "agent-local", + conversationId: "conv-local", + }, + }, + }); + await writeGlobalSettings({ + sessionsByServer: { + "api.letta.com": { + agentId: "agent-global", + conversationId: "conv-global", + }, + }, + }); + + const target = await resolveFromSettings({ + existingAgentIds: ["agent-local", "agent-global"], + forceNew: true, + }); + + expect(target).toEqual({ action: "create" }); + }); + + test("sessionsByServer takes precedence over legacy lastAgent (global)", async () => { + await writeGlobalSettings({ + lastAgent: "agent-legacy-global", + sessionsByServer: { + "api.letta.com": { + agentId: "agent-session-global", + conversationId: "conv-session-global", + }, + }, + }); + + const target = await resolveFromSettings({ + existingAgentIds: ["agent-session-global"], + }); + + expect(target).toEqual({ + action: "resume", + agentId: "agent-session-global", + }); + }); + + test("sessionsByServer takes precedence over legacy lastAgent (local)", async () => { + await writeLocalSettings({ + lastAgent: "agent-legacy-local", + sessionsByServer: { + "api.letta.com": { + agentId: "agent-session-local", + conversationId: "conv-session-local", + }, + }, + }); + + const target = await resolveFromSettings({ + existingAgentIds: ["agent-session-local"], + }); + + expect(target).toEqual({ + action: "resume", + agentId: "agent-session-local", + }); + }); +});