car_server_core/
host_channel.rs1use std::sync::Arc;
31
32use async_trait::async_trait;
33use serde_json::{json, Value};
34
35use car_engine::messaging::{MessageReceipt, OutboundMessage, Recipient};
36use car_engine::ToolExecutor;
37use car_messaging::outbound::OutboundAdapter;
38
39pub const HOST_CHANNEL_SEND_TOOL: &str = "messaging.channel_send";
41
42pub const HOST_CHANNEL: &str = "host";
49
50pub struct HostChannelAdapter {
52 executor: Arc<dyn ToolExecutor>,
53}
54
55impl HostChannelAdapter {
56 pub fn new(executor: Arc<dyn ToolExecutor>) -> Self {
60 Self { executor }
61 }
62}
63
64#[async_trait]
65impl OutboundAdapter for HostChannelAdapter {
66 fn channel(&self) -> &str {
67 HOST_CHANNEL
68 }
69
70 async fn send(&self, msg: &OutboundMessage) -> Result<MessageReceipt, String> {
71 let (kind, to) = match &msg.to {
72 Recipient::Direct(handle) => ("direct", handle.as_str()),
73 Recipient::Channel(id) => ("channel", id.as_str()),
74 };
75 let params = json!({
76 "channel": msg.channel,
77 "kind": kind,
78 "to": to,
79 "body": msg.body,
80 });
81
82 match self.executor.execute(HOST_CHANNEL_SEND_TOOL, ¶ms).await {
83 Ok(value) => Ok(receipt_from_host(&msg.channel, &value)),
84 Err(e) if e.starts_with("unknown tool") => Err(format!(
93 "no transport for messaging channel '{}': this host does not implement the \
94 '{}' tool callback. A host that can deliver on '{}' should handle \
95 '{}' with parameters {{channel, kind, to, body}} and return \
96 {{\"message_id\": \"…\"}} (message_id optional).",
97 msg.channel, HOST_CHANNEL_SEND_TOOL, msg.channel, HOST_CHANNEL_SEND_TOOL
98 )),
99 Err(e) => Err(e),
103 }
104 }
105}
106
107fn receipt_from_host(channel: &str, value: &Value) -> MessageReceipt {
114 let receipt = MessageReceipt::delivered(channel);
115 match value
116 .get("message_id")
117 .and_then(|v| v.as_str())
118 .filter(|s| !s.trim().is_empty())
119 {
120 Some(id) => receipt.with_message_id(id),
121 None => receipt,
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use std::sync::Mutex;
132
133 struct FakeExecutor {
135 seen: Mutex<Vec<(String, Value)>>,
136 result: Result<Value, String>,
137 }
138
139 impl FakeExecutor {
140 fn new(result: Result<Value, String>) -> Arc<Self> {
141 Arc::new(Self {
142 seen: Mutex::new(Vec::new()),
143 result,
144 })
145 }
146
147 fn last(&self) -> (String, Value) {
148 self.seen.lock().unwrap().last().cloned().expect("no call")
149 }
150
151 fn calls(&self) -> usize {
152 self.seen.lock().unwrap().len()
153 }
154 }
155
156 #[async_trait]
157 impl ToolExecutor for FakeExecutor {
158 async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
159 self.seen
160 .lock()
161 .unwrap()
162 .push((tool.to_string(), params.clone()));
163 self.result.clone()
164 }
165 }
166
167 fn msg(kind_channel: bool) -> OutboundMessage {
168 OutboundMessage {
169 channel: "teams".to_string(),
170 to: if kind_channel {
171 Recipient::Channel("19:meeting@thread.v2".to_string())
172 } else {
173 Recipient::Direct("keenan@parslee.ai".to_string())
174 },
175 body: "deploy is green".to_string(),
176 idempotency_key: Some("run-42".to_string()),
177 }
178 }
179
180 #[tokio::test]
181 async fn direct_send_builds_the_expected_callback() {
182 let exec = FakeExecutor::new(Ok(json!({ "message_id": "1700000000.1" })));
183 let adapter = HostChannelAdapter::new(exec.clone());
184
185 let receipt = adapter.send(&msg(false)).await.unwrap();
186 assert_eq!(receipt.channel, "teams");
187 assert_eq!(receipt.message_id.as_deref(), Some("1700000000.1"));
188 assert!(!receipt.deduplicated);
189
190 let (tool, params) = exec.last();
191 assert_eq!(tool, HOST_CHANNEL_SEND_TOOL);
192 assert_eq!(
193 params,
194 json!({
195 "channel": "teams",
196 "kind": "direct",
197 "to": "keenan@parslee.ai",
198 "body": "deploy is green",
199 })
200 );
201 }
202
203 #[tokio::test]
204 async fn channel_send_uses_the_channel_kind() {
205 let exec = FakeExecutor::new(Ok(json!({})));
206 let adapter = HostChannelAdapter::new(exec.clone());
207
208 let receipt = adapter.send(&msg(true)).await.unwrap();
209 assert_eq!(receipt.message_id, None);
211
212 let (_, params) = exec.last();
213 assert_eq!(params["kind"], "channel");
214 assert_eq!(params["to"], "19:meeting@thread.v2");
215 }
216
217 #[tokio::test]
218 async fn unknown_tool_becomes_an_actionable_message() {
219 let exec = FakeExecutor::new(Err("unknown tool: 'messaging.channel_send'".to_string()));
220 let adapter = HostChannelAdapter::new(exec.clone());
221
222 let err = adapter.send(&msg(false)).await.unwrap_err();
223 assert!(
224 !err.contains("unknown tool"),
225 "the raw sentinel must not surface: {err}"
226 );
227 assert!(
228 err.contains("no transport for messaging channel 'teams'"),
229 "{err}"
230 );
231 assert!(err.contains(HOST_CHANNEL_SEND_TOOL), "{err}");
232 assert_eq!(exec.calls(), 1);
233 }
234
235 #[tokio::test]
236 async fn a_host_error_passes_through() {
237 let exec = FakeExecutor::new(Err("not a member of that team".to_string()));
238 let adapter = HostChannelAdapter::new(exec.clone());
239
240 let err = adapter.send(&msg(false)).await.unwrap_err();
241 assert_eq!(err, "not a member of that team");
242 }
243
244 #[tokio::test]
245 async fn a_blank_message_id_is_treated_as_absent() {
246 let exec = FakeExecutor::new(Ok(json!({ "message_id": " " })));
247 let adapter = HostChannelAdapter::new(exec.clone());
248
249 let receipt = adapter.send(&msg(false)).await.unwrap();
250 assert_eq!(receipt.message_id, None);
251 }
252}