1use serde_json::json;
37use std::io::Write;
38use std::sync::Mutex;
39
40pub fn observation_display(content: &str, max_chars: usize) -> String {
45 let total = content.chars().count();
46 let truncated = total > max_chars;
47 if truncated {
48 let head: String = content.chars().take(max_chars).collect();
49 format!(
50 "{head}...\n\n(Display truncated, {total} chars total. Agent received full content.)"
51 )
52 } else {
53 content.to_string()
54 }
55}
56
57pub trait EventSink: Send + Sync {
60 fn emit_line(&self, line: &str);
62
63 fn is_tty(&self) -> bool {
65 false
66 }
67
68 fn supports_incremental(&self) -> bool {
74 false
75 }
76
77 fn started(&self, task: &str) {
80 self.emit_line(&json!({"type": "start", "task": task}).to_string());
81 }
82
83 fn session(&self, event: &str, session_id: &str) {
84 self.emit_line(&json!({"type": event, "session_id": session_id}).to_string());
85 }
86
87 fn seg(&self, seg: &str, content: &str) {
89 self.emit_line(
90 &json!({"type": "seg_content", "seg": seg, "content": content}).to_string(),
91 );
92 }
93
94 fn session_origin(&self, task: &str, session_id: &str, created: bool) {
99 self.emit_line(
100 &json!({
101 "type": "session",
102 "task": task,
103 "session_id": session_id,
104 "created": created,
105 })
106 .to_string(),
107 );
108 }
109
110 fn seg_append(&self, seg: &str, content: &str) {
116 self.emit_line(
117 &json!({"type": "seg_append", "seg": seg, "content": content}).to_string(),
118 );
119 }
120
121 fn seg_reset(&self, seg: &str, content: &str) {
126 self.emit_line(
127 &json!({"type": "seg_reset", "seg": seg, "content": content}).to_string(),
128 );
129 }
130
131 fn seg_large(&self, seg: &str, content: &str, chunk: usize) {
137 self.seg(seg, content);
138 if self.supports_incremental() && content.chars().count() > chunk {
139 self.seg_stream(seg, content, chunk);
140 }
141 }
142
143 fn seg_stream(&self, seg: &str, content: &str, chunk: usize) {
148 self.seg_reset(seg, "");
149 let chars: Vec<char> = content.chars().collect();
150 for c in chars.chunks(chunk.max(1)) {
151 let s: String = c.iter().collect();
152 self.seg_append(seg, &s);
153 }
154 }
155
156 fn seg_observation(&self, content: &str, max_chars: usize) {
163 let total = content.chars().count();
164 let truncated = total > max_chars;
165 let display = observation_display(content, max_chars);
166 self.emit_line(
167 &json!({
168 "type": "seg_content",
169 "seg": "observation",
170 "content": display,
171 "truncated": truncated,
172 "total_chars": total,
173 })
174 .to_string(),
175 );
176 if self.supports_incremental() && truncated {
177 self.seg_stream("observation", &display, max_chars.clamp(1, 512));
178 }
179 }
180
181 fn tool_progress(&self, state: &str, name: &str, chars: usize) {
183 self.emit_line(
184 &json!({"type": "tool_progress", "state": state, "name": name, "chars": chars})
185 .to_string(),
186 );
187 }
188
189 fn action(&self, name: &str, args: &str) {
193 if self.is_tty() {
194 let raw = format!("🛠️ Action: {name}\x00Action Input: {args}");
195 self.token_line(&raw);
196 } else {
197 self.emit_line(
198 &json!({"type": "seg_content", "seg": "action", "name": name, "content": args})
199 .to_string(),
200 );
201 }
202 }
203
204 fn delta(&self, seg: &str, text: &str) {
208 if self.is_tty() {
209 self.token_line(text);
210 } else {
211 self.emit_line(&json!({"type": "delta", "seg": seg, "content": text}).to_string());
212 }
213 }
214
215 fn token_line(&self, text: &str) {
217 if self.is_tty() {
218 self.emit_line(text);
219 } else {
220 self.emit_line(&text.replace('\n', "\x00"));
221 }
222 }
223
224 fn done(&self, session_id: &str) {
225 self.emit_line(&json!({"type": "done", "session_id": session_id}).to_string());
226 }
227
228 fn done_result(&self, session_id: &str, status: &str, iterations: u32, final_text: &str) {
231 self.emit_line(
232 &json!({
233 "type": "done",
234 "session_id": session_id,
235 "status": status,
236 "iterations": iterations,
237 "final_text": final_text,
238 })
239 .to_string(),
240 );
241 }
242
243 fn error(&self, message: &str) {
244 self.emit_line(&json!({"type": "error", "message": message}).to_string());
245 }
246}
247
248pub struct RawStdout {
252 tty: bool,
253}
254
255impl RawStdout {
256 pub fn detect() -> Self {
257 let tty = unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 };
258 RawStdout { tty }
259 }
260}
261
262impl EventSink for RawStdout {
263 fn emit_line(&self, line: &str) {
264 let stdout = std::io::stdout();
265 let mut lock = stdout.lock();
266 let _ = writeln!(lock, "{line}");
267 let _ = lock.flush();
268 }
269 fn is_tty(&self) -> bool {
270 self.tty
271 }
272}
273
274#[derive(Default)]
280struct CliSegState {
281 thinking_started: bool,
282 thought_started: bool,
283}
284
285pub struct CliSink {
297 inner: Box<dyn EventSink>,
298 tty: bool,
299 state: Mutex<CliSegState>,
300}
301
302impl CliSink {
303 pub fn new(tty: bool) -> Self {
305 let raw = RawStdout { tty };
306 CliSink {
307 inner: Box::new(raw),
308 tty,
309 state: Mutex::new(CliSegState::default()),
310 }
311 }
312
313 pub fn detect() -> Self {
315 let raw = RawStdout::detect();
316 let tty = raw.is_tty();
317 CliSink {
318 inner: Box::new(raw),
319 tty,
320 state: Mutex::new(CliSegState::default()),
321 }
322 }
323
324 pub fn wrap(inner: Box<dyn EventSink>) -> Self {
326 let tty = inner.is_tty();
327 CliSink {
328 inner,
329 tty,
330 state: Mutex::new(CliSegState::default()),
331 }
332 }
333}
334
335impl EventSink for CliSink {
336 fn emit_line(&self, line: &str) {
337 self.inner.emit_line(line);
338 }
339
340 fn is_tty(&self) -> bool {
341 self.tty
342 }
343
344 fn started(&self, task: &str) {
345 if self.tty {
346 self.emit(&format!("\x1b[34;1m🎯 {task}\x1b[0m"));
347 } else {
348 self.inner.emit_line(&json!({"type": "start", "task": task}).to_string());
349 }
350 }
351
352 fn session(&self, event: &str, _session_id: &str) {
353 if self.tty {
354 let label = if event == "session_created" {
355 "New session"
356 } else {
357 "Switched session"
358 };
359 self.emit(label);
360 } else {
361 self.inner.emit_line(
362 &json!({"type": event, "session_id": _session_id}).to_string(),
363 );
364 }
365 }
366
367 fn session_origin(&self, task: &str, _session_id: &str, created: bool) {
368 if self.tty {
369 let label = if created { "New session" } else { "Switched session" };
370 self.emit(&format!("\x1b[34;1m{label}: {task}\x1b[0m"));
371 } else {
372 self.inner.session_origin(task, _session_id, created);
373 }
374 }
375
376 fn seg_append(&self, seg: &str, content: &str) {
377 if !self.tty {
380 self.inner.seg_append(seg, content);
381 }
382 }
383
384 fn seg_reset(&self, seg: &str, content: &str) {
385 if !self.tty {
386 self.inner.seg_reset(seg, content);
387 }
388 }
389
390 fn seg_large(&self, seg: &str, content: &str, chunk: usize) {
391 if self.tty {
392 self.seg(seg, content);
393 } else {
394 self.inner.seg_large(seg, content, chunk);
395 }
396 }
397
398 fn seg_observation(&self, content: &str, max_chars: usize) {
399 if self.tty {
400 self.seg("observation", content);
403 } else {
404 self.inner.seg_observation(content, max_chars);
405 }
406 }
407
408 fn seg(&self, seg: &str, content: &str) {
409 if self.tty {
410 match seg {
411 "thinking" => {
412 self.state.lock().unwrap().thinking_started = false;
413 }
414 "thought" => {
415 self.state.lock().unwrap().thought_started = false;
416 }
417 "observation" => {
418 if let Ok(v) = serde_json::from_str::<serde_json::Value>(content) {
419 let code = v.get("returncode").and_then(|x| x.as_i64()).unwrap_or(0);
420 let stdout = v.get("stdout").and_then(|x| x.as_str()).unwrap_or("");
421 let stderr = v.get("stderr").and_then(|x| x.as_str()).unwrap_or("");
422 let ok = code == 0;
423 let marker = if ok { "\x1b[32m✓" } else { "\x1b[31m✗" };
424 let sz = stdout.len();
425 self.emit("\x1b[34;1m📋 Observation\x1b[0m");
426 self.emit(&format!(" {marker} exit {code} · {sz} bytes\x1b[0m"));
427 if !stdout.is_empty() {
428 let s: String = stdout.lines().take(20).collect::<Vec<_>>().join("\n");
429 let more = if stdout.lines().count() > 20 { "\n ... (truncated)" } else { "" };
430 self.emit(&format!("{s}{more}"));
431 }
432 if !stderr.is_empty() {
433 self.emit(&format!("\x1b[31m{stderr}\x1b[0m"));
434 }
435 } else {
436 let preview = content.chars().take(600).collect::<String>();
437 self.emit("\x1b[34;1m📋 Observation\x1b[0m");
438 self.emit(&format!("\x1b[36m {preview}\x1b[0m"));
439 }
440 }
441 _ => self.inner.seg(seg, content),
442 }
443 } else {
444 self.inner.seg(seg, content);
445 }
446 }
447
448 fn tool_progress(&self, _state: &str, _name: &str, _chars: usize) {
449 if !self.tty {
453 self.inner.tool_progress(_state, _name, _chars);
454 }
455 }
456
457 fn action(&self, name: &str, args: &str) {
458 if self.tty {
459 let short = if let Ok(v) = serde_json::from_str::<serde_json::Value>(args) {
460 if name == "run_shell" {
461 v.get("command").and_then(|x| x.as_str()).unwrap_or(args).to_string()
462 } else if let Some(desc) = v.get("description").and_then(|x| x.as_str()) {
463 format!("{desc}")
464 } else if let Some(query) = v.get("query").and_then(|x| x.as_str()) {
465 format!("query: {query}")
466 } else {
467 v.as_object()
468 .and_then(|o| o.iter().next())
469 .map(|(k, val)| format!("{k}: {val}"))
470 .unwrap_or_else(|| args.to_string())
471 }
472 } else {
473 args.to_string()
474 };
475 let short = if short.len() > 200 {
476 format!("{}...", &short[..197])
477 } else {
478 short
479 };
480 self.emit(&format!("\n\x1b[34m🛠️ {name}\x1b[0m"));
481 self.emit(&format!(" {short}"));
482 } else {
483 self.inner.action(name, args);
484 }
485 }
486
487 fn delta(&self, seg: &str, text: &str) {
488 if self.tty {
489 let mut state = self.state.lock().unwrap();
490 let stdout = std::io::stdout();
491 let mut lock = stdout.lock();
492 match seg {
493 "thinking" => {
494 if !state.thinking_started {
495 state.thinking_started = true;
496 let _ = writeln!(lock, "\n\x1b[34m💭 Thinking\x1b[0m");
497 let _ = write!(lock, "\x1b[2m");
498 }
499 let _ = write!(lock, "{text}");
500 }
501 _ => {
502 if !state.thought_started {
503 state.thought_started = true;
504 let _ = writeln!(lock, "\x1b[0m");
505 }
506 let _ = write!(lock, "{text}");
507 }
508 }
509 let _ = lock.flush();
510 } else {
511 self.inner.delta(seg, text);
512 }
513 }
514
515 fn token_line(&self, text: &str) {
516 if self.tty {
517 let stdout = std::io::stdout();
518 let mut lock = stdout.lock();
519 let _ = write!(lock, "{text}");
520 let _ = lock.flush();
521 } else {
522 self.inner.token_line(text);
523 }
524 }
525
526 fn done(&self, session_id: &str) {
527 if self.tty {
528 self.emit("\n\x1b[32m✅ Task completed\x1b[0m");
529 } else {
530 self.inner.done(session_id);
531 }
532 let _ = session_id;
533 }
534
535 fn done_result(&self, session_id: &str, _status: &str, _iterations: u32, _final_text: &str) {
536 if self.tty {
537 self.done(session_id);
538 } else {
539 self.inner.done_result(session_id, _status, _iterations, _final_text);
540 }
541 }
542
543 fn error(&self, message: &str) {
544 if self.tty {
545 self.emit(&format!("\n\x1b[31;1m❌ {message}\x1b[0m"));
546 } else {
547 self.inner.error(message);
548 }
549 }
550}
551
552impl CliSink {
553 fn emit(&self, text: &str) {
554 self.inner.emit_line(text);
555 }
556}
557
558pub type StdoutSink = CliSink;
561
562pub struct CollectingSink {
564 lines: std::sync::Mutex<Vec<String>>,
565 tty: bool,
566}
567
568impl CollectingSink {
569 pub fn new(tty: bool) -> Self {
570 CollectingSink {
571 lines: std::sync::Mutex::new(Vec::new()),
572 tty,
573 }
574 }
575
576 pub fn lines(&self) -> Vec<String> {
577 std::mem::take(&mut *self.lines.lock().unwrap())
578 }
579}
580
581impl EventSink for CollectingSink {
582 fn emit_line(&self, line: &str) {
583 self.lines.lock().unwrap().push(line.to_string());
584 }
585 fn is_tty(&self) -> bool {
586 self.tty
587 }
588}
589
590pub struct CallbackSink {
593 cb: Box<dyn Fn(&str) + Send + Sync>,
594 tty: bool,
595}
596
597impl CallbackSink {
598 pub fn new(cb: Box<dyn Fn(&str) + Send + Sync>) -> Self {
599 CallbackSink { cb, tty: false }
600 }
602}
603
604impl EventSink for CallbackSink {
605 fn emit_line(&self, line: &str) {
606 (self.cb)(line);
607 }
608 fn is_tty(&self) -> bool {
609 self.tty
610 }
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616
617 #[test]
618 fn json_events_shapes() {
619 let s = CollectingSink::new(false);
620 s.started("do x");
621 s.session("session_created", "sess1");
622 s.seg("thinking", "hmm");
623 s.seg("observation", "ok");
624 s.tool_progress("building", "run_shell", 42);
625 s.done("sess1");
626 s.error("boom");
627
628 let l = s.lines();
629 assert_eq!(l[0], r#"{"task":"do x","type":"start"}"#);
630 assert!(l[1].contains(r#""type":"session_created""#));
631 assert!(l[2].contains(r#""seg":"thinking""#));
632 assert!(l[4].contains(r#""state":"building""#));
633 assert!(l[4].contains(r#""chars":42"#));
634 assert!(l[5].contains(r#""type":"done""#));
635 assert!(l[6].contains(r#""type":"error""#));
636 }
637
638 #[test]
639 fn session_origin_merges_task_and_session() {
640 let s = CollectingSink::new(false);
641 s.session_origin("do x", "sess1", true);
642 let v: serde_json::Value = serde_json::from_str(&s.lines()[0]).unwrap();
643 assert_eq!(v["type"], "session");
644 assert_eq!(v["task"], "do x");
645 assert_eq!(v["session_id"], "sess1");
646 assert_eq!(v["created"], true);
647
648 let s2 = CollectingSink::new(false);
649 s2.session_origin("do x", "sess1", false);
650 let v2: serde_json::Value = serde_json::from_str(&s2.lines()[0]).unwrap();
651 assert_eq!(v2["created"], false);
652 }
653
654 #[test]
655 fn incremental_events_gated_off_for_legacy_sinks() {
656 let s = CollectingSink::new(false);
657 s.seg_large("thought", "x".repeat(2000).as_str(), 512);
660 let l = s.lines();
661 assert_eq!(l.len(), 1);
662 assert!(l[0].contains(r#""type":"seg_content""#));
663 assert!(!l[0].contains("seg_reset"));
664 assert!(!l[0].contains("seg_append"));
665 }
666
667 #[test]
668 fn incremental_events_emitted_on_opt_in() {
669 let s = CollectingSink::new(true);
670 assert!(!s.supports_incremental(), "legacy CollectingSink must not opt in");
671 struct Inc(CollectingSink);
673 impl EventSink for Inc {
674 fn emit_line(&self, line: &str) {
675 self.0.emit_line(line);
676 }
677 fn supports_incremental(&self) -> bool {
678 true
679 }
680 }
681 let inc = Inc(CollectingSink::new(false));
682 inc.seg_large("thought", "x".repeat(2000).as_str(), 512);
683 let l = inc.0.lines();
684 assert!(l[0].contains(r#""type":"seg_content""#));
686 assert!(l.iter().any(|x| x.contains(r#""type":"seg_reset""#)));
687 assert!(l.iter().any(|x| x.contains(r#""type":"seg_append""#)));
688 }
689
690 #[test]
691 fn seg_observation_carries_truncation_metadata() {
692 let s = CollectingSink::new(false);
693 let big = "z".repeat(5000);
694 s.seg_observation(&big, 3000);
695 let all = s.lines();
696 assert_eq!(all.len(), 1, "legacy sink must emit only the seg_content line");
697 let v: serde_json::Value = serde_json::from_str(&all[0]).unwrap();
698 assert_eq!(v["type"], "seg_content");
699 assert_eq!(v["seg"], "observation");
700 assert_eq!(v["truncated"], true);
701 assert_eq!(v["total_chars"], 5000);
702 let content = v["content"].as_str().unwrap();
704 assert!(content.len() < 5000);
705 }
706
707 #[test]
708 fn seg_observation_no_truncation_flag_when_small() {
709 let s = CollectingSink::new(false);
710 s.seg_observation("short ok", 3000);
711 let v: serde_json::Value = serde_json::from_str(&s.lines()[0]).unwrap();
712 assert_eq!(v["truncated"], false);
713 assert_eq!(v["total_chars"], 8);
714 assert_eq!(v["content"], "short ok");
715 }
716
717 #[test]
718 fn observation_display_matches_seg_observation() {
719 assert_eq!(observation_display("short ok", 3000), "short ok");
721
722 let big = "z".repeat(5000);
724 let display = observation_display(&big, 3000);
725 assert!(display.len() < 5000, "display must be capped");
726 assert!(display.contains("Display truncated"), "truncation notice present");
727 assert!(display.contains("5000 chars total"), "total chars reported");
728
729 let s = CollectingSink::new(false);
731 s.seg_observation(&big, 3000);
732 let v: serde_json::Value = serde_json::from_str(&s.lines()[0]).unwrap();
733 assert_eq!(v["content"].as_str().unwrap(), display);
734 }
735
736 #[test]
737 fn done_result_carries_status_iterations_final_text() {
738 let s = CollectingSink::new(false);
739 s.done_result("sess1", "completed", 3, "all done");
740 let v: serde_json::Value = serde_json::from_str(&s.lines()[0]).unwrap();
741 assert_eq!(v["type"], "done");
742 assert_eq!(v["session_id"], "sess1");
743 assert_eq!(v["status"], "completed");
744 assert_eq!(v["iterations"], 3);
745 assert_eq!(v["final_text"], "all done");
746 }
747
748 #[test]
749 fn token_escapes_newline_in_pipe_mode() {
750 let s = CollectingSink::new(false);
751 s.token_line("line1\nline2");
752 assert_eq!(s.lines()[0], "line1\x00line2");
753 }
754
755 #[test]
756 fn token_keeps_newline_in_tty_mode() {
757 let s = CollectingSink::new(true);
758 s.token_line("line1\nline2");
759 assert_eq!(s.lines()[0], "line1\nline2");
760 }
761
762 #[test]
763 fn action_json_in_pipe_mode() {
764 let s = CollectingSink::new(false);
765 s.action("run_shell", "{\"command\":\"ls\"}");
766 let v: serde_json::Value = serde_json::from_str(&s.lines()[0]).unwrap();
767 assert_eq!(v["type"], "seg_content");
768 assert_eq!(v["seg"], "action");
769 assert_eq!(v["name"], "run_shell");
770 }
771
772 #[test]
773 fn action_raw_in_tty_mode() {
774 let s = CollectingSink::new(true);
775 s.action("run_shell", "{\"command\":\"ls\"}");
776 let out = &s.lines()[0];
777 assert!(out.starts_with("🛠️ Action: run_shell"));
778 assert!(!out.starts_with('{'));
779 }
780
781 #[test]
782 fn delta_json_in_pipe_mode() {
783 let s = CollectingSink::new(false);
784 s.delta("thinking", "hm\nmm");
785 let v: serde_json::Value = serde_json::from_str(&s.lines()[0]).unwrap();
786 assert_eq!(v["type"], "delta");
787 assert_eq!(v["seg"], "thinking");
788 assert_eq!(v["content"], "hm\nmm");
790 }
791
792 #[test]
793 fn delta_raw_in_tty_mode() {
794 let s = CollectingSink::new(true);
795 s.delta("thought", "hi");
796 assert_eq!(s.lines()[0], "hi");
797 }
798
799 #[test]
800 fn tty_cli_does_not_emit_jsonl_segments() {
801 let cs = CollectingSink::new(false);
804 let _s = CliSink::new(true);
805 let cs2 = CollectingSink::new(true);
809 let _ = cs2;
812 let _ = cs;
813 }
814}