Skip to main content

task_board/
chat.rs

1//! The terminal client: one line in, one run out.
2//!
3//! Generic over its input and output rather than wired to `stdin`/`stdout`, so
4//! the integration tests under `tests/` drive *this* code with a scripted
5//! script and assert on the transcript it produces. A client whose printing is
6//! only exercised by a human is a client whose printing breaks quietly.
7//!
8//! Reading is synchronous inside an async fn on purpose: this is a one-user
9//! terminal, and nothing else needs the runtime while it waits for a keystroke.
10
11use std::io::{self, BufRead, Write};
12
13use ag_ui::client::transport::Transport;
14use ag_ui::client::{
15    MessageChangeKind, MessageUpdate, ReasoningChangeKind, RunEnd, RunStream, Session,
16    SubagentChangeKind, SubagentStatus, SubagentUpdate, Update,
17};
18use ag_ui::{Interrupt, Message};
19use ag_ui_a2ui::binding::Scope;
20use ag_ui_a2ui::constants::ROOT_ID;
21use ag_ui_a2ui::message::{AgentPayload, ChildList, Component};
22use ag_ui_a2ui::toolkit::envelope::{is_operations_envelope, unwrap_operations_envelope};
23use futures_util::StreamExt as _;
24use serde_json::{Value, json};
25
26use crate::board::Board;
27
28/// Where the conversation is read from and written to.
29///
30/// One type rather than a pair of arguments because of `echo`: a piped script
31/// has to have its lines printed for the transcript to read as a conversation,
32/// and a human at a terminal has already seen what they typed.
33#[derive(Debug)]
34pub struct Terminal<R, W> {
35    input: R,
36    output: W,
37    echo: bool,
38}
39
40impl<R: BufRead, W: Write> Terminal<R, W> {
41    /// A terminal that does not echo what it reads.
42    pub fn new(input: R, output: W) -> Self {
43        Self {
44            input,
45            output,
46            echo: false,
47        }
48    }
49
50    /// Echoes every line read, for a script arriving on a pipe.
51    #[must_use]
52    pub fn echoing(mut self) -> Self {
53        self.echo = true;
54        self
55    }
56
57    /// Unwraps the output sink — how a test reads back the transcript.
58    pub fn into_output(self) -> W {
59        self.output
60    }
61
62    /// Writes a prompt and reads one line. `None` at end of input.
63    fn prompt(&mut self, label: &str) -> io::Result<Option<String>> {
64        write!(self.output, "{label}")?;
65        self.output.flush()?;
66
67        let mut line = String::new();
68        if self.input.read_line(&mut line)? == 0 {
69            writeln!(self.output)?;
70            return Ok(None);
71        }
72        if self.echo {
73            writeln!(self.output, "{}", line.trim_end())?;
74        }
75        Ok(Some(line))
76    }
77}
78
79// So every printing helper below can take a plain `&mut impl Write`.
80impl<R, W: Write> Write for Terminal<R, W> {
81    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
82        self.output.write(buf)
83    }
84
85    fn flush(&mut self) -> io::Result<()> {
86        self.output.flush()
87    }
88}
89
90/// Reads lines until the input ends, running one turn per line.
91///
92/// `quit` and end-of-input both stop it.
93pub async fn converse<T: Transport>(
94    session: &mut Session<T, Board>,
95    terminal: &mut Terminal<impl BufRead, impl Write>,
96) -> io::Result<()> {
97    while let Some(line) = terminal.prompt("you> ")? {
98        let said = line.trim().to_owned();
99        if said.is_empty() {
100            continue;
101        }
102        if said.eq_ignore_ascii_case("quit") || said.eq_ignore_ascii_case("exit") {
103            break;
104        }
105        turn(session, &said, terminal).await?;
106    }
107    Ok(())
108}
109
110/// One user turn, including however many interrupts it takes to finish.
111///
112/// The loop is the human-in-the-loop round trip: a paused run is answered and
113/// *resumed*, which is a second request in the same thread, and the resumed run
114/// may pause again.
115async fn turn<T: Transport>(
116    session: &mut Session<T, Board>,
117    said: &str,
118    terminal: &mut Terminal<impl BufRead, impl Write>,
119) -> io::Result<()> {
120    // Each `drive` call ends the mutable borrow `send`/`resume` takes, which is
121    // what lets the next one start.
122    let mut pending = drive(session.send(said), terminal).await?;
123
124    while let Some(interrupt) = pending {
125        pending = if approved(&interrupt, terminal)? {
126            drive(
127                session.resume(&interrupt, json!({"confirm": true})),
128                terminal,
129            )
130            .await?
131        } else {
132            drive(session.cancel(&interrupt), terminal).await?
133        };
134    }
135    Ok(())
136}
137
138/// Consumes one run, printing it, and reports the interrupt it paused on.
139async fn drive<T: Transport>(
140    mut run: RunStream<'_, T, Board>,
141    output: &mut impl Write,
142) -> io::Result<Option<Interrupt>> {
143    let mut pending = None;
144    // The reply and a tool call's arguments print without a newline, so an
145    // update that owns a whole line has to wait for the open one to close
146    // rather than splice itself into it. The board does exactly that now: a
147    // tool call publishes state *while it is open*, so the summary arrives
148    // between the call's arguments and its closing bracket.
149    let mut open_line = false;
150    let mut board = None;
151
152    while let Some(update) = run.next().await {
153        match update {
154            Update::Message(message) => {
155                // A message carries the id of the subagent that produced it,
156                // and the run's registry turns that into a name. Mid-run the
157                // registry is read through the stream, which holds the
158                // session until it is dropped.
159                let speaker = message
160                    .message
161                    .subagent_run_id()
162                    .and_then(|id| run.session().subagent(id))
163                    .map(|subagent| subagent.name.clone());
164                open_line = print_message(output, &message, open_line, speaker.as_deref())?;
165            }
166
167            // One line per lifecycle change. The messages a subagent
168            // produces come out under its own name, between these.
169            Update::Subagent(subagent) => {
170                if let Some(said) = lifecycle(&subagent) {
171                    writeln!(output, "  ⟂ {} {said}", subagent.subagent.name)?;
172                }
173            }
174
175            // Only the finished thought is printed: a reasoning block is
176            // commentary, and streaming it interleaved with the reply is noise
177            // in a terminal. One `Ended` per thought, whatever the agent
178            // bracketed it with, so this is the whole arm.
179            Update::Reasoning(reasoning) if reasoning.change == ReasoningChangeKind::Ended => {
180                writeln!(output, "  ~ {}", reasoning.text)?;
181            }
182
183            // The typed state, already patched: `Board` came off the wire as a
184            // STATE_SNAPSHOT or a STATE_DELTA and neither this line nor the
185            // one above it can tell which.
186            Update::State(state) => board = Some(state.summary()),
187
188            Update::Interrupt(interrupt) => pending = Some(interrupt),
189            Update::Error(error) => writeln!(output, "  !! {error}")?,
190            Update::Done(RunEnd::Failed { message, .. }) => {
191                writeln!(output, "  !! the run failed: {message}")?;
192            }
193            _ => {}
194        }
195
196        if !open_line {
197            if let Some(summary) = board.take() {
198                writeln!(output, "  [state] {summary}")?;
199            }
200        }
201    }
202    Ok(pending)
203}
204
205/// What one subagent lifecycle change prints as, if anything.
206fn lifecycle(update: &SubagentUpdate) -> Option<String> {
207    Some(match &update.change {
208        SubagentChangeKind::Started => "started".to_owned(),
209        // The same invocation, announced again by the run that resumed it.
210        SubagentChangeKind::Resumed => "resumed".to_owned(),
211        SubagentChangeKind::Finished => "done".to_owned(),
212        SubagentChangeKind::Suspended => "waiting on a human".to_owned(),
213        SubagentChangeKind::Failed => match &update.subagent.status {
214            SubagentStatus::Failed { message, .. } => format!("failed: {message}"),
215            _ => "failed".to_owned(),
216        },
217        _ => return None,
218    })
219}
220
221/// Prints one message change, and reports whether it left the line open.
222///
223/// `speaker` is the subagent the message belongs to, when it belongs to one:
224/// its text is printed under that name instead of `agent>`, and its tool calls
225/// are prefixed with it, so a reader can tell the delegate's work from the
226/// supervisor's.
227fn print_message(
228    output: &mut impl Write,
229    update: &MessageUpdate,
230    open_line: bool,
231    speaker: Option<&str>,
232) -> io::Result<bool> {
233    match &update.change {
234        MessageChangeKind::Started => {
235            write!(output, "  {}> ", speaker.unwrap_or("agent"))?;
236            output.flush()?;
237            Ok(true)
238        }
239        // One delta, one word. Flushed so a slow agent reads as typing rather
240        // than as a hang.
241        MessageChangeKind::Content { delta } => {
242            write!(output, "{delta}")?;
243            output.flush()?;
244            Ok(true)
245        }
246        MessageChangeKind::Ended => {
247            writeln!(output)?;
248            Ok(false)
249        }
250
251        MessageChangeKind::ToolCallStarted { name, .. } => {
252            match speaker {
253                Some(speaker) => write!(output, "  · [{speaker}] {name}(")?,
254                None => write!(output, "  · {name}(")?,
255            }
256            Ok(true)
257        }
258        MessageChangeKind::ToolCallArgs { delta, .. } => {
259            write!(output, "{delta}")?;
260            Ok(true)
261        }
262        MessageChangeKind::ToolCallEnded { .. } => {
263            writeln!(output, ")")?;
264            Ok(false)
265        }
266        MessageChangeKind::ToolResult { .. } => {
267            print_result(output, &update.message)?;
268            Ok(false)
269        }
270
271        // Nothing printed, so the line is however the last print left it.
272        _ => Ok(open_line),
273    }
274}
275
276/// Prints a tool result — as a surface when it is one, as JSON otherwise.
277fn print_result(output: &mut impl Write, message: &Message) -> io::Result<()> {
278    let Message::Tool(tool) = message else {
279        return Ok(());
280    };
281    let Ok(value) = serde_json::from_str::<Value>(&tool.content) else {
282        writeln!(output, "    → {}", tool.content)?;
283        return Ok(());
284    };
285
286    // The sniff every A2UI frontend does: a tool result either is an operations
287    // envelope or is an ordinary result, and nothing else distinguishes them.
288    if !is_operations_envelope(&value) {
289        writeln!(output, "    → {value}")?;
290        return Ok(());
291    }
292
293    match surface_lines(&value) {
294        Some(lines) => {
295            writeln!(output, "    ┌ a2ui surface")?;
296            for line in lines {
297                writeln!(output, "    │ {line}")?;
298            }
299            writeln!(output, "    └")
300        }
301        None => writeln!(output, "    → an A2UI envelope this client cannot draw"),
302    }
303}
304
305/// Draws the surface in the envelope, or `None` if it carries no components.
306///
307/// A real renderer owns a widget toolkit and a reactive data model; this walks
308/// the tree far enough to prove the surface arrived whole — every child
309/// reference resolved, every binding evaluated through [`Scope`], the list
310/// template instantiated once per task.
311fn surface_lines(envelope: &Value) -> Option<Vec<String>> {
312    let operations = unwrap_operations_envelope(envelope).ok()?;
313
314    let mut components: Vec<Component> = Vec::new();
315    let mut data = Value::Null;
316    for operation in &operations {
317        match &operation.payload {
318            AgentPayload::UpdateComponents(payload) => components.clone_from(&payload.components),
319            AgentPayload::UpdateDataModel(payload) => data = payload.value.clone(),
320            _ => {}
321        }
322    }
323    if components.is_empty() {
324        return None;
325    }
326
327    let mut lines = Vec::new();
328    draw(&components, &Scope::root(&data), ROOT_ID, &mut lines);
329    Some(lines)
330}
331
332/// Appends the lines one component draws as, children included.
333fn draw(components: &[Component], scope: &Scope<'_>, id: &str, lines: &mut Vec<String>) {
334    let Some(component) = components.iter().find(|component| component.id == id) else {
335        lines.push(format!("<no component {id}>"));
336        return;
337    };
338
339    match component.component.as_str() {
340        "Text" => lines.push(bound(scope, component, "text")),
341        "CheckBox" => {
342            let mark = if bound(scope, component, "value") == "true" {
343                "x"
344            } else {
345                " "
346            };
347            lines.push(format!("[{mark}] {}", bound(scope, component, "label")));
348        }
349        "Card" => {
350            if let Some(child) = component.prop("child").and_then(Value::as_str) {
351                draw(components, scope, child, lines);
352            }
353        }
354        // Every container in the basic catalog spells its children the same
355        // way, so one arm covers them.
356        _ => match component.prop("children").and_then(ChildList::from_value) {
357            Some(ChildList::Ids(ids)) => {
358                for child in ids {
359                    draw(components, scope, &child, lines);
360                }
361            }
362            Some(ChildList::Template(template)) => {
363                let count = scope
364                    .resolve(&template.path)
365                    .and_then(Value::as_array)
366                    .map_or(0, Vec::len);
367                for index in 0..count {
368                    // Entering the item's scope is what makes the template's
369                    // relative paths (`label`, `done`) resolve.
370                    let item = scope.item(&template.path, index);
371                    draw(components, &item, &template.component_id, lines);
372                }
373            }
374            None => lines.push(format!("<{} has no children>", component.component)),
375        },
376    }
377}
378
379/// Resolves one property through the data model: a literal, a `{"path": …}`
380/// binding, or a `formatString` call.
381fn bound(scope: &Scope<'_>, component: &Component, key: &str) -> String {
382    let Some(raw) = component.prop(key) else {
383        return String::new();
384    };
385    match scope.resolve_dynamic(raw) {
386        Ok(Value::String(text)) => text,
387        Ok(Value::Null) => String::new(),
388        Ok(other) => other.to_string(),
389        Err(error) => format!("<{error}>"),
390    }
391}
392
393/// Asks the human the interrupt's question. End of input declines, because the
394/// interrupt exists to stop something destructive.
395fn approved(
396    interrupt: &Interrupt,
397    terminal: &mut Terminal<impl BufRead, impl Write>,
398) -> io::Result<bool> {
399    let question = interrupt
400        .message
401        .as_deref()
402        .unwrap_or("The agent is waiting for a decision.");
403    writeln!(terminal, "  ?? {question}")?;
404
405    let Some(answer) = terminal.prompt("  [y/N] ")? else {
406        writeln!(terminal, "  (no answer — declining)")?;
407        return Ok(false);
408    };
409    let answer = answer.trim();
410    Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
411}