@@ -1061,61 +1061,9 @@ impl ActorLogic<MemoryMessage> for SqliteMemoryActor {
10611061 . map_err ( |e| e. to_string ( ) ) ?
10621062 } ;
10631063
1064- // Gather tool_call_ids of the rows we're about to drop so
1065- // their cached tool results can be pruned together. Tool
1066- // results live on role='tool' rows carrying tool_call_id.
1067- let collect_tool_call_ids =
1068- |tx : & rusqlite:: Transaction ,
1069- after : Option < i64 > |
1070- -> Result < Vec < String > , String > {
1071- let mut stmt = match after {
1072- Some ( _) => tx
1073- . prepare (
1074- "SELECT tool_call_id FROM messages
1075- WHERE thread_id = ?1 AND id > ?2
1076- AND tool_call_id IS NOT NULL" ,
1077- )
1078- . map_err ( |e| e. to_string ( ) ) ?,
1079- None => tx
1080- . prepare (
1081- "SELECT tool_call_id FROM messages
1082- WHERE thread_id = ?1 AND tool_call_id IS NOT NULL" ,
1083- )
1084- . map_err ( |e| e. to_string ( ) ) ?,
1085- } ;
1086- let mut out: Vec < String > = Vec :: new ( ) ;
1087- match after {
1088- Some ( id) => {
1089- let rows = stmt
1090- . query_map ( params ! [ thread_id, id] , |r| {
1091- r. get :: < _ , Option < String > > ( 0 )
1092- } )
1093- . map_err ( |e| e. to_string ( ) ) ?;
1094- for row in rows {
1095- if let Ok ( Some ( v) ) = row {
1096- out. push ( v) ;
1097- }
1098- }
1099- }
1100- None => {
1101- let rows = stmt
1102- . query_map ( params ! [ thread_id] , |r| {
1103- r. get :: < _ , Option < String > > ( 0 )
1104- } )
1105- . map_err ( |e| e. to_string ( ) ) ?;
1106- for row in rows {
1107- if let Ok ( Some ( v) ) = row {
1108- out. push ( v) ;
1109- }
1110- }
1111- }
1112- }
1113- Ok ( out)
1114- } ;
1115-
11161064 let ( deleted, dropped_tool_call_ids) = match cutoff {
11171065 Some ( cutoff_id) => {
1118- let ids = collect_tool_call_ids ( & tx, Some ( cutoff_id) ) ?;
1066+ let ids = dropped_tool_call_ids ( & tx, & thread_id , Some ( cutoff_id) ) ?;
11191067 let n = tx
11201068 . execute (
11211069 "DELETE FROM messages WHERE thread_id = ?1 AND id > ?2" ,
@@ -1125,7 +1073,7 @@ impl ActorLogic<MemoryMessage> for SqliteMemoryActor {
11251073 ( n, ids)
11261074 }
11271075 None if keep_user_messages == 0 => {
1128- let ids = collect_tool_call_ids ( & tx, None ) ?;
1076+ let ids = dropped_tool_call_ids ( & tx, & thread_id , None ) ?;
11291077 let n = tx
11301078 . execute (
11311079 "DELETE FROM messages WHERE thread_id = ?1" ,
@@ -2293,6 +2241,54 @@ impl ActorLogic<MemoryMessage> for SqliteMemoryActor {
22932241 }
22942242}
22952243
2244+ /// Collect the `tool_call_id`s of messages a rewind is about to remove, so
2245+ /// their cached tool results can be pruned in the same transaction. Tool
2246+ /// results live on `role='tool'` rows carrying `tool_call_id`. `after =
2247+ /// Some(id)` restricts to rows with `id > after`; `None` covers the whole
2248+ /// thread (used by the full-wipe path).
2249+ fn dropped_tool_call_ids (
2250+ tx : & rusqlite:: Transaction ,
2251+ thread_id : & str ,
2252+ after : Option < i64 > ,
2253+ ) -> Result < Vec < String > , String > {
2254+ let mut out: Vec < String > = Vec :: new ( ) ;
2255+ match after {
2256+ Some ( id) => {
2257+ let mut stmt = tx
2258+ . prepare (
2259+ "SELECT tool_call_id FROM messages
2260+ WHERE thread_id = ?1 AND id > ?2 AND tool_call_id IS NOT NULL" ,
2261+ )
2262+ . map_err ( |e| e. to_string ( ) ) ?;
2263+ let rows = stmt
2264+ . query_map ( params ! [ thread_id, id] , |r| r. get :: < _ , Option < String > > ( 0 ) )
2265+ . map_err ( |e| e. to_string ( ) ) ?;
2266+ for row in rows {
2267+ if let Ok ( Some ( v) ) = row {
2268+ out. push ( v) ;
2269+ }
2270+ }
2271+ }
2272+ None => {
2273+ let mut stmt = tx
2274+ . prepare (
2275+ "SELECT tool_call_id FROM messages
2276+ WHERE thread_id = ?1 AND tool_call_id IS NOT NULL" ,
2277+ )
2278+ . map_err ( |e| e. to_string ( ) ) ?;
2279+ let rows = stmt
2280+ . query_map ( params ! [ thread_id] , |r| r. get :: < _ , Option < String > > ( 0 ) )
2281+ . map_err ( |e| e. to_string ( ) ) ?;
2282+ for row in rows {
2283+ if let Ok ( Some ( v) ) = row {
2284+ out. push ( v) ;
2285+ }
2286+ }
2287+ }
2288+ }
2289+ Ok ( out)
2290+ }
2291+
22962292#[ cfg( test) ]
22972293mod root_thread_id_tests {
22982294 use super :: { is_root_session_thread_id, MemoryMessage , SharedReply , SqliteMemoryActor } ;
@@ -2426,4 +2422,114 @@ mod root_thread_id_tests {
24262422 assert_eq ! ( deleted, 1 ) ;
24272423 assert ! ( session. get_context( ) . await . expect( "context" ) . is_empty( ) ) ;
24282424 }
2425+
2426+ #[ tokio:: test]
2427+ async fn truncate_prunes_dropped_tool_cache_and_stale_metadata ( ) {
2428+ let actor = SqliteMemoryActor :: new ( ":memory:" ) . expect ( "memory actor" ) ;
2429+ let node = NodeHandle :: new ( actor, 16 , 1 , Duration :: from_millis ( 1 ) ) ;
2430+ let manager = SessionManager :: new ( node. clone ( ) ) ;
2431+ let thread_id = "terminal:660e8400-e29b-41d4-a716-446655440099:" ;
2432+ let mut session = manager. get_session ( thread_id) . await . expect ( "session" ) ;
2433+
2434+ // Layout (ids 1..4): u1, a tool-result row carrying tool_call_id, u2, a2.
2435+ // Rewinding to u1 (keep=1) drops ids 2..4 — the tool row feeds the cache
2436+ // prune; a2 (id=4) backs the stale reflection pointer.
2437+ session
2438+ . add_message ( crate :: utils:: ChatMessage :: user ( "u1" ) )
2439+ . await
2440+ . expect ( "add u1" ) ;
2441+ session
2442+ . add_message ( crate :: utils:: ChatMessage {
2443+ role : "tool" . to_string ( ) ,
2444+ content : Some ( crate :: utils:: MessageContent :: Text (
2445+ "tool result" . to_string ( ) ,
2446+ ) ) ,
2447+ name : None ,
2448+ tool_calls : None ,
2449+ tool_call_id : Some ( "call_1" . to_string ( ) ) ,
2450+ reasoning_content : None ,
2451+ is_error : None ,
2452+ } )
2453+ . await
2454+ . expect ( "add tool" ) ;
2455+ session
2456+ . add_message ( crate :: utils:: ChatMessage :: user ( "u2" ) )
2457+ . await
2458+ . expect ( "add u2" ) ;
2459+ session
2460+ . add_message ( crate :: utils:: ChatMessage :: assistant ( "a2" ) )
2461+ . await
2462+ . expect ( "add a2" ) ;
2463+
2464+ // Cache the tool result and point reflection at a2 (id=4, doomed).
2465+ let ( tx, rx) = tokio:: sync:: oneshot:: channel ( ) ;
2466+ node. send_packet ( MemoryMessage :: CacheToolResult {
2467+ tool_call_id : "call_1" . to_string ( ) ,
2468+ chat_id : thread_id. to_string ( ) ,
2469+ session_key : thread_id. to_string ( ) ,
2470+ tool_name : "demo" . to_string ( ) ,
2471+ full_content : "full tool result" . to_string ( ) ,
2472+ compact_summary : "summary" . to_string ( ) ,
2473+ reply : SharedReply :: new ( tx) ,
2474+ } )
2475+ . await
2476+ . expect ( "send cache" ) ;
2477+ rx. await . expect ( "reply" ) . expect ( "cache ok" ) ;
2478+
2479+ let ( tx, rx) = tokio:: sync:: oneshot:: channel ( ) ;
2480+ node. send_packet ( MemoryMessage :: UpdateThreadMetadata {
2481+ thread_id : thread_id. to_string ( ) ,
2482+ last_reflection_msg_id : Some ( 4 ) ,
2483+ reply : SharedReply :: new ( tx) ,
2484+ } )
2485+ . await
2486+ . expect ( "send metadata" ) ;
2487+ rx. await . expect ( "reply" ) . expect ( "metadata ok" ) ;
2488+
2489+ // Sanity: cache is present before the rewind.
2490+ let ( tx, rx) = tokio:: sync:: oneshot:: channel ( ) ;
2491+ node. send_packet ( MemoryMessage :: FetchToolResult {
2492+ tool_call_id : "call_1" . to_string ( ) ,
2493+ reply : SharedReply :: new ( tx) ,
2494+ } )
2495+ . await
2496+ . expect ( "send fetch" ) ;
2497+ assert_eq ! (
2498+ rx. await . expect( "reply" ) . expect( "fetch ok" ) ,
2499+ Some ( "full tool result" . to_string( ) )
2500+ ) ;
2501+
2502+ // Rewind to u1 — drops ids 2..4.
2503+ let ( tx, rx) = tokio:: sync:: oneshot:: channel ( ) ;
2504+ node. send_packet ( MemoryMessage :: TruncateAfterUserMessage {
2505+ thread_id : thread_id. to_string ( ) ,
2506+ keep_user_messages : 1 ,
2507+ reply : SharedReply :: new ( tx) ,
2508+ } )
2509+ . await
2510+ . expect ( "send truncate" ) ;
2511+ let deleted = rx. await . expect ( "reply" ) . expect ( "truncate ok" ) ;
2512+ assert_eq ! ( deleted, 3 ) ;
2513+
2514+ // Cached tool result for "call_1" was pruned with its message.
2515+ let ( tx, rx) = tokio:: sync:: oneshot:: channel ( ) ;
2516+ node. send_packet ( MemoryMessage :: FetchToolResult {
2517+ tool_call_id : "call_1" . to_string ( ) ,
2518+ reply : SharedReply :: new ( tx) ,
2519+ } )
2520+ . await
2521+ . expect ( "send fetch" ) ;
2522+ assert_eq ! ( rx. await . expect( "reply" ) . expect( "fetch ok" ) , None ) ;
2523+
2524+ // Reflection pointer (id=4) was stale → metadata reset to default.
2525+ let ( tx, rx) = tokio:: sync:: oneshot:: channel ( ) ;
2526+ node. send_packet ( MemoryMessage :: GetThreadMetadata {
2527+ thread_id : thread_id. to_string ( ) ,
2528+ reply : SharedReply :: new ( tx) ,
2529+ } )
2530+ . await
2531+ . expect ( "send get-metadata" ) ;
2532+ let ( last_id, _time) = rx. await . expect ( "reply" ) . expect ( "metadata ok" ) ;
2533+ assert_eq ! ( last_id, None ) ;
2534+ }
24292535}
0 commit comments