Skip to main content

prospero_types/
event.rs

1//! Prospero's normalized event type — the stable contract consumers see.
2//!
3//! Caliban's raw stream-json frames are normalized into [`FleetEvent`]s by
4//! `prospero_core::caliband::stream::normalize_frame`. Consumers (CLI `follow`,
5//! dashboard SSE, history replay) never see raw caliban frames. Moved to
6//! `prospero-types` so the WASM dashboard can share the type (prospero #98).
7
8use serde::{Deserialize, Serialize};
9
10use crate::model::{AgentStatus, WorkspaceHealth};
11
12/// Which textual stream a chunk of output came from.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
16pub enum OutputStream {
17    /// Assistant-visible text.
18    Stdout,
19    /// Model reasoning (dropped from history by default).
20    Thinking,
21}
22
23/// The semantic payload of a fleet event.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25#[serde(tag = "kind", rename_all = "snake_case")]
26#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
27pub enum EventKind {
28    /// Prospero asked caliband to spawn this agent.
29    AgentSpawned,
30    /// A poll discovered an agent Prospero had not seen before.
31    AgentDiscovered,
32    /// The agent's stream emitted its init frame.
33    AgentInit {
34        /// Model the agent is running.
35        model: String,
36        /// Tools available to the agent.
37        tools: Vec<String>,
38        /// Caliban session id.
39        session_id: String,
40    },
41    /// A poll observed a lifecycle transition.
42    StatusChanged {
43        /// Prior status.
44        from: AgentStatus,
45        /// New status.
46        to: AgentStatus,
47    },
48    /// A chunk of streamed output.
49    Output {
50        /// Which stream the chunk belongs to.
51        stream: OutputStream,
52        /// The text chunk.
53        chunk: String,
54    },
55    /// A tool call started.
56    ToolStarted {
57        /// Caliban's `tool_use_id`, correlating this start with its
58        /// [`ToolFinished`]. `#[serde(default)]` for pre-#106 stored events,
59        /// which predate this field (they carry only `name`).
60        #[serde(default)]
61        id: String,
62        /// Tool name (e.g. "Read").
63        name: String,
64        /// Tool input (opaque JSON).
65        input: serde_json::Value,
66    },
67    /// A tool call finished.
68    ToolFinished {
69        /// Caliban's `tool_use_id`, correlating this finish with its
70        /// [`ToolStarted`]. Caliban's `ToolCallEnd` carries the id but not the
71        /// name, so consumers pair on the id. `#[serde(default)]` for pre-#106
72        /// stored events.
73        #[serde(default)]
74        id: String,
75        /// Tool name. Empty in practice — caliban's `ToolCallEnd` omits it; the
76        /// name is on the matching [`ToolStarted`]. Kept for wire compatibility.
77        name: String,
78        /// Whether the tool succeeded.
79        ok: bool,
80        /// What the tool returned, as display text: caliban's `result_text`,
81        /// the head of the result, bounded (see
82        /// `prospero_core::caliband::stream::TOOL_RESULT_CAP`). `None` for
83        /// events stored before #236 or a frame that carried no result.
84        #[serde(default, skip_serializing_if = "Option::is_none")]
85        result: Option<String>,
86        /// `true` when `result` is only the head of a longer result.
87        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
88        truncated: bool,
89    },
90    /// The agent finished; carries the final accounting.
91    AgentFinished {
92        /// Result subtype (e.g. "success", "max_turns", "budget_exceeded").
93        outcome: String,
94        /// Total run cost in USD.
95        cost_usd: f64,
96        /// Number of turns taken.
97        turns: u32,
98    },
99    /// A poll observed the agent disappear from caliband's registry.
100    AgentGone,
101    /// A durable-store append failed, so the event with `lost_seq` reached the
102    /// live bus but is **absent from durable history**. This marker is itself
103    /// persisted (best-effort) so a history reader — not just a log scraper —
104    /// sees that the live and durable views diverged here. See ADR-0004.
105    StorePersistFailed {
106        /// The `seq` of the event that could not be persisted.
107        lost_seq: u64,
108        /// Rendered append error, for diagnosis.
109        detail: String,
110    },
111    /// The spawn carried a wall-clock timeout; this is the deadline it implies
112    /// (#221), recorded at spawn so it outlives the replica that set it.
113    ///
114    /// The durable log *is* the storage: a local timer dies with its replica,
115    /// while any replica taking the agent over can read this back.
116    DeadlineSet {
117        /// When the agent must be killed, RFC-3339.
118        deadline: String,
119    },
120    /// The agent hit its wall-clock deadline and prospero killed it (#221).
121    ///
122    /// Emitted **before** the kill, so the log says why the `killed` that
123    /// follows happened. A distinct kind rather than a new `AgentStatus`: the
124    /// status enum is matched exhaustively by clients (Ariel, the dashboard)
125    /// and by caliban's own vocabulary, whereas event kinds are the log's
126    /// established extension point.
127    AgentTimedOut {
128        /// The deadline it passed, RFC-3339 — the same value the spawn
129        /// recorded, so the log explains itself without a join.
130        deadline: String,
131    },
132    /// A workspace's caliband health changed. (Variant name kept as `RepoHealth`
133    /// for event-store wire compatibility; the payload type is the renamed
134    /// `WorkspaceHealth`, whose serialization is unchanged.)
135    RepoHealth {
136        /// The new health state.
137        state: WorkspaceHealth,
138    },
139}
140
141/// The ordered stream a `(repo, agent_id)` pair belongs to. Agent events key on
142/// the agent id; repo-level events (no agent) on `repo:<name>`; fleet-level
143/// events (neither) on the singleton `fleet` stream. `seq` is monotonic *within*
144/// the returned key.
145pub fn stream_key_for(repo: &str, agent_id: &str) -> String {
146    if !agent_id.is_empty() {
147        agent_id.to_string()
148    } else if !repo.is_empty() {
149        format!("repo:{repo}")
150    } else {
151        "fleet".to_string()
152    }
153}
154
155/// A normalized, sequenced fleet event.
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
158pub struct FleetEvent {
159    /// Monotonic sequence number assigned by the `FleetManager`.
160    pub seq: u64,
161    /// RFC-3339 timestamp.
162    pub ts: String,
163    /// Owning repo name ("" for fleet-level events).
164    pub repo: String,
165    /// Owning agent id ("" for repo-level events).
166    pub agent_id: String,
167    /// The event payload.
168    pub kind: EventKind,
169    /// The API token name that caused this event, when a request did so
170    /// directly (local-fleet spawn / rm). `None` for poll- or watch-derived
171    /// events and when auth is disabled. Additive and optional on the wire (#2).
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub actor: Option<String>,
174    /// Who the requesting client said it was acting **for** (#251).
175    ///
176    /// A multi-user front end (Ariel, an MCP host, a bot) holds one token and
177    /// serves many people, so `actor` alone cannot tell their spawns apart.
178    /// This is the client's own assertion: prosperod cannot authenticate
179    /// someone else's user and does not try. `actor` remains the authenticated
180    /// identity, and both are recorded, so a reader can always see which
181    /// credential made the claim. Additive and optional on the wire.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub on_behalf_of: Option<String>,
184}
185
186impl FleetEvent {
187    /// The ordered stream this event belongs to (see [`stream_key_for`]).
188    pub fn stream_key(&self) -> String {
189        stream_key_for(&self.repo, &self.agent_id)
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn event_kind_is_internally_tagged() {
199        let k = EventKind::ToolFinished {
200            id: "tu_1".into(),
201            name: "Read".into(),
202            ok: true,
203            result: None,
204            truncated: false,
205        };
206        let v = serde_json::to_value(&k).unwrap();
207        assert_eq!(v["kind"], "tool_finished");
208        assert_eq!(v["id"], "tu_1");
209        assert_eq!(v["name"], "Read");
210        assert_eq!(v["ok"], true);
211    }
212
213    #[test]
214    fn tool_finished_carries_its_result_and_truncation() {
215        let k = EventKind::ToolFinished {
216            id: "tu_1".into(),
217            name: String::new(),
218            ok: true,
219            result: Some("src/main.rs".into()),
220            truncated: true,
221        };
222        let v = serde_json::to_value(&k).unwrap();
223        assert_eq!(v["result"], "src/main.rs");
224        assert_eq!(v["truncated"], true);
225        let back: EventKind = serde_json::from_value(v).unwrap();
226        assert_eq!(back, k);
227    }
228
229    #[test]
230    fn tool_finished_stored_before_results_still_deserializes() {
231        // Events persisted before #236 carry no `result`/`truncated`.
232        let old =
233            serde_json::json!({"kind": "tool_finished", "id": "tu_1", "name": "", "ok": false});
234        let k: EventKind = serde_json::from_value(old).unwrap();
235        assert_eq!(
236            k,
237            EventKind::ToolFinished {
238                id: "tu_1".into(),
239                name: String::new(),
240                ok: false,
241                result: None,
242                truncated: false,
243            }
244        );
245    }
246
247    /// #221: the timeout is its own event kind, so history says why an agent
248    /// was killed rather than leaving a bare `killed` to guess at.
249    #[test]
250    fn agent_timed_out_round_trips_with_its_deadline() {
251        let k = EventKind::AgentTimedOut {
252            deadline: "2026-09-20T12:00:00Z".into(),
253        };
254        let v = serde_json::to_value(&k).unwrap();
255        assert_eq!(v["kind"], "agent_timed_out");
256        assert_eq!(v["deadline"], "2026-09-20T12:00:00Z");
257        assert_eq!(serde_json::from_value::<EventKind>(v).unwrap(), k);
258    }
259
260    #[test]
261    fn fleet_event_round_trips() {
262        let e = FleetEvent {
263            seq: 7,
264            ts: "2026-06-05T00:00:00Z".into(),
265            repo: "prospero".into(),
266            agent_id: "a1".into(),
267            kind: EventKind::Output {
268                stream: OutputStream::Stdout,
269                chunk: "hi".into(),
270            },
271            actor: None,
272            on_behalf_of: None,
273        };
274        let s = serde_json::to_string(&e).unwrap();
275        let back: FleetEvent = serde_json::from_str(&s).unwrap();
276        assert_eq!(e, back);
277    }
278
279    #[test]
280    fn stream_key_picks_agent_then_repo_then_fleet() {
281        // Agent-level: the agent id is the stream.
282        assert_eq!(stream_key_for("prospero", "a1"), "a1");
283        // Repo-level (no agent): namespaced repo stream.
284        assert_eq!(stream_key_for("prospero", ""), "repo:prospero");
285        // Fleet-level (neither): the singleton fleet stream.
286        assert_eq!(stream_key_for("", ""), "fleet");
287    }
288
289    #[test]
290    fn fleet_event_stream_key_delegates() {
291        let e = FleetEvent {
292            seq: 1,
293            ts: "t".into(),
294            repo: "prospero".into(),
295            agent_id: "".into(),
296            kind: EventKind::AgentGone,
297            actor: None,
298            on_behalf_of: None,
299        };
300        assert_eq!(e.stream_key(), "repo:prospero");
301    }
302
303    #[test]
304    fn actor_is_optional_and_omitted_when_none() {
305        let mut e = FleetEvent {
306            seq: 1,
307            ts: "2026-09-13T00:00:00Z".into(),
308            repo: "r".into(),
309            agent_id: "a".into(),
310            kind: EventKind::AgentSpawned,
311            actor: None,
312            on_behalf_of: None,
313        };
314        let v = serde_json::to_value(&e).unwrap();
315        assert!(v.get("actor").is_none(), "None must not serialize: {v}");
316        // Old payloads without the field still deserialize.
317        let old = serde_json::json!({
318            "seq": 1, "ts": "t", "repo": "r", "agent_id": "a",
319            "kind": {"kind": "agent_spawned"}
320        });
321        assert_eq!(
322            serde_json::from_value::<FleetEvent>(old).unwrap().actor,
323            None
324        );
325        e.actor = Some("alice".into());
326        let v = serde_json::to_value(&e).unwrap();
327        assert_eq!(v["actor"], "alice");
328    }
329
330    /// #251: `on_behalf_of` is additive. A payload written before it existed
331    /// still reads, and an event without one still serializes without it —
332    /// which is what lets an older daemon and a newer client interoperate.
333    #[test]
334    fn on_behalf_of_is_optional_and_omitted_when_none() {
335        let mut e = FleetEvent {
336            seq: 1,
337            ts: "2026-09-20T00:00:00Z".into(),
338            repo: "r".into(),
339            agent_id: "a".into(),
340            kind: EventKind::AgentSpawned,
341            actor: Some("ariel".into()),
342            on_behalf_of: None,
343        };
344        let v = serde_json::to_value(&e).unwrap();
345        assert!(
346            v.get("on_behalf_of").is_none(),
347            "None must not serialize: {v}"
348        );
349
350        let old = serde_json::json!({
351            "seq": 1, "ts": "t", "repo": "r", "agent_id": "a",
352            "kind": {"kind": "agent_spawned"}, "actor": "ariel"
353        });
354        let back: FleetEvent = serde_json::from_value(old).unwrap();
355        assert_eq!(back.actor.as_deref(), Some("ariel"));
356        assert_eq!(back.on_behalf_of, None);
357
358        e.on_behalf_of = Some("discord:U123".into());
359        let v = serde_json::to_value(&e).unwrap();
360        assert_eq!(v["on_behalf_of"], "discord:U123");
361        assert_eq!(v["actor"], "ariel", "both identities travel together");
362    }
363}