Skip to main content

monoloop_testkit/
console.rs

1//! Append-only console renderer for canonical events (test adapter).
2
3use monoloop_contracts::{
4    CanonicalUnit, CanonicalUnitEvent, InterpretationEnd, InterpreterOutputEvent, TextChannel,
5    ToolRequestState, UnitState,
6};
7use std::sync::Arc;
8use tokio::sync::mpsc;
9
10/// Configuration for the console renderer.
11#[derive(Clone, Debug)]
12pub struct ConsoleRendererConfig {
13    /// Include paragraph/structure verbose lines.
14    pub verbose: bool,
15    /// Show tool request payloads (bounded; default false).
16    pub show_tool_payloads: bool,
17    /// Maximum content characters per line (escape + truncate).
18    pub max_content_chars: usize,
19}
20
21impl Default for ConsoleRendererConfig {
22    fn default() -> Self {
23        Self {
24            verbose: false,
25            show_tool_payloads: false,
26            max_content_chars: 500,
27        }
28    }
29}
30
31/// Sink for rendered lines.
32pub trait ConsoleSink: Send + Sync {
33    /// Write one complete record (already includes newline).
34    fn write_line(&self, line: &str);
35}
36
37/// Sync-friendly memory sink.
38#[derive(Clone, Default)]
39pub struct SyncMemorySink {
40    lines: Arc<std::sync::Mutex<Vec<String>>>,
41}
42
43impl SyncMemorySink {
44    /// Create empty sink.
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// Snapshot.
50    pub fn lines(&self) -> Vec<String> {
51        self.lines.lock().expect("sink").clone()
52    }
53
54    /// Joined output.
55    pub fn join(&self) -> String {
56        self.lines.lock().expect("sink").join("")
57    }
58}
59
60impl ConsoleSink for SyncMemorySink {
61    fn write_line(&self, line: &str) {
62        self.lines.lock().expect("sink").push(line.to_string());
63    }
64}
65
66/// Stdout sink (serialized).
67pub struct StdoutSink;
68
69impl ConsoleSink for StdoutSink {
70    fn write_line(&self, line: &str) {
71        print!("{line}");
72        let _ = std::io::Write::flush(&mut std::io::stdout());
73    }
74}
75
76/// One logical rendered record (before TTY wrapping).
77#[derive(Clone, Debug, PartialEq, Eq)]
78pub struct ConsoleRenderRecord {
79    /// Full line including trailing newline.
80    pub line: String,
81}
82
83/// Passive-only console renderer.
84pub struct ConsoleRenderer {
85    config: ConsoleRendererConfig,
86    sink: Arc<dyn ConsoleSink>,
87}
88
89impl ConsoleRenderer {
90    /// Create a renderer.
91    pub fn new(config: ConsoleRendererConfig, sink: Arc<dyn ConsoleSink>) -> Self {
92        Self { config, sink }
93    }
94
95    /// Render one interpreter output event immediately.
96    pub fn render(&self, event: &InterpreterOutputEvent) -> ConsoleRenderRecord {
97        let line = match event {
98            InterpreterOutputEvent::Unit(u) => self.format_unit(u),
99            InterpreterOutputEvent::Ended(end) => self.format_end(end),
100        };
101        self.sink.write_line(&line);
102        ConsoleRenderRecord { line }
103    }
104
105    /// Spawn a task that consumes a channel of events until Ended (inclusive).
106    pub fn spawn_consumer(
107        self: &Arc<Self>,
108        mut rx: mpsc::Receiver<InterpreterOutputEvent>,
109    ) -> tokio::task::JoinHandle<()> {
110        let this = Arc::clone(self);
111        tokio::spawn(async move {
112            while let Some(ev) = rx.recv().await {
113                let is_end = matches!(ev, InterpreterOutputEvent::Ended(_));
114                this.render(&ev);
115                if is_end {
116                    break;
117                }
118            }
119        })
120    }
121
122    fn format_unit(&self, event: &CanonicalUnitEvent) -> String {
123        let s = event.snapshot();
124        let corr = format!(
125            "[c:{} i:{} f:{} l:{} u:{} g:{}]",
126            short_id(s.connection_id.as_str()),
127            short_id(s.interpretation_id.as_str()),
128            s.flow_id.as_str(),
129            s.lane_id.as_str(),
130            short_id(s.unit_id.as_str()),
131            s.unit_generation,
132        );
133        let (kind_state, label, content) = match &s.unit {
134            CanonicalUnit::Text(t) => {
135                let state = unit_state_label(s.unit_state);
136                (
137                    format!("text/{state}"),
138                    t.channel.label().to_string(),
139                    escape_content(&t.content, self.config.max_content_chars),
140                )
141            }
142            CanonicalUnit::Tool(t) => {
143                let state = tool_state_label(t);
144                let name = t.tool_name.as_deref().unwrap_or("?");
145                let mut content = t.waiting_for.clone().unwrap_or_else(|| state.clone());
146                if self.config.show_tool_payloads {
147                    if let Some(ref p) = t.request_payload {
148                        content.push_str(" args=");
149                        content.push_str(&escape_content(p, self.config.max_content_chars));
150                    }
151                }
152                (
153                    format!("tool/{state}"),
154                    name.to_string(),
155                    escape_content(&content, self.config.max_content_chars),
156                )
157            }
158            CanonicalUnit::Boundary(b) => (
159                "boundary/complete".into(),
160                format!("{:?}", b.kind),
161                String::new(),
162            ),
163            CanonicalUnit::Diagnostic(d) => (
164                "diagnostic".into(),
165                format!("{:?}", d.kind),
166                escape_content(&d.message, self.config.max_content_chars),
167            ),
168            CanonicalUnit::Structure(st) => (
169                "structure/complete".into(),
170                format!("{:?}", st.kind),
171                escape_content(&st.content, self.config.max_content_chars),
172            ),
173            CanonicalUnit::Paragraph(p) => (
174                format!("paragraph/{:?}", p.kind),
175                TextChannel::PublicResponse.label().into(),
176                String::new(),
177            ),
178            CanonicalUnit::Usage(u) => ("usage".into(), "tokens".into(), format!("{u:?}")),
179        };
180        format!("{corr} {kind_state} {label} {content}\n")
181    }
182
183    fn format_end(&self, end: &InterpretationEnd) -> String {
184        format!(
185            "[c:{} i:{}] interpretation/{:?} events={} sentences={} unresolved_bytes={}\n",
186            short_id(end.connection_id.as_str()),
187            short_id(end.interpretation_id.as_str()),
188            end.kind,
189            end.canonical_event_count,
190            end.completed_sentence_count,
191            end.unresolved_text_bytes,
192        )
193    }
194}
195
196fn tool_state_label(t: &monoloop_contracts::ToolActionEvent) -> String {
197    if t.terminal_outcome.is_some() {
198        return "complete".into();
199    }
200    match t.request_state {
201        ToolRequestState::Ready => "ready".into(),
202        ToolRequestState::Assembling => "waiting".into(),
203        ToolRequestState::Incomplete => "incomplete".into(),
204        ToolRequestState::Malformed => "malformed".into(),
205    }
206}
207
208fn unit_state_label(s: UnitState) -> &'static str {
209    match s {
210        UnitState::Complete => "complete",
211        UnitState::Waiting => "waiting",
212        UnitState::Incomplete => "incomplete",
213        UnitState::Malformed => "malformed",
214    }
215}
216
217fn short_id(id: &str) -> &str {
218    if id.len() <= 8 {
219        id
220    } else {
221        &id[..8]
222    }
223}
224
225fn escape_content(s: &str, max: usize) -> String {
226    let mut out = String::with_capacity(s.len().min(max) + 8);
227    for (i, ch) in s.chars().enumerate() {
228        if i >= max {
229            out.push('…');
230            break;
231        }
232        match ch {
233            '\n' => out.push_str("\\n"),
234            '\r' => out.push_str("\\r"),
235            '\t' => out.push_str("\\t"),
236            '\u{1b}' => out.push_str("\\e"),
237            c if c.is_control() => out.push_str(&format!("\\u{{{:x}}}", c as u32)),
238            c => out.push(c),
239        }
240    }
241    out
242}