A React Native app for the ultimate thinking partner.
1/**
2 * useMessageGroups Hook
3 *
4 * Transforms raw Letta messages into unified MessageGroup objects for rendering.
5 *
6 * WHAT IT DOES:
7 * - Groups messages by ID (reasoning + assistant share ID → single group)
8 * - Pairs tool calls with tool returns automatically
9 * - Extracts compaction alerts from user messages
10 * - Parses multipart user messages (text + images)
11 * - Appends streaming group as temporary FlatList item
12 *
13 * WHY IT EXISTS:
14 * Before: Reasoning and assistant messages were separate FlatList items,
15 * requiring complex pairing logic in the render component.
16 * After: One MessageGroup per logical message turn, with reasoning co-located.
17 *
18 * STREAMING BEHAVIOR:
19 * - While streaming: Appends temporary group (id='streaming', groupKey='streaming-assistant')
20 * - Server refresh: Replaces with real messages (different groupKeys prevent flashing)
21 *
22 * This hook is pure - no side effects, just data transformation.
23 */
24
25import { useMemo } from 'react';
26import type { LettaMessage } from '../types/letta';
27
28/**
29 * Unified message group for rendering
30 */
31export interface MessageGroup {
32 // Identification
33 id: string; // Original message ID (or 'streaming')
34 groupKey: string; // Unique key for FlatList (id + type)
35
36 // Type determines rendering component
37 type: 'user' | 'assistant' | 'tool_call' | 'tool_return_orphaned' | 'compaction';
38
39 // Universal content
40 content: string;
41 reasoning?: string;
42
43 // Tool-specific
44 toolCall?: {
45 name: string;
46 args: string; // Python-formatted: "search(query=\"foo\")"
47 };
48 toolReturn?: string;
49
50 // User-specific (multipart messages)
51 images?: Array<{
52 type: string;
53 source: {
54 type: string;
55 data: string;
56 mediaType?: string;
57 media_type?: string;
58 url?: string;
59 };
60 }>;
61
62 // Compaction-specific
63 compactionMessage?: string;
64
65 // Metadata
66 created_at: string;
67 role: 'user' | 'assistant' | 'system' | 'tool';
68
69 // Streaming indicator
70 isStreaming?: boolean;
71}
72
73/**
74 * Simple streaming message
75 */
76interface StreamingMessage {
77 id: string;
78 reasoning: string;
79 content: string;
80 type: 'tool_call' | 'assistant' | null;
81 toolCallName?: string;
82 timestamp: string;
83}
84
85interface UseMessageGroupsParams {
86 messages: LettaMessage[];
87 isStreaming: boolean;
88 currentStreamingMessage: StreamingMessage | null;
89 completedStreamingMessages: StreamingMessage[];
90}
91
92/**
93 * Group messages by ID into unified MessageGroup objects
94 */
95export function useMessageGroups({
96 messages,
97 isStreaming,
98 currentStreamingMessage,
99 completedStreamingMessages,
100}: UseMessageGroupsParams): MessageGroup[] {
101 return useMemo(() => {
102 // Step 1: Filter out system messages and login/heartbeat
103 const filteredMessages = messages.filter((msg) => {
104 if (msg.message_type === 'system_message') return false;
105
106 // Filter login/heartbeat user messages
107 if (msg.message_type === 'user_message' && msg.content) {
108 try {
109 const contentStr = typeof msg.content === 'string'
110 ? msg.content
111 : JSON.stringify(msg.content);
112 const parsed = JSON.parse(contentStr);
113 if (parsed?.type === 'login' || parsed?.type === 'heartbeat') {
114 return false;
115 }
116 } catch {
117 // Not JSON, keep it
118 }
119 }
120
121 return true;
122 });
123
124 // Step 2: Sort chronologically
125 const sorted = [...filteredMessages].sort((a, b) => {
126 const timeA = new Date(a.created_at || 0).getTime();
127 const timeB = new Date(b.created_at || 0).getTime();
128 return timeA - timeB;
129 });
130
131 // Step 3: Group by ID
132 const groupedById = new Map<string, LettaMessage[]>();
133 for (const msg of sorted) {
134 if (!groupedById.has(msg.id)) {
135 groupedById.set(msg.id, []);
136 }
137 groupedById.get(msg.id)!.push(msg);
138 }
139
140 // Step 4: Convert each ID group to MessageGroup
141 const groups: MessageGroup[] = [];
142
143 for (const [id, messagesInGroup] of Array.from(groupedById.entries())) {
144 const group = createMessageGroup(id, messagesInGroup);
145 if (group) {
146 groups.push(group);
147 }
148 }
149
150 // Step 4.5: Remove assistant groups that have a tool call in the same step
151 // When reasoning → assistant → tool call happen in the same step, we only want to show the tool call
152 const stepIdToGroups = new Map<string, MessageGroup[]>();
153 for (const group of groups) {
154 const msg = sorted.find(m => m.id === group.id);
155 const stepId = extractStepId(msg);
156 if (stepId) {
157 if (!stepIdToGroups.has(stepId)) {
158 stepIdToGroups.set(stepId, []);
159 }
160 stepIdToGroups.get(stepId)!.push(group);
161 }
162 }
163
164 // Remove assistant groups if there's a tool_call group in the same step
165 const groupsToRemove = new Set<string>();
166 for (const [stepId, stepGroups] of stepIdToGroups.entries()) {
167 const hasToolCall = stepGroups.some(g => g.type === 'tool_call');
168 if (hasToolCall) {
169 // Remove any assistant groups in this step (tool call supersedes)
170 for (const group of stepGroups) {
171 if (group.type === 'assistant') {
172 groupsToRemove.add(group.id);
173 }
174 }
175 }
176 }
177
178 // Filter out the groups marked for removal
179 const filteredGroups = groups.filter(g => !groupsToRemove.has(g.id));
180
181 // Step 4.6: Pair orphaned tool returns with their tool calls
182 // Letta uses different IDs for tool_call_message and tool_return_message,
183 // but they share the same step_id - that's how we link them
184 const toolCallGroups = new Map<string, MessageGroup>();
185 const orphanedReturns = new Map<string, MessageGroup>();
186
187 // First pass: index tool calls and orphaned returns by step_id
188 for (const group of filteredGroups) {
189 if (group.type === 'tool_call') {
190 const msg = sorted.find(m => m.id === group.id);
191 const stepId = extractStepId(msg);
192 if (stepId) {
193 toolCallGroups.set(stepId, group);
194 }
195 } else if (group.type === 'tool_return_orphaned') {
196 const msg = sorted.find(m => m.id === group.id);
197 const stepId = extractStepId(msg);
198 if (stepId) {
199 orphanedReturns.set(stepId, group);
200 }
201 }
202 }
203
204 // Second pass: pair tool calls with their returns
205 for (const [stepId, returnGroup] of orphanedReturns.entries()) {
206 const callGroup = toolCallGroups.get(stepId);
207 if (callGroup && !callGroup.toolReturn) {
208 // Merge the return into the call group
209 callGroup.toolReturn = returnGroup.content;
210
211 // Remove the orphaned return from filtered groups array
212 const returnIndex = filteredGroups.findIndex(g => g.id === returnGroup.id);
213 if (returnIndex !== -1) {
214 filteredGroups.splice(returnIndex, 1);
215 }
216 }
217 }
218
219 // Step 5: Sort groups by created_at
220 filteredGroups.sort((a, b) => {
221 const timeA = new Date(a.created_at || 0).getTime();
222 const timeB = new Date(b.created_at || 0).getTime();
223 return timeA - timeB;
224 });
225
226 // Step 6: Add completed streaming messages (finished but stream still active)
227 console.log('📊 Completed streaming messages:', completedStreamingMessages.length);
228 completedStreamingMessages.forEach((msg, index) => {
229 const group: MessageGroup = {
230 id: msg.id,
231 groupKey: `streaming-completed-${msg.id}`,
232 type: msg.type === 'tool_call' ? 'tool_call' : 'assistant',
233 content: msg.content,
234 reasoning: msg.reasoning || undefined,
235 created_at: msg.timestamp,
236 role: 'assistant',
237 isStreaming: false, // It's done, just not persisted yet
238 };
239
240 if (msg.type === 'tool_call' && msg.toolCallName) {
241 group.toolCall = {
242 name: msg.toolCallName,
243 args: msg.content,
244 };
245 }
246
247 filteredGroups.push(group);
248 console.log(` ✅ [${index}] ${msg.type}:`, msg.content.substring(0, 40));
249 });
250
251 // Step 7: Add current accumulating message (if any)
252 if (currentStreamingMessage) {
253 const group: MessageGroup = {
254 id: currentStreamingMessage.id,
255 groupKey: `streaming-current-${currentStreamingMessage.id}`,
256 type: currentStreamingMessage.type === 'tool_call' ? 'tool_call' : 'assistant',
257 content: currentStreamingMessage.content,
258 reasoning: currentStreamingMessage.reasoning || undefined,
259 created_at: currentStreamingMessage.timestamp,
260 role: 'assistant',
261 isStreaming: true, // Still accumulating
262 };
263
264 if (currentStreamingMessage.type === 'tool_call' && currentStreamingMessage.toolCallName) {
265 group.toolCall = {
266 name: currentStreamingMessage.toolCallName,
267 args: currentStreamingMessage.content,
268 };
269 }
270
271 filteredGroups.push(group);
272 console.log(' 🔄 Current streaming:', currentStreamingMessage.type, currentStreamingMessage.content.substring(0, 40));
273 }
274
275 console.log('📊 FINAL GROUP COUNT:', filteredGroups.length, 'groups');
276 return filteredGroups;
277 }, [messages, isStreaming, currentStreamingMessage, completedStreamingMessages]);
278}
279
280/**
281 * Create a MessageGroup from messages with the same ID
282 */
283function createMessageGroup(
284 id: string,
285 messagesInGroup: LettaMessage[]
286): MessageGroup | null {
287 if (messagesInGroup.length === 0) return null;
288
289 // Find message types in this group
290 const userMsg = messagesInGroup.find((m) => m.message_type === 'user_message');
291 const assistantMsg = messagesInGroup.find((m) => m.message_type === 'assistant_message');
292 const toolCallMsg = messagesInGroup.find((m) => m.message_type === 'tool_call_message');
293 const toolReturnMsg = messagesInGroup.find((m) => m.message_type === 'tool_return_message');
294
295 // CRITICAL FIX: When a group has BOTH assistant AND tool_call (with 2 reasoning messages),
296 // the tool call should get the LAST reasoning (the one right before the tool call)
297 const allReasoningMsgs = messagesInGroup.filter((m) => m.message_type === 'reasoning_message');
298 let reasoningMsg: LettaMessage | undefined;
299
300 if (allReasoningMsgs.length === 0) {
301 reasoningMsg = undefined;
302 } else if (allReasoningMsgs.length === 1 || !toolCallMsg) {
303 // Single reasoning OR no tool call → use first reasoning
304 reasoningMsg = allReasoningMsgs[0];
305 } else {
306 // Multiple reasoning messages AND we have a tool call → use LAST reasoning
307 reasoningMsg = allReasoningMsgs[allReasoningMsgs.length - 1];
308 }
309
310 // Use first message for metadata
311 const firstMsg = messagesInGroup[0];
312
313 // ========================================
314 // USER MESSAGE
315 // ========================================
316 if (userMsg) {
317 // Check for compaction alert
318 const compactionInfo = extractCompactionInfo(userMsg.content);
319 if (compactionInfo.isCompaction) {
320 return {
321 id,
322 groupKey: `${id}-compaction`,
323 type: 'compaction',
324 content: compactionInfo.message,
325 compactionMessage: compactionInfo.message,
326 created_at: userMsg.created_at,
327 role: userMsg.role,
328 };
329 }
330
331 // Regular user message
332 const { textContent, images } = parseUserContent(userMsg.content);
333
334 // Skip if no content
335 if (!textContent.trim() && images.length === 0) {
336 return null;
337 }
338
339 return {
340 id,
341 groupKey: `${id}-user`,
342 type: 'user',
343 content: textContent,
344 images: images.length > 0 ? images : undefined,
345 created_at: userMsg.created_at,
346 role: userMsg.role,
347 };
348 }
349
350 // ========================================
351 // TOOL CALL MESSAGE
352 // ========================================
353 if (toolCallMsg) {
354 const toolCall = parseToolCall(toolCallMsg);
355 const stepId = extractStepId(toolCallMsg);
356
357 // CRITICAL: Use step_id for groupKey, not message ID
358 // Multiple tool calls can share the same message ID but have different step_ids
359 const groupKey = stepId ? `${stepId}-tool_call` : `${id}-tool_call`;
360
361 return {
362 id,
363 groupKey,
364 type: 'tool_call',
365 content: toolCall.args, // The formatted args string
366 reasoning: reasoningMsg?.reasoning || toolCallMsg?.reasoning,
367 toolCall: {
368 name: toolCall.name,
369 args: toolCall.args,
370 },
371 toolReturn: toolReturnMsg?.content || undefined,
372 created_at: toolCallMsg.created_at,
373 role: toolCallMsg.role,
374 };
375 }
376
377 // ========================================
378 // ORPHANED TOOL RETURN
379 // ========================================
380 if (toolReturnMsg && !toolCallMsg) {
381 return {
382 id,
383 groupKey: `${id}-tool_return_orphaned`,
384 type: 'tool_return_orphaned',
385 content: toolReturnMsg.content,
386 created_at: toolReturnMsg.created_at,
387 role: toolReturnMsg.role,
388 };
389 }
390
391 // ========================================
392 // ASSISTANT MESSAGE
393 // ========================================
394 if (assistantMsg) {
395 return {
396 id,
397 groupKey: `${id}-assistant`,
398 type: 'assistant',
399 content: assistantMsg.content,
400 reasoning: reasoningMsg?.reasoning || assistantMsg?.reasoning,
401 created_at: assistantMsg.created_at,
402 role: assistantMsg.role,
403 };
404 }
405
406 // ========================================
407 // STANDALONE REASONING (edge case)
408 // ========================================
409 if (reasoningMsg) {
410 // Reasoning without assistant message - treat as assistant with empty content
411 return {
412 id,
413 groupKey: `${id}-assistant`,
414 type: 'assistant',
415 content: '',
416 reasoning: reasoningMsg.reasoning,
417 created_at: reasoningMsg.created_at,
418 role: 'assistant',
419 };
420 }
421
422 // Unknown message type - skip
423 return null;
424}
425
426/**
427 * Extract compaction info from user message content
428 */
429function extractCompactionInfo(content: any): {
430 isCompaction: boolean;
431 message: string;
432} {
433 try {
434 const contentStr = typeof content === 'string' ? content : JSON.stringify(content);
435 const parsed = JSON.parse(contentStr);
436
437 if (parsed?.type === 'system_alert') {
438 let messageText = parsed.message || '';
439
440 // Try to extract JSON from code block
441 const jsonMatch = messageText.match(/```json\s*(\{[\s\S]*?\})\s*```/);
442 if (jsonMatch) {
443 try {
444 const innerJson = JSON.parse(jsonMatch[1]);
445 messageText = innerJson.message || messageText;
446 } catch {
447 // Use outer message
448 }
449 }
450
451 // Strip preamble (use [\s\S] instead of . with s flag for ES5 compatibility)
452 messageText = messageText.replace(
453 /^Note: prior messages have been hidden from view[\s\S]*?The following is a summary of the previous messages:\s*/i,
454 ''
455 );
456
457 return {
458 isCompaction: true,
459 message: messageText,
460 };
461 }
462 } catch {
463 // Not JSON
464 }
465
466 return { isCompaction: false, message: '' };
467}
468
469/**
470 * Parse user message content (text + images)
471 */
472function parseUserContent(content: any): {
473 textContent: string;
474 images: Array<{
475 type: string;
476 source: {
477 type: string;
478 data: string;
479 mediaType?: string;
480 media_type?: string;
481 url?: string;
482 };
483 }>;
484} {
485 let textContent = '';
486 let images: any[] = [];
487
488 if (typeof content === 'object' && Array.isArray(content)) {
489 // Multipart message
490 images = content.filter((item: any) => item.type === 'image');
491 const textParts = content.filter((item: any) => item.type === 'text');
492 textContent = textParts
493 .map((item: any) => item.text || '')
494 .filter((t: string) => t)
495 .join('\n');
496 } else if (typeof content === 'string') {
497 textContent = content;
498 } else {
499 textContent = String(content || '');
500 }
501
502 return { textContent, images };
503}
504
505/**
506 * Extract step_id from a message - this is how Letta links tool calls with their returns
507 */
508function extractStepId(msg: LettaMessage | undefined): string | null {
509 if (!msg) return null;
510
511 const msgAny = msg as any;
512 // Letta uses step_id to group tool call and tool return messages
513 return msgAny.step_id || null;
514}
515
516/**
517 * Parse tool call message to extract name and args
518 */
519function parseToolCall(msg: LettaMessage): {
520 name: string;
521 args: string;
522} {
523 // Try to parse from content (already formatted string like "search(query=\"foo\")")
524 if (typeof msg.content === 'string' && msg.content.includes('(')) {
525 return {
526 name: msg.content.split('(')[0],
527 args: msg.content,
528 };
529 }
530
531 // Fallback: extract from tool_call object
532 if (msg.tool_call || msg.tool_calls?.[0]) {
533 const toolCall = msg.tool_call || msg.tool_calls![0];
534 const callObj: any = toolCall.function || toolCall;
535 const name = callObj?.name || 'unknown_tool';
536 let args = callObj?.arguments || callObj?.args || {};
537
538 // If args is a JSON string, parse it first
539 if (typeof args === 'string') {
540 try {
541 args = JSON.parse(args);
542 } catch (e) {
543 // If parse fails, keep as string
544 console.warn('Failed to parse tool arguments:', args);
545 }
546 }
547
548 // Format as Python call
549 const formatArgsPython = (obj: any): string => {
550 if (!obj || typeof obj !== 'object') return '';
551 return Object.entries(obj)
552 .map(([k, v]) => `${k}=${typeof v === 'string' ? `"${v}"` : JSON.stringify(v)}`)
553 .join(', ');
554 };
555
556 const argsStr = `${name}(${formatArgsPython(args)})`;
557
558 return { name, args: argsStr };
559 }
560
561 // Fallback to content as-is
562 return {
563 name: 'unknown_tool',
564 args: String(msg.content || ''),
565 };
566}