Skip to main content

board_watch/
watch.rs

1//! The driver: one line in, one run out, and everything the client assembled
2//! printed as it arrives.
3//!
4//! Generic over its input and output rather than wired to `stdin`/`stdout`, so
5//! the integration tests drive *this* code with a scripted script and assert on
6//! the transcript. A client whose printing is only exercised by a human is a
7//! client whose printing breaks quietly.
8
9use std::io::{self, BufRead, Write};
10
11use ag_ui::client::interrupts::ResumeBuilder;
12use ag_ui::client::transport::Transport;
13use ag_ui::client::{
14    InterruptExt as _, MessageChangeKind, MessageUpdate, ReasoningChangeKind, RunEnd, RunStream,
15    Session, SubagentChangeKind, Update,
16};
17use ag_ui::{Interrupt, Message, MessageId, ResumeEntry, ToolCallId};
18use futures_util::StreamExt as _;
19use serde_json::json;
20
21use crate::board::Board;
22use crate::view;
23
24/// How wide a tool result is allowed to print.
25const CLIP: usize = 88;
26
27/// What to do when a run pauses.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub enum Policy {
30    /// Ask the person at the keyboard.
31    #[default]
32    Ask,
33    /// Approve everything, unattended.
34    Approve,
35    /// Decline everything, unattended.
36    Decline,
37}
38
39/// How the watcher behaves for one session.
40#[derive(Clone, Copy, Debug, Default)]
41pub struct Watch {
42    /// What to do with interrupts.
43    pub policy: Policy,
44    /// Print each delta in brackets instead of joining them, so chunk
45    /// normalization is visible in the transcript.
46    pub fragments: bool,
47    /// Draw one line per update, in arrival order, instead of grouping a tool
48    /// call onto one line when it closes.
49    ///
50    /// The trade `ag-ui-client`'s [session docs] describe, made visible: the
51    /// grouped view reads better and reorders anything that happened inside a
52    /// call; this one is faithful and noisier. Neither is more correct — the
53    /// arrival order is the only nesting there is, so a view that keeps it is
54    /// the one that can show it.
55    ///
56    /// [session docs]: https://docs.rs/ag-ui-client/latest/ag_ui::client/session/index.html
57    pub in_order: bool,
58    /// Stop reading after this many updates and drop the stream — what a user
59    /// hitting Ctrl-C does, and the only cancellation a client actually has.
60    pub stop_after: Option<usize>,
61}
62
63/// Where the conversation is read from and written to.
64///
65/// One type rather than a pair of arguments because of `echo`: a piped script
66/// has to have its lines printed for the transcript to read as a session, and a
67/// human at a terminal has already seen what they typed.
68#[derive(Debug)]
69pub struct Console<R, W> {
70    input: R,
71    output: W,
72    echo: bool,
73}
74
75impl<R: BufRead, W: Write> Console<R, W> {
76    /// A console that does not echo what it reads.
77    pub fn new(input: R, output: W) -> Self {
78        Self {
79            input,
80            output,
81            echo: false,
82        }
83    }
84
85    /// Echoes every line read, for a script arriving on a pipe.
86    #[must_use]
87    pub fn echoing(mut self) -> Self {
88        self.echo = true;
89        self
90    }
91
92    /// Unwraps the output sink — how a test reads back the transcript.
93    pub fn into_output(self) -> W {
94        self.output
95    }
96
97    /// Writes a prompt and reads one line. `None` at end of input.
98    fn prompt(&mut self, label: &str) -> io::Result<Option<String>> {
99        write!(self.output, "{label}")?;
100        self.output.flush()?;
101
102        let mut line = String::new();
103        if self.input.read_line(&mut line)? == 0 {
104            writeln!(self.output)?;
105            return Ok(None);
106        }
107        if self.echo {
108            writeln!(self.output, "{}", line.trim_end())?;
109        }
110        Ok(Some(line))
111    }
112}
113
114// So every printing helper below can take a plain `&mut impl Write`.
115impl<R, W: Write> Write for Console<R, W> {
116    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
117        self.output.write(buf)
118    }
119
120    fn flush(&mut self) -> io::Result<()> {
121        self.output.flush()
122    }
123}
124
125/// Reads lines until the input ends, running one turn per line.
126///
127/// `quit` and end-of-input both stop it.
128pub async fn watch<T: Transport>(
129    session: &mut Session<T, Board>,
130    settings: Watch,
131    console: &mut Console<impl BufRead, impl Write>,
132) -> io::Result<()> {
133    while let Some(line) = console.prompt("> ")? {
134        let said = line.trim().to_owned();
135        if said.is_empty() {
136            continue;
137        }
138        if said.eq_ignore_ascii_case("quit") || said.eq_ignore_ascii_case("exit") {
139            break;
140        }
141        turn(session, &said, settings, console).await?;
142    }
143    Ok(())
144}
145
146/// One turn, including however many pauses it takes to finish.
147pub async fn turn<T: Transport>(
148    session: &mut Session<T, Board>,
149    said: &str,
150    settings: Watch,
151    console: &mut Console<impl BufRead, impl Write>,
152) -> io::Result<()> {
153    // Each `drive` call ends the mutable borrow `send`/`resume_many` takes,
154    // which is what lets the next one start.
155    let mut pending = drive(session.send(said), settings, console).await?;
156
157    while !pending.is_empty() {
158        // Every pending decision answered in *one* request. A run pauses on all
159        // of them at once and only sees what the resuming request carries, so
160        // answering one per request never terminates.
161        let entries = answer(&pending, settings, console)?;
162        pending = drive(session.resume_many(entries), settings, console).await?;
163    }
164
165    for line in view::panel(session) {
166        writeln!(console, "{line}")?;
167    }
168    Ok(())
169}
170
171/// Consumes one run, printing it, and reports what it paused on.
172async fn drive<T: Transport>(
173    mut run: RunStream<'_, T, Board>,
174    settings: Watch,
175    out: &mut impl Write,
176) -> io::Result<Vec<Interrupt>> {
177    let mut pending = Vec::new();
178    let mut seen = 0usize;
179    let mut open = Open::default();
180
181    while let Some(update) = run.next().await {
182        seen += 1;
183
184        // Anything that is not a message change interrupts the line a message
185        // is streaming onto. A producer that never closes its last message —
186        // legal, and what an unbracketed chunk stream does — would otherwise
187        // run the next line into it.
188        if !matches!(update, Update::Message(_)) {
189            open.close_text(out)?;
190        }
191
192        match update {
193            Update::Message(message) => open.print(out, &message, settings)?,
194
195            // Once per thought. The protocol brackets one twice — the block and
196            // the message inside it — and the client reports the lifecycle
197            // rather than the framing, so this arm needs no dedupe.
198            Update::Reasoning(reasoning) if reasoning.change == ReasoningChangeKind::Ended => {
199                writeln!(out, "  think  {}", reasoning.text)?;
200            }
201
202            // Typed, and already patched: this arrived as a STATE_SNAPSHOT or a
203            // STATE_DELTA and nothing here can tell which.
204            Update::State(board) => writeln!(out, "  state  {}", board.summary())?,
205
206            Update::Messages(messages) => {
207                writeln!(out, "  reset  {} messages replaced", messages.len())?;
208            }
209
210            Update::Interrupt(interrupt) => {
211                writeln!(
212                    out,
213                    "  pause  {} · {}",
214                    interrupt.id,
215                    interrupt.message.as_deref().unwrap_or("(no question)")
216                )?;
217                pending.push(interrupt);
218            }
219
220            // A delegate opening or closing. Its messages arrive as ordinary
221            // `Update::Message`s in between, each carrying its id.
222            Update::Subagent(subagent) => {
223                writeln!(
224                    out,
225                    "  child  {} {}",
226                    subagent.subagent.name,
227                    delegated(&subagent.change)
228                )?;
229            }
230
231            Update::Error(error) => writeln!(out, "  error  {error}")?,
232
233            Update::Done(end) => writeln!(out, "  done   {}", ended(&end))?,
234
235            _ => {}
236        }
237
238        // Dropping the stream is the whole of client-side cancellation: polling
239        // it is what pulls bytes, so letting go stops the run at the far end.
240        if settings.stop_after == Some(seen) {
241            writeln!(out, "  stop   dropped the stream after {seen} updates")?;
242            return Ok(pending);
243        }
244    }
245    Ok(pending)
246}
247
248/// What happened to a subagent, in one word.
249///
250/// The `_` arm is not a shrug: [`SubagentChangeKind`] is `#[non_exhaustive]`,
251/// so a kind this client was not written for prints rather than stops the
252/// build.
253fn delegated(change: &SubagentChangeKind) -> &'static str {
254    match change {
255        SubagentChangeKind::Started => "started",
256        SubagentChangeKind::Resumed => "resumed",
257        SubagentChangeKind::Finished => "finished",
258        SubagentChangeKind::Suspended => "suspended",
259        SubagentChangeKind::Failed => "failed",
260        _ => "changed",
261    }
262}
263
264/// How a run ended, in one phrase.
265///
266/// Three arms and no `_`: [`RunEnd`] is exhaustive, so a fourth way for a run
267/// to end would stop this build rather than reach a user as a shrug. That is
268/// the match a client most wants the compiler's help with — the arms decide
269/// whether the prompt comes back, whether an answer is owed, and whether
270/// anything failed.
271fn ended(end: &RunEnd) -> String {
272    match end {
273        RunEnd::Success { .. } => "success".to_owned(),
274        RunEnd::Interrupted { interrupts } => format!("interrupted on {}", interrupts.len()),
275        RunEnd::Failed { message, code } => match code {
276            Some(code) => format!("failed [{code}] {message}"),
277            None => format!("failed {message}"),
278        },
279    }
280}
281
282/// What is part-printed on the current line.
283///
284/// # Why a renderer needs this
285///
286/// The change stream is per *event*, not per message: an
287/// [`Update::Message`] says "this delta arrived for this id", and the ids
288/// interleave. Two tool calls in flight — which a model does whenever it asks
289/// for two things at once — arrive as `args(a) args(b) args(a) end(a) end(b)`,
290/// so the obvious renderer that prints a prefix on `Started` and a newline on
291/// `Ended` produces one garbled line. Text is streamed inline anyway, because
292/// watching a reply type out is the point; a second text id simply closes the
293/// first line and opens another.
294///
295/// # What buffering costs, and what it does not
296///
297/// A call is printed when it *closes*, so anything the agent emitted while it
298/// was open — a `STATE_DELTA` published from inside the call, which
299/// `ag-ui-server`'s handles allow — prints **before** the call line rather than
300/// inside it.
301///
302/// That is a property of *this* rendering, not a limit of the client. Arrival
303/// order carries the nesting; what cannot be had is a call drawn as one line
304/// *and* kept in order, because the line cannot be written until the call
305/// closes. [`Watch::in_order`] takes the other side of that trade and shows the
306/// state between the call's arguments and its end, which is where the wire put
307/// it. Legibility under parallel calls comes from tagging each line with the
308/// call id — not from buffering, which was the wrong conclusion the first time
309/// this was written down.
310#[derive(Debug, Default)]
311struct Open {
312    /// The text message whose line is currently unterminated.
313    text: Option<MessageId>,
314    /// Tool calls opened and not yet closed, oldest first, with the fragments
315    /// each has collected.
316    calls: Vec<(ToolCallId, String, Vec<String>)>,
317}
318
319impl Open {
320    /// Prints one message change, closing whatever it displaces.
321    fn print(
322        &mut self,
323        out: &mut impl Write,
324        update: &MessageUpdate,
325        settings: Watch,
326    ) -> io::Result<()> {
327        match &update.change {
328            MessageChangeKind::Started => self.open_text(out, &update.id),
329
330            // One delta per event. In `fragments` mode each is bracketed, which
331            // is what makes chunk normalization visible: the agent sent five
332            // events and the message is one string.
333            MessageChangeKind::Content { delta } => {
334                if self.text.as_ref() != Some(&update.id) {
335                    self.open_text(out, &update.id)?;
336                }
337                write!(out, "{}", mark(delta, settings))?;
338                out.flush()
339            }
340
341            MessageChangeKind::Ended => self.close_text(out),
342
343            MessageChangeKind::ToolCallStarted { tool_call_id, name } => {
344                self.calls
345                    .push((tool_call_id.clone(), name.clone(), Vec::new()));
346                if settings.in_order {
347                    self.close_text(out)?;
348                    return writeln!(out, "  call   {name} ({})", short(tool_call_id));
349                }
350                Ok(())
351            }
352            MessageChangeKind::ToolCallArgs {
353                tool_call_id,
354                delta,
355            } => {
356                if settings.in_order {
357                    self.close_text(out)?;
358                    // Named, because in arrival order two calls' fragments are
359                    // adjacent and the id is the only thing separating them.
360                    return writeln!(
361                        out,
362                        "  args   ({}) {}",
363                        short(tool_call_id),
364                        mark(delta, settings)
365                    );
366                }
367                if let Some(call) = self.call_mut(tool_call_id) {
368                    call.2.push(delta.clone());
369                }
370                Ok(())
371            }
372            // The whole call on one line, however many events it took and
373            // whatever else was in flight beside it.
374            MessageChangeKind::ToolCallEnded { tool_call_id } => {
375                let Some(index) = self.calls.iter().position(|call| &call.0 == tool_call_id) else {
376                    return Ok(());
377                };
378                let (_, name, fragments) = self.calls.remove(index);
379                self.close_text(out)?;
380
381                if settings.in_order {
382                    return writeln!(out, "  end    {name} ({})", short(tool_call_id));
383                }
384
385                let args: String = fragments
386                    .iter()
387                    .map(|fragment| mark(fragment, settings))
388                    .collect();
389                writeln!(out, "  call   {name} {args}")
390            }
391
392            MessageChangeKind::ToolResult { .. } => {
393                self.close_text(out)?;
394                print_result(out, &update.message)
395            }
396
397            _ => Ok(()),
398        }
399    }
400
401    fn call_mut(&mut self, id: &ToolCallId) -> Option<&mut (ToolCallId, String, Vec<String>)> {
402        self.calls.iter_mut().find(|call| &call.0 == id)
403    }
404
405    fn open_text(&mut self, out: &mut impl Write, id: &MessageId) -> io::Result<()> {
406        self.close_text(out)?;
407        self.text = Some(id.clone());
408        write!(out, "  text   ")?;
409        out.flush()
410    }
411
412    fn close_text(&mut self, out: &mut impl Write) -> io::Result<()> {
413        if self.text.take().is_some() {
414            writeln!(out)?;
415        }
416        Ok(())
417    }
418}
419
420/// The tail of a call id, enough to tell two apart in one run.
421///
422/// Ids are the producer's, and this one only has to disambiguate within a
423/// transcript, so the last segment is plenty and the whole thing is noise.
424fn short(id: &ToolCallId) -> &str {
425    let id = id.as_str();
426    match id.rfind('-') {
427        Some(index) => &id[index + 1..],
428        None => id,
429    }
430}
431
432/// One delta, bracketed when the transcript is meant to show fragmentation.
433fn mark(delta: &str, settings: Watch) -> String {
434    if settings.fragments {
435        format!("[{delta}]")
436    } else {
437        delta.to_owned()
438    }
439}
440
441/// Prints a tool result — as a drawn surface when it is one, clipped otherwise.
442fn print_result(out: &mut impl Write, message: &Message) -> io::Result<()> {
443    let Message::Tool(tool) = message else {
444        return Ok(());
445    };
446
447    match view::surface_lines(&tool.content) {
448        Some(lines) => {
449            writeln!(out, "  surface")?;
450            for line in lines {
451                writeln!(out, "    {line}")?;
452            }
453            Ok(())
454        }
455        None => writeln!(out, "  result {}", view::clip(&tool.content, CLIP)),
456    }
457}
458
459/// Answers every pending interrupt, in one batch.
460fn answer(
461    pending: &[Interrupt],
462    settings: Watch,
463    console: &mut Console<impl BufRead, impl Write>,
464) -> io::Result<Vec<ResumeEntry>> {
465    let mut builder = ResumeBuilder::new();
466
467    for interrupt in pending {
468        let approved = match settings.policy {
469            Policy::Approve => true,
470            Policy::Decline => false,
471            Policy::Ask => ask(interrupt, console)?,
472        };
473        builder = if approved {
474            builder.resolve(interrupt, json!({"confirm": true}))
475        } else {
476            builder.cancel(interrupt)
477        };
478        writeln!(
479            console,
480            "  answer {} · {}",
481            interrupt.id,
482            if approved { "approved" } else { "declined" }
483        )?;
484    }
485    Ok(builder.build())
486}
487
488/// Asks the person at the keyboard. End of input declines: an interrupt exists
489/// to stop something, and silence is not consent.
490fn ask(interrupt: &Interrupt, console: &mut Console<impl BufRead, impl Write>) -> io::Result<bool> {
491    let kind = if interrupt.is_tool_approval() {
492        "approve"
493    } else {
494        "answer"
495    };
496    let label = format!("  {kind} {} [y/N] ", interrupt.id);
497
498    let Some(reply) = console.prompt(&label)? else {
499        writeln!(console, "  (no answer — declining)")?;
500        return Ok(false);
501    };
502    let reply = reply.trim();
503    Ok(reply.eq_ignore_ascii_case("y") || reply.eq_ignore_ascii_case("yes"))
504}