1use serde_json::{json, Value};
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 runtime_info(&self) {
186 if self.is_tty() {
187 return;
188 }
189 let exe = std::env::current_exe()
190 .ok()
191 .map(|p| p.to_string_lossy().to_string());
192 let mut ev = json!({
193 "type": "runtime_info",
194 "name": "aacode-rs",
195 "version": env!("CARGO_PKG_VERSION"),
196 "platform": std::env::consts::OS,
197 "arch": std::env::consts::ARCH,
198 });
199 if let Some(exe) = exe {
200 ev["file"] = Value::String(exe);
201 }
202 self.emit_line(&ev.to_string());
203 }
204
205 fn tool_status(
209 &self,
210 call_id: &str,
211 tool: &str,
212 status: &str,
213 duration_ms: u64,
214 error_code: Option<&str>,
215 error_message: Option<&str>,
216 ) {
217 if self.is_tty() {
219 return;
220 }
221 let mut event = json!({
222 "type": "tool_status",
223 "call_id": call_id,
224 "tool": tool,
225 "status": status,
226 "duration_ms": duration_ms,
227 });
228 if let Some(code) = error_code {
229 event["error_code"] = Value::String(code.to_string());
230 }
231 if let Some(msg) = error_message {
232 let clipped: String = msg.chars().take(500).collect();
233 event["error_message"] = Value::String(clipped);
234 }
235 self.emit_line(&event.to_string());
236 }
237
238 fn action(&self, name: &str, args: &str) {
242 if self.is_tty() {
243 let raw = format!("🛠️ Action: {name}\x00Action Input: {args}");
244 self.token_line(&raw);
245 } else {
246 self.emit_line(
247 &json!({"type": "seg_content", "seg": "action", "name": name, "content": args})
248 .to_string(),
249 );
250 }
251 }
252
253 fn delta(&self, seg: &str, text: &str) {
257 if self.is_tty() {
258 self.token_line(text);
259 } else {
260 self.emit_line(&json!({"type": "delta", "seg": seg, "content": text}).to_string());
261 }
262 }
263
264 fn token_line(&self, text: &str) {
266 if self.is_tty() {
267 self.emit_line(text);
268 } else {
269 self.emit_line(&text.replace('\n', "\x00"));
270 }
271 }
272
273 fn done(&self, session_id: &str) {
274 self.emit_line(&json!({"type": "done", "session_id": session_id}).to_string());
275 }
276
277 fn done_result(&self, session_id: &str, status: &str, iterations: u32, final_text: &str) {
280 self.emit_line(
281 &json!({
282 "type": "done",
283 "session_id": session_id,
284 "status": status,
285 "iterations": iterations,
286 "final_text": final_text,
287 })
288 .to_string(),
289 );
290 }
291
292 fn error(&self, message: &str) {
293 self.emit_line(&json!({"type": "error", "message": message}).to_string());
294 }
295}
296
297pub struct RawStdout {
301 tty: bool,
302}
303
304impl RawStdout {
305 pub fn detect() -> Self {
306 let tty = unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 };
307 RawStdout { tty }
308 }
309}
310
311impl EventSink for RawStdout {
312 fn emit_line(&self, line: &str) {
313 let stdout = std::io::stdout();
314 let mut lock = stdout.lock();
315 let _ = writeln!(lock, "{line}");
316 let _ = lock.flush();
317 }
318 fn is_tty(&self) -> bool {
319 self.tty
320 }
321}
322
323#[derive(Default)]
329struct CliSegState {
330 thinking_started: bool,
331 thought_started: bool,
332}
333
334pub struct CliSink {
346 inner: Box<dyn EventSink>,
347 tty: bool,
348 state: Mutex<CliSegState>,
349}
350
351impl CliSink {
352 pub fn new(tty: bool) -> Self {
354 let raw = RawStdout { tty };
355 CliSink {
356 inner: Box::new(raw),
357 tty,
358 state: Mutex::new(CliSegState::default()),
359 }
360 }
361
362 pub fn detect() -> Self {
364 let raw = RawStdout::detect();
365 let tty = raw.is_tty();
366 CliSink {
367 inner: Box::new(raw),
368 tty,
369 state: Mutex::new(CliSegState::default()),
370 }
371 }
372
373 pub fn wrap(inner: Box<dyn EventSink>) -> Self {
375 let tty = inner.is_tty();
376 CliSink {
377 inner,
378 tty,
379 state: Mutex::new(CliSegState::default()),
380 }
381 }
382}
383
384impl EventSink for CliSink {
385 fn emit_line(&self, line: &str) {
386 self.inner.emit_line(line);
387 }
388
389 fn is_tty(&self) -> bool {
390 self.tty
391 }
392
393 fn started(&self, task: &str) {
394 if self.tty {
395 self.emit(&format!("\x1b[34;1m🎯 {task}\x1b[0m"));
396 } else {
397 self.inner
398 .emit_line(&json!({"type": "start", "task": task}).to_string());
399 }
400 }
401
402 fn session(&self, event: &str, _session_id: &str) {
403 if self.tty {
404 let label = if event == "session_created" {
405 "New session"
406 } else {
407 "Switched session"
408 };
409 self.emit(label);
410 } else {
411 self.inner
412 .emit_line(&json!({"type": event, "session_id": _session_id}).to_string());
413 }
414 }
415
416 fn session_origin(&self, task: &str, _session_id: &str, created: bool) {
417 if self.tty {
418 let label = if created {
419 "New session"
420 } else {
421 "Switched session"
422 };
423 self.emit(&format!("\x1b[34;1m{label}: {task}\x1b[0m"));
424 } else {
425 self.inner.session_origin(task, _session_id, created);
426 }
427 }
428
429 fn seg_append(&self, seg: &str, content: &str) {
430 if !self.tty {
433 self.inner.seg_append(seg, content);
434 }
435 }
436
437 fn seg_reset(&self, seg: &str, content: &str) {
438 if !self.tty {
439 self.inner.seg_reset(seg, content);
440 }
441 }
442
443 fn seg_large(&self, seg: &str, content: &str, chunk: usize) {
444 if self.tty {
445 self.seg(seg, content);
446 } else {
447 self.inner.seg_large(seg, content, chunk);
448 }
449 }
450
451 fn seg_observation(&self, content: &str, max_chars: usize) {
452 if self.tty {
453 self.seg("observation", content);
456 } else {
457 self.inner.seg_observation(content, max_chars);
458 }
459 }
460
461 fn seg(&self, seg: &str, content: &str) {
462 if self.tty {
463 match seg {
464 "thinking" => {
465 self.state.lock().unwrap().thinking_started = false;
466 }
467 "thought" => {
468 self.state.lock().unwrap().thought_started = false;
469 }
470 "observation" => {
471 if let Ok(v) = serde_json::from_str::<serde_json::Value>(content) {
472 let code = v.get("returncode").and_then(|x| x.as_i64()).unwrap_or(0);
473 let stdout = v.get("stdout").and_then(|x| x.as_str()).unwrap_or("");
474 let stderr = v.get("stderr").and_then(|x| x.as_str()).unwrap_or("");
475 let ok = code == 0;
476 let marker = if ok { "\x1b[32m✓" } else { "\x1b[31m✗" };
477 let sz = stdout.len();
478 self.emit("\x1b[34;1m📋 Observation\x1b[0m");
479 self.emit(&format!(" {marker} exit {code} · {sz} bytes\x1b[0m"));
480 if !stdout.is_empty() {
481 let s: String = stdout.lines().take(20).collect::<Vec<_>>().join("\n");
482 let more = if stdout.lines().count() > 20 {
483 "\n ... (truncated)"
484 } else {
485 ""
486 };
487 self.emit(&format!("{s}{more}"));
488 }
489 if !stderr.is_empty() {
490 self.emit(&format!("\x1b[31m{stderr}\x1b[0m"));
491 }
492 } else {
493 let preview = content.chars().take(600).collect::<String>();
494 self.emit("\x1b[34;1m📋 Observation\x1b[0m");
495 self.emit(&format!("\x1b[36m {preview}\x1b[0m"));
496 }
497 }
498 _ => self.inner.seg(seg, content),
499 }
500 } else {
501 self.inner.seg(seg, content);
502 }
503 }
504
505 fn tool_progress(&self, _state: &str, _name: &str, _chars: usize) {
506 if !self.tty {
510 self.inner.tool_progress(_state, _name, _chars);
511 }
512 }
513
514 fn action(&self, name: &str, args: &str) {
515 if self.tty {
516 let short = if let Ok(v) = serde_json::from_str::<serde_json::Value>(args) {
517 if name == "run_shell" {
518 v.get("command")
519 .and_then(|x| x.as_str())
520 .unwrap_or(args)
521 .to_string()
522 } else if let Some(desc) = v.get("description").and_then(|x| x.as_str()) {
523 format!("{desc}")
524 } else if let Some(query) = v.get("query").and_then(|x| x.as_str()) {
525 format!("query: {query}")
526 } else {
527 v.as_object()
528 .and_then(|o| o.iter().next())
529 .map(|(k, val)| format!("{k}: {val}"))
530 .unwrap_or_else(|| args.to_string())
531 }
532 } else {
533 args.to_string()
534 };
535 let short = if short.len() > 200 {
536 format!("{}...", &short[..197])
537 } else {
538 short
539 };
540 self.emit(&format!("\n\x1b[34m🛠️ {name}\x1b[0m"));
541 self.emit(&format!(" {short}"));
542 } else {
543 self.inner.action(name, args);
544 }
545 }
546
547 fn delta(&self, seg: &str, text: &str) {
548 if self.tty {
549 let mut state = self.state.lock().unwrap();
550 let stdout = std::io::stdout();
551 let mut lock = stdout.lock();
552 match seg {
553 "thinking" => {
554 if !state.thinking_started {
555 state.thinking_started = true;
556 let _ = writeln!(lock, "\n\x1b[34m💭 Thinking\x1b[0m");
557 let _ = write!(lock, "\x1b[2m");
558 }
559 let _ = write!(lock, "{text}");
560 }
561 _ => {
562 if !state.thought_started {
563 state.thought_started = true;
564 let _ = writeln!(lock, "\x1b[0m");
565 }
566 let _ = write!(lock, "{text}");
567 }
568 }
569 let _ = lock.flush();
570 } else {
571 self.inner.delta(seg, text);
572 }
573 }
574
575 fn token_line(&self, text: &str) {
576 if self.tty {
577 let stdout = std::io::stdout();
578 let mut lock = stdout.lock();
579 let _ = write!(lock, "{text}");
580 let _ = lock.flush();
581 } else {
582 self.inner.token_line(text);
583 }
584 }
585
586 fn done(&self, session_id: &str) {
587 if self.tty {
588 self.emit("\n\x1b[32m✅ Task completed\x1b[0m");
589 } else {
590 self.inner.done(session_id);
591 }
592 let _ = session_id;
593 }
594
595 fn done_result(&self, session_id: &str, _status: &str, _iterations: u32, _final_text: &str) {
596 if self.tty {
597 self.done(session_id);
598 } else {
599 self.inner
600 .done_result(session_id, _status, _iterations, _final_text);
601 }
602 }
603
604 fn error(&self, message: &str) {
605 if self.tty {
606 self.emit(&format!("\n\x1b[31;1m❌ {message}\x1b[0m"));
607 } else {
608 self.inner.error(message);
609 }
610 }
611}
612
613impl CliSink {
614 fn emit(&self, text: &str) {
615 self.inner.emit_line(text);
616 }
617}
618
619pub type StdoutSink = CliSink;
622
623pub struct CollectingSink {
625 lines: std::sync::Mutex<Vec<String>>,
626 tty: bool,
627}
628
629impl CollectingSink {
630 pub fn new(tty: bool) -> Self {
631 CollectingSink {
632 lines: std::sync::Mutex::new(Vec::new()),
633 tty,
634 }
635 }
636
637 pub fn lines(&self) -> Vec<String> {
638 std::mem::take(&mut *self.lines.lock().unwrap())
639 }
640}
641
642impl EventSink for CollectingSink {
643 fn emit_line(&self, line: &str) {
644 self.lines.lock().unwrap().push(line.to_string());
645 }
646 fn is_tty(&self) -> bool {
647 self.tty
648 }
649}
650
651pub struct CallbackSink {
654 cb: Box<dyn Fn(&str) + Send + Sync>,
655 tty: bool,
656}
657
658impl CallbackSink {
659 pub fn new(cb: Box<dyn Fn(&str) + Send + Sync>) -> Self {
660 CallbackSink { cb, tty: false }
661 }
663}
664
665impl EventSink for CallbackSink {
666 fn emit_line(&self, line: &str) {
667 (self.cb)(line);
668 }
669 fn is_tty(&self) -> bool {
670 self.tty
671 }
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677
678 #[test]
679 fn json_events_shapes() {
680 let s = CollectingSink::new(false);
681 s.started("do x");
682 s.session("session_created", "sess1");
683 s.seg("thinking", "hmm");
684 s.seg("observation", "ok");
685 s.tool_progress("building", "run_shell", 42);
686 s.done("sess1");
687 s.error("boom");
688
689 let l = s.lines();
690 assert_eq!(l[0], r#"{"task":"do x","type":"start"}"#);
691 assert!(l[1].contains(r#""type":"session_created""#));
692 assert!(l[2].contains(r#""seg":"thinking""#));
693 assert!(l[4].contains(r#""state":"building""#));
694 assert!(l[4].contains(r#""chars":42"#));
695 assert!(l[5].contains(r#""type":"done""#));
696 assert!(l[6].contains(r#""type":"error""#));
697 }
698
699 #[test]
700 fn tool_status_and_runtime_info_events() {
701 let s = CollectingSink::new(false);
702 s.runtime_info();
703 s.tool_status(
704 "c1",
705 "run_shell",
706 "error",
707 12,
708 Some("timeout"),
709 Some("boom"),
710 );
711 let lines = s.lines();
712 let rt: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
713 assert_eq!(rt["type"], "runtime_info");
714 assert_eq!(rt["version"], env!("CARGO_PKG_VERSION"));
715 let ts: serde_json::Value = serde_json::from_str(&lines[1]).unwrap();
716 assert_eq!(ts["type"], "tool_status");
717 assert_eq!(ts["status"], "error");
718 assert_eq!(ts["error_code"], "timeout");
719 assert_eq!(ts["duration_ms"], 12);
720 assert_eq!(ts["error_message"], "boom");
721 }
722
723 #[test]
724 fn session_origin_merges_task_and_session() {
725 let s = CollectingSink::new(false);
726 s.session_origin("do x", "sess1", true);
727 let v: serde_json::Value = serde_json::from_str(&s.lines()[0]).unwrap();
728 assert_eq!(v["type"], "session");
729 assert_eq!(v["task"], "do x");
730 assert_eq!(v["session_id"], "sess1");
731 assert_eq!(v["created"], true);
732
733 let s2 = CollectingSink::new(false);
734 s2.session_origin("do x", "sess1", false);
735 let v2: serde_json::Value = serde_json::from_str(&s2.lines()[0]).unwrap();
736 assert_eq!(v2["created"], false);
737 }
738
739 #[test]
740 fn incremental_events_gated_off_for_legacy_sinks() {
741 let s = CollectingSink::new(false);
742 s.seg_large("thought", "x".repeat(2000).as_str(), 512);
745 let l = s.lines();
746 assert_eq!(l.len(), 1);
747 assert!(l[0].contains(r#""type":"seg_content""#));
748 assert!(!l[0].contains("seg_reset"));
749 assert!(!l[0].contains("seg_append"));
750 }
751
752 #[test]
753 fn incremental_events_emitted_on_opt_in() {
754 let s = CollectingSink::new(true);
755 assert!(
756 !s.supports_incremental(),
757 "legacy CollectingSink must not opt in"
758 );
759 struct Inc(CollectingSink);
761 impl EventSink for Inc {
762 fn emit_line(&self, line: &str) {
763 self.0.emit_line(line);
764 }
765 fn supports_incremental(&self) -> bool {
766 true
767 }
768 }
769 let inc = Inc(CollectingSink::new(false));
770 inc.seg_large("thought", "x".repeat(2000).as_str(), 512);
771 let l = inc.0.lines();
772 assert!(l[0].contains(r#""type":"seg_content""#));
774 assert!(l.iter().any(|x| x.contains(r#""type":"seg_reset""#)));
775 assert!(l.iter().any(|x| x.contains(r#""type":"seg_append""#)));
776 }
777
778 #[test]
779 fn seg_observation_carries_truncation_metadata() {
780 let s = CollectingSink::new(false);
781 let big = "z".repeat(5000);
782 s.seg_observation(&big, 3000);
783 let all = s.lines();
784 assert_eq!(
785 all.len(),
786 1,
787 "legacy sink must emit only the seg_content line"
788 );
789 let v: serde_json::Value = serde_json::from_str(&all[0]).unwrap();
790 assert_eq!(v["type"], "seg_content");
791 assert_eq!(v["seg"], "observation");
792 assert_eq!(v["truncated"], true);
793 assert_eq!(v["total_chars"], 5000);
794 let content = v["content"].as_str().unwrap();
796 assert!(content.len() < 5000);
797 }
798
799 #[test]
800 fn seg_observation_no_truncation_flag_when_small() {
801 let s = CollectingSink::new(false);
802 s.seg_observation("short ok", 3000);
803 let v: serde_json::Value = serde_json::from_str(&s.lines()[0]).unwrap();
804 assert_eq!(v["truncated"], false);
805 assert_eq!(v["total_chars"], 8);
806 assert_eq!(v["content"], "short ok");
807 }
808
809 #[test]
810 fn observation_display_matches_seg_observation() {
811 assert_eq!(observation_display("short ok", 3000), "short ok");
813
814 let big = "z".repeat(5000);
816 let display = observation_display(&big, 3000);
817 assert!(display.len() < 5000, "display must be capped");
818 assert!(
819 display.contains("Display truncated"),
820 "truncation notice present"
821 );
822 assert!(display.contains("5000 chars total"), "total chars reported");
823
824 let s = CollectingSink::new(false);
826 s.seg_observation(&big, 3000);
827 let v: serde_json::Value = serde_json::from_str(&s.lines()[0]).unwrap();
828 assert_eq!(v["content"].as_str().unwrap(), display);
829 }
830
831 #[test]
832 fn done_result_carries_status_iterations_final_text() {
833 let s = CollectingSink::new(false);
834 s.done_result("sess1", "completed", 3, "all done");
835 let v: serde_json::Value = serde_json::from_str(&s.lines()[0]).unwrap();
836 assert_eq!(v["type"], "done");
837 assert_eq!(v["session_id"], "sess1");
838 assert_eq!(v["status"], "completed");
839 assert_eq!(v["iterations"], 3);
840 assert_eq!(v["final_text"], "all done");
841 }
842
843 #[test]
844 fn token_escapes_newline_in_pipe_mode() {
845 let s = CollectingSink::new(false);
846 s.token_line("line1\nline2");
847 assert_eq!(s.lines()[0], "line1\x00line2");
848 }
849
850 #[test]
851 fn token_keeps_newline_in_tty_mode() {
852 let s = CollectingSink::new(true);
853 s.token_line("line1\nline2");
854 assert_eq!(s.lines()[0], "line1\nline2");
855 }
856
857 #[test]
858 fn action_json_in_pipe_mode() {
859 let s = CollectingSink::new(false);
860 s.action("run_shell", "{\"command\":\"ls\"}");
861 let v: serde_json::Value = serde_json::from_str(&s.lines()[0]).unwrap();
862 assert_eq!(v["type"], "seg_content");
863 assert_eq!(v["seg"], "action");
864 assert_eq!(v["name"], "run_shell");
865 }
866
867 #[test]
868 fn action_raw_in_tty_mode() {
869 let s = CollectingSink::new(true);
870 s.action("run_shell", "{\"command\":\"ls\"}");
871 let out = &s.lines()[0];
872 assert!(out.starts_with("🛠️ Action: run_shell"));
873 assert!(!out.starts_with('{'));
874 }
875
876 #[test]
877 fn delta_json_in_pipe_mode() {
878 let s = CollectingSink::new(false);
879 s.delta("thinking", "hm\nmm");
880 let v: serde_json::Value = serde_json::from_str(&s.lines()[0]).unwrap();
881 assert_eq!(v["type"], "delta");
882 assert_eq!(v["seg"], "thinking");
883 assert_eq!(v["content"], "hm\nmm");
885 }
886
887 #[test]
888 fn delta_raw_in_tty_mode() {
889 let s = CollectingSink::new(true);
890 s.delta("thought", "hi");
891 assert_eq!(s.lines()[0], "hi");
892 }
893
894 #[test]
895 fn tty_cli_does_not_emit_jsonl_segments() {
896 let cs = CollectingSink::new(false);
899 let _s = CliSink::new(true);
900 let cs2 = CollectingSink::new(true);
904 let _ = cs2;
907 let _ = cs;
908 }
909}