1use serde::{Deserialize, Serialize};
20
21pub use caliban_contract::wire::AgentStatus as WireAgentStatus;
26pub use caliban_contract::wire::PermissionPosture as WirePermissionPosture;
29pub use caliban_contract::wire::{
30 AgentRecord, CtlReply, CtlRequest, DaemonStatus, DrainedAgent, DriveProtocol, Endpoint,
31 SpawnSpec, SupervisorError,
32};
33
34pub use crate::model::{AgentStatus, PermissionPosture};
35
36#[must_use]
45pub fn domain_status(status: WireAgentStatus) -> AgentStatus {
46 match status {
47 WireAgentStatus::Spawning => AgentStatus::Spawning,
48 WireAgentStatus::Running => AgentStatus::Running,
49 WireAgentStatus::Idle => AgentStatus::Idle,
50 WireAgentStatus::Killed => AgentStatus::Killed,
51 WireAgentStatus::Drained | WireAgentStatus::Done => AgentStatus::Done,
52 WireAgentStatus::Failed => AgentStatus::Failed,
53 WireAgentStatus::Crashed => AgentStatus::Crashed,
54 }
55}
56
57#[must_use]
62pub fn wire_status(status: AgentStatus) -> WireAgentStatus {
63 match status {
64 AgentStatus::Spawning => WireAgentStatus::Spawning,
65 AgentStatus::Running => WireAgentStatus::Running,
66 AgentStatus::Idle => WireAgentStatus::Idle,
67 AgentStatus::Killed => WireAgentStatus::Killed,
68 AgentStatus::Done => WireAgentStatus::Done,
69 AgentStatus::Failed => WireAgentStatus::Failed,
70 AgentStatus::Crashed => WireAgentStatus::Crashed,
71 }
72}
73
74#[must_use]
76pub fn wire_posture(posture: PermissionPosture) -> WirePermissionPosture {
77 match posture {
78 PermissionPosture::Supervised => WirePermissionPosture::Supervised,
79 PermissionPosture::Unattended => WirePermissionPosture::Unattended,
80 }
81}
82
83pub trait EndpointExt {
90 fn unix_socket_path(&self) -> Option<&std::path::Path>;
92}
93
94impl EndpointExt for Endpoint {
95 fn unix_socket_path(&self) -> Option<&std::path::Path> {
96 match self {
97 Endpoint::Unix { path } => Some(path.as_path()),
98 Endpoint::Tcp { .. } => None,
99 }
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(tag = "type")]
108pub enum AttachInbound {
109 UserMessage {
111 text: String,
113 },
114 EndInput,
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use serde_json::json;
122
123 #[test]
124 fn endpoint_matches_caliban_wire_shape() {
125 let unix = Endpoint::Unix {
127 path: "/tmp/a1.sock".into(),
128 };
129 assert_eq!(
130 serde_json::to_string(&unix).unwrap(),
131 r#"{"scheme":"unix","path":"/tmp/a1.sock"}"#
132 );
133 let tcp = Endpoint::Tcp {
134 addr: "host.ns.svc:9443".into(),
135 };
136 assert_eq!(
137 serde_json::to_string(&tcp).unwrap(),
138 r#"{"scheme":"tcp","addr":"host.ns.svc:9443"}"#
139 );
140 for e in [unix, tcp] {
142 let s = serde_json::to_string(&e).unwrap();
143 assert_eq!(serde_json::from_str::<Endpoint>(&s).unwrap(), e);
144 }
145 }
146
147 #[test]
148 fn endpoint_unix_socket_path_accessor() {
149 assert_eq!(
150 Endpoint::Unix {
151 path: "/x.sock".into()
152 }
153 .unix_socket_path(),
154 Some(std::path::Path::new("/x.sock"))
155 );
156 assert_eq!(
157 Endpoint::Tcp { addr: "h:1".into() }.unix_socket_path(),
158 None
159 );
160 }
161
162 #[test]
163 fn ctl_request_list_is_tagged() {
164 assert_eq!(
165 serde_json::to_string(&CtlRequest::List).unwrap(),
166 "{\"kind\":\"list\"}"
167 );
168 }
169
170 #[test]
174 fn ctl_request_rm_force_defaults_false() {
175 let r: CtlRequest = serde_json::from_str("{\"kind\":\"rm\",\"id\":\"a1\"}").unwrap();
176 assert_eq!(
177 serde_json::to_value(&r).unwrap(),
178 json!({"kind": "rm", "id": "a1", "force": false})
179 );
180 }
181
182 #[test]
183 fn spawn_spec_defaults_inherit_hooks_true() {
184 let s: SpawnSpec = serde_json::from_str("{\"initial_prompt\":\"hi\"}").unwrap();
185 assert!(s.inherit_hooks);
186 assert!(!s.isolation_worktree);
187 assert!(s.model.is_none());
188 assert!(s.provider.is_none());
189 }
190
191 #[test]
197 fn a_drained_agent_does_not_break_the_whole_list_reply() {
198 let listed = json!({
199 "kind": "listed",
200 "agents": [{
201 "id": "a1", "name": "one", "status": "running",
202 "started_at": "2026-09-19T00:00:00Z", "session_dir": "/s/a1",
203 "endpoint": {"scheme": "unix", "path": "/s/a1.sock"},
204 "spec": {"initial_prompt": "hi"}
205 }, {
206 "id": "a2", "name": "two", "status": "drained",
207 "started_at": "2026-09-19T00:00:00Z", "session_dir": "/s/a2",
208 "endpoint": {"scheme": "unix", "path": "/s/a2.sock"},
209 "spec": {"initial_prompt": "hi"}
210 }]
211 });
212
213 let reply: CtlReply = serde_json::from_value(listed).expect("drained must parse");
214 let CtlReply::Listed { agents } = reply else {
215 panic!("expected Listed");
216 };
217 assert_eq!(agents.len(), 2, "a drained agent must not drop its peers");
218 }
219
220 #[test]
223 fn posture_converts_to_calibans_wire_values() {
224 assert_eq!(
225 serde_json::to_value(wire_posture(PermissionPosture::Unattended)).unwrap(),
226 json!("unattended")
227 );
228 assert_eq!(
229 serde_json::to_value(wire_posture(PermissionPosture::Supervised)).unwrap(),
230 json!("supervised")
231 );
232 let s: SpawnSpec = serde_json::from_str(r#"{"initial_prompt":"hi"}"#).unwrap();
234 assert_eq!(s.permission_posture, WirePermissionPosture::Supervised);
235 }
236
237 #[test]
241 fn every_caliban_status_maps_to_a_domain_status() {
242 use WireAgentStatus as W;
243 for (wire, expected) in [
244 (W::Spawning, AgentStatus::Spawning),
245 (W::Running, AgentStatus::Running),
246 (W::Idle, AgentStatus::Idle),
247 (W::Killed, AgentStatus::Killed),
248 (W::Done, AgentStatus::Done),
249 (W::Failed, AgentStatus::Failed),
250 (W::Crashed, AgentStatus::Crashed),
251 (W::Drained, AgentStatus::Done),
253 ] {
254 assert_eq!(domain_status(wire), expected, "{wire:?}");
255 }
256 }
257
258 #[test]
260 fn domain_status_round_trips_through_the_wire_status() {
261 for s in [
262 AgentStatus::Spawning,
263 AgentStatus::Running,
264 AgentStatus::Idle,
265 AgentStatus::Killed,
266 AgentStatus::Done,
267 AgentStatus::Failed,
268 AgentStatus::Crashed,
269 ] {
270 assert_eq!(domain_status(wire_status(s)), s);
271 }
272 }
273
274 #[test]
280 fn caliban_spawn_spec_payloads_still_parse() {
281 let interactive = r#"{"label":null,"frontmatter_path":null,"initial_prompt":"hi","model":null,"provider":null,"tool_allowlist":null,"isolation_worktree":false,"inherit_hooks":true,"interactive":true}"#;
282 let spec: SpawnSpec = serde_json::from_str(interactive).expect("caliban spec parses");
283 assert!(spec.interactive);
284 assert_eq!(spec.permission_posture, WirePermissionPosture::Supervised);
285
286 let with_provider = r#"{"initial_prompt":"hi","provider":"openai"}"#;
288 let spec: SpawnSpec = serde_json::from_str(with_provider).unwrap();
289 assert_eq!(spec.provider.as_deref(), Some("openai"));
290 assert_eq!(
291 serde_json::to_value(&spec).unwrap()["provider"],
292 json!("openai")
293 );
294 }
295
296 #[test]
297 fn spawn_spec_without_provider_defaults_none() {
298 let old = r#"{"initial_prompt":"hi"}"#;
300 let spec: SpawnSpec = serde_json::from_str(old).unwrap();
301 assert!(spec.provider.is_none());
302 }
303
304 #[test]
305 fn spawn_spec_without_interactive_defaults_false() {
306 let old = r#"{"initial_prompt":"hi"}"#;
308 let spec: SpawnSpec = serde_json::from_str(old).unwrap();
309 assert!(!spec.interactive);
310 }
311
312 #[test]
313 fn attach_inbound_user_message_serializes() {
314 let j = serde_json::to_string(&AttachInbound::UserMessage {
315 text: "hi there".into(),
316 })
317 .unwrap();
318 assert_eq!(j, r#"{"type":"UserMessage","text":"hi there"}"#);
319 }
320
321 #[test]
322 fn attach_inbound_end_input_serializes() {
323 let j = serde_json::to_string(&AttachInbound::EndInput).unwrap();
324 assert_eq!(j, r#"{"type":"EndInput"}"#);
325 }
326
327 #[test]
328 fn attach_inbound_round_trips() {
329 for frame in [
332 AttachInbound::UserMessage { text: "hi".into() },
333 AttachInbound::EndInput,
334 ] {
335 let s = serde_json::to_string(&frame).unwrap();
336 let back: AttachInbound = serde_json::from_str(&s).unwrap();
337 assert_eq!(frame, back);
338 }
339 }
340
341 #[test]
342 fn ctl_reply_error_round_trips() {
343 let reply = CtlReply::Error {
344 error: SupervisorError::NotFound { id: "x".into() },
345 };
346 let s = serde_json::to_string(&reply).unwrap();
347 let back: CtlReply = serde_json::from_str(&s).unwrap();
348 assert_eq!(
349 serde_json::to_string(&back).unwrap(),
350 s,
351 "round-trip must preserve the wire form"
352 );
353 }
354
355 #[test]
356 fn spawned_reply_parses() {
357 let json =
358 r#"{"kind":"spawned","id":"a1","endpoint":{"scheme":"unix","path":"/tmp/a1.sock"}}"#;
359 let r: CtlReply = serde_json::from_str(json).unwrap();
360 let CtlReply::Spawned { id, endpoint } = r else {
361 panic!("expected Spawned");
362 };
363 assert_eq!(id, "a1");
364 assert_eq!(
365 endpoint,
366 Endpoint::Unix {
367 path: "/tmp/a1.sock".into()
368 }
369 );
370 }
371
372 #[test]
373 fn spawned_reply_parses_tcp_endpoint() {
374 let json =
375 r#"{"kind":"spawned","id":"a1","endpoint":{"scheme":"tcp","addr":"pod.ns.svc:9443"}}"#;
376 let r: CtlReply = serde_json::from_str(json).unwrap();
377 let CtlReply::Spawned { id, endpoint } = r else {
378 panic!("expected Spawned");
379 };
380 assert_eq!(id, "a1");
381 assert_eq!(
382 endpoint,
383 Endpoint::Tcp {
384 addr: "pod.ns.svc:9443".into()
385 }
386 );
387 }
388}