From a7d840863a6f4849e107c641bf47c2760ff7cf04 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 31 Mar 2026 16:29:03 +1100 Subject: [PATCH 1/2] feat(acp-client): expose session title, model state, and config options Extend the MessageWriter trait with default callbacks for session metadata events (title updates, model state, config option changes) and wire them through both the notification handler and session setup so consumers can react to these ACP protocol features. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/acp-client/src/driver.rs | 53 +++++++++++++++++++++++++++++++-- crates/acp-client/src/lib.rs | 5 +++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/acp-client/src/driver.rs b/crates/acp-client/src/driver.rs index 5683d068..d566218e 100644 --- a/crates/acp-client/src/driver.rs +++ b/crates/acp-client/src/driver.rs @@ -17,8 +17,8 @@ use agent_client_protocol::{ Agent, ClientSideConnection, ContentBlock as AcpContentBlock, ImageContent, Implementation, InitializeRequest, LoadSessionRequest, McpServer, NewSessionRequest, PermissionOptionId, PromptRequest, ProtocolVersion, RequestPermissionOutcome, RequestPermissionRequest, - RequestPermissionResponse, SelectedPermissionOutcome, SessionNotification, SessionUpdate, - TextContent, + RequestPermissionResponse, SelectedPermissionOutcome, SessionConfigOption, SessionModelState, + SessionNotification, SessionUpdate, TextContent, }; use async_trait::async_trait; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -58,6 +58,17 @@ pub trait MessageWriter: Send + Sync { /// Record the result/output of a tool call. async fn record_tool_result(&self, content: &str); + + /// Called when the session title changes. + /// + /// `title` is `Some` when a title is set, `None` when explicitly cleared. + async fn on_session_title_update(&self, _title: Option<&str>) {} + + /// Called when model state is received (from session setup or notification). + async fn on_model_state_update(&self, _state: &SessionModelState) {} + + /// Called when session configuration options change. + async fn on_config_option_update(&self, _options: &[SessionConfigOption]) {} } /// Storage interface for persisting agent session data. @@ -834,6 +845,25 @@ impl agent_client_protocol::Client for AcpNotificationHandler { &self, notification: SessionNotification, ) -> agent_client_protocol::Result<()> { + // Session metadata events are forwarded regardless of phase. + match ¬ification.update { + SessionUpdate::SessionInfoUpdate(info) => { + if let Some(title_opt) = info.title.as_opt_ref() { + self.writer + .on_session_title_update(title_opt.map(|s| s.as_str())) + .await; + } + return Ok(()); + } + SessionUpdate::ConfigOptionUpdate(update) => { + self.writer + .on_config_option_update(&update.config_options) + .await; + return Ok(()); + } + _ => {} + } + // Determine the action to take under the lock, then drop the lock // before calling into the writer to avoid holding it across await points. enum LiveAction { @@ -1032,6 +1062,7 @@ async fn run_acp_protocol( connection, working_dir, store, + &handler.writer, our_session_id, acp_session_id, mcp_servers, @@ -1092,6 +1123,7 @@ async fn setup_acp_session( connection: &ClientSideConnection, working_dir: &Path, store: &Arc, + writer: &Arc, our_session_id: &str, acp_session_id: Option<&str>, mcp_servers: &[McpServer], @@ -1136,7 +1168,7 @@ async fn setup_acp_session( "Resuming ACP session {existing_id} via load_session for session {our_session_id}" ); - connection + let load_response = connection .load_session( LoadSessionRequest::new(existing_id.to_string(), working_dir.to_path_buf()) .mcp_servers(mcp_servers.to_vec()), @@ -1144,6 +1176,13 @@ async fn setup_acp_session( .await .map_err(|e| format!("Failed to load ACP session: {e:?}"))?; + if let Some(ref models) = load_response.models { + writer.on_model_state_update(models).await; + } + if let Some(ref options) = load_response.config_options { + writer.on_config_option_update(options).await; + } + Ok(existing_id.to_string()) } None => { @@ -1158,6 +1197,14 @@ async fn setup_acp_session( store .set_agent_session_id(our_session_id, &new_id) .map_err(|e| format!("Failed to save agent session ID: {e}"))?; + + if let Some(ref models) = session_response.models { + writer.on_model_state_update(models).await; + } + if let Some(ref options) = session_response.config_options { + writer.on_config_option_update(options).await; + } + Ok(new_id) } } diff --git a/crates/acp-client/src/lib.rs b/crates/acp-client/src/lib.rs index 467a2431..be16747a 100644 --- a/crates/acp-client/src/lib.rs +++ b/crates/acp-client/src/lib.rs @@ -22,7 +22,10 @@ mod simple; mod types; // Re-export the main API -pub use agent_client_protocol::{McpServer, McpServerHttp, McpServerSse}; +pub use agent_client_protocol::{ + ConfigOptionUpdate, McpServer, McpServerHttp, McpServerSse, ModelInfo, SessionConfigOption, + SessionConfigOptionCategory, SessionInfoUpdate, SessionModelState, +}; pub use driver::{ strip_code_fences, AcpDriver, AgentDriver, BasicMessageWriter, MessageWriter, Store, }; From 2e39edc22c4fca61b2f5a0410af86134d5846e90 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 31 Mar 2026 16:40:11 +1100 Subject: [PATCH 2/2] fix(acp-client): pass full SessionInfoUpdate and fix model state docs Broaden `on_session_title_update` to `on_session_info_update` so consumers receive the entire `SessionInfoUpdate` (including `updated_at`), not just the title. Correct the `on_model_state_update` doc comment to reflect that `SessionModelState` is only delivered in setup responses, not via notifications. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/acp-client/src/driver.rs | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/acp-client/src/driver.rs b/crates/acp-client/src/driver.rs index d566218e..092961de 100644 --- a/crates/acp-client/src/driver.rs +++ b/crates/acp-client/src/driver.rs @@ -17,8 +17,8 @@ use agent_client_protocol::{ Agent, ClientSideConnection, ContentBlock as AcpContentBlock, ImageContent, Implementation, InitializeRequest, LoadSessionRequest, McpServer, NewSessionRequest, PermissionOptionId, PromptRequest, ProtocolVersion, RequestPermissionOutcome, RequestPermissionRequest, - RequestPermissionResponse, SelectedPermissionOutcome, SessionConfigOption, SessionModelState, - SessionNotification, SessionUpdate, TextContent, + RequestPermissionResponse, SelectedPermissionOutcome, SessionConfigOption, SessionInfoUpdate, + SessionModelState, SessionNotification, SessionUpdate, TextContent, }; use async_trait::async_trait; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -59,12 +59,17 @@ pub trait MessageWriter: Send + Sync { /// Record the result/output of a tool call. async fn record_tool_result(&self, content: &str); - /// Called when the session title changes. + /// Called when session info is updated (title, timestamps, etc.). /// - /// `title` is `Some` when a title is set, `None` when explicitly cleared. - async fn on_session_title_update(&self, _title: Option<&str>) {} + /// Delivered via `SessionUpdate::SessionInfoUpdate` notifications during a + /// session, or extracted from setup responses. + async fn on_session_info_update(&self, _info: &SessionInfoUpdate) {} - /// Called when model state is received (from session setup or notification). + /// Called when model state is received from session setup responses. + /// + /// `SessionModelState` is only delivered in `NewSessionResponse` and + /// `LoadSessionResponse`. Mid-session model changes are surfaced through + /// `on_config_option_update` via `ConfigOptionUpdate` with category `Model`. async fn on_model_state_update(&self, _state: &SessionModelState) {} /// Called when session configuration options change. @@ -848,11 +853,7 @@ impl agent_client_protocol::Client for AcpNotificationHandler { // Session metadata events are forwarded regardless of phase. match ¬ification.update { SessionUpdate::SessionInfoUpdate(info) => { - if let Some(title_opt) = info.title.as_opt_ref() { - self.writer - .on_session_title_update(title_opt.map(|s| s.as_str())) - .await; - } + self.writer.on_session_info_update(info).await; return Ok(()); } SessionUpdate::ConfigOptionUpdate(update) => {