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}