fix: /resume nonexistent conversation (#548)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-14 20:31:13 -08:00
committed by GitHub
parent c799937b7b
commit f964b020d5
4 changed files with 147 additions and 62 deletions

View File

@@ -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: [] };
}

View File

@@ -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);

View File

@@ -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<ReturnType<typeof getResumeData>>;
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 || [];

View File

@@ -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<void> {
}
// 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<void> {
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<void> {
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],