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