@@ -17,7 +17,7 @@ pub(super) enum Translation {
1717 message_id : Option < String > ,
1818 thinking : bool ,
1919 } ,
20- ToolStart ( AcpSessionUpdate ) ,
20+ ToolBoundary ( AcpSessionUpdate ) ,
2121 AgentMode ( String ) ,
2222 ConfigOptions ( Vec < acp:: SessionConfigOption > ) ,
2323 Ignore ,
@@ -43,9 +43,11 @@ pub(super) fn translate(notification: acp::SessionNotification) -> (String, Tran
4343 thinking : true ,
4444 } ,
4545 acp:: SessionUpdate :: ToolCall ( tool_call) => {
46- Translation :: ToolStart ( tool_start_update ( & tool_call) )
46+ Translation :: ToolBoundary ( tool_start_update ( & tool_call) )
47+ }
48+ acp:: SessionUpdate :: ToolCallUpdate ( update) => {
49+ Translation :: ToolBoundary ( tool_call_update ( update) )
4750 }
48- acp:: SessionUpdate :: ToolCallUpdate ( update) => Translation :: Update ( tool_call_update ( update) ) ,
4951 acp:: SessionUpdate :: CurrentModeUpdate ( update) => {
5052 Translation :: AgentMode ( update. current_mode_id . to_string ( ) )
5153 }
@@ -69,7 +71,7 @@ pub(super) async fn apply(
6971) {
7072 match translation {
7173 Translation :: Update ( update) => emit_or_buffer ( state, events, & session_id, update) . await ,
72- Translation :: ToolStart ( update) => match state. replay . route ( & session_id, update) . await {
74+ Translation :: ToolBoundary ( update) => match state. replay . route ( & session_id, update) . await {
7375 None => { }
7476 Some ( update) => {
7577 flush_assistant ( state, events, & session_id) . await ;
@@ -248,6 +250,8 @@ fn value_to_text(value: &Value) -> String {
248250#[ cfg( test) ]
249251mod tests {
250252 use super :: * ;
253+ use crate :: runtime_events:: ServerChannelMsg ;
254+ use tokio:: sync:: mpsc;
251255
252256 fn notification ( update : acp:: SessionUpdate ) -> acp:: SessionNotification {
253257 acp:: SessionNotification :: new ( "session-1" , update)
@@ -322,7 +326,7 @@ mod tests {
322326 ) ) ) ;
323327 assert ! ( matches!(
324328 started,
325- Translation :: ToolStart ( AcpSessionUpdate :: ToolCallStart {
329+ Translation :: ToolBoundary ( AcpSessionUpdate :: ToolCallStart {
326330 tool_call_id: Some ( id) ,
327331 name,
328332 arguments: Some ( arguments) ,
@@ -339,7 +343,7 @@ mod tests {
339343 ) ) ) ;
340344 assert ! ( matches!(
341345 pending,
342- Translation :: Update ( AcpSessionUpdate :: ToolCallStart {
346+ Translation :: ToolBoundary ( AcpSessionUpdate :: ToolCallStart {
343347 tool_call_id: Some ( id) ,
344348 name,
345349 arguments: Some ( arguments) ,
@@ -365,7 +369,7 @@ mod tests {
365369 ) ) ) ;
366370 assert ! ( matches!(
367371 completed,
368- Translation :: Update ( AcpSessionUpdate :: ToolCallEnd {
372+ Translation :: ToolBoundary ( AcpSessionUpdate :: ToolCallEnd {
369373 tool_call_id: Some ( id) ,
370374 name,
371375 is_error: false ,
@@ -388,7 +392,7 @@ mod tests {
388392 ] ) ,
389393 ) ,
390394 ) ) ) ;
391- let Translation :: Update ( AcpSessionUpdate :: ToolCallEnd {
395+ let Translation :: ToolBoundary ( AcpSessionUpdate :: ToolCallEnd {
392396 tool_call_id : Some ( id) ,
393397 name,
394398 is_error,
@@ -466,6 +470,126 @@ mod tests {
466470 }
467471 }
468472
473+ #[ tokio:: test]
474+ async fn live_tool_updates_flush_assistant_content_at_each_boundary ( ) {
475+ let state = Arc :: new ( RuntimeState :: new ( None ) ) ;
476+ let ( tx, mut rx) = mpsc:: unbounded_channel ( ) ;
477+ let events = EventSink :: new ( tx) ;
478+
479+ apply (
480+ & state,
481+ & events,
482+ "session-1" . into ( ) ,
483+ Translation :: AssistantChunk {
484+ text : "before start" . into ( ) ,
485+ message_id : Some ( "a1" . into ( ) ) ,
486+ thinking : false ,
487+ } ,
488+ )
489+ . await ;
490+ apply (
491+ & state,
492+ & events,
493+ "session-1" . into ( ) ,
494+ Translation :: ToolBoundary ( AcpSessionUpdate :: ToolCallStart {
495+ tool_call_id : Some ( "tool-1" . into ( ) ) ,
496+ name : "shell" . into ( ) ,
497+ arguments : None ,
498+ } ) ,
499+ )
500+ . await ;
501+ apply (
502+ & state,
503+ & events,
504+ "session-1" . into ( ) ,
505+ Translation :: AssistantChunk {
506+ text : "before end" . into ( ) ,
507+ message_id : Some ( "a2" . into ( ) ) ,
508+ thinking : false ,
509+ } ,
510+ )
511+ . await ;
512+ apply (
513+ & state,
514+ & events,
515+ "session-1" . into ( ) ,
516+ Translation :: ToolBoundary ( AcpSessionUpdate :: ToolCallEnd {
517+ tool_call_id : Some ( "tool-1" . into ( ) ) ,
518+ name : "shell" . into ( ) ,
519+ is_error : false ,
520+ result : None ,
521+ } ) ,
522+ )
523+ . await ;
524+
525+ let updates = ( 0 ..6 )
526+ . map ( |_| match rx. try_recv ( ) . expect ( "session update" ) {
527+ ServerChannelMsg :: Acp ( AcpAppEvent :: SessionUpdate { update, .. } ) => update,
528+ other => panic ! ( "unexpected event: {other:?}" ) ,
529+ } )
530+ . collect :: < Vec < _ > > ( ) ;
531+ assert ! ( matches!(
532+ & updates[ ..] ,
533+ [
534+ AcpSessionUpdate :: AssistantContentDelta { content: first_delta, .. } ,
535+ AcpSessionUpdate :: AssistantMessage { content: first_final, .. } ,
536+ AcpSessionUpdate :: ToolCallStart { .. } ,
537+ AcpSessionUpdate :: AssistantContentDelta { content: second_delta, .. } ,
538+ AcpSessionUpdate :: AssistantMessage { content: second_final, .. } ,
539+ AcpSessionUpdate :: ToolCallEnd { .. } ,
540+ ] if first_delta == "before start"
541+ && first_final == "before start"
542+ && second_delta == "before end"
543+ && second_final == "before end"
544+ ) ) ;
545+ }
546+
547+ #[ tokio:: test]
548+ async fn usage_updates_do_not_flush_assistant_content ( ) {
549+ let state = Arc :: new ( RuntimeState :: new ( None ) ) ;
550+ let ( tx, mut rx) = mpsc:: unbounded_channel ( ) ;
551+ let events = EventSink :: new ( tx) ;
552+ apply (
553+ & state,
554+ & events,
555+ "session-1" . into ( ) ,
556+ Translation :: AssistantChunk {
557+ text : "buffered" . into ( ) ,
558+ message_id : None ,
559+ thinking : false ,
560+ } ,
561+ )
562+ . await ;
563+ apply (
564+ & state,
565+ & events,
566+ "session-1" . into ( ) ,
567+ Translation :: Update ( AcpSessionUpdate :: UsageUpdate {
568+ used : 1 ,
569+ size : 2 ,
570+ cost_usd : None ,
571+ } ) ,
572+ )
573+ . await ;
574+
575+ assert ! ( matches!(
576+ rx. try_recv( ) ,
577+ Ok ( ServerChannelMsg :: Acp ( AcpAppEvent :: SessionUpdate {
578+ update: AcpSessionUpdate :: AssistantContentDelta { .. } ,
579+ ..
580+ } ) )
581+ ) ) ;
582+ assert ! ( matches!(
583+ rx. try_recv( ) ,
584+ Ok ( ServerChannelMsg :: Acp ( AcpAppEvent :: SessionUpdate {
585+ update: AcpSessionUpdate :: UsageUpdate { .. } ,
586+ ..
587+ } ) )
588+ ) ) ;
589+ assert ! ( rx. try_recv( ) . is_err( ) ) ;
590+ assert ! ( state. assistants. flush( "session-1" ) . await . is_some( ) ) ;
591+ }
592+
469593 #[ test]
470594 fn usage_translation_keeps_only_usd_cost ( ) {
471595 for ( cost, expected) in [
0 commit comments