fix(cli): resolve global LRU agent on directory switch instead of cre… (#951)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -31,7 +31,7 @@ const INCOGNITO_DESCRIPTION =
|
||||
*/
|
||||
export const DEFAULT_AGENT_CONFIGS: Record<string, CreateAgentOptions> = {
|
||||
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
|
||||
|
||||
@@ -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<void> {
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
88
src/agent/resolve-startup-agent.ts
Normal file
88
src/agent/resolve-startup-agent.ts
Normal file
@@ -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" };
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
123
src/index.ts
123
src/index.ts
@@ -1147,7 +1147,6 @@ async function main(): Promise<void> {
|
||||
// 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<void> {
|
||||
|
||||
// =====================================================================
|
||||
// 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<void> {
|
||||
});
|
||||
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)
|
||||
|
||||
201
src/tests/nux-agent-resolution.test.ts
Normal file
201
src/tests/nux-agent-resolution.test.ts
Normal file
@@ -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> = {},
|
||||
): 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" });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
290
src/tests/startup-resolution-files.test.ts
Normal file
290
src/tests/startup-resolution-files.test.ts
Normal file
@@ -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<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
|
||||
}
|
||||
|
||||
async function writeGlobalSettings(settings: Record<string, unknown>) {
|
||||
await writeJson(join(testHomeDir, ".letta", "settings.json"), settings);
|
||||
}
|
||||
|
||||
async function writeLocalSettings(settings: Record<string, unknown>) {
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user