1use serde::Serialize;
20
21use cli_stream::ProcessEvent;
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct ByteRange {
29 pub start: u64,
30 pub end: u64,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
36#[serde(rename_all = "camelCase")]
37pub struct SuggestedEdit {
38 pub file_path: String,
39 pub range: ByteRange,
40 pub replacement: String,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub title: Option<String>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52#[serde(rename_all = "camelCase")]
53pub struct ToolCallStart {
54 pub tool_call_id: String,
55 pub name: String,
56 pub input: Option<String>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
64#[serde(rename_all = "camelCase")]
65pub struct ToolCallEnd {
66 pub tool_call_id: String,
67 pub ok: bool,
68 pub output: Option<String>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
81#[non_exhaustive]
85pub enum RunEvent {
86 Started { run_id: String },
90 Session {
97 run_id: String,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 session_id: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 model: Option<String>,
102 },
103 Text { run_id: String, delta: String },
105 Thinking { run_id: String, delta: String },
109 ToolStart {
113 run_id: String,
114 tool_call_id: String,
115 name: String,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 input: Option<String>,
118 },
119 ToolEnd {
122 run_id: String,
123 tool_call_id: String,
124 ok: bool,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 output: Option<String>,
127 },
128 SuggestedEdits {
130 run_id: String,
131 edits: Vec<SuggestedEdit>,
132 },
133 Activity { run_id: String, message: String },
136 Usage {
142 run_id: String,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 input_tokens: Option<u64>,
145 #[serde(skip_serializing_if = "Option::is_none")]
146 output_tokens: Option<u64>,
147 #[serde(skip_serializing_if = "Option::is_none")]
148 total_tokens: Option<u64>,
149 },
150 Error { run_id: String, message: String },
152 Exited {
154 run_id: String,
155 exit_code: Option<i32>,
156 cancelled: bool,
157 },
158}
159
160#[derive(Debug, Default, Clone, PartialEq, Eq)]
162pub struct SessionInfo {
163 pub session_id: Option<String>,
164 pub model: Option<String>,
165}
166
167#[derive(Debug, Default, Clone, PartialEq, Eq)]
169pub struct UsageInfo {
170 pub input_tokens: Option<u64>,
171 pub output_tokens: Option<u64>,
172 pub total_tokens: Option<u64>,
173}
174
175#[derive(Debug, Default, Clone, PartialEq, Eq)]
178pub struct ParsedLine {
179 pub text: Option<String>,
180 pub thinking: Option<String>,
183 pub session: Option<SessionInfo>,
185 pub tool_start: Option<ToolCallStart>,
187 pub tool_end: Option<ToolCallEnd>,
189 pub edits: Vec<SuggestedEdit>,
190 pub usage: Option<UsageInfo>,
192 pub activity: Option<String>,
193}
194
195impl ParsedLine {
196 pub fn is_empty(&self) -> bool {
200 self.text.is_none()
201 && self.thinking.is_none()
202 && self.session.is_none()
203 && self.tool_start.is_none()
204 && self.tool_end.is_none()
205 && self.edits.is_empty()
206 && self.usage.is_none()
207 && self.activity.is_none()
208 }
209}
210
211pub fn normalize_process_event(
218 event: ProcessEvent,
219 mut parse_line: impl FnMut(&str) -> ParsedLine,
220) -> Vec<RunEvent> {
221 match event {
222 ProcessEvent::Started { run_id } => vec![RunEvent::Started { run_id }],
223 ProcessEvent::Exited {
224 run_id,
225 exit_code,
226 cancelled,
227 } => vec![RunEvent::Exited {
228 run_id,
229 exit_code,
230 cancelled,
231 }],
232 ProcessEvent::Error { run_id, message } => vec![RunEvent::Error { run_id, message }],
233 ProcessEvent::Stderr { run_id, line } => {
234 let message = truncate(&line, 240);
237 if message.is_empty() {
238 vec![]
239 } else {
240 vec![RunEvent::Activity { run_id, message }]
241 }
242 }
243 ProcessEvent::Stdout { run_id, line } => run_events_from_parsed(&run_id, parse_line(&line)),
244 _ => Vec::new(),
247 }
248}
249
250pub fn run_events_from_parsed(run_id: &str, parsed: ParsedLine) -> Vec<RunEvent> {
265 let mut out = Vec::new();
266 if let Some(session) = parsed.session {
267 out.push(RunEvent::Session {
268 run_id: run_id.to_owned(),
269 session_id: session.session_id,
270 model: session.model,
271 });
272 }
273 if let Some(text) = parsed.text {
274 out.push(RunEvent::Text {
275 run_id: run_id.to_owned(),
276 delta: text,
277 });
278 }
279 if let Some(thinking) = parsed.thinking {
280 out.push(RunEvent::Thinking {
281 run_id: run_id.to_owned(),
282 delta: thinking,
283 });
284 }
285 if let Some(start) = parsed.tool_start {
286 out.push(RunEvent::ToolStart {
287 run_id: run_id.to_owned(),
288 tool_call_id: start.tool_call_id,
289 name: start.name,
290 input: start.input,
291 });
292 }
293 if let Some(end) = parsed.tool_end {
294 out.push(RunEvent::ToolEnd {
295 run_id: run_id.to_owned(),
296 tool_call_id: end.tool_call_id,
297 ok: end.ok,
298 output: end.output,
299 });
300 }
301 if !parsed.edits.is_empty() {
302 out.push(RunEvent::SuggestedEdits {
303 run_id: run_id.to_owned(),
304 edits: parsed.edits,
305 });
306 }
307 if let Some(usage) = parsed.usage {
308 out.push(RunEvent::Usage {
309 run_id: run_id.to_owned(),
310 input_tokens: usage.input_tokens,
311 output_tokens: usage.output_tokens,
312 total_tokens: usage.total_tokens,
313 });
314 }
315 if let Some(activity) = parsed.activity {
316 out.push(RunEvent::Activity {
317 run_id: run_id.to_owned(),
318 message: activity,
319 });
320 }
321 out
322}
323
324fn truncate(s: &str, max_chars: usize) -> String {
327 s.chars().take(max_chars).collect()
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 fn empty_parser(_: &str) -> ParsedLine {
337 ParsedLine::default()
338 }
339
340 #[test]
341 fn normalize_passes_through_lifecycle_events() {
342 assert!(matches!(
343 normalize_process_event(ProcessEvent::Started { run_id: "r".into() }, empty_parser)
344 .as_slice(),
345 [RunEvent::Started { .. }]
346 ));
347 assert!(matches!(
348 normalize_process_event(
349 ProcessEvent::Exited {
350 run_id: "r".into(),
351 exit_code: Some(0),
352 cancelled: false
353 },
354 empty_parser
355 )
356 .as_slice(),
357 [RunEvent::Exited { exit_code: Some(0), cancelled: false, .. }]
358 ));
359 }
360
361 #[test]
362 fn stderr_becomes_truncated_activity() {
363 let long = "x".repeat(500);
364 let events = normalize_process_event(
365 ProcessEvent::Stderr {
366 run_id: "r1".into(),
367 line: long,
368 },
369 empty_parser,
370 );
371 match events.as_slice() {
372 [RunEvent::Activity { run_id, message }] => {
373 assert_eq!(run_id, "r1");
374 assert_eq!(message.chars().count(), 240);
375 }
376 other => panic!("expected one Activity, got {other:?}"),
377 }
378 assert!(normalize_process_event(
380 ProcessEvent::Stderr {
381 run_id: "r1".into(),
382 line: String::new(),
383 },
384 empty_parser,
385 )
386 .is_empty());
387 }
388
389 #[test]
390 fn thinking_normalizes_and_serializes() {
391 let events = normalize_process_event(
392 ProcessEvent::Stdout {
393 run_id: "r1".to_owned(),
394 line: "ignored".to_owned(),
395 },
396 |_| ParsedLine {
397 thinking: Some("pondering".to_owned()),
398 ..ParsedLine::default()
399 },
400 );
401 assert!(matches!(
402 events.as_slice(),
403 [RunEvent::Thinking { run_id, delta }] if run_id == "r1" && delta == "pondering"
404 ));
405 let json = serde_json::to_value(RunEvent::Thinking {
406 run_id: "r1".to_owned(),
407 delta: "d".to_owned(),
408 })
409 .unwrap();
410 assert_eq!(json["kind"], "thinking");
411 assert_eq!(json["runId"], "r1");
412 assert_eq!(json["delta"], "d");
413 }
414
415 #[test]
416 fn run_event_serializes_with_kind_and_camelcase() {
417 let json = serde_json::to_value(RunEvent::Exited {
418 run_id: "r1".to_owned(),
419 exit_code: Some(2),
420 cancelled: true,
421 })
422 .unwrap();
423 assert_eq!(json["kind"], "exited");
424 assert_eq!(json["runId"], "r1");
425 assert_eq!(json["exitCode"], 2);
426 assert_eq!(json["cancelled"], true);
427 }
428
429 #[test]
430 fn session_normalizes_and_serializes() {
431 let events = normalize_process_event(
432 ProcessEvent::Stdout {
433 run_id: "r1".to_owned(),
434 line: "ignored".to_owned(),
435 },
436 |_| ParsedLine {
437 session: Some(SessionInfo {
438 session_id: Some("sess-1".to_owned()),
439 model: Some("opus".to_owned()),
440 }),
441 ..ParsedLine::default()
442 },
443 );
444 assert!(matches!(
445 events.as_slice(),
446 [RunEvent::Session { run_id, session_id, model }]
447 if run_id == "r1"
448 && session_id.as_deref() == Some("sess-1")
449 && model.as_deref() == Some("opus")
450 ));
451 let json = serde_json::to_value(RunEvent::Session {
452 run_id: "r1".to_owned(),
453 session_id: Some("sess-1".to_owned()),
454 model: None,
455 })
456 .unwrap();
457 assert_eq!(json["kind"], "session");
458 assert_eq!(json["sessionId"], "sess-1");
459 assert!(json.get("model").is_none());
461 }
462
463 #[test]
464 fn usage_normalizes_and_serializes() {
465 let events = normalize_process_event(
466 ProcessEvent::Stdout {
467 run_id: "r1".to_owned(),
468 line: "ignored".to_owned(),
469 },
470 |_| ParsedLine {
471 usage: Some(UsageInfo {
472 input_tokens: Some(10),
473 output_tokens: Some(20),
474 total_tokens: Some(30),
475 }),
476 ..ParsedLine::default()
477 },
478 );
479 assert!(matches!(
480 events.as_slice(),
481 [RunEvent::Usage { run_id, input_tokens: Some(10), output_tokens: Some(20), total_tokens: Some(30) }]
482 if run_id == "r1"
483 ));
484 let json = serde_json::to_value(RunEvent::Usage {
485 run_id: "r1".to_owned(),
486 input_tokens: Some(10),
487 output_tokens: None,
488 total_tokens: Some(30),
489 })
490 .unwrap();
491 assert_eq!(json["kind"], "usage");
492 assert_eq!(json["inputTokens"], 10);
493 assert_eq!(json["totalTokens"], 30);
494 assert!(json.get("outputTokens").is_none()); }
496
497 #[test]
498 fn tool_io_is_carried_and_omitted_when_absent() {
499 let start = normalize_process_event(
501 ProcessEvent::Stdout {
502 run_id: "r1".to_owned(),
503 line: "ignored".to_owned(),
504 },
505 |_| ParsedLine {
506 tool_start: Some(ToolCallStart {
507 tool_call_id: "t1".to_owned(),
508 name: "ls".to_owned(),
509 input: Some("{\"dir\":\"/x\"}".to_owned()),
510 }),
511 ..ParsedLine::default()
512 },
513 );
514 assert!(matches!(
515 start.as_slice(),
516 [RunEvent::ToolStart { input: Some(i), .. }] if i == "{\"dir\":\"/x\"}"
517 ));
518 let json = serde_json::to_value(RunEvent::ToolStart {
521 run_id: "r1".to_owned(),
522 tool_call_id: "t1".to_owned(),
523 name: "ls".to_owned(),
524 input: None,
525 })
526 .unwrap();
527 assert_eq!(json["kind"], "toolStart");
528 assert_eq!(json["toolCallId"], "t1");
529 assert!(json.get("input").is_none());
530
531 let json = serde_json::to_value(RunEvent::ToolEnd {
532 run_id: "r1".to_owned(),
533 tool_call_id: "t1".to_owned(),
534 ok: true,
535 output: Some("done".to_owned()),
536 })
537 .unwrap();
538 assert_eq!(json["kind"], "toolEnd");
539 assert_eq!(json["output"], "done");
540 }
541
542 #[test]
543 fn suggested_edits_event_serializes_camelcase() {
544 let json = serde_json::to_value(RunEvent::SuggestedEdits {
545 run_id: "r1".to_owned(),
546 edits: vec![SuggestedEdit {
547 file_path: "a.md".to_owned(),
548 range: ByteRange { start: 1, end: 2 },
549 replacement: "x".to_owned(),
550 title: None,
551 }],
552 })
553 .unwrap();
554 assert_eq!(json["kind"], "suggestedEdits");
555 assert_eq!(json["edits"][0]["filePath"], "a.md");
556 assert_eq!(json["edits"][0]["range"]["start"], 1);
557 assert!(json["edits"][0].get("title").is_none());
559 }
560}