use serde_json::json;
use std::io::Write;
use std::sync::Mutex;
pub fn observation_display(content: &str, max_chars: usize) -> String {
let total = content.chars().count();
let truncated = total > max_chars;
if truncated {
let head: String = content.chars().take(max_chars).collect();
format!(
"{head}...\n\n(Display truncated, {total} chars total. Agent received full content.)"
)
} else {
content.to_string()
}
}
pub trait EventSink: Send + Sync {
fn emit_line(&self, line: &str);
fn is_tty(&self) -> bool {
false
}
fn supports_incremental(&self) -> bool {
false
}
fn started(&self, task: &str) {
self.emit_line(&json!({"type": "start", "task": task}).to_string());
}
fn session(&self, event: &str, session_id: &str) {
self.emit_line(&json!({"type": event, "session_id": session_id}).to_string());
}
fn seg(&self, seg: &str, content: &str) {
self.emit_line(&json!({"type": "seg_content", "seg": seg, "content": content}).to_string());
}
fn session_origin(&self, task: &str, session_id: &str, created: bool) {
self.emit_line(
&json!({
"type": "session",
"task": task,
"session_id": session_id,
"created": created,
})
.to_string(),
);
}
fn seg_append(&self, seg: &str, content: &str) {
self.emit_line(&json!({"type": "seg_append", "seg": seg, "content": content}).to_string());
}
fn seg_reset(&self, seg: &str, content: &str) {
self.emit_line(&json!({"type": "seg_reset", "seg": seg, "content": content}).to_string());
}
fn seg_large(&self, seg: &str, content: &str, chunk: usize) {
self.seg(seg, content);
if self.supports_incremental() && content.chars().count() > chunk {
self.seg_stream(seg, content, chunk);
}
}
fn seg_stream(&self, seg: &str, content: &str, chunk: usize) {
self.seg_reset(seg, "");
let chars: Vec<char> = content.chars().collect();
for c in chars.chunks(chunk.max(1)) {
let s: String = c.iter().collect();
self.seg_append(seg, &s);
}
}
fn seg_observation(&self, content: &str, max_chars: usize) {
let total = content.chars().count();
let truncated = total > max_chars;
let display = observation_display(content, max_chars);
self.emit_line(
&json!({
"type": "seg_content",
"seg": "observation",
"content": display,
"truncated": truncated,
"total_chars": total,
})
.to_string(),
);
if self.supports_incremental() && truncated {
self.seg_stream("observation", &display, max_chars.clamp(1, 512));
}
}
fn tool_progress(&self, state: &str, name: &str, chars: usize) {
self.emit_line(
&json!({"type": "tool_progress", "state": state, "name": name, "chars": chars})
.to_string(),
);
}
fn action(&self, name: &str, args: &str) {
if self.is_tty() {
let raw = format!("🛠️ Action: {name}\x00Action Input: {args}");
self.token_line(&raw);
} else {
self.emit_line(
&json!({"type": "seg_content", "seg": "action", "name": name, "content": args})
.to_string(),
);
}
}
fn delta(&self, seg: &str, text: &str) {
if self.is_tty() {
self.token_line(text);
} else {
self.emit_line(&json!({"type": "delta", "seg": seg, "content": text}).to_string());
}
}
fn token_line(&self, text: &str) {
if self.is_tty() {
self.emit_line(text);
} else {
self.emit_line(&text.replace('\n', "\x00"));
}
}
fn done(&self, session_id: &str) {
self.emit_line(&json!({"type": "done", "session_id": session_id}).to_string());
}
fn done_result(&self, session_id: &str, status: &str, iterations: u32, final_text: &str) {
self.emit_line(
&json!({
"type": "done",
"session_id": session_id,
"status": status,
"iterations": iterations,
"final_text": final_text,
})
.to_string(),
);
}
fn error(&self, message: &str) {
self.emit_line(&json!({"type": "error", "message": message}).to_string());
}
}
pub struct RawStdout {
tty: bool,
}
impl RawStdout {
pub fn detect() -> Self {
let tty = unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 };
RawStdout { tty }
}
}
impl EventSink for RawStdout {
fn emit_line(&self, line: &str) {
let stdout = std::io::stdout();
let mut lock = stdout.lock();
let _ = writeln!(lock, "{line}");
let _ = lock.flush();
}
fn is_tty(&self) -> bool {
self.tty
}
}
#[derive(Default)]
struct CliSegState {
thinking_started: bool,
thought_started: bool,
}
pub struct CliSink {
inner: Box<dyn EventSink>,
tty: bool,
state: Mutex<CliSegState>,
}
impl CliSink {
pub fn new(tty: bool) -> Self {
let raw = RawStdout { tty };
CliSink {
inner: Box::new(raw),
tty,
state: Mutex::new(CliSegState::default()),
}
}
pub fn detect() -> Self {
let raw = RawStdout::detect();
let tty = raw.is_tty();
CliSink {
inner: Box::new(raw),
tty,
state: Mutex::new(CliSegState::default()),
}
}
pub fn wrap(inner: Box<dyn EventSink>) -> Self {
let tty = inner.is_tty();
CliSink {
inner,
tty,
state: Mutex::new(CliSegState::default()),
}
}
}
impl EventSink for CliSink {
fn emit_line(&self, line: &str) {
self.inner.emit_line(line);
}
fn is_tty(&self) -> bool {
self.tty
}
fn started(&self, task: &str) {
if self.tty {
self.emit(&format!("\x1b[34;1m🎯 {task}\x1b[0m"));
} else {
self.inner
.emit_line(&json!({"type": "start", "task": task}).to_string());
}
}
fn session(&self, event: &str, _session_id: &str) {
if self.tty {
let label = if event == "session_created" {
"New session"
} else {
"Switched session"
};
self.emit(label);
} else {
self.inner
.emit_line(&json!({"type": event, "session_id": _session_id}).to_string());
}
}
fn session_origin(&self, task: &str, _session_id: &str, created: bool) {
if self.tty {
let label = if created {
"New session"
} else {
"Switched session"
};
self.emit(&format!("\x1b[34;1m{label}: {task}\x1b[0m"));
} else {
self.inner.session_origin(task, _session_id, created);
}
}
fn seg_append(&self, seg: &str, content: &str) {
if !self.tty {
self.inner.seg_append(seg, content);
}
}
fn seg_reset(&self, seg: &str, content: &str) {
if !self.tty {
self.inner.seg_reset(seg, content);
}
}
fn seg_large(&self, seg: &str, content: &str, chunk: usize) {
if self.tty {
self.seg(seg, content);
} else {
self.inner.seg_large(seg, content, chunk);
}
}
fn seg_observation(&self, content: &str, max_chars: usize) {
if self.tty {
self.seg("observation", content);
} else {
self.inner.seg_observation(content, max_chars);
}
}
fn seg(&self, seg: &str, content: &str) {
if self.tty {
match seg {
"thinking" => {
self.state.lock().unwrap().thinking_started = false;
}
"thought" => {
self.state.lock().unwrap().thought_started = false;
}
"observation" => {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(content) {
let code = v.get("returncode").and_then(|x| x.as_i64()).unwrap_or(0);
let stdout = v.get("stdout").and_then(|x| x.as_str()).unwrap_or("");
let stderr = v.get("stderr").and_then(|x| x.as_str()).unwrap_or("");
let ok = code == 0;
let marker = if ok { "\x1b[32m✓" } else { "\x1b[31m✗" };
let sz = stdout.len();
self.emit("\x1b[34;1m📋 Observation\x1b[0m");
self.emit(&format!(" {marker} exit {code} · {sz} bytes\x1b[0m"));
if !stdout.is_empty() {
let s: String = stdout.lines().take(20).collect::<Vec<_>>().join("\n");
let more = if stdout.lines().count() > 20 {
"\n ... (truncated)"
} else {
""
};
self.emit(&format!("{s}{more}"));
}
if !stderr.is_empty() {
self.emit(&format!("\x1b[31m{stderr}\x1b[0m"));
}
} else {
let preview = content.chars().take(600).collect::<String>();
self.emit("\x1b[34;1m📋 Observation\x1b[0m");
self.emit(&format!("\x1b[36m {preview}\x1b[0m"));
}
}
_ => self.inner.seg(seg, content),
}
} else {
self.inner.seg(seg, content);
}
}
fn tool_progress(&self, _state: &str, _name: &str, _chars: usize) {
if !self.tty {
self.inner.tool_progress(_state, _name, _chars);
}
}
fn action(&self, name: &str, args: &str) {
if self.tty {
let short = if let Ok(v) = serde_json::from_str::<serde_json::Value>(args) {
if name == "run_shell" {
v.get("command")
.and_then(|x| x.as_str())
.unwrap_or(args)
.to_string()
} else if let Some(desc) = v.get("description").and_then(|x| x.as_str()) {
format!("{desc}")
} else if let Some(query) = v.get("query").and_then(|x| x.as_str()) {
format!("query: {query}")
} else {
v.as_object()
.and_then(|o| o.iter().next())
.map(|(k, val)| format!("{k}: {val}"))
.unwrap_or_else(|| args.to_string())
}
} else {
args.to_string()
};
let short = if short.len() > 200 {
format!("{}...", &short[..197])
} else {
short
};
self.emit(&format!("\n\x1b[34m🛠️ {name}\x1b[0m"));
self.emit(&format!(" {short}"));
} else {
self.inner.action(name, args);
}
}
fn delta(&self, seg: &str, text: &str) {
if self.tty {
let mut state = self.state.lock().unwrap();
let stdout = std::io::stdout();
let mut lock = stdout.lock();
match seg {
"thinking" => {
if !state.thinking_started {
state.thinking_started = true;
let _ = writeln!(lock, "\n\x1b[34m💭 Thinking\x1b[0m");
let _ = write!(lock, "\x1b[2m");
}
let _ = write!(lock, "{text}");
}
_ => {
if !state.thought_started {
state.thought_started = true;
let _ = writeln!(lock, "\x1b[0m");
}
let _ = write!(lock, "{text}");
}
}
let _ = lock.flush();
} else {
self.inner.delta(seg, text);
}
}
fn token_line(&self, text: &str) {
if self.tty {
let stdout = std::io::stdout();
let mut lock = stdout.lock();
let _ = write!(lock, "{text}");
let _ = lock.flush();
} else {
self.inner.token_line(text);
}
}
fn done(&self, session_id: &str) {
if self.tty {
self.emit("\n\x1b[32m✅ Task completed\x1b[0m");
} else {
self.inner.done(session_id);
}
let _ = session_id;
}
fn done_result(&self, session_id: &str, _status: &str, _iterations: u32, _final_text: &str) {
if self.tty {
self.done(session_id);
} else {
self.inner
.done_result(session_id, _status, _iterations, _final_text);
}
}
fn error(&self, message: &str) {
if self.tty {
self.emit(&format!("\n\x1b[31;1m❌ {message}\x1b[0m"));
} else {
self.inner.error(message);
}
}
}
impl CliSink {
fn emit(&self, text: &str) {
self.inner.emit_line(text);
}
}
pub type StdoutSink = CliSink;
pub struct CollectingSink {
lines: std::sync::Mutex<Vec<String>>,
tty: bool,
}
impl CollectingSink {
pub fn new(tty: bool) -> Self {
CollectingSink {
lines: std::sync::Mutex::new(Vec::new()),
tty,
}
}
pub fn lines(&self) -> Vec<String> {
std::mem::take(&mut *self.lines.lock().unwrap())
}
}
impl EventSink for CollectingSink {
fn emit_line(&self, line: &str) {
self.lines.lock().unwrap().push(line.to_string());
}
fn is_tty(&self) -> bool {
self.tty
}
}
pub struct CallbackSink {
cb: Box<dyn Fn(&str) + Send + Sync>,
tty: bool,
}
impl CallbackSink {
pub fn new(cb: Box<dyn Fn(&str) + Send + Sync>) -> Self {
CallbackSink { cb, tty: false }
}
}
impl EventSink for CallbackSink {
fn emit_line(&self, line: &str) {
(self.cb)(line);
}
fn is_tty(&self) -> bool {
self.tty
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_events_shapes() {
let s = CollectingSink::new(false);
s.started("do x");
s.session("session_created", "sess1");
s.seg("thinking", "hmm");
s.seg("observation", "ok");
s.tool_progress("building", "run_shell", 42);
s.done("sess1");
s.error("boom");
let l = s.lines();
assert_eq!(l[0], r#"{"task":"do x","type":"start"}"#);
assert!(l[1].contains(r#""type":"session_created""#));
assert!(l[2].contains(r#""seg":"thinking""#));
assert!(l[4].contains(r#""state":"building""#));
assert!(l[4].contains(r#""chars":42"#));
assert!(l[5].contains(r#""type":"done""#));
assert!(l[6].contains(r#""type":"error""#));
}
#[test]
fn session_origin_merges_task_and_session() {
let s = CollectingSink::new(false);
s.session_origin("do x", "sess1", true);
let v: serde_json::Value = serde_json::from_str(&s.lines()[0]).unwrap();
assert_eq!(v["type"], "session");
assert_eq!(v["task"], "do x");
assert_eq!(v["session_id"], "sess1");
assert_eq!(v["created"], true);
let s2 = CollectingSink::new(false);
s2.session_origin("do x", "sess1", false);
let v2: serde_json::Value = serde_json::from_str(&s2.lines()[0]).unwrap();
assert_eq!(v2["created"], false);
}
#[test]
fn incremental_events_gated_off_for_legacy_sinks() {
let s = CollectingSink::new(false);
s.seg_large("thought", "x".repeat(2000).as_str(), 512);
let l = s.lines();
assert_eq!(l.len(), 1);
assert!(l[0].contains(r#""type":"seg_content""#));
assert!(!l[0].contains("seg_reset"));
assert!(!l[0].contains("seg_append"));
}
#[test]
fn incremental_events_emitted_on_opt_in() {
let s = CollectingSink::new(true);
assert!(
!s.supports_incremental(),
"legacy CollectingSink must not opt in"
);
struct Inc(CollectingSink);
impl EventSink for Inc {
fn emit_line(&self, line: &str) {
self.0.emit_line(line);
}
fn supports_incremental(&self) -> bool {
true
}
}
let inc = Inc(CollectingSink::new(false));
inc.seg_large("thought", "x".repeat(2000).as_str(), 512);
let l = inc.0.lines();
assert!(l[0].contains(r#""type":"seg_content""#));
assert!(l.iter().any(|x| x.contains(r#""type":"seg_reset""#)));
assert!(l.iter().any(|x| x.contains(r#""type":"seg_append""#)));
}
#[test]
fn seg_observation_carries_truncation_metadata() {
let s = CollectingSink::new(false);
let big = "z".repeat(5000);
s.seg_observation(&big, 3000);
let all = s.lines();
assert_eq!(
all.len(),
1,
"legacy sink must emit only the seg_content line"
);
let v: serde_json::Value = serde_json::from_str(&all[0]).unwrap();
assert_eq!(v["type"], "seg_content");
assert_eq!(v["seg"], "observation");
assert_eq!(v["truncated"], true);
assert_eq!(v["total_chars"], 5000);
let content = v["content"].as_str().unwrap();
assert!(content.len() < 5000);
}
#[test]
fn seg_observation_no_truncation_flag_when_small() {
let s = CollectingSink::new(false);
s.seg_observation("short ok", 3000);
let v: serde_json::Value = serde_json::from_str(&s.lines()[0]).unwrap();
assert_eq!(v["truncated"], false);
assert_eq!(v["total_chars"], 8);
assert_eq!(v["content"], "short ok");
}
#[test]
fn observation_display_matches_seg_observation() {
assert_eq!(observation_display("short ok", 3000), "short ok");
let big = "z".repeat(5000);
let display = observation_display(&big, 3000);
assert!(display.len() < 5000, "display must be capped");
assert!(
display.contains("Display truncated"),
"truncation notice present"
);
assert!(display.contains("5000 chars total"), "total chars reported");
let s = CollectingSink::new(false);
s.seg_observation(&big, 3000);
let v: serde_json::Value = serde_json::from_str(&s.lines()[0]).unwrap();
assert_eq!(v["content"].as_str().unwrap(), display);
}
#[test]
fn done_result_carries_status_iterations_final_text() {
let s = CollectingSink::new(false);
s.done_result("sess1", "completed", 3, "all done");
let v: serde_json::Value = serde_json::from_str(&s.lines()[0]).unwrap();
assert_eq!(v["type"], "done");
assert_eq!(v["session_id"], "sess1");
assert_eq!(v["status"], "completed");
assert_eq!(v["iterations"], 3);
assert_eq!(v["final_text"], "all done");
}
#[test]
fn token_escapes_newline_in_pipe_mode() {
let s = CollectingSink::new(false);
s.token_line("line1\nline2");
assert_eq!(s.lines()[0], "line1\x00line2");
}
#[test]
fn token_keeps_newline_in_tty_mode() {
let s = CollectingSink::new(true);
s.token_line("line1\nline2");
assert_eq!(s.lines()[0], "line1\nline2");
}
#[test]
fn action_json_in_pipe_mode() {
let s = CollectingSink::new(false);
s.action("run_shell", "{\"command\":\"ls\"}");
let v: serde_json::Value = serde_json::from_str(&s.lines()[0]).unwrap();
assert_eq!(v["type"], "seg_content");
assert_eq!(v["seg"], "action");
assert_eq!(v["name"], "run_shell");
}
#[test]
fn action_raw_in_tty_mode() {
let s = CollectingSink::new(true);
s.action("run_shell", "{\"command\":\"ls\"}");
let out = &s.lines()[0];
assert!(out.starts_with("🛠️ Action: run_shell"));
assert!(!out.starts_with('{'));
}
#[test]
fn delta_json_in_pipe_mode() {
let s = CollectingSink::new(false);
s.delta("thinking", "hm\nmm");
let v: serde_json::Value = serde_json::from_str(&s.lines()[0]).unwrap();
assert_eq!(v["type"], "delta");
assert_eq!(v["seg"], "thinking");
assert_eq!(v["content"], "hm\nmm");
}
#[test]
fn delta_raw_in_tty_mode() {
let s = CollectingSink::new(true);
s.delta("thought", "hi");
assert_eq!(s.lines()[0], "hi");
}
#[test]
fn tty_cli_does_not_emit_jsonl_segments() {
let cs = CollectingSink::new(false);
let _s = CliSink::new(true);
let cs2 = CollectingSink::new(true);
let _ = cs2;
let _ = cs;
}
}