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}