@@ -17,18 +17,22 @@ use agentkit_acp::{
1717 BlobResourceContents , CancelNotification , CloseSessionRequest , CloseSessionResponse ,
1818 ContentBlock , ContentChunk , EmbeddedResource , EmbeddedResourceResource , ImageContent ,
1919 InitializeRequest , InitializeResponse , LoadSessionRequest , LoadSessionResponse ,
20- NewSessionRequest , NewSessionResponse , PromptCapabilities , PromptRequest , PromptResponse ,
21- ResourceLink , SessionAdditionalDirectoriesCapabilities , SessionCapabilities ,
22- SessionCloseCapabilities , SessionConfigOption , SessionConfigOptionCategory ,
23- SessionConfigSelectGroup , SessionConfigSelectOption , SessionNotification , SessionUpdate ,
24- SetSessionConfigOptionRequest , SetSessionConfigOptionResponse , StopReason , TextContent ,
25- TextResourceContents , ToolCallStatus , ToolCallUpdateFields ,
20+ NewSessionRequest , NewSessionResponse , Notice , NoticeSeverity , PromptCapabilities ,
21+ PromptRequest , PromptResponse , ResourceLink , SessionAdditionalDirectoriesCapabilities ,
22+ SessionCapabilities , SessionCloseCapabilities , SessionConfigOption ,
23+ SessionConfigOptionCategory , SessionConfigSelectGroup , SessionConfigSelectOption ,
24+ SessionNotification , SessionUpdate , SetSessionConfigOptionRequest ,
25+ SetSessionConfigOptionResponse , StopReason , TextContent , TextResourceContents , ToolCallStatus ,
26+ ToolCallUpdateFields ,
2627} ;
2728use agentkit_core:: {
2829 CancellationController , DataRef , FinishReason , Item , ItemKind , MediaPart , MetadataMap ,
2930 Modality , Part , SessionId as AgentkitSessionId , ToolOutput ,
3031} ;
31- use agentkit_loop:: { LoopDriver , LoopError , LoopInterrupt , LoopStep , ModelSession } ;
32+ use agentkit_loop:: {
33+ AgentEvent , LoopDriver , LoopError , LoopInterrupt , LoopObserver , LoopStep , ModelSession ,
34+ ObservedEvent ,
35+ } ;
3236use agentkit_task_manager:: { TaskEvent , TaskManagerHandle } ;
3337use base64:: { Engine as _, engine:: general_purpose:: STANDARD as BASE64 } ;
3438use serde:: { Deserialize , Serialize } ;
@@ -560,6 +564,49 @@ struct SessionBindingGuard {
560564 session_id : agentkit_acp:: SessionId ,
561565}
562566
567+ #[ derive( Clone ) ]
568+ struct ResponseInterruptionNoticeObserver {
569+ inner : AcpIntegration ,
570+ client : AcpClientHandle ,
571+ session_id : agentkit_acp:: SessionId ,
572+ }
573+
574+ impl ResponseInterruptionNoticeObserver {
575+ fn new (
576+ inner : AcpIntegration ,
577+ client : AcpClientHandle ,
578+ session_id : agentkit_acp:: SessionId ,
579+ ) -> Self {
580+ Self {
581+ inner,
582+ client,
583+ session_id,
584+ }
585+ }
586+ }
587+
588+ impl LoopObserver for ResponseInterruptionNoticeObserver {
589+ fn handle_event ( & self , event : ObservedEvent ) {
590+ if matches ! (
591+ & event. event,
592+ AgentEvent :: ContentDelta ( delta) if crate :: response_attempt:: is_marker( delta)
593+ ) {
594+ let notification = SessionNotification :: new (
595+ self . session_id . clone ( ) ,
596+ SessionUpdate :: Notice ( Notice :: new (
597+ NoticeSeverity :: Warning ,
598+ "Response interrupted; replacement follows" ,
599+ ) ) ,
600+ ) ;
601+ if let Err ( error) = self . client . notify_session ( notification) {
602+ tracing:: debug!( %error, "failed to queue ACP v1 interruption notice" ) ;
603+ }
604+ return ;
605+ }
606+ self . inner . handle_event ( event) ;
607+ }
608+ }
609+
563610impl SessionBindingGuard {
564611 fn new ( integration : Arc < AcpIntegration > , session_id : agentkit_acp:: SessionId ) -> Self {
565612 Self {
@@ -702,20 +749,27 @@ impl Server {
702749 json ! ( additional_directories) ,
703750 ) ;
704751 let mcp_events = self . runtime . subscribe_mcp ( session_id. to_string ( ) ) ;
705- let binding = AcpSessionBinding :: new ( session_id. clone ( ) , agentkit_session_id, client)
706- . cancellation ( cancellation)
707- . workspace ( cwd. clone ( ) , additional_directories. clone ( ) )
708- . metadata ( metadata) ;
752+ let binding =
753+ AcpSessionBinding :: new ( session_id. clone ( ) , agentkit_session_id, client. clone ( ) )
754+ . cancellation ( cancellation)
755+ . workspace ( cwd. clone ( ) , additional_directories. clone ( ) )
756+ . metadata ( metadata) ;
709757 let handle = self
710758 . integration
711759 . bind_session ( binding)
712760 . map_err ( |error| record_acp_runtime_failure ( & session_id, "session_bind" , error) ) ?;
713761 let binding = SessionBindingGuard :: new ( Arc :: clone ( & self . integration ) , session_id. clone ( ) ) ;
762+ let observer = ResponseInterruptionNoticeObserver :: new (
763+ self . integration . as_ref ( ) . clone ( ) ,
764+ client,
765+ session_id. clone ( ) ,
766+ ) ;
714767 let context = AcpDriverContext {
715768 cwd,
716769 additional_directories,
717- integration : Arc :: clone ( & self . integration ) ,
770+ integration : Arc :: new ( observer ) ,
718771 cancellation : handle. cancellation_handle ( ) ,
772+ response_attempt_replacement : true ,
719773 } ;
720774 let driver = match self . runtime . start_acp_driver ( context, & mut claim) . await {
721775 Ok ( driver) => driver,
@@ -1804,6 +1858,68 @@ mod tests {
18041858 ) ) ;
18051859 }
18061860
1861+ #[ tokio:: test]
1862+ async fn response_interruption_marker_becomes_v1_warning_before_replacement ( ) {
1863+ let integration = AcpIntegration :: builder ( )
1864+ . name ( "response-interruption-test" )
1865+ . approval_resolver ( AutoDenyResolver )
1866+ . build ( )
1867+ . unwrap ( ) ;
1868+ let session_id = agentkit_acp:: SessionId :: new ( "v1-interruption" ) ;
1869+ let loop_session_id = AgentkitSessionId :: new ( "v1-interruption-loop" ) ;
1870+ let ( client, mut messages) = AcpClientHandle :: channel ( ) ;
1871+ integration
1872+ . bind_session ( AcpSessionBinding :: new (
1873+ session_id. clone ( ) ,
1874+ loop_session_id. clone ( ) ,
1875+ client. clone ( ) ,
1876+ ) )
1877+ . unwrap ( ) ;
1878+ let observer = ResponseInterruptionNoticeObserver :: new (
1879+ integration. clone ( ) ,
1880+ client,
1881+ session_id. clone ( ) ,
1882+ ) ;
1883+ let emit = |event| {
1884+ observer. handle_event ( ObservedEvent {
1885+ session_id : Arc :: new ( loop_session_id. clone ( ) ) ,
1886+ event,
1887+ } ) ;
1888+ } ;
1889+ let ModelTurnEvent :: Delta ( marker) = crate :: response_attempt:: marker_event ( ) else {
1890+ panic ! ( "replacement marker must be a delta" ) ;
1891+ } ;
1892+
1893+ emit ( AgentEvent :: ContentDelta ( marker) ) ;
1894+ emit ( AgentEvent :: ContentDelta ( Delta :: BeginPart {
1895+ part_id : PartId :: new ( "replacement" ) ,
1896+ kind : PartKind :: Text ,
1897+ } ) ) ;
1898+ emit ( AgentEvent :: ContentDelta ( Delta :: AppendText {
1899+ part_id : PartId :: new ( "replacement" ) ,
1900+ chunk : "fresh response" . into ( ) ,
1901+ } ) ) ;
1902+
1903+ let Some ( AcpClientMessage :: SessionNotification ( notice) ) = messages. recv ( ) . await else {
1904+ panic ! ( "expected interruption notice" ) ;
1905+ } ;
1906+ let SessionUpdate :: Notice ( notice) = notice. update else {
1907+ panic ! ( "expected notice before replacement output" ) ;
1908+ } ;
1909+ assert_eq ! ( notice. severity, NoticeSeverity :: Warning ) ;
1910+ assert_eq ! ( notice. title, "Response interrupted; replacement follows" ) ;
1911+
1912+ let Some ( AcpClientMessage :: SessionNotification ( replacement) ) = messages. recv ( ) . await else {
1913+ panic ! ( "expected replacement output" ) ;
1914+ } ;
1915+ assert ! ( matches!(
1916+ replacement. update,
1917+ SessionUpdate :: AgentMessageChunk ( chunk)
1918+ if matches!( & chunk. content, ContentBlock :: Text ( text) if text. text == "fresh response" )
1919+ ) ) ;
1920+ integration. unbind_session ( & session_id) . unwrap ( ) ;
1921+ }
1922+
18071923 #[ test]
18081924 fn dropping_an_in_flight_binding_guard_releases_the_durable_identity ( ) {
18091925 let integration = Arc :: new (
0 commit comments