1use crate::event::{EventKind, OutputStream};
19
20#[derive(Debug, Clone, Copy, Default)]
22pub struct NormalizeOptions {
23 pub include_thinking: bool,
26}
27
28#[derive(Debug, PartialEq)]
30pub enum Normalized {
31 Event(EventKind),
33 Dropped,
36 Unknown,
38}
39
40pub 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 "AssistantTextDelta" => Normalized::Event(EventKind::Output {
50 stream: OutputStream::Stdout,
51 chunk: str_field(frame, "text"),
52 }),
53 "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 "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 "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 "RunEnd" => Normalized::Event(EventKind::AgentFinished {
98 outcome: stop_label(frame.get("stopped_for")),
99 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 "TurnStart" | "ToolCallInputDelta" | "TurnEnd" => Normalized::Dropped,
110 _ => Normalized::Unknown,
111 }
112}
113
114pub const TOOL_RESULT_CAP: usize = 8 * 1024;
119
120fn 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
140fn 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 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 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 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 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 #[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}