Skip to main content

prospero_core/caliband/
stream.rs

1//! Normalize caliban **`TurnEvent`** NDJSON frames into Prospero
2//! [`EventKind`]s.
3//!
4//! Caliban's agent worker writes [`caliban_agent_core::stream::TurnEvent`]
5//! records to its stream, each tagged by a PascalCase `"type"` field
6//! (`#[serde(tag = "type")]`): `TurnStart`, `AssistantTextDelta`,
7//! `AssistantThinkingDelta`, `ToolCallStart`, `ToolCallInputDelta`,
8//! `ToolCallEnd`, `TurnEnd`, `RunEnd`. **That enum is the wire contract**
9//! (ADR-0003) — this module is the only place that knows its shape.
10//!
11//! We parse each line into a [`serde_json::Value`] (rather than depending on
12//! caliban's crate) so that **unknown frame types are skipped, not fatal** —
13//! forward compatibility with caliban. The trade-off is that a *renamed* or
14//! *new* variant silently becomes [`Normalized::Unknown`] (counted via the
15//! `unknown_frames` metric); `recognizes_every_known_turnevent_type` below is
16//! the guard that fails loudly when the known set drifts.
17
18use crate::event::{EventKind, OutputStream};
19
20/// Options controlling normalization.
21#[derive(Debug, Clone, Copy, Default)]
22pub struct NormalizeOptions {
23    /// Include `AssistantThinkingDelta` as `Output { stream: Thinking }`.
24    /// Defaults to `false` (privacy/volume).
25    pub include_thinking: bool,
26}
27
28/// Outcome of normalizing one frame.
29#[derive(Debug, PartialEq)]
30pub enum Normalized {
31    /// A normalized event to emit.
32    Event(EventKind),
33    /// A recognized frame that intentionally produces no event (e.g. a
34    /// `TurnStart` book-keeping frame or a dropped thinking delta).
35    Dropped,
36    /// An unrecognized frame `type`; the caller should log + count it.
37    Unknown,
38}
39
40/// Normalize a single parsed caliban `TurnEvent` frame.
41pub fn normalize_frame(frame: &serde_json::Value, opts: NormalizeOptions) -> Normalized {
42    let ty = match frame.get("type").and_then(|v| v.as_str()) {
43        Some(t) => t,
44        None => return Normalized::Unknown,
45    };
46
47    match ty {
48        // Assistant-visible text deltas are the substance of the run.
49        "AssistantTextDelta" => Normalized::Event(EventKind::Output {
50            stream: OutputStream::Stdout,
51            chunk: str_field(frame, "text"),
52        }),
53        // Reasoning deltas are dropped by default (privacy/volume).
54        "AssistantThinkingDelta" => {
55            if opts.include_thinking {
56                Normalized::Event(EventKind::Output {
57                    stream: OutputStream::Thinking,
58                    chunk: str_field(frame, "text"),
59                })
60            } else {
61                Normalized::Dropped
62            }
63        }
64        // A tool call opened. The input arrives later via
65        // `ToolCallInputDelta`s, so it is not yet known here.
66        "ToolCallStart" => Normalized::Event(EventKind::ToolStarted {
67            id: str_field(frame, "tool_use_id"),
68            name: str_field(frame, "name"),
69            input: serde_json::Value::Null,
70        }),
71        // A tool call completed. caliban's `ToolCallEnd` carries the
72        // `tool_use_id` and `is_error` but not the tool name (that was on the
73        // matching `ToolCallStart`), so `name` is left empty and consumers pair
74        // the finish to its start on `tool_use_id`.
75        "ToolCallEnd" => Normalized::Event(EventKind::ToolFinished {
76            id: str_field(frame, "tool_use_id"),
77            name: String::new(),
78            ok: !frame
79                .get("is_error")
80                .and_then(|v| v.as_bool())
81                .unwrap_or(false),
82        }),
83        // Terminal frame: the whole run finished. This closes the SSE stream.
84        "RunEnd" => Normalized::Event(EventKind::AgentFinished {
85            outcome: stop_label(frame.get("stopped_for")),
86            // caliban reports token usage, not a USD cost; surface 0.0.
87            cost_usd: 0.0,
88            turns: frame
89                .get("turn_count")
90                .and_then(|v| v.as_u64())
91                .unwrap_or(0) as u32,
92        }),
93        // Recognized book-keeping frames we intentionally don't surface:
94        // turn boundaries and incremental tool-input JSON (the deltas are
95        // already captured as text / the tool lifecycle).
96        "TurnStart" | "ToolCallInputDelta" | "TurnEnd" => Normalized::Dropped,
97        _ => Normalized::Unknown,
98    }
99}
100
101fn str_field(frame: &serde_json::Value, key: &str) -> String {
102    frame
103        .get(key)
104        .and_then(|v| v.as_str())
105        .unwrap_or_default()
106        .to_string()
107}
108
109/// Render caliban's `StopCondition` into a short outcome label. It is
110/// serialized externally-tagged, so unit variants arrive as a JSON string
111/// (`"EndOfTurn"`) and data-carrying variants as a single-key object
112/// (`{"MaxTurnsReached": 5}`); both yield the variant name.
113fn stop_label(v: Option<&serde_json::Value>) -> String {
114    match v {
115        Some(serde_json::Value::String(s)) => s.clone(),
116        Some(serde_json::Value::Object(m)) => m.keys().next().cloned().unwrap_or_default(),
117        _ => String::new(),
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use serde_json::json;
125
126    fn norm(v: serde_json::Value) -> Normalized {
127        normalize_frame(&v, NormalizeOptions::default())
128    }
129
130    #[test]
131    fn assistant_text_delta_maps_to_stdout_output() {
132        // Frame copied from caliban's own `attach` fixtures.
133        let f = json!({
134            "type": "AssistantTextDelta",
135            "turn_index": 0, "content_block_index": 0, "text": "hello "
136        });
137        assert_eq!(
138            norm(f),
139            Normalized::Event(EventKind::Output {
140                stream: OutputStream::Stdout,
141                chunk: "hello ".into()
142            })
143        );
144    }
145
146    #[test]
147    fn thinking_delta_dropped_by_default_but_included_on_request() {
148        let f = json!({
149            "type": "AssistantThinkingDelta",
150            "turn_index": 0, "content_block_index": 0, "text": "hmm"
151        });
152        assert_eq!(norm(f.clone()), Normalized::Dropped);
153        let opts = NormalizeOptions {
154            include_thinking: true,
155        };
156        assert_eq!(
157            normalize_frame(&f, opts),
158            Normalized::Event(EventKind::Output {
159                stream: OutputStream::Thinking,
160                chunk: "hmm".into()
161            })
162        );
163    }
164
165    #[test]
166    fn tool_call_start_maps_to_tool_started() {
167        assert_eq!(
168            norm(json!({
169                "type": "ToolCallStart",
170                "turn_index": 0, "tool_use_id": "tu_1", "name": "Read"
171            })),
172            Normalized::Event(EventKind::ToolStarted {
173                id: "tu_1".into(),
174                name: "Read".into(),
175                input: serde_json::Value::Null,
176            })
177        );
178    }
179
180    #[test]
181    fn tool_call_end_ok_is_inverse_of_is_error() {
182        assert_eq!(
183            norm(json!({
184                "type": "ToolCallEnd",
185                "turn_index": 0, "tool_use_id": "tu_1", "is_error": true, "content": []
186            })),
187            Normalized::Event(EventKind::ToolFinished {
188                id: "tu_1".into(),
189                name: String::new(),
190                ok: false
191            })
192        );
193        assert_eq!(
194            norm(json!({
195                "type": "ToolCallEnd",
196                "turn_index": 0, "tool_use_id": "tu_1", "is_error": false, "content": []
197            })),
198            Normalized::Event(EventKind::ToolFinished {
199                id: "tu_1".into(),
200                name: String::new(),
201                ok: true
202            })
203        );
204    }
205
206    #[test]
207    fn run_end_is_terminal_and_carries_turns_and_outcome() {
208        // Frame shape copied from caliban's `attach` fixtures (StopCondition
209        // unit variant serializes as a bare string).
210        let f = json!({
211            "type": "RunEnd",
212            "final_messages": [],
213            "total_usage": {"input_tokens": 0, "output_tokens": 0},
214            "turn_count": 3,
215            "stopped_for": "EndOfTurn"
216        });
217        assert_eq!(
218            norm(f),
219            Normalized::Event(EventKind::AgentFinished {
220                outcome: "EndOfTurn".into(),
221                cost_usd: 0.0,
222                turns: 3
223            })
224        );
225    }
226
227    #[test]
228    fn run_end_outcome_from_data_carrying_stop_condition() {
229        let f = json!({
230            "type": "RunEnd",
231            "final_messages": [], "total_usage": {},
232            "turn_count": 10,
233            "stopped_for": {"MaxTurnsReached": 10}
234        });
235        assert_eq!(
236            norm(f),
237            Normalized::Event(EventKind::AgentFinished {
238                outcome: "MaxTurnsReached".into(),
239                cost_usd: 0.0,
240                turns: 10
241            })
242        );
243    }
244
245    #[test]
246    fn turn_boundary_and_tool_input_frames_are_dropped_not_unknown() {
247        for f in [
248            json!({"type": "TurnStart", "turn_index": 0, "message_id": "m1", "model": "x"}),
249            json!({"type": "ToolCallInputDelta", "turn_index": 0, "tool_use_id": "tu_1", "partial_json": "{"}),
250            json!({"type": "TurnEnd", "turn_index": 0}),
251        ] {
252            assert_eq!(norm(f), Normalized::Dropped);
253        }
254    }
255
256    #[test]
257    fn unknown_or_typeless_frames_are_unknown() {
258        assert_eq!(norm(json!({"type": "FutureThing"})), Normalized::Unknown);
259        assert_eq!(norm(json!({"no_type": true})), Normalized::Unknown);
260    }
261
262    /// Contract guard: every variant of caliban's `TurnEvent` enum (the wire
263    /// contract, ADR-0003) must be recognized — i.e. never `Unknown`. If
264    /// caliban adds or renames a variant, sync the `match` in `normalize_frame`
265    /// and this list together. Source of truth:
266    /// `caliban-agent-core::stream::TurnEvent`.
267    #[test]
268    fn recognizes_every_known_turnevent_type() {
269        let opts = NormalizeOptions {
270            include_thinking: true,
271        };
272        for ty in [
273            "TurnStart",
274            "AssistantTextDelta",
275            "AssistantThinkingDelta",
276            "ToolCallStart",
277            "ToolCallInputDelta",
278            "ToolCallEnd",
279            "TurnEnd",
280            "RunEnd",
281        ] {
282            let got = normalize_frame(&json!({ "type": ty }), opts);
283            assert_ne!(
284                got,
285                Normalized::Unknown,
286                "caliban TurnEvent::{ty} is not recognized by normalize_frame"
287            );
288        }
289    }
290}