diff --git a/rsworkspace/crates/acp-nats/src/client/mod.rs b/rsworkspace/crates/acp-nats/src/client/mod.rs index 00b96cc04..f91818837 100644 --- a/rsworkspace/crates/acp-nats/src/client/mod.rs +++ b/rsworkspace/crates/acp-nats/src/client/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod rpc_reply; pub(crate) mod session_update; pub(crate) mod terminal_create; pub(crate) mod terminal_kill; +pub(crate) mod terminal_output; use crate::agent::Bridge; use crate::error::AGENT_UNAVAILABLE; @@ -225,6 +226,17 @@ async fn dispatch_client_method< ) .await; } + ClientMethod::TerminalOutput => { + terminal_output::handle( + &payload, + ctx.client, + reply.as_deref(), + ctx.nats, + parsed.session_id.as_str(), + ctx.serializer, + ) + .await; + } } } @@ -237,7 +249,7 @@ mod tests { KillTerminalCommandRequest, KillTerminalCommandResponse, ReadTextFileRequest, ReadTextFileResponse, Request, RequestId, RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SessionNotification, SessionUpdate, - ToolCallUpdate, ToolCallUpdateFields, + TerminalOutputRequest, TerminalOutputResponse, ToolCallUpdate, ToolCallUpdateFields, }; use async_trait::async_trait; use std::cell::RefCell; @@ -248,6 +260,7 @@ mod tests { pub(super) struct MockClient { notifications: RefCell>, kill_terminal_calls: RefCell, + terminal_output_calls: RefCell, } impl MockClient { @@ -255,12 +268,17 @@ mod tests { Self { notifications: RefCell::new(Vec::new()), kill_terminal_calls: RefCell::new(0), + terminal_output_calls: RefCell::new(0), } } pub(super) fn kill_terminal_call_count(&self) -> usize { *self.kill_terminal_calls.borrow() } + + pub(super) fn terminal_output_call_count(&self) -> usize { + *self.terminal_output_calls.borrow() + } } #[async_trait(?Send)] @@ -304,6 +322,17 @@ mod tests { *self.kill_terminal_calls.borrow_mut() += 1; Ok(KillTerminalCommandResponse::new()) } + + async fn terminal_output( + &self, + _: TerminalOutputRequest, + ) -> agent_client_protocol::Result { + *self.terminal_output_calls.borrow_mut() += 1; + Ok(TerminalOutputResponse::new( + "mock output".to_string(), + false, + )) + } } fn make_msg(subject: &str, payload: &[u8], reply: Option<&str>) -> async_nats::Message { @@ -577,6 +606,171 @@ mod tests { assert!(response.get("error").is_none()); } + #[tokio::test] + async fn dispatch_client_method_dispatches_terminal_output() { + let nats = MockNatsClient::new(); + let client = MockClient::new(); + let session_id = AcpSessionId::new("sess-1").unwrap(); + + let envelope = Request { + id: RequestId::Number(1), + method: std::sync::Arc::from("terminal/output"), + params: Some(TerminalOutputRequest::new("sess-1", "term-001")), + }; + let payload = bytes::Bytes::from(serde_json::to_vec(&envelope).unwrap()); + + let parsed = crate::nats::ParsedClientSubject { + session_id, + method: ClientMethod::TerminalOutput, + }; + + let ctx = DispatchContext { + nats: &nats, + client: &client, + serializer: &StdJsonSerialize, + }; + dispatch_client_method( + "acp.sess-1.client.terminal.output", + parsed, + payload, + Some("_INBOX.reply".to_string()), + &ctx, + ) + .await; + + assert_eq!(nats.published_messages(), vec!["_INBOX.reply"]); + let payloads = nats.published_payloads(); + assert_eq!(payloads.len(), 1); + let response: serde_json::Value = serde_json::from_slice(payloads[0].as_ref()).unwrap(); + assert_eq!(response.get("id"), Some(&serde_json::Value::from(1))); + assert!(response.get("result").is_some()); + assert!(response.get("error").is_none()); + assert_eq!( + client.terminal_output_call_count(), + 1, + "terminal_output handler must run" + ); + assert_eq!( + client.kill_terminal_call_count(), + 0, + "kill handler must not run" + ); + } + + #[tokio::test] + async fn dispatch_client_method_dispatches_terminal_output_client_error_publishes_error_reply() + { + let nats = MockNatsClient::new(); + let client = TerminalKillFailingClient; + let session_id = AcpSessionId::new("sess-1").unwrap(); + + let envelope = Request { + id: RequestId::Number(1), + method: std::sync::Arc::from("terminal/output"), + params: Some(TerminalOutputRequest::new("sess-1", "term-001")), + }; + let payload = bytes::Bytes::from(serde_json::to_vec(&envelope).unwrap()); + + let parsed = crate::nats::ParsedClientSubject { + session_id, + method: ClientMethod::TerminalOutput, + }; + + let ctx = DispatchContext { + nats: &nats, + client: &client, + serializer: &StdJsonSerialize, + }; + dispatch_client_method( + "acp.sess-1.client.terminal.output", + parsed, + payload, + Some("_INBOX.reply".to_string()), + &ctx, + ) + .await; + + assert_eq!(nats.published_messages(), vec!["_INBOX.reply"]); + let payloads = nats.published_payloads(); + let response: serde_json::Value = serde_json::from_slice(payloads[0].as_ref()).unwrap(); + assert!(response.get("error").is_some()); + assert_eq!( + response.get("error").and_then(|e| e.get("code")), + Some(&serde_json::Value::from(-32603)) + ); + } + + #[tokio::test] + async fn dispatch_client_method_dispatches_terminal_output_with_rpc_mock_client() { + let nats = MockNatsClient::new(); + let client = RpcMockClient; + let session_id = AcpSessionId::new("sess-1").unwrap(); + + let envelope = Request { + id: RequestId::Number(1), + method: std::sync::Arc::from("terminal/output"), + params: Some(TerminalOutputRequest::new("sess-1", "term-001")), + }; + let payload = bytes::Bytes::from(serde_json::to_vec(&envelope).unwrap()); + + let parsed = crate::nats::ParsedClientSubject { + session_id, + method: ClientMethod::TerminalOutput, + }; + + let ctx = DispatchContext { + nats: &nats, + client: &client, + serializer: &StdJsonSerialize, + }; + dispatch_client_method( + "acp.sess-1.client.terminal.output", + parsed, + payload, + Some("_INBOX.reply".to_string()), + &ctx, + ) + .await; + + assert_eq!(nats.published_messages(), vec!["_INBOX.reply"]); + } + + #[tokio::test] + async fn dispatch_client_method_dispatches_terminal_output_serialization_fallback() { + let nats = MockNatsClient::new(); + let client = MockClient::new(); + let serializer = FailNextSerialize::new(1); + let session_id = AcpSessionId::new("sess-1").unwrap(); + + let envelope = Request { + id: RequestId::Number(1), + method: std::sync::Arc::from("terminal/output"), + params: Some(TerminalOutputRequest::new("sess-1", "term-001")), + }; + let payload = bytes::Bytes::from(serde_json::to_vec(&envelope).unwrap()); + + let parsed = crate::nats::ParsedClientSubject { + session_id, + method: ClientMethod::TerminalOutput, + }; + + let ctx = DispatchContext { + nats: &nats, + client: &client, + serializer: &serializer, + }; + dispatch_client_method( + "acp.sess-1.client.terminal.output", + parsed, + payload, + Some("_INBOX.reply".to_string()), + &ctx, + ) + .await; + + assert_eq!(nats.published_messages(), vec!["_INBOX.reply"]); + } + #[tokio::test] async fn dispatch_client_method_terminal_kill_no_reply_does_not_call_client_or_publish() { let nats = MockNatsClient::new(); @@ -753,6 +947,16 @@ mod tests { "mock kill_terminal_command failure", )) } + + async fn terminal_output( + &self, + _: TerminalOutputRequest, + ) -> agent_client_protocol::Result { + Err(agent_client_protocol::Error::new( + -32603, + "mock terminal_output failure", + )) + } } #[tokio::test] @@ -1259,6 +1463,16 @@ mod tests { ) -> agent_client_protocol::Result { Ok(ReadTextFileResponse::new("file contents".to_string())) } + + async fn terminal_output( + &self, + _: TerminalOutputRequest, + ) -> agent_client_protocol::Result { + Ok(TerminalOutputResponse::new( + "rpc mock output".to_string(), + false, + )) + } } #[tokio::test] diff --git a/rsworkspace/crates/acp-nats/src/client/terminal_output.rs b/rsworkspace/crates/acp-nats/src/client/terminal_output.rs new file mode 100644 index 000000000..b844f6c11 --- /dev/null +++ b/rsworkspace/crates/acp-nats/src/client/terminal_output.rs @@ -0,0 +1,574 @@ +use crate::client::rpc_reply; +use crate::jsonrpc::extract_request_id; +use crate::nats::{FlushClient, PublishClient}; +use agent_client_protocol::{ + Client, ErrorCode, Request, Response, TerminalOutputRequest, TerminalOutputResponse, +}; +use bytes::Bytes; +use tracing::{instrument, warn}; +use trogon_std::JsonSerialize; + +#[derive(Debug)] +pub enum TerminalOutputError { + MalformedJson(serde_json::Error), + InvalidParams(agent_client_protocol::Error), + ClientError(agent_client_protocol::Error), +} + +impl std::fmt::Display for TerminalOutputError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MalformedJson(e) => write!(f, "malformed JSON: {}", e), + Self::InvalidParams(e) => write!(f, "invalid params: {}", e), + Self::ClientError(e) => write!(f, "client error: {}", e), + } + } +} + +impl std::error::Error for TerminalOutputError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::MalformedJson(e) => Some(e), + Self::InvalidParams(e) => Some(e), + Self::ClientError(e) => Some(e), + } + } +} + +fn invalid_params_error(message: impl Into) -> TerminalOutputError { + TerminalOutputError::InvalidParams(agent_client_protocol::Error::new( + i32::from(ErrorCode::InvalidParams), + message.into(), + )) +} + +pub fn error_code_and_message(e: &TerminalOutputError) -> (ErrorCode, String) { + match e { + TerminalOutputError::MalformedJson(inner) => ( + ErrorCode::ParseError, + format!("Malformed terminal/output request JSON: {}", inner), + ), + TerminalOutputError::InvalidParams(inner) => (inner.code, inner.message.clone()), + TerminalOutputError::ClientError(inner) => (inner.code, inner.message.clone()), + } +} + +/// Handles terminal/output: parses request, calls client, wraps response in JSON-RPC envelope, +/// and publishes to reply subject. Reply is required (request-reply pattern). +#[instrument( + name = "acp.client.terminal.output", + skip(payload, client, nats, serializer) +)] +pub async fn handle( + payload: &[u8], + client: &C, + reply: Option<&str>, + nats: &N, + session_id: &str, + serializer: &S, +) { + let reply_to = match reply { + Some(r) => r, + None => { + warn!( + session_id = %session_id, + "terminal/output requires reply subject; ignoring message" + ); + return; + } + }; + + let request_id = extract_request_id(payload); + match forward_to_client(payload, client, session_id).await { + Ok(response) => { + let (response_bytes, content_type) = serializer + .to_vec(&Response::Result { + id: request_id.clone(), + result: response, + }) + .map(|v| (Bytes::from(v), rpc_reply::CONTENT_TYPE_JSON)) + .unwrap_or_else(|e| { + warn!(error = %e, "JSON serialization of response failed, sending error reply"); + rpc_reply::error_response_bytes( + serializer, + request_id, + ErrorCode::InternalError, + &format!("Failed to serialize response: {}", e), + ) + }); + rpc_reply::publish_reply( + nats, + reply_to, + response_bytes, + content_type, + "terminal_output reply", + ) + .await; + } + Err(e) => { + let (code, message) = error_code_and_message(&e); + warn!( + error = %e, + session_id = %session_id, + "Failed to handle terminal/output" + ); + let (bytes, content_type) = + rpc_reply::error_response_bytes(serializer, request_id, code, &message); + rpc_reply::publish_reply( + nats, + reply_to, + bytes, + content_type, + "terminal_output error reply", + ) + .await; + } + } +} + +async fn forward_to_client( + payload: &[u8], + client: &C, + expected_session_id: &str, +) -> Result { + let payload_value: serde_json::Value = + serde_json::from_slice(payload).map_err(TerminalOutputError::MalformedJson)?; + let envelope: Request = serde_json::from_value(payload_value) + .map_err(|e| invalid_params_error(format!("Invalid terminal/output request: {}", e)))?; + let request = envelope + .params + .ok_or_else(|| invalid_params_error("params is null or missing"))?; + let params_session_id = request.session_id.to_string(); + if params_session_id != expected_session_id { + return Err(invalid_params_error(format!( + "params.sessionId ({}) does not match subject session id ({})", + params_session_id, expected_session_id + ))); + } + client + .terminal_output(request) + .await + .map_err(TerminalOutputError::ClientError) +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_client_protocol::{ + ContentBlock, ContentChunk, Request, RequestId, RequestPermissionRequest, + RequestPermissionResponse, SessionNotification, SessionUpdate, TerminalOutputRequest, + TerminalOutputResponse, + }; + use async_trait::async_trait; + use std::error::Error; + use trogon_nats::{AdvancedMockNatsClient, MockNatsClient}; + use trogon_std::{FailNextSerialize, StdJsonSerialize}; + + struct MockClient; + + impl MockClient { + fn new() -> Self { + Self + } + } + + #[async_trait(?Send)] + impl Client for MockClient { + async fn session_notification( + &self, + _: SessionNotification, + ) -> agent_client_protocol::Result<()> { + Ok(()) + } + + async fn request_permission( + &self, + _: RequestPermissionRequest, + ) -> agent_client_protocol::Result { + Err(agent_client_protocol::Error::new( + -32603, + "not implemented in test mock", + )) + } + + async fn terminal_output( + &self, + _: TerminalOutputRequest, + ) -> agent_client_protocol::Result { + Ok(TerminalOutputResponse::new( + "output data".to_string(), + false, + )) + } + } + + struct FailingClient; + + #[async_trait(?Send)] + impl Client for FailingClient { + async fn session_notification( + &self, + _: SessionNotification, + ) -> agent_client_protocol::Result<()> { + Ok(()) + } + + async fn request_permission( + &self, + _: RequestPermissionRequest, + ) -> agent_client_protocol::Result { + Err(agent_client_protocol::Error::new( + -32603, + "not implemented in test mock", + )) + } + + async fn terminal_output( + &self, + _: TerminalOutputRequest, + ) -> agent_client_protocol::Result { + Err(agent_client_protocol::Error::new( + -32603, + "mock terminal_output failure", + )) + } + } + + #[tokio::test] + async fn handle_success_publishes_response_to_reply_subject() { + let nats = MockNatsClient::new(); + let client = MockClient::new(); + let request = TerminalOutputRequest::new("sess-1", "term-001"); + let envelope = Request { + id: RequestId::Number(1), + method: std::sync::Arc::from("terminal/output"), + params: Some(request), + }; + let payload = serde_json::to_vec(&envelope).unwrap(); + + handle( + &payload, + &client, + Some("_INBOX.reply"), + &nats, + "sess-1", + &StdJsonSerialize, + ) + .await; + + assert_eq!(nats.published_messages(), vec!["_INBOX.reply"]); + } + + #[tokio::test] + async fn handle_no_reply_does_not_publish() { + let nats = MockNatsClient::new(); + let client = MockClient::new(); + let request = TerminalOutputRequest::new("sess-1", "term-001"); + let envelope = Request { + id: RequestId::Number(1), + method: std::sync::Arc::from("terminal/output"), + params: Some(request), + }; + let payload = serde_json::to_vec(&envelope).unwrap(); + + handle(&payload, &client, None, &nats, "sess-1", &StdJsonSerialize).await; + + assert!(nats.published_messages().is_empty()); + } + + #[tokio::test] + async fn handle_invalid_payload_publishes_error_reply() { + let nats = MockNatsClient::new(); + let client = MockClient::new(); + + handle( + b"not json", + &client, + Some("_INBOX.err"), + &nats, + "sess-1", + &StdJsonSerialize, + ) + .await; + + assert_eq!(nats.published_messages(), vec!["_INBOX.err"]); + let payloads = nats.published_payloads(); + let response: serde_json::Value = serde_json::from_slice(payloads[0].as_ref()).unwrap(); + assert_eq!( + response.get("error").and_then(|e| e.get("code")), + Some(&serde_json::Value::from(-32700)), + "malformed JSON should return ParseError (-32700)" + ); + } + + #[tokio::test] + async fn handle_invalid_params_publishes_invalid_params_error() { + let nats = MockNatsClient::new(); + let client = MockClient::new(); + let payload = br#"{"id":1,"method":"terminal/output","params":{}}"#; + + handle( + payload.as_slice(), + &client, + Some("_INBOX.err"), + &nats, + "sess-1", + &StdJsonSerialize, + ) + .await; + + assert_eq!(nats.published_messages(), vec!["_INBOX.err"]); + let payloads = nats.published_payloads(); + let response: serde_json::Value = serde_json::from_slice(payloads[0].as_ref()).unwrap(); + assert_eq!( + response.get("error").and_then(|e| e.get("code")), + Some(&serde_json::Value::from(-32602)), + "valid JSON with invalid params should return InvalidParams (-32602)" + ); + } + + #[tokio::test] + async fn handle_client_error_publishes_error_reply() { + let nats = MockNatsClient::new(); + let client = FailingClient; + let request = TerminalOutputRequest::new("sess-1", "term-001"); + let envelope = Request { + id: RequestId::Number(1), + method: std::sync::Arc::from("terminal/output"), + params: Some(request), + }; + let payload = serde_json::to_vec(&envelope).unwrap(); + + handle( + &payload, + &client, + Some("_INBOX.err"), + &nats, + "sess-1", + &StdJsonSerialize, + ) + .await; + + assert_eq!(nats.published_messages(), vec!["_INBOX.err"]); + } + + #[tokio::test] + async fn handle_session_id_mismatch_publishes_error_reply() { + let nats = MockNatsClient::new(); + let client = MockClient::new(); + let request = TerminalOutputRequest::new("sess-b", "term-001"); + let envelope = Request { + id: RequestId::Number(1), + method: std::sync::Arc::from("terminal/output"), + params: Some(request), + }; + let payload = serde_json::to_vec(&envelope).unwrap(); + + handle( + &payload, + &client, + Some("_INBOX.err"), + &nats, + "sess-a", + &StdJsonSerialize, + ) + .await; + + assert_eq!(nats.published_messages(), vec!["_INBOX.err"]); + } + + #[tokio::test] + async fn handle_success_serialization_fallback_sends_error_reply() { + let nats = MockNatsClient::new(); + let client = MockClient::new(); + let serializer = FailNextSerialize::new(1); + let request = TerminalOutputRequest::new("sess-1", "term-001"); + let envelope = Request { + id: RequestId::Number(1), + method: std::sync::Arc::from("terminal/output"), + params: Some(request), + }; + let payload = serde_json::to_vec(&envelope).unwrap(); + + handle( + &payload, + &client, + Some("_INBOX.reply"), + &nats, + "sess-1", + &serializer, + ) + .await; + + assert_eq!(nats.published_messages(), vec!["_INBOX.reply"]); + } + + #[tokio::test] + async fn handle_success_publish_failure_exercises_error_path() { + let nats = AdvancedMockNatsClient::new(); + nats.fail_next_publish(); + let client = MockClient::new(); + let request = TerminalOutputRequest::new("sess-1", "term-001"); + let envelope = Request { + id: RequestId::Number(1), + method: std::sync::Arc::from("terminal/output"), + params: Some(request), + }; + let payload = serde_json::to_vec(&envelope).unwrap(); + + handle( + &payload, + &client, + Some("_INBOX.reply"), + &nats, + "sess-1", + &StdJsonSerialize, + ) + .await; + + assert!(nats.published_messages().is_empty()); + } + + #[tokio::test] + async fn handle_success_flush_failure_exercises_warn_path() { + let nats = AdvancedMockNatsClient::new(); + nats.fail_next_flush(); + let client = MockClient::new(); + let request = TerminalOutputRequest::new("sess-1", "term-001"); + let envelope = Request { + id: RequestId::Number(1), + method: std::sync::Arc::from("terminal/output"), + params: Some(request), + }; + let payload = serde_json::to_vec(&envelope).unwrap(); + + handle( + &payload, + &client, + Some("_INBOX.reply"), + &nats, + "sess-1", + &StdJsonSerialize, + ) + .await; + + assert_eq!(nats.published_messages(), vec!["_INBOX.reply"]); + } + + #[test] + fn error_code_and_message_malformed_json_returns_parse_error() { + let err = serde_json::from_slice::(b"not json").unwrap_err(); + let to_err = TerminalOutputError::MalformedJson(err); + let (code, message) = error_code_and_message(&to_err); + assert_eq!(code, ErrorCode::ParseError); + assert!(message.contains("Malformed terminal/output request JSON")); + } + + #[test] + fn error_code_and_message_invalid_params_preserves_code_and_message() { + let inner = + agent_client_protocol::Error::new(ErrorCode::InvalidParams.into(), "params is null"); + let to_err = TerminalOutputError::InvalidParams(inner); + let (code, message) = error_code_and_message(&to_err); + assert_eq!(code, ErrorCode::InvalidParams); + assert_eq!(message, "params is null"); + } + + #[test] + fn error_code_and_message_client_error_preserves_client_code() { + let client_err = + agent_client_protocol::Error::new(ErrorCode::InvalidParams.into(), "denied"); + let to_err = TerminalOutputError::ClientError(client_err); + let (code, message) = error_code_and_message(&to_err); + assert_eq!(code, ErrorCode::InvalidParams); + assert_eq!(message, "denied"); + } + + #[test] + fn terminal_output_error_display() { + let malformed = TerminalOutputError::MalformedJson( + serde_json::from_slice::(b"not json").unwrap_err(), + ); + assert!(malformed.to_string().contains("malformed JSON")); + + let invalid_params = TerminalOutputError::InvalidParams(agent_client_protocol::Error::new( + ErrorCode::InvalidParams.into(), + "bad params", + )); + assert!(invalid_params.to_string().contains("invalid params")); + + let client_err = TerminalOutputError::ClientError(agent_client_protocol::Error::new( + ErrorCode::InvalidParams.into(), + "client fail", + )); + assert!(client_err.to_string().contains("client error")); + } + + #[test] + fn terminal_output_error_source() { + let malformed = TerminalOutputError::MalformedJson( + serde_json::from_slice::(b"not json").unwrap_err(), + ); + assert!(malformed.source().is_some()); + + let invalid_params = TerminalOutputError::InvalidParams(agent_client_protocol::Error::new( + ErrorCode::InvalidParams.into(), + "bad params", + )); + assert!(invalid_params.source().is_some()); + + let client_err = TerminalOutputError::ClientError(agent_client_protocol::Error::new( + ErrorCode::InvalidParams.into(), + "client fail", + )); + assert!(client_err.source().is_some()); + } + + #[tokio::test] + async fn mock_client_session_notification_returns_ok() { + let client = MockClient::new(); + let notification = SessionNotification::new( + "sess-1", + SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from("hi"))), + ); + let result = client.session_notification(notification).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn failing_client_session_notification_returns_ok() { + let client = FailingClient; + let notification = SessionNotification::new( + "sess-1", + SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from("hi"))), + ); + let result = client.session_notification(notification).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn mock_client_request_permission_returns_err() { + let client = MockClient::new(); + let req: RequestPermissionRequest = serde_json::from_value(serde_json::json!({ + "sessionId": "sess-1", + "toolCall": { "toolCallId": "call-1" }, + "options": [] + })) + .unwrap(); + let result = client.request_permission(req).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn failing_client_request_permission_returns_err() { + let client = FailingClient; + let req: RequestPermissionRequest = serde_json::from_value(serde_json::json!({ + "sessionId": "sess-1", + "toolCall": { "toolCallId": "call-1" }, + "options": [] + })) + .unwrap(); + let result = client.request_permission(req).await; + assert!(result.is_err()); + } +} diff --git a/rsworkspace/crates/acp-nats/src/nats/client_method.rs b/rsworkspace/crates/acp-nats/src/nats/client_method.rs index b2add888d..5338b28c7 100644 --- a/rsworkspace/crates/acp-nats/src/nats/client_method.rs +++ b/rsworkspace/crates/acp-nats/src/nats/client_method.rs @@ -5,6 +5,7 @@ pub enum ClientMethod { SessionUpdate, TerminalCreate, TerminalKill, + TerminalOutput, } impl ClientMethod { @@ -15,6 +16,7 @@ impl ClientMethod { "client.session.update" => Some(Self::SessionUpdate), "client.terminal.create" => Some(Self::TerminalCreate), "client.terminal.kill" => Some(Self::TerminalKill), + "client.terminal.output" => Some(Self::TerminalOutput), _ => None, } } diff --git a/rsworkspace/crates/acp-nats/src/nats/parsing.rs b/rsworkspace/crates/acp-nats/src/nats/parsing.rs index b2c73f9b9..5d41552fd 100644 --- a/rsworkspace/crates/acp-nats/src/nats/parsing.rs +++ b/rsworkspace/crates/acp-nats/src/nats/parsing.rs @@ -60,6 +60,14 @@ mod tests { assert_eq!(parsed.method, ClientMethod::TerminalKill); } + #[test] + fn test_parse_terminal_output() { + let subject = "acp.sess123.client.terminal.output"; + let parsed = parse_client_subject(subject).unwrap(); + assert_eq!(parsed.session_id.as_str(), "sess123"); + assert_eq!(parsed.method, ClientMethod::TerminalOutput); + } + #[test] fn test_parse_with_custom_prefix() { let subject = "myapp.sess123.client.session.update"; diff --git a/rsworkspace/crates/acp-nats/src/nats/subjects.rs b/rsworkspace/crates/acp-nats/src/nats/subjects.rs index 2c5e786e9..7e8d95fee 100644 --- a/rsworkspace/crates/acp-nats/src/nats/subjects.rs +++ b/rsworkspace/crates/acp-nats/src/nats/subjects.rs @@ -62,6 +62,10 @@ pub mod client { format!("{}.{}.client.terminal.kill", prefix, session_id) } + pub fn terminal_output(prefix: &str, session_id: &str) -> String { + format!("{}.{}.client.terminal.output", prefix, session_id) + } + pub mod wildcards { pub fn all(prefix: &str) -> String { format!("{}.*.client.>", prefix) @@ -114,6 +118,14 @@ mod tests { ); } + #[test] + fn client_terminal_output_subject() { + assert_eq!( + client::terminal_output("acp", "s1"), + "acp.s1.client.terminal.output" + ); + } + #[test] fn client_wildcards_all() { assert_eq!(client::wildcards::all("foo"), "foo.*.client.>");