A React Native app for the ultimate thinking partner.

fix(messages): use step_id to pair tool calls with returns

Problem: Tool return messages don't have tool_call_id field, so
previous pairing strategy failed.

Root cause discovered: Letta stores tool_call_id inside the tool_call
object for tool call messages, but tool return messages have NO
tool_call object at all. However, both message types share the same
step_id field.

Solution: Extract step_id instead of tool_call_id and use it for
pairing. Both tool_call_message and tool_return_message have the same
step_id when they belong to the same tool execution.

Changes:
- Replaced extractToolCallId() with extractStepId()
- Updated pairing maps to use step_id as the linking key
- Added debug logging in useMessages to show raw tool message JSON
- Updated all pairing logs to reference step_id

This should now correctly merge "(co updated memory)" into a single
unified message group.

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

+31 -33
+23 -33
src/hooks/useMessageGroups.ts
··· 167 167 168 168 // Step 4.5: Pair orphaned tool returns with their tool calls 169 169 // Letta uses different IDs for tool_call_message and tool_return_message, 170 - // so we need to link them using tool_call_id 170 + // but they share the same step_id - that's how we link them 171 171 const toolCallGroups = new Map<string, MessageGroup>(); 172 172 const orphanedReturns = new Map<string, MessageGroup>(); 173 173 174 - // First pass: index tool calls and orphaned returns by tool_call_id 174 + // First pass: index tool calls and orphaned returns by step_id 175 175 for (const group of groups) { 176 176 if (group.type === 'tool_call') { 177 - const toolCallId = extractToolCallId(sorted.find(m => m.id === group.id)); 178 - if (toolCallId) { 179 - toolCallGroups.set(toolCallId, group); 177 + const msg = sorted.find(m => m.id === group.id); 178 + const stepId = extractStepId(msg); 179 + if (stepId) { 180 + toolCallGroups.set(stepId, group); 181 + console.log(`[useMessageGroups] Indexed tool call ${group.id} with step_id=${stepId}`); 182 + } else { 183 + console.log(`[useMessageGroups] WARNING: Tool call ${group.id} has no step_id`); 180 184 } 181 185 } else if (group.type === 'tool_return_orphaned') { 182 - const toolCallId = extractToolCallId(sorted.find(m => m.id === group.id)); 183 - if (toolCallId) { 184 - orphanedReturns.set(toolCallId, group); 186 + const msg = sorted.find(m => m.id === group.id); 187 + const stepId = extractStepId(msg); 188 + if (stepId) { 189 + orphanedReturns.set(stepId, group); 190 + console.log(`[useMessageGroups] Indexed orphaned return ${group.id} with step_id=${stepId}`); 191 + } else { 192 + console.log(`[useMessageGroups] WARNING: Orphaned return ${group.id} has no step_id`); 185 193 } 186 194 } 187 195 } 188 196 189 197 // Second pass: pair tool calls with their returns 190 - for (const [toolCallId, returnGroup] of orphanedReturns.entries()) { 191 - const callGroup = toolCallGroups.get(toolCallId); 198 + for (const [stepId, returnGroup] of orphanedReturns.entries()) { 199 + const callGroup = toolCallGroups.get(stepId); 192 200 if (callGroup && !callGroup.toolReturn) { 193 201 // Merge the return into the call group 194 202 callGroup.toolReturn = returnGroup.content; ··· 199 207 groups.splice(returnIndex, 1); 200 208 } 201 209 202 - console.log(`[useMessageGroups] Paired tool call ${callGroup.id} with return ${returnGroup.id} via tool_call_id=${toolCallId}`); 210 + console.log(`[useMessageGroups] Paired tool call ${callGroup.id} with return ${returnGroup.id} via step_id=${stepId}`); 203 211 } 204 212 } 205 213 ··· 477 485 } 478 486 479 487 /** 480 - * Extract tool_call_id from a message (if present) 488 + * Extract step_id from a message - this is how Letta links tool calls with their returns 481 489 */ 482 - function extractToolCallId(msg: LettaMessage | undefined): string | null { 490 + function extractStepId(msg: LettaMessage | undefined): string | null { 483 491 if (!msg) return null; 484 492 485 - // Check for tool_call_id directly on message 486 493 const msgAny = msg as any; 487 - if (msgAny.tool_call_id) { 488 - return msgAny.tool_call_id; 489 - } 490 - 491 - // Check in tool_call object 492 - if (msg.tool_call) { 493 - const toolCall = msg.tool_call as any; 494 - if (toolCall.id) return toolCall.id; 495 - if (toolCall.tool_call_id) return toolCall.tool_call_id; 496 - } 497 - 498 - // Check in tool_calls array 499 - if (msg.tool_calls && msg.tool_calls.length > 0) { 500 - const toolCall = msg.tool_calls[0] as any; 501 - if (toolCall.id) return toolCall.id; 502 - if (toolCall.tool_call_id) return toolCall.tool_call_id; 503 - } 504 - 505 - return null; 494 + // Letta uses step_id to group tool call and tool return messages 495 + return msgAny.step_id || null; 506 496 } 507 497 508 498 /**
+8
src/hooks/useMessages.ts
··· 43 43 44 44 console.log('[LOAD MESSAGES] Received', loadedMessages.length, 'messages from server'); 45 45 46 + // DEBUG: Log raw tool call/return messages 47 + const toolMessages = loadedMessages.filter(m => 48 + m.message_type === 'tool_call_message' || m.message_type === 'tool_return_message' 49 + ); 50 + if (toolMessages.length > 0) { 51 + console.log('[LOAD MESSAGES] Raw tool messages:', JSON.stringify(toolMessages.slice(0, 4), null, 2)); 52 + } 53 + 46 54 if (loadedMessages.length > 0) { 47 55 if (before) { 48 56 // Loading older messages - prepend them