Skip to main content

prospero_core/caliband/
wire.rs

1//! caliband's control-plane wire types.
2//!
3//! These are **caliban's own definitions**, re-exported from the published
4//! `caliban-contract` crate (#239): serde-only, no daemon internals, so it is
5//! not the wide coupling [ADR-0003] rejected — that was `caliban-supervisor`,
6//! the whole daemon. ADR-0003's "revisit if" clause called for exactly this
7//! once hand-mirroring became a drift source, and it had: the mirror never
8//! learned caliban's `AgentStatus::Drained` (caliban ADR 0057), so a single
9//! drained agent failed the *entire* `Listed` reply.
10//!
11//! What stays here is what has no upstream home: [`AttachInbound`] (caliban's
12//! per-agent attach protocol, not part of the control-plane contract) and the
13//! boundary conversion to prospero's own domain [`crate::model::AgentStatus`],
14//! which is prospero's public API vocabulary and deliberately not caliban's
15//! type.
16//!
17//! [ADR-0003]: ../../../../docs/adr/0003-couple-to-caliban-via-ndjson-wire-format.md
18
19use serde::{Deserialize, Serialize};
20
21/// caliban's lifecycle enum, as it appears on the wire. Converted to
22/// prospero's [`crate::model::AgentStatus`] at the boundary by
23/// [`domain_status`] — the two are separate on purpose: prospero's is the
24/// vocabulary its own API and dashboard speak.
25pub use caliban_contract::wire::AgentStatus as WireAgentStatus;
26/// caliban's permission posture, as it appears on the wire. Prospero's own
27/// [`crate::model::PermissionPosture`] (#238) is what the API and CR use.
28pub 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/// Map caliban's wire status onto prospero's domain status.
37///
38/// `Drained` (caliban ADR 0057) has no prospero equivalent: prospero's model
39/// has no "stopped but resumable" state, and inventing one here would change
40/// the public API. It reads as [`AgentStatus::Done`] — terminal and not a
41/// failure, which is what a drained agent is from the fleet's point of view.
42/// The exhaustive match is the point: a future caliban state stops compiling
43/// here instead of failing a poll at runtime.
44#[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/// Map prospero's domain status onto caliban's wire status.
58///
59/// Total in this direction — prospero's model has no state caliban lacks. Used
60/// where prospero writes a status caliban would have sent (the fake caliband).
61#[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/// Map prospero's domain posture onto caliban's wire posture (#238).
75#[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
83/// Prospero's accessor on caliban's [`Endpoint`].
84///
85/// The contract crate puts the equivalent on `AgentRecord`, but prospero asks
86/// it of a bare endpoint (a transport decision, made before any record exists),
87/// so it lives here as an extension rather than forcing the call sites to
88/// re-match.
89pub trait EndpointExt {
90    /// The Unix socket path, when this endpoint is one.
91    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/// Inbound control frames written to an interactive agent's per-agent socket.
104/// Mirrors caliban `AttachInbound` (`caliban/src/attach.rs`); the outbound
105/// stream stays caliban stream-json, so the two never share a direction.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(tag = "type")]
108pub enum AttachInbound {
109    /// Inject a user message and resume the run.
110    UserMessage {
111        /// Message text.
112        text: String,
113    },
114    /// Signal end-of-input: the agent finishes after this.
115    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        // Byte-for-byte parity with caliban's transport::Endpoint.
126        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        // Round-trips both ways.
141        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    /// Compared through serde rather than `==`: the contract's request/reply
171    /// enums derive no `PartialEq`, and the serialized form is the contract
172    /// anyway.
173    #[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    /// #239: caliban's `AgentStatus` grew a `drained` state (caliban ADR 0057,
192    /// #650) that prospero's hand-mirror never learned. The mirror has no
193    /// catch-all, so one drained agent fails the **whole** `Listed` reply —
194    /// every agent on that caliband disappears from the poll, not just the
195    /// drained one. Exactly the silent drift the contract crate ends.
196    #[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    /// #238 / #239: the posture rides caliban's own type now, so what prospero
221    /// must get right is the *conversion*, not the serialization.
222    #[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        // Absent on the wire ⇒ supervised, fail-closed (caliban ADR 0059).
233        let s: SpawnSpec = serde_json::from_str(r#"{"initial_prompt":"hi"}"#).unwrap();
234        assert_eq!(s.permission_posture, WirePermissionPosture::Supervised);
235    }
236
237    /// #239: every caliban lifecycle state maps onto one prospero shows. The
238    /// match is exhaustive, so a new caliban state breaks the build here rather
239    /// than a poll at runtime — which is what the hand-mirror got wrong.
240    #[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            // No prospero equivalent: drained is terminal and not a failure.
252            (W::Drained, AgentStatus::Done),
253        ] {
254            assert_eq!(domain_status(wire), expected, "{wire:?}");
255        }
256    }
257
258    /// The reverse direction is total and round-trips for every domain state.
259    #[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    /// A caliband-shaped spawn spec still deserializes, including one written
275    /// before a field existed. The old goldens here pinned prospero's *own*
276    /// serialization of its mirror; with the contract crate that would only
277    /// re-test caliban's serde, so what is worth asserting is that the payloads
278    /// prospero actually sees still parse (#239).
279    #[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        // #93: the provider round-trips.
287        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        // Back-compat: a pre-provider spec (field absent) still deserializes.
299        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        // Back-compat: a pre-interactive spec (field absent) still deserializes.
307        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        // Symmetric drift guard: the tagged shape must survive a serialize →
330        // deserialize round-trip for both variants.
331        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}