feat: simplify startup flow with auto-select Memo for fresh users (#568)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -13,8 +13,8 @@ import { parseMdxFrontmatter } from "./memory";
|
||||
import { MEMORY_PROMPTS } from "./promptAssets";
|
||||
|
||||
// Tags used to identify default agents
|
||||
const MEMO_TAG = "default:memo";
|
||||
const INCOGNITO_TAG = "default:incognito";
|
||||
export const MEMO_TAG = "default:memo";
|
||||
export const INCOGNITO_TAG = "default:incognito";
|
||||
|
||||
// Memo's persona - loaded from persona_memo.mdx
|
||||
const MEMO_PERSONA = parseMdxFrontmatter(
|
||||
@@ -125,8 +125,9 @@ export async function ensureDefaultAgents(
|
||||
settingsManager.pinGlobal(agent.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Warning: Failed to ensure default agents: ${err instanceof Error ? err.message : String(err)}`,
|
||||
// Re-throw so caller can handle/exit appropriately
|
||||
throw new Error(
|
||||
`Failed to create default agents: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -206,6 +206,26 @@ export async function handleHeadlessCommand(
|
||||
const sleeptimeFlag = (values.sleeptime as boolean | undefined) ?? undefined;
|
||||
const fromAfFile = values["from-af"] as string | undefined;
|
||||
|
||||
// Validate --conversation flag (mutually exclusive with agent-selection flags)
|
||||
if (specifiedConversationId) {
|
||||
if (specifiedAgentId) {
|
||||
console.error("Error: --conversation cannot be used with --agent");
|
||||
process.exit(1);
|
||||
}
|
||||
if (forceNew) {
|
||||
console.error("Error: --conversation cannot be used with --new-agent");
|
||||
process.exit(1);
|
||||
}
|
||||
if (fromAfFile) {
|
||||
console.error("Error: --conversation cannot be used with --from-af");
|
||||
process.exit(1);
|
||||
}
|
||||
if (shouldContinue) {
|
||||
console.error("Error: --conversation cannot be used with --continue");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate --from-af flag
|
||||
if (fromAfFile) {
|
||||
if (specifiedAgentId) {
|
||||
@@ -340,8 +360,21 @@ export async function handleHeadlessCommand(
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 0: --conversation derives agent from conversation ID
|
||||
if (specifiedConversationId) {
|
||||
try {
|
||||
const conversation = await client.conversations.retrieve(
|
||||
specifiedConversationId,
|
||||
);
|
||||
agent = await client.agents.retrieve(conversation.agent_id);
|
||||
} catch (_error) {
|
||||
console.error(`Conversation ${specifiedConversationId} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 1: Import from AgentFile template
|
||||
if (fromAfFile) {
|
||||
if (!agent && fromAfFile) {
|
||||
const { importAgentFromFile } = await import("./agent/import");
|
||||
const result = await importAgentFromFile({
|
||||
filePath: fromAfFile,
|
||||
@@ -356,7 +389,8 @@ export async function handleHeadlessCommand(
|
||||
try {
|
||||
agent = await client.agents.retrieve(specifiedAgentId);
|
||||
} catch (_error) {
|
||||
console.error(`Agent ${specifiedAgentId} not found, creating new one...`);
|
||||
console.error(`Agent ${specifiedAgentId} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,38 +423,36 @@ export async function handleHeadlessCommand(
|
||||
try {
|
||||
agent = await client.agents.retrieve(localProjectSettings.lastAgent);
|
||||
} catch (_error) {
|
||||
// Local LRU agent doesn't exist - log and continue
|
||||
console.error(
|
||||
`Project agent ${localProjectSettings.lastAgent} not found, creating new one...`,
|
||||
`Unable to locate agent ${localProjectSettings.lastAgent} in .letta/`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 5: Try to reuse global lastAgent if --continue flag is passed
|
||||
if (!agent && shouldContinue && settings.lastAgent) {
|
||||
try {
|
||||
agent = await client.agents.retrieve(settings.lastAgent);
|
||||
} catch (_error) {
|
||||
console.error(
|
||||
`Previous agent ${settings.lastAgent} not found, creating new one...`,
|
||||
);
|
||||
if (!agent && shouldContinue) {
|
||||
if (settings.lastAgent) {
|
||||
try {
|
||||
agent = await client.agents.retrieve(settings.lastAgent);
|
||||
} 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: Create a new agent
|
||||
// All paths should have resolved to an agent by now
|
||||
// If not, it's an unexpected state - error out instead of auto-creating
|
||||
if (!agent) {
|
||||
const updateArgs = getModelUpdateArgs(model);
|
||||
const createOptions = {
|
||||
model,
|
||||
updateArgs,
|
||||
skillsDirectory,
|
||||
parallelToolCalls: true,
|
||||
enableSleeptime: sleeptimeFlag ?? settings.enableSleeptime,
|
||||
systemPromptPreset,
|
||||
// Note: systemCustom, systemAppend, and memoryBlocks only apply with --new flag
|
||||
};
|
||||
const result = await createAgent(createOptions);
|
||||
agent = result.agent;
|
||||
console.error("No agent found. Use --new-agent to create a new agent.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if we're resuming an existing agent (not creating a new one)
|
||||
|
||||
315
src/index.ts
315
src/index.ts
@@ -7,6 +7,7 @@ import { getResumeData, type ResumeData } from "./agent/check-approval";
|
||||
import { getClient } from "./agent/client";
|
||||
import { initializeLoadedSkillsFlag, setAgentContext } from "./agent/context";
|
||||
import type { AgentProvenance } from "./agent/create";
|
||||
import { INCOGNITO_TAG, MEMO_TAG } from "./agent/defaults";
|
||||
import { ensureSkillsBlocks, ISOLATED_BLOCK_LABELS } from "./agent/memory";
|
||||
import { LETTA_CLOUD_API_URL } from "./auth/oauth";
|
||||
import { ConversationSelector } from "./cli/components/ConversationSelector";
|
||||
@@ -23,6 +24,37 @@ import { loadTools } from "./tools/manager";
|
||||
const EMPTY_APPROVAL_ARRAY: ApprovalRequest[] = [];
|
||||
const EMPTY_MESSAGE_ARRAY: Message[] = [];
|
||||
|
||||
/**
|
||||
* Check if pinned agents consist only of default agents (Memo + Incognito).
|
||||
* Used to auto-select Memo for fresh users without showing a selector.
|
||||
*/
|
||||
async function hasOnlyDefaultAgents(
|
||||
pinnedIds: string[],
|
||||
): Promise<{ onlyDefaults: boolean; memoId: string | null }> {
|
||||
if (pinnedIds.length === 0) return { onlyDefaults: true, memoId: null };
|
||||
if (pinnedIds.length > 2) return { onlyDefaults: false, memoId: null };
|
||||
|
||||
const client = await getClient();
|
||||
let memoId: string | null = null;
|
||||
|
||||
for (const id of pinnedIds) {
|
||||
try {
|
||||
const agent = await client.agents.retrieve(id);
|
||||
const tags = agent.tags || [];
|
||||
if (tags.includes(MEMO_TAG)) {
|
||||
memoId = agent.id;
|
||||
} else if (!tags.includes(INCOGNITO_TAG)) {
|
||||
// Found a non-default agent
|
||||
return { onlyDefaults: false, memoId: null };
|
||||
}
|
||||
} catch {
|
||||
// Agent doesn't exist, skip it
|
||||
}
|
||||
}
|
||||
|
||||
return { onlyDefaults: true, memoId };
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
// Keep this plaintext (no colors) so output pipes cleanly
|
||||
const usage = `
|
||||
@@ -576,6 +608,34 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate --conversation flag (mutually exclusive with agent-selection flags)
|
||||
if (specifiedConversationId) {
|
||||
if (specifiedAgentId) {
|
||||
console.error("Error: --conversation cannot be used with --agent");
|
||||
process.exit(1);
|
||||
}
|
||||
if (specifiedAgentName) {
|
||||
console.error("Error: --conversation cannot be used with --name");
|
||||
process.exit(1);
|
||||
}
|
||||
if (forceNew) {
|
||||
console.error("Error: --conversation cannot be used with --new-agent");
|
||||
process.exit(1);
|
||||
}
|
||||
if (fromAfFile) {
|
||||
console.error("Error: --conversation cannot be used with --from-af");
|
||||
process.exit(1);
|
||||
}
|
||||
if (shouldResume) {
|
||||
console.error("Error: --conversation cannot be used with --resume");
|
||||
process.exit(1);
|
||||
}
|
||||
if (shouldContinue) {
|
||||
console.error("Error: --conversation cannot be used with --continue");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate --from-af flag
|
||||
if (fromAfFile) {
|
||||
if (specifiedAgentId) {
|
||||
@@ -936,70 +996,206 @@ async function main(): Promise<void> {
|
||||
await settingsManager.loadLocalProjectSettings();
|
||||
const localSettings = settingsManager.getLocalProjectSettings();
|
||||
let globalPinned = settingsManager.getGlobalPinnedAgents();
|
||||
|
||||
// Check if user would see selector (fresh dir, no bypass flags)
|
||||
const wouldShowSelector =
|
||||
!localSettings.lastAgent &&
|
||||
!forceNew &&
|
||||
!agentIdArg &&
|
||||
!fromAfFile &&
|
||||
!continueSession;
|
||||
|
||||
// Ensure default agents (Memo/Incognito) exist for all users
|
||||
const client = await getClient();
|
||||
const { ensureDefaultAgents } = await import("./agent/defaults");
|
||||
|
||||
if (wouldShowSelector && globalPinned.length === 0) {
|
||||
// New user with no agents - block and show loading while creating defaults
|
||||
setLoadingState("assembling");
|
||||
// =====================================================================
|
||||
// TOP-LEVEL PATH: --conversation <id>
|
||||
// Conversation ID is unique, so we can derive the agent from it
|
||||
// =====================================================================
|
||||
if (specifiedConversationId) {
|
||||
try {
|
||||
await ensureDefaultAgents(client);
|
||||
// Refresh pinned list after defaults created
|
||||
globalPinned = settingsManager.getGlobalPinnedAgents();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Warning: Failed to create default agents: ${err instanceof Error ? err.message : String(err)}`,
|
||||
const conversation = await client.conversations.retrieve(
|
||||
specifiedConversationId,
|
||||
);
|
||||
// Use the agent that owns this conversation
|
||||
setSelectedGlobalAgentId(conversation.agent_id);
|
||||
setSelectedConversationId(specifiedConversationId);
|
||||
setLoadingState("assembling");
|
||||
return;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof APIError &&
|
||||
(error.status === 404 || error.status === 422)
|
||||
) {
|
||||
console.error(
|
||||
`Conversation ${specifiedConversationId} not found`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// Existing user - fire and forget, don't block startup
|
||||
ensureDefaultAgents(client).catch((err) =>
|
||||
console.warn(
|
||||
`Warning: Failed to ensure default agents: ${err instanceof Error ? err.message : String(err)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle --resume flag: show conversation selector directly
|
||||
// =====================================================================
|
||||
// TOP-LEVEL PATH: --resume
|
||||
// Show conversation selector for last-used agent (local → global fallback)
|
||||
// =====================================================================
|
||||
if (shouldResume) {
|
||||
// Find the last-used agent for this project
|
||||
const lastSession =
|
||||
settingsManager.getLocalLastSession(process.cwd()) ??
|
||||
settingsManager.getGlobalLastSession();
|
||||
const lastAgentId = lastSession?.agentId ?? localSettings.lastAgent;
|
||||
const localSession = settingsManager.getLocalLastSession(
|
||||
process.cwd(),
|
||||
);
|
||||
const localAgentId = localSession?.agentId ?? localSettings.lastAgent;
|
||||
|
||||
if (lastAgentId) {
|
||||
// Verify agent exists
|
||||
// Try local LRU first
|
||||
if (localAgentId) {
|
||||
try {
|
||||
const agent = await client.agents.retrieve(lastAgentId);
|
||||
setResumeAgentId(lastAgentId);
|
||||
const agent = await client.agents.retrieve(localAgentId);
|
||||
setResumeAgentId(localAgentId);
|
||||
setResumeAgentName(agent.name ?? null);
|
||||
setLoadingState("selecting_conversation");
|
||||
return;
|
||||
} catch {
|
||||
// Agent doesn't exist, fall through to normal flow
|
||||
// Local agent doesn't exist, try global
|
||||
console.log(
|
||||
`Unable to locate agent ${localAgentId} in .letta/, checking global (~/.letta)`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log("No recent agent in .letta/, using global (~/.letta)");
|
||||
}
|
||||
|
||||
// Try global LRU
|
||||
const globalSession = settingsManager.getGlobalLastSession();
|
||||
const globalAgentId = globalSession?.agentId;
|
||||
if (globalAgentId) {
|
||||
try {
|
||||
const agent = await client.agents.retrieve(globalAgentId);
|
||||
setResumeAgentId(globalAgentId);
|
||||
setResumeAgentName(agent.name ?? null);
|
||||
setLoadingState("selecting_conversation");
|
||||
return;
|
||||
} catch {
|
||||
// Global agent also doesn't exist
|
||||
}
|
||||
}
|
||||
// No valid agent found, fall through to normal startup
|
||||
|
||||
// No valid agent found anywhere
|
||||
console.error("No recent session found in .letta/ or ~/.letta.");
|
||||
console.error("Run 'letta' to get started.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Show selector if:
|
||||
// 1. No lastAgent in this project (fresh directory)
|
||||
// 2. No explicit flags that bypass selection (--new, --agent, --from-af, --continue)
|
||||
// 3. Has global pinned agents available
|
||||
const shouldShowSelector = wouldShowSelector && globalPinned.length > 0;
|
||||
// =====================================================================
|
||||
// TOP-LEVEL PATH: --continue
|
||||
// Resume last session directly (local → global fallback)
|
||||
// =====================================================================
|
||||
if (continueSession) {
|
||||
const localSession = settingsManager.getLocalLastSession(
|
||||
process.cwd(),
|
||||
);
|
||||
const localAgentId = localSession?.agentId ?? localSettings.lastAgent;
|
||||
|
||||
if (shouldShowSelector) {
|
||||
// Try local LRU first
|
||||
if (localAgentId) {
|
||||
try {
|
||||
await client.agents.retrieve(localAgentId);
|
||||
setSelectedGlobalAgentId(localAgentId);
|
||||
if (localSession?.conversationId) {
|
||||
setSelectedConversationId(localSession.conversationId);
|
||||
}
|
||||
setLoadingState("assembling");
|
||||
return;
|
||||
} catch {
|
||||
// Local agent doesn't exist, try global
|
||||
console.log(
|
||||
`Unable to locate agent ${localAgentId} in .letta/, checking global (~/.letta)`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log("No recent agent in .letta/, using global (~/.letta)");
|
||||
}
|
||||
|
||||
// Try global LRU
|
||||
const globalSession = settingsManager.getGlobalLastSession();
|
||||
const globalAgentId = globalSession?.agentId;
|
||||
if (globalAgentId) {
|
||||
try {
|
||||
await client.agents.retrieve(globalAgentId);
|
||||
setSelectedGlobalAgentId(globalAgentId);
|
||||
if (globalSession?.conversationId) {
|
||||
setSelectedConversationId(globalSession.conversationId);
|
||||
}
|
||||
setLoadingState("assembling");
|
||||
return;
|
||||
} catch {
|
||||
// Global agent also doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
// No valid agent found anywhere
|
||||
console.error("No recent session found in .letta/ or ~/.letta.");
|
||||
console.error("Run 'letta' to get started.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// DEFAULT PATH: No special flags
|
||||
// Check local LRU, then selector, then defaults
|
||||
// =====================================================================
|
||||
|
||||
// Check if user would see selector (fresh dir, no bypass flags)
|
||||
const wouldShowSelector =
|
||||
!localSettings.lastAgent && !forceNew && !agentIdArg && !fromAfFile;
|
||||
|
||||
// Ensure default agents (Memo/Incognito) exist for all users
|
||||
const { ensureDefaultAgents } = await import("./agent/defaults");
|
||||
|
||||
if (wouldShowSelector && globalPinned.length === 0) {
|
||||
// New user with no agents - create defaults first, then trigger init
|
||||
// NOTE: Don't set loadingState to "assembling" until we have the agent ID,
|
||||
// otherwise init will run before we've set selectedGlobalAgentId
|
||||
try {
|
||||
const memoAgent = await ensureDefaultAgents(client);
|
||||
// Refresh pinned list after defaults created
|
||||
globalPinned = settingsManager.getGlobalPinnedAgents();
|
||||
// Auto-select Memo for fresh users
|
||||
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)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// Existing user - fire and forget, don't block startup
|
||||
ensureDefaultAgents(client).catch(() => {
|
||||
// Silently ignore - defaults may already exist
|
||||
});
|
||||
}
|
||||
|
||||
// If there's a local LRU, use it directly
|
||||
if (localSettings.lastAgent) {
|
||||
try {
|
||||
await client.agents.retrieve(localSettings.lastAgent);
|
||||
setLoadingState("assembling");
|
||||
return;
|
||||
} catch {
|
||||
// LRU agent doesn't exist, show message and fall through to selector
|
||||
console.log(
|
||||
`Unable to locate recently used agent ${localSettings.lastAgent}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should show selector or auto-select Memo
|
||||
if (wouldShowSelector && globalPinned.length > 0) {
|
||||
// Check if only default agents are pinned
|
||||
const { onlyDefaults, memoId } =
|
||||
await hasOnlyDefaultAgents(globalPinned);
|
||||
|
||||
if (onlyDefaults && memoId) {
|
||||
// Only defaults pinned - auto-select Memo
|
||||
setSelectedGlobalAgentId(memoId);
|
||||
setLoadingState("assembling");
|
||||
return;
|
||||
}
|
||||
|
||||
// Has custom agents - show selector
|
||||
setLoadingState("selecting_global");
|
||||
return;
|
||||
}
|
||||
@@ -1007,7 +1203,14 @@ async function main(): Promise<void> {
|
||||
setLoadingState("assembling");
|
||||
}
|
||||
checkAndStart();
|
||||
}, [forceNew, agentIdArg, fromAfFile, continueSession, shouldResume]);
|
||||
}, [
|
||||
forceNew,
|
||||
agentIdArg,
|
||||
fromAfFile,
|
||||
continueSession,
|
||||
shouldResume,
|
||||
specifiedConversationId,
|
||||
]);
|
||||
|
||||
// Main initialization effect - runs after profile selection
|
||||
useEffect(() => {
|
||||
@@ -1196,23 +1399,13 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 7: Create a new agent
|
||||
// All paths should have resolved to an agent by now
|
||||
// If not, it's an unexpected state - error out instead of auto-creating
|
||||
if (!agent) {
|
||||
const updateArgs = getModelUpdateArgs(model);
|
||||
const result = await createAgent(
|
||||
undefined,
|
||||
model,
|
||||
undefined,
|
||||
updateArgs,
|
||||
skillsDirectory,
|
||||
true, // parallelToolCalls always enabled
|
||||
sleeptimeFlag ?? settings.enableSleeptime,
|
||||
systemPromptPreset,
|
||||
undefined,
|
||||
undefined,
|
||||
console.error(
|
||||
"No agent found. Use --new-agent to create a new agent.",
|
||||
);
|
||||
agent = result.agent;
|
||||
setAgentProvenance(result.provenance);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure local project settings are loaded before updating
|
||||
|
||||
338
src/tests/startup-flow.test.ts
Normal file
338
src/tests/startup-flow.test.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Integration tests for CLI startup flows.
|
||||
*
|
||||
* These tests verify the boot flow decision tree:
|
||||
* - Flag conflict detection
|
||||
* - --conversation: derives agent from conversation
|
||||
* - --agent: uses specified agent
|
||||
* - --new-agent: creates new agent
|
||||
* - Error messages for invalid inputs
|
||||
*
|
||||
* Note: Tests that depend on settings files (.letta/) are harder to isolate
|
||||
* because the CLI uses process.cwd(). For now, we focus on flag-based tests.
|
||||
*/
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
// Helper to run CLI and capture output
|
||||
async function runCli(
|
||||
args: string[],
|
||||
options: {
|
||||
timeoutMs?: number;
|
||||
expectExit?: number;
|
||||
} = {},
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
|
||||
const { timeoutMs = 30000, expectExit } = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("bun", ["run", "dev", ...args], {
|
||||
cwd: projectRoot,
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(
|
||||
new Error(
|
||||
`Timeout after ${timeoutMs}ms. stdout: ${stdout}, stderr: ${stderr}`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
|
||||
proc.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (expectExit !== undefined && code !== expectExit) {
|
||||
reject(
|
||||
new Error(
|
||||
`Expected exit code ${expectExit}, got ${code}. stdout: ${stdout}, stderr: ${stderr}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
resolve({ stdout, stderr, exitCode: code });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Flag Conflict Tests (fast, no API calls needed)
|
||||
// ============================================================================
|
||||
|
||||
describe("Startup Flow - Flag Conflicts", () => {
|
||||
test("--conversation conflicts with --agent", async () => {
|
||||
const result = await runCli(
|
||||
["--conversation", "conv-123", "--agent", "agent-123"],
|
||||
{ expectExit: 1 },
|
||||
);
|
||||
expect(result.stderr).toContain(
|
||||
"--conversation cannot be used with --agent",
|
||||
);
|
||||
});
|
||||
|
||||
test("--conversation conflicts with --new-agent", async () => {
|
||||
const result = await runCli(["--conversation", "conv-123", "--new-agent"], {
|
||||
expectExit: 1,
|
||||
});
|
||||
expect(result.stderr).toContain(
|
||||
"--conversation cannot be used with --new-agent",
|
||||
);
|
||||
});
|
||||
|
||||
test("--conversation conflicts with --resume", async () => {
|
||||
const result = await runCli(["--conversation", "conv-123", "--resume"], {
|
||||
expectExit: 1,
|
||||
});
|
||||
expect(result.stderr).toContain(
|
||||
"--conversation cannot be used with --resume",
|
||||
);
|
||||
});
|
||||
|
||||
test("--conversation conflicts with --continue", async () => {
|
||||
const result = await runCli(["--conversation", "conv-123", "--continue"], {
|
||||
expectExit: 1,
|
||||
});
|
||||
expect(result.stderr).toContain(
|
||||
"--conversation cannot be used with --continue",
|
||||
);
|
||||
});
|
||||
|
||||
test("--conversation conflicts with --from-af", async () => {
|
||||
const result = await runCli(
|
||||
["--conversation", "conv-123", "--from-af", "test.af"],
|
||||
{ expectExit: 1 },
|
||||
);
|
||||
expect(result.stderr).toContain(
|
||||
"--conversation cannot be used with --from-af",
|
||||
);
|
||||
});
|
||||
|
||||
test("--conversation conflicts with --name", async () => {
|
||||
const result = await runCli(
|
||||
["--conversation", "conv-123", "--name", "MyAgent"],
|
||||
{ expectExit: 1 },
|
||||
);
|
||||
expect(result.stderr).toContain(
|
||||
"--conversation cannot be used with --name",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Invalid Input Tests (require API calls but fail fast)
|
||||
// ============================================================================
|
||||
|
||||
describe("Startup Flow - Invalid Inputs", () => {
|
||||
test(
|
||||
"--agent with nonexistent ID shows error",
|
||||
async () => {
|
||||
const result = await runCli(
|
||||
["--agent", "agent-definitely-does-not-exist-12345", "-p", "test"],
|
||||
{ expectExit: 1, timeoutMs: 60000 },
|
||||
);
|
||||
expect(result.stderr).toContain("not found");
|
||||
},
|
||||
{ timeout: 70000 },
|
||||
);
|
||||
|
||||
test(
|
||||
"--conversation with nonexistent ID shows error",
|
||||
async () => {
|
||||
const result = await runCli(
|
||||
[
|
||||
"--conversation",
|
||||
"conversation-definitely-does-not-exist-12345",
|
||||
"-p",
|
||||
"test",
|
||||
],
|
||||
{ expectExit: 1, timeoutMs: 60000 },
|
||||
);
|
||||
expect(result.stderr).toContain("not found");
|
||||
},
|
||||
{ timeout: 70000 },
|
||||
);
|
||||
|
||||
test("--from-af with nonexistent file shows error", async () => {
|
||||
const result = await runCli(
|
||||
["--from-af", "/nonexistent/path/agent.af", "-p", "test"],
|
||||
{ expectExit: 1 },
|
||||
);
|
||||
expect(result.stderr).toContain("not found");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Integration Tests (require API access, create real agents)
|
||||
// ============================================================================
|
||||
|
||||
describe("Startup Flow - Integration", () => {
|
||||
// Store created agent/conversation IDs for cleanup and reuse
|
||||
let testAgentId: string | null = null;
|
||||
let testConversationId: string | null = null;
|
||||
|
||||
test(
|
||||
"--new-agent creates agent and responds",
|
||||
async () => {
|
||||
const result = await runCli(
|
||||
[
|
||||
"--new-agent",
|
||||
"-m",
|
||||
"haiku",
|
||||
"-p",
|
||||
"Say OK and nothing else",
|
||||
"--output-format",
|
||||
"json",
|
||||
],
|
||||
{ timeoutMs: 120000 },
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
// stdout includes the bun invocation line, extract just the JSON
|
||||
const jsonStart = result.stdout.indexOf("{");
|
||||
const output = JSON.parse(result.stdout.slice(jsonStart));
|
||||
expect(output.agent_id).toBeDefined();
|
||||
expect(output.result).toBeDefined();
|
||||
|
||||
// Save for later tests
|
||||
testAgentId = output.agent_id;
|
||||
testConversationId = output.conversation_id;
|
||||
},
|
||||
{ timeout: 130000 },
|
||||
);
|
||||
|
||||
test(
|
||||
"--agent with valid ID uses that agent",
|
||||
async () => {
|
||||
// Skip if previous test didn't create an agent
|
||||
if (!testAgentId) {
|
||||
console.log("Skipping: no test agent available");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await runCli(
|
||||
[
|
||||
"--agent",
|
||||
testAgentId,
|
||||
"-m",
|
||||
"haiku",
|
||||
"-p",
|
||||
"Say OK",
|
||||
"--output-format",
|
||||
"json",
|
||||
],
|
||||
{ timeoutMs: 120000 },
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const jsonStart = result.stdout.indexOf("{");
|
||||
const output = JSON.parse(result.stdout.slice(jsonStart));
|
||||
expect(output.agent_id).toBe(testAgentId);
|
||||
},
|
||||
{ timeout: 130000 },
|
||||
);
|
||||
|
||||
test(
|
||||
"--conversation with valid ID derives agent and uses conversation",
|
||||
async () => {
|
||||
// Skip if previous test didn't create an agent/conversation
|
||||
if (!testAgentId || !testConversationId) {
|
||||
console.log("Skipping: no test conversation available");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await runCli(
|
||||
[
|
||||
"--conversation",
|
||||
testConversationId,
|
||||
"-m",
|
||||
"haiku",
|
||||
"-p",
|
||||
"Say OK",
|
||||
"--output-format",
|
||||
"json",
|
||||
],
|
||||
{ timeoutMs: 120000 },
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const jsonStart = result.stdout.indexOf("{");
|
||||
const output = JSON.parse(result.stdout.slice(jsonStart));
|
||||
// Should use the same agent that owns the conversation
|
||||
expect(output.agent_id).toBe(testAgentId);
|
||||
// Should use the specified conversation
|
||||
expect(output.conversation_id).toBe(testConversationId);
|
||||
},
|
||||
{ timeout: 130000 },
|
||||
);
|
||||
|
||||
test(
|
||||
"--new-agent with --init-blocks none creates minimal agent",
|
||||
async () => {
|
||||
const result = await runCli(
|
||||
[
|
||||
"--new-agent",
|
||||
"--init-blocks",
|
||||
"none",
|
||||
"-m",
|
||||
"haiku",
|
||||
"-p",
|
||||
"Say OK",
|
||||
"--output-format",
|
||||
"json",
|
||||
],
|
||||
{ timeoutMs: 120000 },
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
// stdout includes the bun invocation line, extract just the JSON
|
||||
const jsonStart = result.stdout.indexOf("{");
|
||||
const output = JSON.parse(result.stdout.slice(jsonStart));
|
||||
expect(output.agent_id).toBeDefined();
|
||||
},
|
||||
{ timeout: 130000 },
|
||||
);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// --continue Tests (depend on LRU state, harder to isolate)
|
||||
// ============================================================================
|
||||
|
||||
describe("Startup Flow - Continue Flag", () => {
|
||||
test(
|
||||
"--continue with no LRU shows error",
|
||||
async () => {
|
||||
// This test relies on running in a directory with no .letta/ settings
|
||||
// In practice, this might use the project's .letta/ which has an LRU
|
||||
// So we check for either success (if LRU exists) or error (if not)
|
||||
const result = await runCli(
|
||||
["--continue", "-p", "Say OK", "--output-format", "json"],
|
||||
{ timeoutMs: 60000 },
|
||||
);
|
||||
|
||||
// Either succeeds (LRU exists) or fails with specific error
|
||||
if (result.exitCode !== 0) {
|
||||
expect(result.stderr).toContain("No recent session found");
|
||||
}
|
||||
// If it succeeds, that's also valid (test env has LRU)
|
||||
},
|
||||
{ timeout: 70000 },
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user