@@ -245,6 +245,25 @@ fn matched_slash_command(raw_user_input: &str, commands: &[AvailableCommand]) ->
245245/// (Claude, Qwen, CodeBuddy, Codex, etc.). Communication now happens via
246246/// the `agent-client-protocol` SDK's JSON-RPC transport, replacing the
247247/// previous hand-crafted JSON-over-stdin/stdout approach.
248+ fn mark_session_opened_after_protocol_ready (
249+ session : & mut AcpSession ,
250+ sid : String ,
251+ protocol_connected : bool ,
252+ conversation_id : & str ,
253+ backend : Option < & str > ,
254+ ) -> Result < String , AgentError > {
255+ if !protocol_connected {
256+ warn ! (
257+ conversation_id = %conversation_id,
258+ backend = backend. unwrap_or( "-" ) ,
259+ "ACP session open returned after protocol disconnected; rejecting opened transition"
260+ ) ;
261+ return Err ( AcpError :: NotConnected . into ( ) ) ;
262+ }
263+ session. mark_opened ( ) ;
264+ Ok ( sid)
265+ }
266+
248267pub struct AcpAgentManager {
249268 /// Pre-computed, immutable session parameters assembled by the factory.
250269 pub ( super ) params : Arc < AcpSessionParams > ,
@@ -465,6 +484,19 @@ impl AcpAgentManager {
465484 runtime. bump_activity ( ) ;
466485 }
467486
487+ fn ensure_protocol_connected_for_operation ( & self , operation : & ' static str ) -> Result < ( ) , AgentError > {
488+ if self . protocol . is_connected ( ) {
489+ return Ok ( ( ) ) ;
490+ }
491+ warn ! (
492+ conversation_id = %self . params. conversation_id,
493+ agent_backend = ?self . params. metadata. backend,
494+ operation,
495+ "ACP operation rejected because protocol is disconnected"
496+ ) ;
497+ Err ( AcpError :: NotConnected . into ( ) )
498+ }
499+
468500 pub ( crate ) async fn mode ( & self ) -> Result < aionui_api_types:: AgentModeResponse , AgentError > {
469501 let desired = self
470502 . session
@@ -551,6 +583,8 @@ impl AcpAgentManager {
551583 option_id : & str ,
552584 value : & str ,
553585 ) -> Result < SetConfigOptionResponse , AgentError > {
586+ self . ensure_protocol_connected_for_operation ( "set_config_option" ) ?;
587+
554588 let ( session_id, set_path, is_mode_option) = {
555589 let session = self . session . read ( ) . await ;
556590 let snapshot = session. config_snapshot ( ) ;
@@ -761,176 +795,6 @@ impl AcpAgentManager {
761795 }
762796 }
763797
764- /// Set the mode for the current session.
765- pub ( crate ) async fn set_mode ( & self , mode : & str ) -> Result < ( ) , AgentError > {
766- let normalized_mode = normalize_requested_mode ( & self . params . metadata , mode) ;
767- if normalized_mode. is_empty ( ) {
768- return Err ( AgentError :: bad_request ( "mode must not be empty" ) ) ;
769- }
770-
771- let session_id = {
772- let session = self . session . read ( ) . await ;
773- if !session. can_select_mode ( & normalized_mode) {
774- warn ! (
775- conversation_id = %self . params. conversation_id,
776- agent_backend = ?self . params. metadata. backend,
777- requested_mode_id = %normalized_mode,
778- "acp_set_mode_rejected_unavailable"
779- ) ;
780- return Err ( AgentError :: bad_request ( format ! (
781- "Mode '{normalized_mode}' is not available for this ACP session"
782- ) ) ) ;
783- }
784- session. session_id ( ) . map ( ToOwned :: to_owned)
785- }
786- . ok_or_else ( || {
787- warn ! (
788- conversation_id = %self . params. conversation_id,
789- agent_backend = ?self . params. metadata. backend,
790- requested_mode_id = %normalized_mode,
791- "acp_set_command_missing_session"
792- ) ;
793- AgentError :: bad_request ( "No active session" )
794- } ) ?;
795-
796- info ! (
797- conversation_id = %self . params. conversation_id,
798- agent_backend = ?self . params. metadata. backend,
799- requested_mode_id = %normalized_mode,
800- "acp_set_mode_requested"
801- ) ;
802- codex_sandbox:: sync_for_agent ( & self . params . metadata , Some ( & normalized_mode) ) . await ;
803-
804- if let Err ( e) = self
805- . protocol
806- . set_mode ( SetSessionModeRequest :: new (
807- SessionId :: new ( session_id. clone ( ) ) ,
808- normalized_mode. clone ( ) ,
809- ) )
810- . await
811- {
812- warn ! (
813- conversation_id = %self . params. conversation_id,
814- agent_backend = ?self . params. metadata. backend,
815- requested_mode_id = %normalized_mode,
816- error = %e,
817- "acp_set_mode_failed"
818- ) ;
819- return Err ( AgentError :: from ( e) ) ;
820- }
821-
822- let mut session = self . session . write ( ) . await ;
823- if session. session_id ( ) != Some ( session_id. as_str ( ) ) {
824- warn ! (
825- conversation_id = %self . params. conversation_id,
826- agent_backend = ?self . params. metadata. backend,
827- requested_mode_id = %normalized_mode,
828- confirmed_session_id = %session_id,
829- active_session_id = ?session. session_id( ) ,
830- "acp_set_mode_session_changed"
831- ) ;
832- return Err ( AgentError :: conflict ( "Active ACP session changed while applying mode" ) ) ;
833- }
834- session. confirm_mode ( ModeId :: new ( & normalized_mode) ) ;
835- self . commit_session_changes ( & mut session) . await ;
836- info ! (
837- conversation_id = %self . params. conversation_id,
838- agent_backend = ?self . params. metadata. backend,
839- confirmed_mode_id = %normalized_mode,
840- "acp_set_mode_confirmed"
841- ) ;
842- Ok ( ( ) )
843- }
844-
845- async fn apply_confirmed_model_selection ( & self , model_id : & str ) -> Result < SessionModelState , AgentError > {
846- let session_id = {
847- let session = self . session . read ( ) . await ;
848- if !session. can_select_model ( model_id) {
849- warn ! (
850- conversation_id = %self . params. conversation_id,
851- agent_backend = ?self . params. metadata. backend,
852- requested_model_id = %model_id,
853- "acp_set_model_rejected_unavailable"
854- ) ;
855- return Err ( AgentError :: bad_request ( format ! (
856- "Model '{model_id}' is not available for this ACP session"
857- ) ) ) ;
858- }
859- session. session_id ( ) . map ( ToOwned :: to_owned)
860- }
861- . ok_or_else ( || {
862- warn ! (
863- conversation_id = %self . params. conversation_id,
864- agent_backend = ?self . params. metadata. backend,
865- requested_model_id = %model_id,
866- "acp_set_command_missing_session"
867- ) ;
868- AgentError :: bad_request ( "No active session" )
869- } ) ?;
870-
871- info ! (
872- conversation_id = %self . params. conversation_id,
873- agent_backend = ?self . params. metadata. backend,
874- requested_model_id = %model_id,
875- "acp_set_model_requested"
876- ) ;
877- if let Err ( e) = self
878- . protocol
879- . set_model ( SetSessionModelRequest :: new (
880- SessionId :: new ( session_id. clone ( ) ) ,
881- model_id. to_owned ( ) ,
882- ) )
883- . await
884- {
885- warn ! (
886- conversation_id = %self . params. conversation_id,
887- agent_backend = ?self . params. metadata. backend,
888- requested_model_id = %model_id,
889- error = %e,
890- "acp_set_model_failed"
891- ) ;
892- return Err ( AgentError :: from ( e) ) ;
893- }
894-
895- let mut session = self . session . write ( ) . await ;
896- if session. session_id ( ) != Some ( session_id. as_str ( ) ) {
897- warn ! (
898- conversation_id = %self . params. conversation_id,
899- agent_backend = ?self . params. metadata. backend,
900- requested_model_id = %model_id,
901- confirmed_session_id = %session_id,
902- active_session_id = ?session. session_id( ) ,
903- "acp_set_model_session_changed"
904- ) ;
905- return Err ( AgentError :: conflict ( "Active ACP session changed while applying model" ) ) ;
906- }
907- session. confirm_model ( ModelId :: new ( model_id) ) ;
908- let confirmed_model = session
909- . model_info ( )
910- . cloned ( )
911- . unwrap_or_else ( || SessionModelState :: new ( model_id. to_owned ( ) , Vec :: new ( ) ) ) ;
912- self . commit_session_changes ( & mut session) . await ;
913- info ! (
914- conversation_id = %self . params. conversation_id,
915- agent_backend = ?self . params. metadata. backend,
916- confirmed_model_id = %model_id,
917- "acp_set_model_confirmed"
918- ) ;
919- Ok ( confirmed_model)
920- }
921-
922- /// Set the model for the current session.
923- pub ( crate ) async fn set_model ( & self , model_id : & str ) -> Result < ( ) , AgentError > {
924- self . apply_confirmed_model_selection ( model_id) . await ?;
925- Ok ( ( ) )
926- }
927-
928- /// Set the model and return the confirmed model state from this write,
929- /// without re-reading the asynchronously mutable session cache.
930- pub ( crate ) async fn set_model_confirmed ( & self , model_id : & str ) -> Result < SessionModelState , AgentError > {
931- self . apply_confirmed_model_selection ( model_id) . await
932- }
933-
934798 /// Return available slash commands from the session aggregate.
935799 pub ( crate ) async fn load_slash_commands ( & self ) -> Result < Vec < SlashCommandItem > , AgentError > {
936800 let session = self . session . read ( ) . await ;
@@ -990,6 +854,7 @@ impl AcpAgentManager {
990854 async fn ensure_session_opened ( & self ) -> Result < String , AgentError > {
991855 debug ! ( "Ensuring ACP session is opened" ) ;
992856 let _lock = self . session_lock . lock ( ) . await ;
857+ self . ensure_protocol_connected_for_operation ( "ensure_session_opened" ) ?;
993858
994859 let ( session_id, opened) = {
995860 let s = self . session . read ( ) . await ;
@@ -1004,10 +869,16 @@ impl AcpAgentManager {
1004869
1005870 {
1006871 let mut s = self . session . write ( ) . await ;
1007- s. mark_opened ( ) ;
872+ let sid = mark_session_opened_after_protocol_ready (
873+ & mut s,
874+ sid,
875+ self . protocol . is_connected ( ) ,
876+ & self . params . conversation_id ,
877+ self . backend ( ) ,
878+ ) ?;
1008879 self . commit_session_changes ( & mut s) . await ;
880+ Ok ( sid)
1009881 }
1010- Ok ( sid)
1011882 }
1012883
1013884 /// Initialize or resume a session, then send the user message.
@@ -1312,8 +1183,8 @@ mod tests {
13121183 use crate :: agent_runtime:: AgentRuntime ;
13131184 use crate :: error:: AgentError ;
13141185 use crate :: manager:: acp:: { AcpAgentManager , AcpSession } ;
1315- use crate :: protocol:: error:: CloseReason ;
1316- use crate :: shared_kernel:: { ConfigKey , ConfigValue } ;
1186+ use crate :: protocol:: error:: { AcpError , CloseReason } ;
1187+ use crate :: shared_kernel:: { ConfigKey , ConfigValue , SessionId as DomainSessionId } ;
13171188 use agent_client_protocol:: schema:: { AvailableCommand , SessionConfigOptionCategory } ;
13181189 use serde_json:: json;
13191190 use std:: collections:: HashMap ;
@@ -1354,6 +1225,31 @@ mod tests {
13541225 assert_eq ! ( user_facing_message( & err) , "Rate limited" ) ;
13551226 }
13561227
1228+ #[ test]
1229+ fn warmup_does_not_mark_opened_when_protocol_disconnected_after_open ( ) {
1230+ let mut session = AcpSession :: new ( None , None , Default :: default ( ) ) ;
1231+ session. set_session_id ( DomainSessionId :: new ( "sess-disconnected" ) ) ;
1232+
1233+ let err = super :: mark_session_opened_after_protocol_ready (
1234+ & mut session,
1235+ "sess-disconnected" . to_owned ( ) ,
1236+ false ,
1237+ "conv-test" ,
1238+ Some ( "codex" ) ,
1239+ )
1240+ . expect_err ( "disconnected protocol must reject the opened transition" ) ;
1241+
1242+ assert ! (
1243+ matches!( err, AgentError :: Acp ( AcpError :: NotConnected ) ) ,
1244+ "expected AcpError::NotConnected, got {err:?}"
1245+ ) ;
1246+ assert_eq ! ( session. session_id( ) , Some ( "sess-disconnected" ) ) ;
1247+ assert ! (
1248+ !session. is_opened( ) ,
1249+ "warmup must not mark the aggregate opened when the protocol is already disconnected"
1250+ ) ;
1251+ }
1252+
13571253 #[ test]
13581254 fn nested_colons_only_strip_first ( ) {
13591255 // "Bad gateway: Internal error: API Error: ..." → keep everything after the first ": "
0 commit comments