diff --git a/src/agent/check-approval.ts b/src/agent/check-approval.ts index a226b2f..f478f47 100644 --- a/src/agent/check-approval.ts +++ b/src/agent/check-approval.ts @@ -2,6 +2,7 @@ // Check for pending approvals and retrieve recent message history when resuming an agent/conversation import type Letta from "@letta-ai/letta-client"; +import { APIError } from "@letta-ai/letta-client/core/error"; import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents"; import type { Message } from "@letta-ai/letta-client/resources/agents/messages"; import type { ApprovalRequest } from "../cli/helpers/stream"; @@ -293,6 +294,14 @@ export async function getResumeData( }; } } catch (error) { + // Re-throw "not found" errors (404/422) so callers can handle appropriately + // (e.g., /resume command should fail for non-existent conversations) + if ( + error instanceof APIError && + (error.status === 404 || error.status === 422) + ) { + throw error; + } console.error("Error getting resume data:", error); return { pendingApproval: null, pendingApprovals: [], messageHistory: [] }; } diff --git a/src/cli/App.tsx b/src/cli/App.tsx index dd5a621..72335e9 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -4332,18 +4332,8 @@ export default function App({ refreshDerived(); try { - // Update conversation ID and settings - setConversationId(targetConvId); - settingsManager.setLocalLastSession( - { agentId, conversationId: targetConvId }, - process.cwd(), - ); - settingsManager.setGlobalLastSession({ - agentId, - conversationId: targetConvId, - }); - - // Fetch message history for the selected conversation + // Validate conversation exists BEFORE updating state + // (getResumeData throws 404/422 for non-existent conversations) if (agentState) { const client = await getClient(); const resumeData = await getResumeData( @@ -4352,6 +4342,17 @@ export default function App({ targetConvId, ); + // Only update state after validation succeeds + setConversationId(targetConvId); + settingsManager.setLocalLastSession( + { agentId, conversationId: targetConvId }, + process.cwd(), + ); + settingsManager.setGlobalLastSession({ + agentId, + conversationId: targetConvId, + }); + // Clear current transcript and static items buffersRef.current.byId.clear(); buffersRef.current.order = []; @@ -4442,16 +4443,28 @@ export default function App({ } } } catch (error) { - const errorCmdId = uid("cmd"); - buffersRef.current.byId.set(errorCmdId, { + // Update existing loading message instead of creating new one + // Format error message to be user-friendly (avoid raw JSON/internal details) + let errorMsg = "Unknown error"; + if (error instanceof APIError) { + if (error.status === 404) { + errorMsg = "Conversation not found"; + } else if (error.status === 422) { + errorMsg = "Invalid conversation ID"; + } else { + errorMsg = error.message; + } + } else if (error instanceof Error) { + errorMsg = error.message; + } + buffersRef.current.byId.set(cmdId, { kind: "command", - id: errorCmdId, + id: cmdId, input: msg.trim(), - output: `Failed to switch conversation: ${error instanceof Error ? error.message : String(error)}`, + output: `Failed to switch conversation: ${errorMsg}`, phase: "finished", success: false, }); - buffersRef.current.order.push(errorCmdId); refreshDerived(); } finally { setCommandRunning(false); @@ -7454,18 +7467,8 @@ Plan file path: ${planFilePath}`; refreshDerived(); try { - // Update conversation ID and settings - setConversationId(convId); - settingsManager.setLocalLastSession( - { agentId, conversationId: convId }, - process.cwd(), - ); - settingsManager.setGlobalLastSession({ - agentId, - conversationId: convId, - }); - - // Fetch message history for the selected conversation + // Validate conversation exists BEFORE updating state + // (getResumeData throws 404/422 for non-existent conversations) if (agentState) { const client = await getClient(); const resumeData = await getResumeData( @@ -7474,6 +7477,17 @@ Plan file path: ${planFilePath}`; convId, ); + // Only update state after validation succeeds + setConversationId(convId); + settingsManager.setLocalLastSession( + { agentId, conversationId: convId }, + process.cwd(), + ); + settingsManager.setGlobalLastSession({ + agentId, + conversationId: convId, + }); + // Clear current transcript and static items buffersRef.current.byId.clear(); buffersRef.current.order = []; @@ -7574,16 +7588,28 @@ Plan file path: ${planFilePath}`; } } } catch (error) { - const errorCmdId = uid("cmd"); - buffersRef.current.byId.set(errorCmdId, { + // Update existing loading message instead of creating new one + // Format error message to be user-friendly (avoid raw JSON/internal details) + let errorMsg = "Unknown error"; + if (error instanceof APIError) { + if (error.status === 404) { + errorMsg = "Conversation not found"; + } else if (error.status === 422) { + errorMsg = "Invalid conversation ID"; + } else { + errorMsg = error.message; + } + } else if (error instanceof Error) { + errorMsg = error.message; + } + buffersRef.current.byId.set(cmdId, { kind: "command", - id: errorCmdId, + id: cmdId, input: inputCmd, - output: `Failed to switch conversation: ${error instanceof Error ? error.message : String(error)}`, + output: `Failed to switch conversation: ${errorMsg}`, phase: "finished", success: false, }); - buffersRef.current.order.push(errorCmdId); refreshDerived(); } finally { setCommandRunning(false); diff --git a/src/headless.ts b/src/headless.ts index 0a614c9..0a8aa11 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -1,5 +1,6 @@ import { parseArgs } from "node:util"; import type { Letta } from "@letta-ai/letta-client"; +import { APIError } from "@letta-ai/letta-client/core/error"; import type { AgentState, MessageCreate, @@ -563,7 +564,20 @@ export async function handleHeadlessCommand( while (true) { // Re-fetch agent to get latest in-context messages (source of truth for backend) const freshAgent = await client.agents.retrieve(agent.id); - const resume = await getResumeData(client, freshAgent); + + let resume: Awaited>; + try { + resume = await getResumeData(client, freshAgent); + } catch (error) { + // Treat 404/422 as "no approvals" - stale message/conversation state + if ( + error instanceof APIError && + (error.status === 404 || error.status === 422) + ) { + break; + } + throw error; + } // Use plural field for parallel tool calls const pendingApprovals = resume.pendingApprovals || []; diff --git a/src/index.ts b/src/index.ts index 59c5403..f1733af 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun import { parseArgs } from "node:util"; +import { APIError } from "@letta-ai/letta-client/core/error"; import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents"; import type { Message } from "@letta-ai/letta-client/resources/agents/messages"; import { getResumeData, type ResumeData } from "./agent/check-approval"; @@ -1277,7 +1278,8 @@ async function main(): Promise { } // Handle conversation: either resume existing or create new - let conversationIdToUse: string; + // Using definite assignment assertion - all branches below either set this or exit/throw + let conversationIdToUse!: string; // Debug: log resume flag status if (process.env.DEBUG) { @@ -1289,19 +1291,33 @@ async function main(): Promise { if (specifiedConversationId) { // Use the explicitly specified conversation ID + // User explicitly requested this conversation, so error if it doesn't exist conversationIdToUse = specifiedConversationId; setResumedExistingConversation(true); - - // Load message history and pending approvals from the conversation - // Re-fetch agent to get fresh message_ids for accurate pending approval detection - setLoadingState("checking"); - const freshAgent = await client.agents.retrieve(agent.id); - const data = await getResumeData( - client, - freshAgent, - specifiedConversationId, - ); - setResumeData(data); + try { + // Load message history and pending approvals from the conversation + // Re-fetch agent to get fresh message_ids for accurate pending approval detection + setLoadingState("checking"); + const freshAgent = await client.agents.retrieve(agent.id); + const data = await getResumeData( + client, + freshAgent, + specifiedConversationId, + ); + setResumeData(data); + } catch (error) { + // Only treat 404/422 as "not found", rethrow other errors + if ( + error instanceof APIError && + (error.status === 404 || error.status === 422) + ) { + console.error( + `Conversation ${specifiedConversationId} not found`, + ); + process.exit(1); + } + throw error; + } } else if (shouldResume) { // Try to load the last session for this agent const lastSession = @@ -1313,23 +1329,43 @@ async function main(): Promise { console.log(`[DEBUG] agent.id=${agent.id}`); } + let resumedSuccessfully = false; if (lastSession && lastSession.agentId === agent.id) { - // Resume the exact last conversation - conversationIdToUse = lastSession.conversationId; - setResumedExistingConversation(true); + // Try to resume the exact last conversation + // If it no longer exists, fall back to creating new + try { + // Load message history and pending approvals from the conversation + // Re-fetch agent to get fresh message_ids for accurate pending approval detection + setLoadingState("checking"); + const freshAgent = await client.agents.retrieve(agent.id); + const data = await getResumeData( + client, + freshAgent, + lastSession.conversationId, + ); + // Only set state after validation succeeds + conversationIdToUse = lastSession.conversationId; + setResumedExistingConversation(true); + setResumeData(data); + resumedSuccessfully = true; + } catch (error) { + // Only treat 404/422 as "not found", rethrow other errors + if ( + error instanceof APIError && + (error.status === 404 || error.status === 422) + ) { + // Conversation no longer exists, will create new below + console.warn( + `Previous conversation ${lastSession.conversationId} not found, creating new`, + ); + } else { + throw error; + } + } + } - // Load message history and pending approvals from the conversation - // Re-fetch agent to get fresh message_ids for accurate pending approval detection - setLoadingState("checking"); - const freshAgent = await client.agents.retrieve(agent.id); - const data = await getResumeData( - client, - freshAgent, - lastSession.conversationId, - ); - setResumeData(data); - } else { - // No valid session to resume for this agent, create new + if (!resumedSuccessfully) { + // No valid session to resume for this agent, or it failed - create new const conversation = await client.conversations.create({ agent_id: agent.id, isolated_block_labels: [...ISOLATED_BLOCK_LABELS],