diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 30eb47b..e1d3dc6 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -865,6 +865,11 @@ export default function App({ const resumeKey = useSuspend(); + // Pending conversation switch context — consumed on first message after a switch + const pendingConversationSwitchRef = useRef< + import("./helpers/conversationSwitchAlert").ConversationSwitchContext | null + >(null); + // Track previous prop values to detect actual prop changes (not internal state changes) const prevInitialAgentIdRef = useRef(initialAgentId); const prevInitialAgentStateRef = useRef(initialAgentState); @@ -4997,6 +5002,31 @@ export default function App({ setLlmConfig(agent.llm_config); setConversationId(targetConversationId); + // Set conversation switch context for agent switch + { + const { getModelDisplayName } = await import("../agent/model"); + const modelHandle = + agent.model || + (agent.llm_config?.model_endpoint_type && agent.llm_config?.model + ? `${agent.llm_config.model_endpoint_type}/${agent.llm_config.model}` + : null); + const modelLabel = + (modelHandle && getModelDisplayName(modelHandle)) || + modelHandle || + "unknown"; + pendingConversationSwitchRef.current = { + origin: "agent-switch", + conversationId: targetConversationId, + isDefault: targetConversationId === "default", + agentSwitchContext: { + name: agent.name || targetAgentId, + description: agent.description ?? undefined, + model: modelLabel, + blockCount: agent.blocks?.length ?? 0, + }, + }; + } + // Reset context token tracking for new agent resetContextHistory(contextTrackerRef.current); @@ -6379,6 +6409,12 @@ export default function App({ // Update conversationId state setConversationId(conversation.id); + pendingConversationSwitchRef.current = { + origin: "new", + conversationId: conversation.id, + isDefault: false, + }; + // Save the new session to settings settingsManager.setLocalLastSession( { agentId, conversationId: conversation.id }, @@ -6451,6 +6487,13 @@ export default function App({ isolated_block_labels: [...ISOLATED_BLOCK_LABELS], }); setConversationId(conversation.id); + + pendingConversationSwitchRef.current = { + origin: "clear", + conversationId: conversation.id, + isDefault: false, + }; + settingsManager.setLocalLastSession( { agentId, conversationId: conversation.id }, process.cwd(), @@ -6777,6 +6820,15 @@ export default function App({ // Only update state after validation succeeds setConversationId(targetConvId); + + pendingConversationSwitchRef.current = { + origin: "resume-direct", + conversationId: targetConvId, + isDefault: targetConvId === "default", + messageCount: resumeData.messageHistory.length, + messageHistory: resumeData.messageHistory, + }; + settingsManager.setLocalLastSession( { agentId, conversationId: targetConvId }, process.cwd(), @@ -8047,8 +8099,24 @@ ${SYSTEM_REMINDER_CLOSE} } } + // Build conversation switch alert if a switch is pending (behind feature flag) + let conversationSwitchAlert = ""; + if ( + pendingConversationSwitchRef.current && + settingsManager.getSetting("conversationSwitchAlertEnabled") + ) { + const { buildConversationSwitchAlert } = await import( + "./helpers/conversationSwitchAlert" + ); + conversationSwitchAlert = buildConversationSwitchAlert( + pendingConversationSwitchRef.current, + ); + } + pendingConversationSwitchRef.current = null; + pushReminder(sessionStartHookFeedback); pushReminder(permissionModeAlert); + pushReminder(conversationSwitchAlert); pushReminder(planModeReminder); pushReminder(ralphModeReminder); @@ -9793,6 +9861,15 @@ ${SYSTEM_REMINDER_CLOSE} ); setConversationId(action.conversationId); + + pendingConversationSwitchRef.current = { + origin: "resume-selector", + conversationId: action.conversationId, + isDefault: action.conversationId === "default", + messageCount: resumeData.messageHistory.length, + messageHistory: resumeData.messageHistory, + }; + settingsManager.setLocalLastSession( { agentId, conversationId: action.conversationId }, process.cwd(), @@ -11009,7 +11086,7 @@ Plan file path: ${planFilePath}`; agentId={agentId} agentName={agentName ?? undefined} currentConversationId={conversationId} - onSelect={async (convId) => { + onSelect={async (convId, selectorContext) => { const overlayCommand = consumeOverlayCommand("conversations"); closeOverlay(); @@ -11071,6 +11148,18 @@ Plan file path: ${planFilePath}`; // Only update state after validation succeeds setConversationId(convId); + + pendingConversationSwitchRef.current = { + origin: "resume-selector", + conversationId: convId, + isDefault: convId === "default", + messageCount: + selectorContext?.messageCount ?? + resumeData.messageHistory.length, + summary: selectorContext?.summary, + messageHistory: resumeData.messageHistory, + }; + settingsManager.setLocalLastSession( { agentId, conversationId: convId }, process.cwd(), @@ -11289,7 +11378,11 @@ Plan file path: ${planFilePath}`; initialQuery={searchQuery || undefined} agentId={agentId} conversationId={conversationId} - onOpenConversation={async (targetAgentId, targetConvId) => { + onOpenConversation={async ( + targetAgentId, + targetConvId, + searchContext, + ) => { const overlayCommand = consumeOverlayCommand("search"); closeOverlay(); @@ -11358,6 +11451,17 @@ Plan file path: ${planFilePath}`; ); setConversationId(actualTargetConv); + + pendingConversationSwitchRef.current = { + origin: "search", + conversationId: actualTargetConv, + isDefault: actualTargetConv === "default", + messageCount: resumeData.messageHistory.length, + messageHistory: resumeData.messageHistory, + searchQuery: searchContext?.query, + searchMessage: searchContext?.message, + }; + settingsManager.setLocalLastSession( { agentId, conversationId: actualTargetConv }, process.cwd(), diff --git a/src/cli/components/ConversationSelector.tsx b/src/cli/components/ConversationSelector.tsx index 957055f..fe5c805 100644 --- a/src/cli/components/ConversationSelector.tsx +++ b/src/cli/components/ConversationSelector.tsx @@ -17,7 +17,13 @@ interface ConversationSelectorProps { agentId: string; agentName?: string; currentConversationId: string; - onSelect: (conversationId: string) => void; + onSelect: ( + conversationId: string, + context?: { + summary?: string; + messageCount: number; + }, + ) => void; onNewConversation: () => void; onCancel: () => void; } @@ -370,7 +376,10 @@ export function ConversationSelector({ } else if (key.return) { const selected = pageConversations[selectedIndex]; if (selected?.conversation.id) { - onSelect(selected.conversation.id); + onSelect(selected.conversation.id, { + summary: selected.conversation.summary ?? undefined, + messageCount: selected.messageCount, + }); } } else if (key.escape) { onCancel(); diff --git a/src/cli/components/MessageSearch.tsx b/src/cli/components/MessageSearch.tsx index cc1b828..630c385 100644 --- a/src/cli/components/MessageSearch.tsx +++ b/src/cli/components/MessageSearch.tsx @@ -18,7 +18,11 @@ interface MessageSearchProps { /** Current conversation ID for "current conv" filter */ conversationId?: string; /** Callback when user wants to open a conversation */ - onOpenConversation?: (agentId: string, conversationId?: string) => void; + onOpenConversation?: ( + agentId: string, + conversationId?: string, + searchContext?: { query: string; message: string }, + ) => void; } const VISIBLE_ITEMS = 5; @@ -347,7 +351,11 @@ export function MessageSearch({ conversation_id?: string; }; if (msgData.agent_id) { - onOpenConversation(msgData.agent_id, msgData.conversation_id); + const fullText = getMessageText(expandedMessage); + onOpenConversation(msgData.agent_id, msgData.conversation_id, { + query: activeQuery, + message: fullText, + }); } } return; diff --git a/src/cli/helpers/conversationSwitchAlert.ts b/src/cli/helpers/conversationSwitchAlert.ts new file mode 100644 index 0000000..8a18fe6 --- /dev/null +++ b/src/cli/helpers/conversationSwitchAlert.ts @@ -0,0 +1,154 @@ +import type { Message } from "@letta-ai/letta-client/resources/agents/messages"; + +const SYSTEM_ALERT_OPEN = ""; +const SYSTEM_ALERT_CLOSE = ""; + +const MAX_HISTORY_MESSAGES = 8; +const MAX_MESSAGE_CHARS = 500; + +export interface ConversationSwitchContext { + origin: + | "resume-direct" + | "resume-selector" + | "new" + | "clear" + | "search" + | "agent-switch"; + conversationId: string; + isDefault: boolean; + + summary?: string; + messageCount?: number; + messageHistory?: Message[]; + + searchQuery?: string; + searchMessage?: string; + + agentSwitchContext?: { + name: string; + description?: string; + model: string; + blockCount: number; + }; +} + +export function buildConversationSwitchAlert( + ctx: ConversationSwitchContext, +): string { + const parts: string[] = []; + + if (ctx.origin === "new" || ctx.origin === "clear") { + parts.push( + "New conversation started. This is a fresh conversation thread with no prior messages.", + ); + parts.push(`Conversation: ${ctx.conversationId}`); + } else if (ctx.origin === "search") { + parts.push( + `Conversation switched. The user searched for "${ctx.searchQuery}" and jumped to this conversation based on a matching message.`, + ); + if (ctx.searchMessage) { + parts.push(`Selected message: "${ctx.searchMessage}"`); + } + pushConversationMeta(parts, ctx); + pushMessageHistory(parts, ctx); + } else if (ctx.origin === "agent-switch" && ctx.agentSwitchContext) { + const a = ctx.agentSwitchContext; + parts.push("Switched to a different agent."); + parts.push(`Agent: ${a.name}`); + if (a.description) { + parts.push(`Description: ${a.description}`); + } + parts.push( + `Model: ${a.model} · ${a.blockCount} memory block${a.blockCount === 1 ? "" : "s"}`, + ); + pushMessageHistory(parts, ctx); + parts.push( + "The conversation context has changed entirely — review the in-context messages.", + ); + } else if (ctx.isDefault) { + parts.push( + "Switched to the agent's default conversation (the primary, non-isolated message history).", + ); + parts.push( + "This conversation is shared across all sessions that don't use explicit conversation IDs.", + ); + pushMessageHistory(parts, ctx); + parts.push("Review the in-context messages for full conversation history."); + } else { + const via = + ctx.origin === "resume-selector" ? "/resume selector" : "/resume"; + parts.push(`Conversation resumed via ${via}.`); + pushConversationMeta(parts, ctx); + pushMessageHistory(parts, ctx); + parts.push("Review the in-context messages for full conversation history."); + } + + return `${SYSTEM_ALERT_OPEN}\n${parts.join("\n")}\n${SYSTEM_ALERT_CLOSE}\n\n`; +} + +function pushConversationMeta( + parts: string[], + ctx: ConversationSwitchContext, +): void { + const label = ctx.isDefault ? "default" : ctx.conversationId; + const countSuffix = + ctx.messageCount != null ? ` (${ctx.messageCount} messages)` : ""; + parts.push(`Conversation: ${label}${countSuffix}`); + if (ctx.summary) { + parts.push(`Summary: ${ctx.summary}`); + } +} + +function extractMessageText(msg: Message): string | null { + const content = ( + msg as Message & { + content?: string | Array<{ type?: string; text?: string }>; + } + ).content; + + if (!content) return null; + + if (typeof content === "string") return content.trim(); + + if (Array.isArray(content)) { + const texts = content + .filter( + (p): p is { type: string; text: string } => + p?.type === "text" && !!p.text, + ) + .map((p) => p.text.trim()) + .filter(Boolean); + return texts.join("\n") || null; + } + + return null; +} + +function pushMessageHistory( + parts: string[], + ctx: ConversationSwitchContext, +): void { + if (!ctx.messageHistory || ctx.messageHistory.length === 0) return; + + const relevant = ctx.messageHistory + .filter( + (m) => + m.message_type === "user_message" || + m.message_type === "assistant_message", + ) + .slice(-MAX_HISTORY_MESSAGES); + + if (relevant.length === 0) return; + + parts.push("Recent conversation messages:"); + for (const msg of relevant) { + const text = extractMessageText(msg); + if (!text) continue; + const role = msg.message_type === "user_message" ? "user" : "assistant"; + const clipped = + text.length > MAX_MESSAGE_CHARS + ? `${text.slice(0, MAX_MESSAGE_CHARS)}...` + : text; + parts.push(`[${role}] ${clipped}`); + } +} diff --git a/src/settings-manager.ts b/src/settings-manager.ts index 23b9bcd..df464e5 100644 --- a/src/settings-manager.ts +++ b/src/settings-manager.ts @@ -59,6 +59,7 @@ export interface Settings { reflectionTrigger: "off" | "step-count" | "compaction-event"; reflectionBehavior: "reminder" | "auto-launch"; reflectionStepCount: number; + conversationSwitchAlertEnabled: boolean; // Send system-alert when switching conversations/agents globalSharedBlockIds: Record; // DEPRECATED: kept for backwards compat profiles?: Record; // DEPRECATED: old format, kept for migration pinnedAgents?: string[]; // DEPRECATED: kept for backwards compat, use pinnedAgentsByServer @@ -116,6 +117,7 @@ const DEFAULT_SETTINGS: Settings = { tokenStreaming: false, showCompactions: false, enableSleeptime: false, + conversationSwitchAlertEnabled: false, sessionContextEnabled: true, memoryReminderInterval: 25, // DEPRECATED: use reflection* fields reflectionTrigger: "step-count",