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`. The result comes from
75        // `result_text`, caliban's bounded display preview (caliban#391) —
76        // not the full structured `content`, which can be arbitrarily large.
77        "ToolCallEnd" => {
78            let (result, capped) = match frame.get("result_text").and_then(|v| v.as_str()) {
79                Some(text) => {
80                    let (head, capped) = cap_chars(text, TOOL_RESULT_CAP);
81                    (Some(head), capped)
82                }
83                None => (None, false),
84            };
85            Normalized::Event(EventKind::ToolFinished {
86                id: str_field(frame, "tool_use_id"),
87                name: String::new(),
88                ok: !frame
89                    .get("is_error")
90                    .and_then(|v| v.as_bool())
91                    .unwrap_or(false),
92                result,
93                truncated: capped || bool_field(frame, "truncated"),
94            })
95        }
96        // Terminal frame: the whole run finished. This closes the SSE stream.
97        "RunEnd" => Normalized::Event(EventKind::AgentFinished {
98            outcome: stop_label(frame.get("stopped_for")),
99            // caliban reports token usage, not a USD cost; surface 0.0.
100            cost_usd: 0.0,
101            turns: frame
102                .get("turn_count")
103                .and_then(|v| v.as_u64())
104                .unwrap_or(0) as u32,
105        }),
106        // Recognized book-keeping frames we intentionally don't surface:
107        // turn boundaries and incremental tool-input JSON (the deltas are
108        // already captured as text / the tool lifecycle).
109        "TurnStart" | "ToolCallInputDelta" | "TurnEnd" => Normalized::Dropped,
110        _ => Normalized::Unknown,
111    }
112}
113
114/// Longest tool result, in chars, prospero keeps on a `ToolFinished` event.
115/// Matches caliban's `STREAM_RESULT_TEXT_CAP` (8 KiB), so a current caliban's
116/// `result_text` passes through unchanged; the cap is prospero's own guarantee
117/// that one tool call cannot put an unbounded body into the event stores or SSE.
118pub const TOOL_RESULT_CAP: usize = 8 * 1024;
119
120/// The first `max` chars of `text`, and whether anything was cut.
121fn cap_chars(text: &str, max: usize) -> (String, bool) {
122    match text.char_indices().nth(max) {
123        Some((at, _)) => (text[..at].to_string(), true),
124        None => (text.to_string(), false),
125    }
126}
127
128fn bool_field(frame: &serde_json::Value, key: &str) -> bool {
129    frame.get(key).and_then(|v| v.as_bool()).unwrap_or(false)
130}
131
132fn str_field(frame: &serde_json::Value, key: &str) -> String {
133    frame
134        .get(key)
135        .and_then(|v| v.as_str())
136        .unwrap_or_default()
137        .to_string()
138}
139
140/// Render caliban's `StopCondition` into a short outcome label. It is
141/// serialized externally-tagged, so unit variants arrive as a JSON string
142/// (`"EndOfTurn"`) and data-carrying variants as a single-key object
143/// (`{"MaxTurnsReached": 5}`); both yield the variant name.
144fn stop_label(v: Option<&serde_json::Value>) -> String {
145    match v {
146        Some(serde_json::Value::String(s)) => s.clone(),
147        Some(serde_json::Value::Object(m)) => m.keys().next().cloned().unwrap_or_default(),
148        _ => String::new(),
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use serde_json::json;
156
157    fn norm(v: serde_json::Value) -> Normalized {
158        normalize_frame(&v, NormalizeOptions::default())
159    }
160
161    #[test]
162    fn assistant_text_delta_maps_to_stdout_output() {
163        // Frame copied from caliban's own `attach` fixtures.
164        let f = json!({
165            "type": "AssistantTextDelta",
166            "turn_index": 0, "content_block_index": 0, "text": "hello "
167        });
168        assert_eq!(
169            norm(f),
170            Normalized::Event(EventKind::Output {
171                stream: OutputStream::Stdout,
172                chunk: "hello ".into()
173            })
174        );
175    }
176
177    #[test]
178    fn thinking_delta_dropped_by_default_but_included_on_request() {
179        let f = json!({
180            "type": "AssistantThinkingDelta",
181            "turn_index": 0, "content_block_index": 0, "text": "hmm"
182        });
183        assert_eq!(norm(f.clone()), Normalized::Dropped);
184        let opts = NormalizeOptions {
185            include_thinking: true,
186        };
187        assert_eq!(
188            normalize_frame(&f, opts),
189            Normalized::Event(EventKind::Output {
190                stream: OutputStream::Thinking,
191                chunk: "hmm".into()
192            })
193        );
194    }
195
196    #[test]
197    fn tool_call_start_maps_to_tool_started() {
198        assert_eq!(
199            norm(json!({
200                "type": "ToolCallStart",
201                "turn_index": 0, "tool_use_id": "tu_1", "name": "Read"
202            })),
203            Normalized::Event(EventKind::ToolStarted {
204                id: "tu_1".into(),
205                name: "Read".into(),
206                input: serde_json::Value::Null,
207            })
208        );
209    }
210
211    #[test]
212    fn tool_call_end_ok_is_inverse_of_is_error() {
213        assert_eq!(
214            norm(json!({
215                "type": "ToolCallEnd",
216                "turn_index": 0, "tool_use_id": "tu_1", "is_error": true, "content": []
217            })),
218            Normalized::Event(EventKind::ToolFinished {
219                id: "tu_1".into(),
220                name: String::new(),
221                ok: false,
222                result: None,
223                truncated: false,
224            })
225        );
226        assert_eq!(
227            norm(json!({
228                "type": "ToolCallEnd",
229                "turn_index": 0, "tool_use_id": "tu_1", "is_error": false, "content": []
230            })),
231            Normalized::Event(EventKind::ToolFinished {
232                id: "tu_1".into(),
233                name: String::new(),
234                ok: true,
235                result: None,
236                truncated: false,
237            })
238        );
239    }
240
241    #[test]
242    fn tool_call_end_carries_the_result_text_and_its_truncation() {
243        // Shape from caliban's `TurnEvent::ToolCallEnd` (caliban#391).
244        let f = json!({
245            "type": "ToolCallEnd", "turn_index": 1, "tool_use_id": "tu_2",
246            "is_error": false,
247            "content": [{"type": "text", "text": "a.rs\nb.rs\n...the full body"}],
248            "result_text": "a.rs\nb.rs\n", "truncated": true
249        });
250        assert_eq!(
251            norm(f),
252            Normalized::Event(EventKind::ToolFinished {
253                id: "tu_2".into(),
254                name: String::new(),
255                ok: true,
256                result: Some("a.rs\nb.rs\n".into()),
257                truncated: true,
258            })
259        );
260    }
261
262    #[test]
263    fn tool_call_end_result_is_capped_on_a_char_boundary() {
264        // A caliban without its own cap must not push an unbounded body into
265        // the event stores: keep the head, flag it truncated.
266        let long = "é".repeat(TOOL_RESULT_CAP + 10);
267        let f = json!({
268            "type": "ToolCallEnd", "tool_use_id": "tu_3", "is_error": false,
269            "result_text": long, "truncated": false
270        });
271        let Normalized::Event(EventKind::ToolFinished {
272            result, truncated, ..
273        }) = norm(f)
274        else {
275            panic!("expected ToolFinished");
276        };
277        let result = result.expect("result kept");
278        assert_eq!(result.chars().count(), TOOL_RESULT_CAP);
279        assert!(result.chars().all(|c| c == 'é'));
280        assert!(truncated);
281    }
282
283    #[test]
284    fn run_end_is_terminal_and_carries_turns_and_outcome() {
285        // Frame shape copied from caliban's `attach` fixtures (StopCondition
286        // unit variant serializes as a bare string).
287        let f = json!({
288            "type": "RunEnd",
289            "final_messages": [],
290            "total_usage": {"input_tokens": 0, "output_tokens": 0},
291            "turn_count": 3,
292            "stopped_for": "EndOfTurn"
293        });
294        assert_eq!(
295            norm(f),
296            Normalized::Event(EventKind::AgentFinished {
297                outcome: "EndOfTurn".into(),
298                cost_usd: 0.0,
299                turns: 3
300            })
301        );
302    }
303
304    #[test]
305    fn run_end_outcome_from_data_carrying_stop_condition() {
306        let f = json!({
307            "type": "RunEnd",
308            "final_messages": [], "total_usage": {},
309            "turn_count": 10,
310            "stopped_for": {"MaxTurnsReached": 10}
311        });
312        assert_eq!(
313            norm(f),
314            Normalized::Event(EventKind::AgentFinished {
315                outcome: "MaxTurnsReached".into(),
316                cost_usd: 0.0,
317                turns: 10
318            })
319        );
320    }
321
322    #[test]
323    fn turn_boundary_and_tool_input_frames_are_dropped_not_unknown() {
324        for f in [
325            json!({"type": "TurnStart", "turn_index": 0, "message_id": "m1", "model": "x"}),
326            json!({"type": "ToolCallInputDelta", "turn_index": 0, "tool_use_id": "tu_1", "partial_json": "{"}),
327            json!({"type": "TurnEnd", "turn_index": 0}),
328        ] {
329            assert_eq!(norm(f), Normalized::Dropped);
330        }
331    }
332
333    #[test]
334    fn unknown_or_typeless_frames_are_unknown() {
335        assert_eq!(norm(json!({"type": "FutureThing"})), Normalized::Unknown);
336        assert_eq!(norm(json!({"no_type": true})), Normalized::Unknown);
337    }
338
339    /// Contract guard: every variant of caliban's `TurnEvent` enum (the wire
340    /// contract, ADR-0003) must be recognized — i.e. never `Unknown`. If
341    /// caliban adds or renames a variant, sync the `match` in `normalize_frame`
342    /// and this list together. Source of truth:
343    /// `caliban-agent-core::stream::TurnEvent`.
344    #[test]
345    fn recognizes_every_known_turnevent_type() {
346        let opts = NormalizeOptions {
347            include_thinking: true,
348        };
349        for ty in [
350            "TurnStart",
351            "AssistantTextDelta",
352            "AssistantThinkingDelta",
353            "ToolCallStart",
354            "ToolCallInputDelta",
355            "ToolCallEnd",
356            "TurnEnd",
357            "RunEnd",
358        ] {
359            let got = normalize_frame(&json!({ "type": ty }), opts);
360            assert_ne!(
361                got,
362                Normalized::Unknown,
363                "caliban TurnEvent::{ty} is not recognized by normalize_frame"
364            );
365        }
366    }
367}