Skip to main content

task_board/
board.rs

1//! The board: the shared state, the tools that change it, and the A2UI surface
2//! that draws it.
3//!
4//! Nothing here knows about AG-UI events. The state is a plain `serde` struct
5//! that [`ag_ui::server::RunContext`] publishes as `STATE_SNAPSHOT` /
6//! `STATE_DELTA`, the tools are [`ag_ui::Tool`] definitions the client
7//! offers, and the surface is an [`ag_ui_a2ui`] component tree. Keeping the
8//! domain free of the protocol is what makes [`crate::agent`] short.
9
10use ag_ui::Tool;
11use ag_ui_a2ui::message::Component;
12use ag_ui_a2ui::toolkit::ops::SurfaceSpec;
13use serde::{Deserialize, Serialize};
14use serde_json::{Value, json};
15
16/// The A2UI surface every render targets.
17pub const SURFACE_ID: &str = "task-board";
18
19/// Adds one task.
20pub const ADD_TASK: &str = "add_task";
21/// Marks one task done.
22pub const COMPLETE_TASK: &str = "complete_task";
23/// Puts a minute estimate on one task.
24pub const ESTIMATE: &str = "estimate";
25/// Removes every task. Destructive, so the agent asks first.
26pub const CLEAR_BOARD: &str = "clear_board";
27
28/// One item on the board.
29#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct Task {
32    /// Stable within a thread: ids come from [`Board::next_id`], which the
33    /// client carries back in `RunAgentInput::state` on the next run.
34    pub id: u32,
35    /// What the user typed.
36    pub title: String,
37    /// Minutes, once somebody has estimated it.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub estimate_minutes: Option<u32>,
40    /// Whether it is finished.
41    pub done: bool,
42}
43
44impl Task {
45    /// How the task reads on one line.
46    ///
47    /// Carries no done marker: on the surface that is the checkbox's job, and
48    /// duplicating it there was the first thing the terminal showed.
49    pub fn label(&self) -> String {
50        let estimate = match self.estimate_minutes {
51            Some(minutes) => format!(" · {minutes}m"),
52            None => String::new(),
53        };
54        format!("#{} {}{estimate}", self.id, self.title)
55    }
56}
57
58/// Everything the user and the agent share.
59///
60/// This is `Agent::State`, so it round-trips: the agent publishes it, the
61/// client mirrors it, and the client sends it back with the next run. Ids and
62/// estimates therefore survive a run boundary without the agent storing
63/// anything.
64#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct Board {
67    /// Every task, in the order they were added.
68    #[serde(default)]
69    pub tasks: Vec<Task>,
70    /// The id the next task will get.
71    #[serde(default)]
72    pub next_id: u32,
73}
74
75impl Board {
76    /// Appends a task and returns it.
77    pub fn add(&mut self, title: impl Into<String>) -> &Task {
78        self.next_id += 1;
79        self.tasks.push(Task {
80            id: self.next_id,
81            title: title.into(),
82            estimate_minutes: None,
83            done: false,
84        });
85        self.tasks.last().expect("just pushed")
86    }
87
88    /// Finds a task by id, or by a case-insensitive substring of its title.
89    ///
90    /// `complete 2` and `complete book the room` both have to work, because
91    /// both are what a person types.
92    pub fn find(&self, needle: &str) -> Option<&Task> {
93        self.index_of(needle).map(|index| &self.tasks[index])
94    }
95
96    fn index_of(&self, needle: &str) -> Option<usize> {
97        let needle = needle.trim();
98        if let Ok(id) = needle.parse::<u32>() {
99            if let Some(index) = self.tasks.iter().position(|task| task.id == id) {
100                return Some(index);
101            }
102        }
103        let lowered = needle.to_lowercase();
104        if lowered.is_empty() {
105            return None;
106        }
107        self.tasks
108            .iter()
109            .position(|task| task.title.to_lowercase().contains(&lowered))
110    }
111
112    /// Marks a task done. Returns it, or `None` if nothing matched.
113    pub fn complete(&mut self, needle: &str) -> Option<&Task> {
114        let index = self.index_of(needle)?;
115        self.tasks[index].done = true;
116        Some(&self.tasks[index])
117    }
118
119    /// Puts an estimate on a task. Returns it, or `None` if nothing matched.
120    pub fn estimate(&mut self, needle: &str, minutes: u32) -> Option<&Task> {
121        let index = self.index_of(needle)?;
122        self.tasks[index].estimate_minutes = Some(minutes);
123        Some(&self.tasks[index])
124    }
125
126    /// Removes every task and returns how many there were. Ids keep counting,
127    /// so a cleared board does not reuse them.
128    pub fn clear(&mut self) -> usize {
129        let removed = self.tasks.len();
130        self.tasks.clear();
131        removed
132    }
133
134    /// How many tasks are not done.
135    pub fn open(&self) -> usize {
136        self.tasks.iter().filter(|task| !task.done).count()
137    }
138
139    /// How many tasks are done.
140    pub fn done(&self) -> usize {
141        self.tasks.iter().filter(|task| task.done).count()
142    }
143
144    /// Total estimated minutes over the tasks that still have to be done.
145    pub fn remaining_minutes(&self) -> u32 {
146        self.tasks
147            .iter()
148            .filter(|task| !task.done)
149            .filter_map(|task| task.estimate_minutes)
150            .sum()
151    }
152
153    /// The status line under the heading.
154    pub fn summary(&self) -> String {
155        if self.tasks.is_empty() {
156            return "nothing on the board".to_owned();
157        }
158        let mut summary = format!("{} open · {} done", self.open(), self.done());
159        let minutes = self.remaining_minutes();
160        if minutes > 0 {
161            summary.push_str(&format!(" · {minutes}m to go"));
162        }
163        summary
164    }
165}
166
167/// The tools the client offers and the agent executes.
168///
169/// The client sends these on every run and the agent reads them back out of
170/// [`RunContext::tools`](ag_ui::server::RunContext::tools), so a run that names
171/// a tool the client never offered is a bug this example can actually catch.
172pub fn tools() -> Vec<Tool> {
173    vec![
174        Tool::new(
175            ADD_TASK,
176            "Add one task to the board.",
177            json!({
178                "type": "object",
179                "properties": {
180                    "title": {"type": "string", "description": "What has to be done."},
181                },
182                "required": ["title"],
183            }),
184        ),
185        Tool::new(
186            COMPLETE_TASK,
187            "Mark a task done, by id or by a piece of its title.",
188            json!({
189                "type": "object",
190                "properties": {
191                    "task": {"type": "string", "description": "Task id, or part of the title."},
192                },
193                "required": ["task"],
194            }),
195        ),
196        Tool::new(
197            ESTIMATE,
198            "Put a minute estimate on a task.",
199            json!({
200                "type": "object",
201                "properties": {
202                    "task": {"type": "string", "description": "Task id, or part of the title."},
203                    "minutes": {"type": "integer", "minimum": 1},
204                },
205                "required": ["task", "minutes"],
206            }),
207        ),
208        Tool::new(
209            CLEAR_BOARD,
210            "Remove every task. Destructive: ask the human first.",
211            json!({"type": "object", "properties": {}}),
212        ),
213    ]
214}
215
216/// The board as an A2UI surface: a card, a heading, a status line, and one
217/// two-way bound checkbox per task.
218///
219/// The component tree is fixed and the data model is what moves, which is the
220/// shape A2UI is built for — a renderer that already has this tree redraws from
221/// `updateDataModel` alone.
222pub fn surface(board: &Board) -> SurfaceSpec {
223    SurfaceSpec::new(SURFACE_ID)
224        .with_components(vec![
225            Component::new("root", "Card").with("child", json!("body")),
226            Component::new("body", "Column").with("children", json!(["heading", "status", "list"])),
227            Component::new("heading", "Text")
228                .with("text", json!({"path": "/title"}))
229                .with("variant", json!("h2")),
230            Component::new("status", "Text")
231                .with("text", json!({"path": "/summary"}))
232                .with("variant", json!("caption")),
233            Component::new("list", "List")
234                // A child *template*, not a child list: one instance per
235                // element of `/tasks`, each with its own scope.
236                .with("children", json!({"componentId": "task", "path": "/tasks"})),
237            Component::new("task", "CheckBox")
238                // Relative paths, resolved inside the template's scope.
239                .with("label", json!({"path": "label"}))
240                .with("value", json!({"path": "done"})),
241        ])
242        .with_data_model(data_model(board))
243}
244
245/// What the surface's bindings read.
246pub fn data_model(board: &Board) -> Value {
247    json!({
248        "title": "Workshop board",
249        "summary": board.summary(),
250        "tasks": board
251            .tasks
252            .iter()
253            .map(|task| json!({"label": task.label(), "done": task.done}))
254            .collect::<Vec<_>>(),
255    })
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use ag_ui_a2ui::catalog::Catalog;
262    use ag_ui_a2ui::validate::Validator;
263
264    fn board() -> Board {
265        let mut board = Board::default();
266        board.add("draft the agenda");
267        board.add("book the room");
268        board.estimate("2", 45).expect("task 2 exists");
269        board.complete("agenda").expect("matched by title");
270        board
271    }
272
273    #[test]
274    fn tasks_are_found_by_id_and_by_title() {
275        let board = board();
276        assert_eq!(board.find("1").map(|task| task.id), Some(1));
277        assert_eq!(board.find("BOOK").map(|task| task.id), Some(2));
278        assert_eq!(board.find("nothing like this"), None);
279    }
280
281    #[test]
282    fn a_cleared_board_does_not_reuse_ids() {
283        let mut board = board();
284        assert_eq!(board.clear(), 2);
285        assert_eq!(board.add("start over").id, 3);
286    }
287
288    #[test]
289    fn the_summary_counts_only_the_work_that_is_left() {
290        let board = board();
291        assert_eq!(board.summary(), "1 open · 1 done · 45m to go");
292        assert_eq!(Board::default().summary(), "nothing on the board");
293    }
294
295    /// The agent ships this tree to a renderer it cannot see, so the only
296    /// check available before it leaves is the catalog's own.
297    #[test]
298    fn the_surface_validates_against_the_basic_catalog() {
299        let board = board();
300        let spec = surface(&board);
301        let model = spec.data_model.clone().expect("a data model");
302
303        let report =
304            Validator::new(&Catalog::basic()).validate_surface(&spec.components, Some(&model));
305        assert!(report.is_valid(), "{:?}", report.errors);
306        assert!(report.unreachable.is_empty(), "{:?}", report.unreachable);
307    }
308}