#[cfg(test)]
use std::sync::Arc;
use std::{
env,
fs::{self, OpenOptions},
io::{BufWriter, Write},
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::Serialize;
use serde_json::Value;
use super::projection::ProjectionEventRecord;
use crate::presentation::{PresentationItem, Renderable, RenderedCell, TimedItem};
const PRESENTATION_LOG_ENV: &str = "HOLON_TUI_PRESENTATION_LOG";
const PRESENTATION_LOG_MAX_BYTES_ENV: &str = "HOLON_TUI_PRESENTATION_LOG_MAX_BYTES";
const DEFAULT_PRESENTATION_LOG_MAX_BYTES: u64 = 5 * 1024 * 1024;
const MAX_REDUCER_EVENT_SUMMARIES: usize = 100;
#[derive(Debug, Clone)]
pub(crate) struct TuiLogWriter {
root: PathBuf,
presentation_logging_enabled: bool,
presentation_log_max_bytes: u64,
#[cfg(test)]
_tempdir: Option<Arc<tempfile::TempDir>>,
}
impl TuiLogWriter {
pub(crate) fn new(log_root: impl Into<PathBuf>) -> Result<Self> {
let root = log_root.into().join("tui");
fs::create_dir_all(&root)
.with_context(|| format!("failed to create {}", root.display()))?;
Ok(Self {
root,
presentation_logging_enabled: presentation_logging_enabled_from_env(),
presentation_log_max_bytes: presentation_log_max_bytes_from_env(),
#[cfg(test)]
_tempdir: None,
})
}
#[cfg(test)]
pub(crate) fn new_temp() -> Result<Self> {
let tempdir = Arc::new(tempfile::tempdir()?);
let root = tempdir.path().join("logs").join("tui");
fs::create_dir_all(&root)
.with_context(|| format!("failed to create {}", root.display()))?;
Ok(Self {
root,
presentation_logging_enabled: false,
presentation_log_max_bytes: DEFAULT_PRESENTATION_LOG_MAX_BYTES,
_tempdir: Some(tempdir),
})
}
#[cfg(test)]
pub(crate) fn new_temp_with_presentation_logging(max_bytes: u64) -> Result<Self> {
let mut writer = Self::new_temp()?;
writer.presentation_logging_enabled = true;
writer.presentation_log_max_bytes = max_bytes;
Ok(writer)
}
#[cfg(test)]
pub(crate) fn root(&self) -> &Path {
&self.root
}
pub(crate) fn write_event(&self, event: &ProjectionEventRecord) -> Result<()> {
if is_error_log_event(&event.kind) {
append_jsonl(
&self.root.join("errors.jsonl"),
&PersistedTuiEventLogRecord::from_event(event),
)?;
}
if is_turn_log_event(&event.kind) {
append_jsonl(
&self.root.join("turns.jsonl"),
&PersistedTuiEventLogRecord::from_event(event),
)?;
}
Ok(())
}
pub(crate) fn write_presentation_items(
&self,
reducer_events: &[ProjectionEventRecord],
items: &[TimedItem],
) -> Result<()> {
if !self.presentation_logging_enabled || items.is_empty() {
return Ok(());
}
let path = self.root.join("presentation.jsonl");
let lines = items
.iter()
.map(|item| {
serde_json::to_string(&PersistedPresentationLogRecord::from_timed_item(
reducer_events,
item,
))
})
.collect::<Result<Vec<_>, _>>()?;
append_bounded_jsonl_lines(&path, lines.as_slice(), self.presentation_log_max_bytes)?;
Ok(())
}
}
#[derive(Debug, Serialize)]
struct PersistedTuiEventLogRecord<'a> {
ts: DateTime<Utc>,
event_id: &'a str,
event_seq: u64,
kind: &'a str,
summary: &'a str,
payload: &'a Value,
}
impl<'a> PersistedTuiEventLogRecord<'a> {
fn from_event(event: &'a ProjectionEventRecord) -> Self {
Self {
ts: event.ts,
event_id: &event.id,
event_seq: event.event_seq,
kind: &event.kind,
summary: &event.summary,
payload: &event.payload,
}
}
}
#[derive(Debug, Serialize)]
struct PersistedPresentationLogRecord<'a> {
ts: DateTime<Utc>,
item_kind: &'static str,
min_display_level: u8,
reducer_event_ids: Vec<&'a str>,
reducer_event_kinds: Vec<&'a str>,
reducer_event_seqs: Vec<u64>,
reducer_event_summaries: Vec<&'a str>,
displays: Vec<PersistedDisplayRecord>,
#[serde(skip_serializing_if = "Option::is_none")]
reducer_event_summaries_truncated: Option<String>,
}
impl<'a> PersistedPresentationLogRecord<'a> {
fn from_timed_item(reducer_events: &'a [ProjectionEventRecord], item: &TimedItem) -> Self {
Self {
ts: item.ts,
item_kind: presentation_item_kind(&item.item),
min_display_level: item.item.min_display_level(),
reducer_event_ids: if MAX_REDUCER_EVENT_SUMMARIES == 0 {
Vec::new()
} else {
reducer_events
.iter()
.take(MAX_REDUCER_EVENT_SUMMARIES)
.map(|e| e.id.as_str())
.collect()
},
reducer_event_kinds: if MAX_REDUCER_EVENT_SUMMARIES == 0 {
Vec::new()
} else {
reducer_events
.iter()
.take(MAX_REDUCER_EVENT_SUMMARIES)
.map(|e| e.kind.as_str())
.collect()
},
reducer_event_seqs: if MAX_REDUCER_EVENT_SUMMARIES == 0 {
Vec::new()
} else {
reducer_events
.iter()
.take(MAX_REDUCER_EVENT_SUMMARIES)
.map(|e| e.event_seq)
.collect()
},
reducer_event_summaries: if MAX_REDUCER_EVENT_SUMMARIES == 0 {
Vec::new()
} else {
reducer_events
.iter()
.take(MAX_REDUCER_EVENT_SUMMARIES)
.map(|e| e.summary.as_str())
.collect()
},
reducer_event_summaries_truncated: if MAX_REDUCER_EVENT_SUMMARIES > 0
&& reducer_events.len() > MAX_REDUCER_EVENT_SUMMARIES
{
Some(format!(
"...and {} more",
reducer_events.len() - MAX_REDUCER_EVENT_SUMMARIES
))
} else {
None
},
displays: [3, 4, 5]
.into_iter()
.map(|display_level| PersistedDisplayRecord::from_item(display_level, &item.item))
.collect(),
}
}
}
#[derive(Debug, Serialize)]
struct PersistedDisplayRecord {
display_level: u8,
decision: &'static str,
cells: Vec<PersistedRenderedCell>,
}
impl PersistedDisplayRecord {
fn from_item(display_level: u8, item: &PresentationItem) -> Self {
let cells = item.render(display_level);
Self {
display_level,
decision: if cells.is_empty() { "hidden" } else { "shown" },
cells: cells
.iter()
.map(PersistedRenderedCell::from_rendered_cell)
.collect(),
}
}
}
#[derive(Debug, Serialize)]
struct PersistedRenderedCell {
speaker: String,
body_preview: String,
body_char_count: usize,
body_line_count: usize,
is_live: bool,
indent_level: u8,
}
impl PersistedRenderedCell {
fn from_rendered_cell(cell: &RenderedCell) -> Self {
Self {
speaker: cell.speaker.clone(),
body_preview: preview_text(&cell.body, 512),
body_char_count: cell.body.chars().count(),
body_line_count: cell.body_lines.len(),
is_live: cell.is_live,
indent_level: cell.indent_level,
}
}
}
fn preview_text(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
let mut preview = text
.chars()
.take(max_chars.saturating_sub(1))
.collect::<String>();
preview.push('…');
preview
}
fn presentation_item_kind(item: &PresentationItem) -> &'static str {
match item {
PresentationItem::UserMessage { .. } => "user_message",
PresentationItem::AssistantResult { .. } => "assistant_result",
PresentationItem::SystemAlert { .. } => "system_alert",
PresentationItem::WaitingNotice { .. } => "waiting_notice",
PresentationItem::WorkItemCard { .. } => "work_item_card",
PresentationItem::AssistantProgress { .. } => "assistant_progress",
PresentationItem::ActionGroup { .. } => "action_group",
PresentationItem::CommandExecuted { .. } => "command_executed",
PresentationItem::FileRead { .. } => "file_read",
PresentationItem::FileChange { .. } => "file_change",
PresentationItem::PlanShown { .. } => "plan_shown",
PresentationItem::ProviderRound { .. } => "provider_round",
PresentationItem::InternalTransition { .. } => "internal_transition",
PresentationItem::TaskLifecycle { .. } => "task_lifecycle",
PresentationItem::WorkItemBookkeeping { .. } => "work_item_bookkeeping",
PresentationItem::WorkspaceChange { .. } => "workspace_change",
PresentationItem::ContinuationDetail { .. } => "continuation_detail",
PresentationItem::GenericEvent { .. } => "generic_event",
}
}
fn is_error_log_event(kind: &str) -> bool {
matches!(kind, "runtime_error")
}
fn is_turn_log_event(kind: &str) -> bool {
matches!(kind, "turn_terminal")
}
fn presentation_logging_enabled_from_env() -> bool {
env::var(PRESENTATION_LOG_ENV)
.ok()
.is_some_and(|value| matches!(value.as_str(), "1" | "true" | "yes" | "on" | "debug"))
}
fn presentation_log_max_bytes_from_env() -> u64 {
env::var(PRESENTATION_LOG_MAX_BYTES_ENV)
.ok()
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| *value > 0)
.unwrap_or(DEFAULT_PRESENTATION_LOG_MAX_BYTES)
}
fn append_jsonl<T: Serialize>(path: &Path, value: &T) -> Result<()> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("failed to open {}", path.display()))?;
let line = serde_json::to_string(value)?;
writeln!(file, "{line}")?;
Ok(())
}
fn append_bounded_jsonl_lines(path: &Path, lines: &[String], max_bytes: u64) -> Result<()> {
let mut current_size = path.metadata().map(|metadata| metadata.len()).unwrap_or(0);
let mut writer: Option<BufWriter<std::fs::File>> = None;
for line in lines {
let incoming_bytes = line.len() as u64 + 1;
if incoming_bytes > max_bytes {
continue;
}
if current_size.saturating_add(incoming_bytes) > max_bytes {
if let Some(mut open_writer) = writer.take() {
open_writer.flush()?;
}
rotate_jsonl(path)?;
current_size = 0;
}
if writer.is_none() {
writer = Some(BufWriter::new(
OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("failed to open {}", path.display()))?,
));
}
if let Some(open_writer) = writer.as_mut() {
writeln!(open_writer, "{line}")?;
}
current_size += incoming_bytes;
}
if let Some(mut open_writer) = writer {
open_writer.flush()?;
}
Ok(())
}
fn rotate_jsonl(path: &Path) -> Result<()> {
if !path.exists() {
return Ok(());
}
let rotated = path.with_extension("jsonl.1");
if rotated.exists() {
fs::remove_file(&rotated)
.with_context(|| format!("failed to remove {}", rotated.display()))?;
}
fs::rename(path, &rotated).with_context(|| {
format!(
"failed to rotate {} to {}",
path.display(),
rotated.display()
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::operator_event::{
OperatorEventCategory, OperatorEventPresentation, OperatorVisibility,
};
use crate::presentation::Outcome;
use crate::tui::projection::{ProjectionEventLane, ProjectionEventRecord};
use serde_json::json;
#[test]
fn new_uses_log_root_not_agent_root() {
let tempdir = tempfile::tempdir().unwrap();
let log_root = tempdir.path().join("logs");
let writer = TuiLogWriter::new(&log_root).unwrap();
assert_eq!(writer.root, log_root.join("tui"));
assert!(writer.root.exists());
assert!(
!tempdir
.path()
.join("agents")
.join("logs")
.join("tui")
.exists(),
"TUI diagnostics must not create a pseudo agent under agents/logs"
);
}
#[test]
fn presentation_log_is_disabled_by_default() {
let writer = TuiLogWriter::new_temp().unwrap();
let event = ProjectionEventRecord {
id: "evt_1".into(),
event_seq: 7,
ts: Utc::now(),
kind: "brief_created".into(),
lane: ProjectionEventLane::Timeline,
summary: "completed work".into(),
presentation: OperatorEventPresentation {
visibility: OperatorVisibility::TurnResult,
category: OperatorEventCategory::Brief,
title: "Holon".into(),
body: Some("completed work".into()),
summary: "completed work".into(),
source_event_kind: "brief_created".into(),
},
payload: json!({ "id": "brief_1", "text": "completed work" }),
};
let item = TimedItem::with_key(
PresentationItem::AssistantResult {
brief_id: Some("brief_1".into()),
body: "completed work".into(),
outcome: Outcome::Success,
},
event.ts,
"test-item",
);
writer
.write_event(&event)
.and_then(|_| writer.write_presentation_items(std::slice::from_ref(&event), &[item]))
.unwrap();
assert!(
!writer.root.join("presentation.jsonl").exists(),
"presentation logging is debug instrumentation and must default off"
);
}
#[test]
fn presentation_log_records_display_decisions_without_raw_conversation_log_when_enabled() {
let writer = TuiLogWriter::new_temp_with_presentation_logging(4096).unwrap();
let event = ProjectionEventRecord {
id: "evt_1".into(),
event_seq: 7,
ts: Utc::now(),
kind: "brief_created".into(),
lane: ProjectionEventLane::Timeline,
summary: "completed work".into(),
presentation: OperatorEventPresentation {
visibility: OperatorVisibility::TurnResult,
category: OperatorEventCategory::Brief,
title: "Holon".into(),
body: Some("completed work".into()),
summary: "completed work".into(),
source_event_kind: "brief_created".into(),
},
payload: json!({ "id": "brief_1", "text": "completed work" }),
};
let item = TimedItem::with_key(
PresentationItem::AssistantResult {
brief_id: Some("brief_1".into()),
body: "completed work".into(),
outcome: Outcome::Success,
},
event.ts,
"test-item",
);
writer
.write_event(&event)
.and_then(|_| writer.write_presentation_items(std::slice::from_ref(&event), &[item]))
.unwrap();
let presentation_path = writer.root.join("presentation.jsonl");
let line = fs::read_to_string(&presentation_path).unwrap();
let record: Value = serde_json::from_str(line.trim()).unwrap();
assert_eq!(record["item_kind"], "assistant_result");
assert_eq!(record["min_display_level"], 3);
assert_eq!(record["reducer_event_ids"], json!(["evt_1"]));
assert_eq!(record["displays"][0]["display_level"], 3);
assert_eq!(record["displays"][0]["decision"], "shown");
assert_eq!(record["displays"][0]["cells"][0]["speaker"], "Holon");
assert_eq!(
record["displays"][0]["cells"][0]["body_preview"],
"✓ completed work"
);
assert!(
!writer.root.join("conversation.jsonl").exists(),
"TUI should not duplicate raw conversation events"
);
}
#[test]
fn presentation_log_marks_lower_display_levels_hidden() {
let writer = TuiLogWriter::new_temp_with_presentation_logging(4096).unwrap();
let event = ProjectionEventRecord {
id: "evt_debug".into(),
event_seq: 9,
ts: Utc::now(),
kind: "provider_round_completed".into(),
lane: ProjectionEventLane::Debug,
summary: "provider completed".into(),
presentation: OperatorEventPresentation {
visibility: OperatorVisibility::Trace,
category: OperatorEventCategory::Trace,
title: "Provider".into(),
body: None,
summary: "provider completed".into(),
source_event_kind: "provider_round_completed".into(),
},
payload: json!({ "model": "test-model" }),
};
let item = TimedItem::with_key(
PresentationItem::GenericEvent {
kind: "provider_round_completed".into(),
summary: "provider completed".into(),
},
event.ts,
"test-item",
);
writer
.write_presentation_items(std::slice::from_ref(&event), &[item])
.unwrap();
let line = fs::read_to_string(writer.root.join("presentation.jsonl")).unwrap();
let record: Value = serde_json::from_str(line.trim()).unwrap();
assert_eq!(record["min_display_level"], 5);
assert_eq!(record["displays"][0]["decision"], "hidden");
assert_eq!(record["displays"][1]["decision"], "hidden");
assert_eq!(record["displays"][2]["decision"], "shown");
}
#[test]
fn presentation_log_rotates_when_enabled_debug_log_reaches_limit() {
let writer = TuiLogWriter::new_temp_with_presentation_logging(1800).unwrap();
let event = ProjectionEventRecord {
id: "evt_debug".into(),
event_seq: 9,
ts: Utc::now(),
kind: "provider_round_completed".into(),
lane: ProjectionEventLane::Debug,
summary: "provider completed".into(),
presentation: OperatorEventPresentation {
visibility: OperatorVisibility::Trace,
category: OperatorEventCategory::Trace,
title: "Provider".into(),
body: None,
summary: "provider completed".into(),
source_event_kind: "provider_round_completed".into(),
},
payload: json!({ "model": "test-model" }),
};
let item = TimedItem::with_key(
PresentationItem::GenericEvent {
kind: "provider_round_completed".into(),
summary: "provider completed".into(),
},
event.ts,
"test-item",
);
for _ in 0..8 {
writer
.write_presentation_items(std::slice::from_ref(&event), std::slice::from_ref(&item))
.unwrap();
}
let path = writer.root.join("presentation.jsonl");
let rotated = writer.root.join("presentation.jsonl.1");
assert!(path.exists());
assert!(rotated.exists());
assert!(path.metadata().unwrap().len() <= writer.presentation_log_max_bytes);
assert!(rotated.metadata().unwrap().len() <= writer.presentation_log_max_bytes);
}
#[test]
fn presentation_log_drops_single_records_larger_than_limit() {
let writer = TuiLogWriter::new_temp_with_presentation_logging(128).unwrap();
let event = ProjectionEventRecord {
id: "evt_large".into(),
event_seq: 9,
ts: Utc::now(),
kind: "provider_round_completed".into(),
lane: ProjectionEventLane::Debug,
summary: "provider completed".repeat(100),
presentation: OperatorEventPresentation {
visibility: OperatorVisibility::Trace,
category: OperatorEventCategory::Trace,
title: "Provider".into(),
body: None,
summary: "provider completed".repeat(100),
source_event_kind: "provider_round_completed".into(),
},
payload: json!({ "model": "test-model" }),
};
let item = TimedItem::with_key(
PresentationItem::GenericEvent {
kind: "provider_round_completed".into(),
summary: "provider completed".repeat(100),
},
event.ts,
"test-item",
);
writer
.write_presentation_items(std::slice::from_ref(&event), std::slice::from_ref(&item))
.unwrap();
assert!(!writer.root.join("presentation.jsonl").exists());
}
#[test]
fn turn_terminal_records_are_routed_to_turns_not_errors() {
let writer = TuiLogWriter::new_temp().unwrap();
let event = ProjectionEventRecord {
id: "evt_terminal".into(),
event_seq: 10,
ts: Utc::now(),
kind: "turn_terminal".into(),
lane: ProjectionEventLane::Debug,
summary: "turn completed".into(),
presentation: OperatorEventPresentation {
visibility: OperatorVisibility::Trace,
category: OperatorEventCategory::Trace,
title: "Turn".into(),
body: None,
summary: "turn completed".into(),
source_event_kind: "turn_terminal".into(),
},
payload: json!({ "kind": "completed" }),
};
writer.write_event(&event).unwrap();
assert!(!writer.root.join("errors.jsonl").exists());
let line = fs::read_to_string(writer.root.join("turns.jsonl")).unwrap();
let record: Value = serde_json::from_str(line.trim()).unwrap();
assert_eq!(record["kind"], "turn_terminal");
}
#[test]
fn runtime_errors_are_routed_to_errors_not_turns() {
let writer = TuiLogWriter::new_temp().unwrap();
let event = ProjectionEventRecord {
id: "evt_error".into(),
event_seq: 11,
ts: Utc::now(),
kind: "runtime_error".into(),
lane: ProjectionEventLane::Debug,
summary: "runtime failed".into(),
presentation: OperatorEventPresentation {
visibility: OperatorVisibility::Trace,
category: OperatorEventCategory::Trace,
title: "Runtime".into(),
body: None,
summary: "runtime failed".into(),
source_event_kind: "runtime_error".into(),
},
payload: json!({ "error": "failed" }),
};
writer.write_event(&event).unwrap();
assert!(!writer.root.join("turns.jsonl").exists());
let line = fs::read_to_string(writer.root.join("errors.jsonl")).unwrap();
let record: Value = serde_json::from_str(line.trim()).unwrap();
assert_eq!(record["kind"], "runtime_error");
}
}