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")]
15pub enum OutputStream {
16    /// Assistant-visible text.
17    Stdout,
18    /// Model reasoning (dropped from history by default).
19    Thinking,
20}
21
22/// The semantic payload of a fleet event.
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[serde(tag = "kind", rename_all = "snake_case")]
25pub enum EventKind {
26    /// Prospero asked caliband to spawn this agent.
27    AgentSpawned,
28    /// A poll discovered an agent Prospero had not seen before.
29    AgentDiscovered,
30    /// The agent's stream emitted its init frame.
31    AgentInit {
32        /// Model the agent is running.
33        model: String,
34        /// Tools available to the agent.
35        tools: Vec<String>,
36        /// Caliban session id.
37        session_id: String,
38    },
39    /// A poll observed a lifecycle transition.
40    StatusChanged {
41        /// Prior status.
42        from: AgentStatus,
43        /// New status.
44        to: AgentStatus,
45    },
46    /// A chunk of streamed output.
47    Output {
48        /// Which stream the chunk belongs to.
49        stream: OutputStream,
50        /// The text chunk.
51        chunk: String,
52    },
53    /// A tool call started.
54    ToolStarted {
55        /// Caliban's `tool_use_id`, correlating this start with its
56        /// [`ToolFinished`]. `#[serde(default)]` for pre-#106 stored events,
57        /// which predate this field (they carry only `name`).
58        #[serde(default)]
59        id: String,
60        /// Tool name (e.g. "Read").
61        name: String,
62        /// Tool input (opaque JSON).
63        input: serde_json::Value,
64    },
65    /// A tool call finished.
66    ToolFinished {
67        /// Caliban's `tool_use_id`, correlating this finish with its
68        /// [`ToolStarted`]. Caliban's `ToolCallEnd` carries the id but not the
69        /// name, so consumers pair on the id. `#[serde(default)]` for pre-#106
70        /// stored events.
71        #[serde(default)]
72        id: String,
73        /// Tool name. Empty in practice — caliban's `ToolCallEnd` omits it; the
74        /// name is on the matching [`ToolStarted`]. Kept for wire compatibility.
75        name: String,
76        /// Whether the tool succeeded.
77        ok: bool,
78    },
79    /// The agent finished; carries the final accounting.
80    AgentFinished {
81        /// Result subtype (e.g. "success", "max_turns", "budget_exceeded").
82        outcome: String,
83        /// Total run cost in USD.
84        cost_usd: f64,
85        /// Number of turns taken.
86        turns: u32,
87    },
88    /// A poll observed the agent disappear from caliband's registry.
89    AgentGone,
90    /// A durable-store append failed, so the event with `lost_seq` reached the
91    /// live bus but is **absent from durable history**. This marker is itself
92    /// persisted (best-effort) so a history reader — not just a log scraper —
93    /// sees that the live and durable views diverged here. See ADR-0004.
94    StorePersistFailed {
95        /// The `seq` of the event that could not be persisted.
96        lost_seq: u64,
97        /// Rendered append error, for diagnosis.
98        detail: String,
99    },
100    /// A workspace's caliband health changed. (Variant name kept as `RepoHealth`
101    /// for event-store wire compatibility; the payload type is the renamed
102    /// `WorkspaceHealth`, whose serialization is unchanged.)
103    RepoHealth {
104        /// The new health state.
105        state: WorkspaceHealth,
106    },
107}
108
109/// The ordered stream a `(repo, agent_id)` pair belongs to. Agent events key on
110/// the agent id; repo-level events (no agent) on `repo:<name>`; fleet-level
111/// events (neither) on the singleton `fleet` stream. `seq` is monotonic *within*
112/// the returned key.
113pub fn stream_key_for(repo: &str, agent_id: &str) -> String {
114    if !agent_id.is_empty() {
115        agent_id.to_string()
116    } else if !repo.is_empty() {
117        format!("repo:{repo}")
118    } else {
119        "fleet".to_string()
120    }
121}
122
123/// A normalized, sequenced fleet event.
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub struct FleetEvent {
126    /// Monotonic sequence number assigned by the `FleetManager`.
127    pub seq: u64,
128    /// RFC-3339 timestamp.
129    pub ts: String,
130    /// Owning repo name ("" for fleet-level events).
131    pub repo: String,
132    /// Owning agent id ("" for repo-level events).
133    pub agent_id: String,
134    /// The event payload.
135    pub kind: EventKind,
136}
137
138impl FleetEvent {
139    /// The ordered stream this event belongs to (see [`stream_key_for`]).
140    pub fn stream_key(&self) -> String {
141        stream_key_for(&self.repo, &self.agent_id)
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn event_kind_is_internally_tagged() {
151        let k = EventKind::ToolFinished {
152            id: "tu_1".into(),
153            name: "Read".into(),
154            ok: true,
155        };
156        let v = serde_json::to_value(&k).unwrap();
157        assert_eq!(v["kind"], "tool_finished");
158        assert_eq!(v["id"], "tu_1");
159        assert_eq!(v["name"], "Read");
160        assert_eq!(v["ok"], true);
161    }
162
163    #[test]
164    fn fleet_event_round_trips() {
165        let e = FleetEvent {
166            seq: 7,
167            ts: "2026-06-05T00:00:00Z".into(),
168            repo: "prospero".into(),
169            agent_id: "a1".into(),
170            kind: EventKind::Output {
171                stream: OutputStream::Stdout,
172                chunk: "hi".into(),
173            },
174        };
175        let s = serde_json::to_string(&e).unwrap();
176        let back: FleetEvent = serde_json::from_str(&s).unwrap();
177        assert_eq!(e, back);
178    }
179
180    #[test]
181    fn stream_key_picks_agent_then_repo_then_fleet() {
182        // Agent-level: the agent id is the stream.
183        assert_eq!(stream_key_for("prospero", "a1"), "a1");
184        // Repo-level (no agent): namespaced repo stream.
185        assert_eq!(stream_key_for("prospero", ""), "repo:prospero");
186        // Fleet-level (neither): the singleton fleet stream.
187        assert_eq!(stream_key_for("", ""), "fleet");
188    }
189
190    #[test]
191    fn fleet_event_stream_key_delegates() {
192        let e = FleetEvent {
193            seq: 1,
194            ts: "t".into(),
195            repo: "prospero".into(),
196            agent_id: "".into(),
197            kind: EventKind::AgentGone,
198        };
199        assert_eq!(e.stream_key(), "repo:prospero");
200    }
201}