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}