1use std::collections::HashMap;
32use std::sync::Arc;
33use std::time::Duration;
34
35use tokio::time::interval;
36
37use crate::backend::blockcontroller::{get_block_controller_status, STATUS_RUNNING};
38use crate::backend::storage::filestore::FileStore;
39use crate::backend::mps::Broker;
40use crate::backend::work_facts::overlap_notes::{NewEdit, NewPr};
41
42use super::get_global_handler;
43
44const SWEEP_INTERVAL_SECS: u64 = 3;
47
48const MAX_READ_BYTES: i64 = 4 * 1024 * 1024;
53
54const MAX_TODOS: usize = 24;
58
59const MAX_OPEN_TOOLS: usize = 32;
63
64pub const EVENT_AGENT_PROGRESS: &str = "agent:progress";
65
66const REPUBLISH_EVERY_TICKS: u64 = 5;
76
77const TODO_TOOL_NAMES: &[&str] = &["TodoWrite", "TaskCreate", "TaskUpdate"];
92
93fn is_todo_tool(name: &str) -> bool {
94 TODO_TOOL_NAMES.contains(&name) || name.contains("Todo")
95}
96
97const FILE_EDIT_TOOL_NAMES: &[&str] = &["Edit", "Write", "MultiEdit", "NotebookEdit"];
101
102fn is_shell_tool(name: &str) -> bool {
105 let lower = name.to_ascii_lowercase();
106 lower.contains("bash") || lower.ends_with("shell")
107}
108
109#[cfg(test)]
114fn is_pr_create(command: &str) -> bool {
115 pr_create(command).is_some()
116}
117
118fn pr_create(command: &str) -> Option<NewPrCommand> {
123 let clean = |w: &str| w.trim_matches(|c| matches!(c, '"' | '\'' | '(' | ')' | ';')).to_string();
124 let raw: Vec<&str> = command.split_whitespace().collect();
125 for (i, first) in raw.iter().enumerate() {
126 let word = clean(first);
127 let program = word.rsplit(['/', '\\']).next().unwrap_or(&word);
128 let program = program.trim_end_matches(".cmd").trim_end_matches(".exe");
129 if !matches!(program, "gh" | "gh-agent") {
130 continue;
131 }
132 let mut rest: Vec<String> = Vec::new();
133 for w in &raw[i + 1..] {
134 if matches!(*w, "&&" | "||" | "|" | ";") {
135 break;
136 }
137 rest.push(clean(w));
138 if w.ends_with(';') {
139 break;
140 }
141 }
142 let (mut k, mut words) = (0, Vec::new());
144 while k < rest.len() && words.len() < 2 {
145 match rest[k].as_str() {
146 "-R" | "--repo" => k += 2,
147 w if w.starts_with('-') => k += 1,
148 w => {
149 words.push(w.to_string());
150 k += 1;
151 }
152 }
153 }
154 if words != ["pr", "create"] {
155 continue;
156 }
157 let mut pr = NewPrCommand::default();
158 while k < rest.len() {
159 let w = rest[k].as_str();
160 let next = || rest.get(k + 1).cloned();
161 match w {
162 "-H" | "--head" => {
163 pr.head = next();
164 k += 2;
165 continue;
166 }
167 "-B" | "--base" => {
168 pr.base = next();
169 k += 2;
170 continue;
171 }
172 _ => {}
173 }
174 if let Some(v) = w.strip_prefix("--head=") {
175 pr.head = Some(v.to_string());
176 } else if let Some(v) = w.strip_prefix("--base=") {
177 pr.base = Some(v.to_string());
178 }
179 k += 1;
180 }
181 return Some(pr);
182 }
183 None
184}
185
186#[derive(Debug, Clone, Default, PartialEq, Eq)]
188struct NewPrCommand {
189 head: Option<String>,
190 base: Option<String>,
191}
192
193const MAX_NEW_PRS: usize = 4;
195
196pub const MAX_RECENT_FILES: usize = 50;
198
199#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
201pub struct RecentFile {
202 pub path: String,
204 pub tool: String,
205 pub ts_ms: u64,
210}
211
212fn line_timestamp(v: &serde_json::Value) -> Option<u64> {
215 let ts = v.get("timestamp")?;
216 if let Some(n) = ts.as_u64() {
217 return Some(n);
218 }
219 chrono::DateTime::parse_from_rfc3339(ts.as_str()?)
220 .ok()
221 .and_then(|dt| u64::try_from(dt.timestamp_millis()).ok())
222}
223
224#[derive(Debug, Clone, Default)]
227pub struct WatchedWork {
228 pub progress: AgentProgress,
229 pub recent_files: Vec<RecentFile>,
230}
231
232fn snapshot() -> &'static parking_lot::RwLock<HashMap<String, WatchedWork>> {
234 static SNAPSHOT: std::sync::LazyLock<parking_lot::RwLock<HashMap<String, WatchedWork>>> =
235 std::sync::LazyLock::new(Default::default);
236 &SNAPSHOT
237}
238
239pub fn watched_work(block_id: &str) -> Option<WatchedWork> {
242 snapshot().read().get(block_id).cloned()
243}
244
245fn note_recent_file(files: &mut Vec<RecentFile>, path: &str, tool: &str, ts_ms: u64) {
248 files.retain(|f| f.path != path);
249 files.insert(0, RecentFile { path: path.to_string(), tool: tool.to_string(), ts_ms });
250 files.truncate(MAX_RECENT_FILES);
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
255#[ts(export, export_to = "../../../frontend/types/rpc/")]
256pub struct TodoItem {
257 pub text: String,
258 pub status: String,
263}
264
265#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
267pub struct AgentProgress {
268 pub todos: Vec<TodoItem>,
269 pub todos_truncated: usize,
272 pub todos_partial: bool,
277 pub current_tool: Option<String>,
280}
281
282impl AgentProgress {
283 fn is_empty(&self) -> bool {
285 self.todos.is_empty() && self.current_tool.is_none()
286 }
287}
288
289#[derive(Debug, Default)]
291struct BlockState {
292 next_offset: i64,
294 tasks: Vec<(String, TodoItem)>,
298 list: Option<Vec<TodoItem>>,
301 open: Vec<(String, String)>,
303 partial: bool,
305 recent_files: Vec<RecentFile>,
307 new_edits: Vec<RecentFile>,
310 new_prs: Vec<NewPrCommand>,
313 seen: bool,
315}
316
317impl BlockState {
318 fn progress(&self) -> AgentProgress {
319 let mut todos = self
320 .list
321 .clone()
322 .unwrap_or_else(|| self.tasks.iter().map(|(_, i)| i.clone()).collect());
323 let todos_truncated = todos.len().saturating_sub(MAX_TODOS);
324 todos.truncate(MAX_TODOS);
325 AgentProgress {
326 todos,
327 todos_truncated,
328 todos_partial: self.partial && self.list.is_none(),
331 current_tool: self.open.first().map(|(_, name)| name.clone()),
332 }
333 }
334}
335
336fn apply_lines(state: &mut BlockState, lines: &[&str], live_now_ms: Option<u64>) {
348 for line in lines {
349 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
350 continue;
351 };
352
353 for id in tool_result_ids(&v) {
356 state.open.retain(|(open_id, _)| *open_id != id);
357 }
358
359 if v.get("type").and_then(|t| t.as_str()) == Some("item.started") {
362 let item = v.get("item");
363 let is_command = item.and_then(|i| i.get("type")).and_then(|t| t.as_str()) == Some("command_execution");
364 let command = item.and_then(|i| i.get("command")).and_then(|c| c.as_str());
365 if let Some(pr) = command.filter(|_| is_command).and_then(pr_create) {
366 if live_now_ms.is_some() && state.new_prs.len() < MAX_NEW_PRS {
367 state.new_prs.push(pr);
368 }
369 }
370 }
371
372 for frame in tool_frames(&v) {
373 if frame.get("type").and_then(|t| t.as_str()) != Some("tool_use") {
374 continue;
375 }
376 let Some(name) = frame_tool_name(frame) else { continue };
377 if let Some(id) = frame_tool_id(frame) {
378 state.open.push((id.to_string(), name.to_string()));
379 if state.open.len() > MAX_OPEN_TOOLS {
382 state.open.remove(0);
383 }
384 }
385
386 if is_shell_tool(name) {
387 let opens = frame_input(frame).and_then(|i| first_string(i, &["command", "cmd"])).and_then(|c| pr_create(&c));
388 if let Some(pr) = opens {
389 if live_now_ms.is_some() && state.new_prs.len() < MAX_NEW_PRS {
390 state.new_prs.push(pr);
391 }
392 }
393 continue;
394 }
395
396 if FILE_EDIT_TOOL_NAMES.contains(&name) {
397 if let Some(path) = frame_input(frame)
398 .and_then(|i| first_string(i, &["file_path", "notebook_path"]))
399 {
400 if let Some(ts) = line_timestamp(&v).or(live_now_ms) {
401 note_recent_file(&mut state.recent_files, &path, name, ts);
402 if live_now_ms.is_some() && state.new_edits.len() < MAX_RECENT_FILES {
405 state.new_edits.push(RecentFile { path, tool: name.to_string(), ts_ms: ts });
406 }
407 }
408 }
409 continue;
410 }
411
412 if !is_todo_tool(name) {
413 continue;
414 }
415 let Some(input) = frame_input(frame) else { continue };
416
417 if let Some(items) = parse_todo_array(input) {
418 state.list = Some(items);
419 state.tasks.clear();
420 continue;
421 }
422 if state.list.is_none() {
426 merge_single_task(&mut state.tasks, input, name);
427 }
428 }
429 }
430}
431
432fn tool_frames(v: &serde_json::Value) -> Vec<&serde_json::Value> {
444 let mut frames: Vec<&serde_json::Value> = v
445 .get("message")
446 .and_then(|m| m.get("content"))
447 .or_else(|| v.get("content"))
448 .and_then(|c| c.as_array())
449 .map(|a| a.iter().collect())
450 .unwrap_or_default();
451 if matches!(v.get("type").and_then(|t| t.as_str()), Some("tool_use") | Some("tool_result")) {
454 frames.push(v);
455 }
456 frames
457}
458
459fn frame_tool_name(frame: &serde_json::Value) -> Option<&str> {
462 frame
463 .get("name")
464 .or_else(|| frame.get("tool_name"))
465 .and_then(|n| n.as_str())
466}
467
468fn frame_tool_id(frame: &serde_json::Value) -> Option<&str> {
470 frame
471 .get("id")
472 .or_else(|| frame.get("tool_id"))
473 .and_then(|i| i.as_str())
474}
475
476fn frame_input(frame: &serde_json::Value) -> Option<&serde_json::Value> {
478 frame.get("input").or_else(|| frame.get("parameters"))
479}
480
481fn tool_result_ids(v: &serde_json::Value) -> Vec<String> {
482 let mut ids = Vec::new();
483 if let Some(id) = v.get("tool_use_id").and_then(|i| i.as_str()) {
484 ids.push(id.to_string());
485 }
486 for frame in tool_frames(v) {
487 if frame.get("type").and_then(|t| t.as_str()) != Some("tool_result") {
488 continue;
489 }
490 if let Some(id) = frame
493 .get("tool_use_id")
494 .or_else(|| frame.get("tool_id"))
495 .and_then(|i| i.as_str())
496 {
497 ids.push(id.to_string());
498 }
499 }
500 ids
501}
502
503fn parse_todo_array(input: &serde_json::Value) -> Option<Vec<TodoItem>> {
505 let arr = input
506 .get("todos")
507 .or_else(|| input.get("items"))
508 .and_then(|t| t.as_array())?;
509 Some(arr.iter().filter_map(parse_todo_entry).collect())
512}
513
514fn parse_todo_entry(entry: &serde_json::Value) -> Option<TodoItem> {
515 let text = first_string(entry, &["content", "title", "text", "activeForm", "description"])?;
516 let status = first_string(entry, &["status", "state"]).unwrap_or_else(|| "pending".to_string());
517 Some(TodoItem { text, status })
518}
519
520fn is_create_call(tool_name: &str) -> bool {
539 tool_name.contains("Create") || tool_name.contains("Add") || tool_name.contains("New")
540}
541
542fn merge_single_task(items: &mut Vec<(String, TodoItem)>, input: &serde_json::Value, tool_name: &str) {
543 let text = first_string(input, &["content", "title", "text", "activeForm", "description"]);
544 let status = first_string(input, &["status", "state"]);
545 let id = first_string(input, &["task_id", "taskId", "id"]);
546
547 if id.is_none() && is_create_call(tool_name) {
549 let Some(text) = text else { return };
550 items.push((
551 format!("{}#{}", text, items.len()),
554 TodoItem { text, status: status.unwrap_or_else(|| "pending".to_string()) },
555 ));
556 return;
557 }
558
559 let existing = match &id {
564 Some(id) => items.iter_mut().find(|(k, _)| k == id),
565 None => match &text {
566 Some(text) => items.iter_mut().find(|(_, item)| item.text == *text),
567 None => None,
568 },
569 };
570 if let Some(slot) = existing {
571 if let Some(text) = text {
572 slot.1.text = text;
573 }
574 if let Some(status) = status {
575 slot.1.status = status;
576 }
577 return;
578 }
579
580 let Some(key) = id.or_else(|| text.clone()) else {
581 return;
583 };
584
585 let Some(text) = text else { return };
588 items.push((key, TodoItem { text, status: status.unwrap_or_else(|| "pending".to_string()) }));
589}
590
591fn first_string(v: &serde_json::Value, keys: &[&str]) -> Option<String> {
592 keys.iter()
593 .find_map(|k| v.get(*k).and_then(|s| s.as_str()))
594 .map(|s| s.trim().to_string())
595 .filter(|s| !s.is_empty())
596}
597
598fn consume_new_output(filestore: &FileStore, block_id: &str, state: &mut BlockState) -> bool {
604 let size = match filestore.stat(block_id, "output") {
605 Ok(Some(wf)) => wf.size,
606 Ok(None) => 0,
607 Err(_) => return false,
608 };
609 let first_sight = !state.seen;
613 state.seen = true;
614 if size <= 0 {
615 return false;
616 }
617
618 if size < state.next_offset {
622 *state = BlockState { seen: true, ..BlockState::default() };
623 }
624
625 if state.next_offset == 0 && size > MAX_READ_BYTES {
626 state.next_offset = size - MAX_READ_BYTES;
630 state.partial = true;
631 }
632
633 let available = size - state.next_offset;
634 if available <= 0 {
635 return false;
636 }
637 let want = available.min(MAX_READ_BYTES);
638 let Ok((_, bytes)) = filestore.read_at(block_id, "output", state.next_offset, want) else {
639 return false;
640 };
641 if bytes.is_empty() {
642 return false;
643 }
644
645 let consumed = match bytes.iter().rposition(|b| *b == b'\n') {
649 Some(idx) => idx + 1,
650 None if want == MAX_READ_BYTES => bytes.len(),
653 None => return false,
654 };
655 state.next_offset += consumed as i64;
656
657 let text = String::from_utf8_lossy(&bytes[..consumed]);
658 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
659 if lines.is_empty() {
660 return false;
661 }
662 let live_now_ms = (!first_sight).then(agentmux_common::time::now_ms_u64);
663 apply_lines(state, &lines, live_now_ms);
664 true
665}
666
667struct SweepOutcome {
670 agent_id: String,
671 block_id: String,
672 progress: AgentProgress,
673}
674
675type Swept = (HashMap<String, BlockState>, Vec<SweepOutcome>, (Vec<NewEdit>, Vec<NewPr>));
678
679fn sweep_blocking(
688 filestore: &FileStore,
689 agents: Vec<(String, String)>,
690 mut states: HashMap<String, BlockState>,
691 last_published: &HashMap<String, AgentProgress>,
692 force_republish: bool,
693) -> Swept {
694 let mut out = Vec::new();
695 let mut edits = Vec::new();
696 let mut prs = Vec::new();
697
698 for (agent_id, block_id) in agents {
699 let Some(status) = get_block_controller_status(&block_id) else { continue };
702 if status.shellprocstatus != STATUS_RUNNING || !status.is_agent_pane {
703 continue;
704 }
705
706 let state = states.entry(block_id.clone()).or_default();
707 let had_new = consume_new_output(filestore, &block_id, state);
708 edits.extend(state.new_edits.drain(..).map(|f| NewEdit {
709 agent_id: agent_id.clone(),
710 block_id: block_id.clone(),
711 path: f.path,
712 }));
713 prs.extend(state.new_prs.drain(..).map(|p| NewPr {
714 agent_id: agent_id.clone(),
715 block_id: block_id.clone(),
716 head: p.head,
717 base: p.base,
718 }));
719 let progress = state.progress();
720
721 if !had_new && !last_published.contains_key(&block_id) {
724 continue;
725 }
726 if progress.is_empty() && !last_published.contains_key(&block_id) {
727 continue;
728 }
729 if !force_republish && last_published.get(&block_id) == Some(&progress) {
730 continue;
731 }
732 out.push(SweepOutcome { agent_id, block_id, progress });
733 }
734
735 (states, out, (edits, prs))
736}
737
738pub async fn run_agent_progress_loop(filestore: Arc<FileStore>, broker: Arc<Broker>) {
740 let mut ticker = interval(Duration::from_secs(SWEEP_INTERVAL_SECS));
741 let mut tick: u64 = 0;
742 let mut states: HashMap<String, BlockState> = HashMap::new();
743 let mut last_published: HashMap<String, AgentProgress> = HashMap::new();
746
747 loop {
748 ticker.tick().await;
749 tick += 1;
750 let force_republish = tick % REPUBLISH_EVERY_TICKS == 0;
751
752 let agents: Vec<(String, String)> = get_global_handler()
753 .list_agents()
754 .into_iter()
755 .map(|a| (a.agent_id, a.block_id))
756 .collect();
757 let registered: std::collections::HashSet<String> =
758 agents.iter().map(|(_, block_id)| block_id.clone()).collect();
759 states.retain(|block_id, _| registered.contains(block_id));
762 last_published.retain(|block_id, _| registered.contains(block_id));
763
764 let fs = filestore.clone();
765 let published = last_published.clone();
766 let taken = std::mem::take(&mut states);
767 let joined = tokio::task::spawn_blocking(move || {
768 sweep_blocking(&fs, agents, taken, &published, force_republish)
769 })
770 .await;
771
772 let (returned_states, outcomes, (edits, prs)) = match joined {
778 Ok(v) => v,
779 Err(e) => {
780 tracing::error!(error = %e, "progress sweep task failed; state reset for next tick");
781 continue;
782 }
783 };
784 states = returned_states;
785 crate::backend::work_facts::overlap_notes::on_new_edits(edits);
787 crate::backend::work_facts::overlap_notes::on_new_prs(prs);
788 *snapshot().write() = states
789 .iter()
790 .map(|(block_id, s)| {
791 (block_id.clone(), WatchedWork { progress: s.progress(), recent_files: s.recent_files.clone() })
792 })
793 .collect();
794
795 for SweepOutcome { agent_id, block_id, progress } in outcomes {
796 last_published.insert(block_id.clone(), progress.clone());
797
798 let ts = agentmux_common::time::now_ms_u64();
799
800 let event = crate::backend::mps_events::AgentProgressEvent {
801 agent_id,
802 block_id: block_id.clone(),
803 todos: progress.todos,
804 todos_truncated: progress.todos_truncated,
805 todos_partial: progress.todos_partial,
806 current_tool: progress.current_tool,
807 ts,
808 };
809 broker.publish_event::<crate::backend::mps_events::AgentProgress>(&event, vec![format!("block:{}", block_id)], 1);
813 }
814 }
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820
821 fn todo_write(id: &str, todos: &str) -> String {
822 format!(
823 r#"{{"type":"assistant","message":{{"content":[{{"type":"tool_use","id":"{id}","name":"TodoWrite","input":{{"todos":{todos}}}}}]}}}}"#
824 )
825 }
826
827 fn task_call(id: &str, tool: &str, tid: &str, title: &str, status: &str) -> String {
828 format!(
829 r#"{{"type":"assistant","message":{{"content":[{{"type":"tool_use","id":"{id}","name":"{tool}","input":{{"task_id":"{tid}","title":"{title}","status":"{status}"}}}}]}}}}"#
830 )
831 }
832
833 fn tool_use(id: &str, name: &str) -> String {
834 format!(
835 r#"{{"type":"assistant","message":{{"content":[{{"type":"tool_use","id":"{id}","name":"{name}","input":{{}}}}]}}}}"#
836 )
837 }
838
839 fn tool_result(id: &str) -> String {
840 format!(
841 r#"{{"type":"user","message":{{"content":[{{"type":"tool_result","tool_use_id":"{id}"}}]}}}}"#
842 )
843 }
844
845 fn feed(state: &mut BlockState, lines: &[String]) {
847 feed_at(state, lines, 1_000);
848 }
849
850 fn feed_at(state: &mut BlockState, lines: &[String], now_ms: u64) {
851 let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
852 apply_lines(state, &refs, Some(now_ms));
853 }
854
855 fn output_store(block: &str, text: &str) -> FileStore {
856 use crate::backend::storage::filestore::{FileMeta, FileOpts};
857 let fs = FileStore::open_in_memory().unwrap();
858 fs.make_file(block, "output", FileMeta::default(), FileOpts::default()).unwrap();
859 fs.append_data(block, "output", text.as_bytes()).unwrap();
860 fs
861 }
862
863 #[test]
867 fn a_first_sight_backlog_does_not_read_as_just_edited() {
868 let block = "b-backlog";
869 let stamped = r#"{"type":"assistant","timestamp":"2026-10-01T10:00:00Z","message":{"content":[{"type":"tool_use","id":"s1","name":"Edit","input":{"file_path":"/r/stamped.rs"}}]}}"#;
870 let backlog = format!("{}
871{}
872", edit_call("e1", "Edit", "file_path", "/r/old.rs"), stamped);
873 let fs = output_store(block, &backlog);
874 let mut st = BlockState::default();
875 assert!(consume_new_output(&fs, block, &mut st));
876 let got: Vec<(&str, u64)> = st.recent_files.iter().map(|f| (f.path.as_str(), f.ts_ms)).collect();
877 assert_eq!(got, vec![("/r/stamped.rs", 1_790_848_800_000)], "the untimed backlog edit is not recorded");
878 assert!(st.new_edits.is_empty(), "backlog edits are never checked for overlap");
879
880 let before = agentmux_common::time::now_ms_u64();
881 fs.append_data(block, "output", format!("{}
882", edit_call("e2", "Write", "file_path", "/r/new.rs")).as_bytes())
883 .unwrap();
884 assert!(consume_new_output(&fs, block, &mut st));
885 assert_eq!(st.recent_files[0].path, "/r/new.rs");
886 assert!(st.recent_files[0].ts_ms >= before, "a live edit takes the read time");
887 let live: Vec<&str> = st.new_edits.iter().map(|f| f.path.as_str()).collect();
888 assert_eq!(live, vec!["/r/new.rs"], "a live edit is handed to the overlap check");
889 }
890
891 #[test]
893 fn a_block_first_seen_empty_records_its_first_edits() {
894 let block = "b-empty";
895 let fs = FileStore::open_in_memory().unwrap();
896 let mut st = BlockState::default();
897 assert!(!consume_new_output(&fs, block, &mut st));
898 let fs = output_store(block, &format!("{}
899", edit_call("e1", "Edit", "file_path", "/r/a.rs")));
900 assert!(consume_new_output(&fs, block, &mut st));
901 assert_eq!(st.recent_files.len(), 1);
902 }
903
904 fn edit_call(id: &str, tool: &str, key: &str, path: &str) -> String {
905 format!(
906 r#"{{"type":"assistant","message":{{"content":[{{"type":"tool_use","id":"{id}","name":"{tool}","input":{{"{key}":"{path}","old_string":"a","new_string":"b"}}}}]}}}}"#
907 )
908 }
909
910 #[test]
913 fn edit_tools_fill_recent_files_newest_first() {
914 let mut st = BlockState::default();
915 feed_at(
916 &mut st,
917 &[
918 edit_call("e1", "Edit", "file_path", "C:/r/a.rs"),
919 tool_result("e1"),
920 edit_call("e2", "Write", "file_path", "C:/r/b.rs"),
921 edit_call("e3", "MultiEdit", "file_path", "C:/r/c.rs"),
922 ],
923 10,
924 );
925 feed_at(&mut st, &[edit_call("e4", "NotebookEdit", "notebook_path", "C:/r/n.ipynb")], 20);
926 let got: Vec<(&str, &str, u64)> =
927 st.recent_files.iter().map(|f| (f.path.as_str(), f.tool.as_str(), f.ts_ms)).collect();
928 assert_eq!(
929 got,
930 vec![
931 ("C:/r/n.ipynb", "NotebookEdit", 20),
932 ("C:/r/c.rs", "MultiEdit", 10),
933 ("C:/r/b.rs", "Write", 10),
934 ("C:/r/a.rs", "Edit", 10),
935 ]
936 );
937 assert!(st.progress().todos.is_empty());
939 assert_eq!(st.progress().current_tool.as_deref(), Some("Write"));
940 }
941
942 #[test]
943 fn editing_a_file_again_moves_it_to_the_front_without_duplicating_it() {
944 let mut st = BlockState::default();
945 feed_at(&mut st, &[edit_call("e1", "Edit", "file_path", "/r/a.rs")], 1);
946 feed_at(&mut st, &[edit_call("e2", "Edit", "file_path", "/r/b.rs")], 2);
947 feed_at(&mut st, &[edit_call("e3", "Write", "file_path", "/r/a.rs")], 3);
948 let got: Vec<(&str, u64)> = st.recent_files.iter().map(|f| (f.path.as_str(), f.ts_ms)).collect();
949 assert_eq!(got, vec![("/r/a.rs", 3), ("/r/b.rs", 2)]);
950 assert_eq!(st.recent_files[0].tool, "Write");
951 }
952
953 #[test]
954 fn recent_files_stay_bounded() {
955 let mut st = BlockState::default();
956 let calls: Vec<String> = (0..MAX_RECENT_FILES + 7)
957 .map(|i| edit_call(&format!("e{i}"), "Edit", "file_path", &format!("/r/f{i}.rs")))
958 .collect();
959 feed(&mut st, &calls);
960 assert_eq!(st.recent_files.len(), MAX_RECENT_FILES);
961 assert_eq!(st.recent_files[0].path, format!("/r/f{}.rs", MAX_RECENT_FILES + 6));
962 }
963
964 #[test]
965 fn reads_and_other_tools_add_no_recent_files() {
966 let mut st = BlockState::default();
967 feed(
968 &mut st,
969 &[
970 edit_call("r1", "Read", "file_path", "/r/a.rs"),
971 edit_call("g1", "Grep", "file_path", "/r/b.rs"),
972 edit_call("w1", "Edit", "old_string", "no path"),
973 ],
974 );
975 assert!(st.recent_files.is_empty());
976 }
977
978 fn run(lines: &[String]) -> AgentProgress {
979 let mut st = BlockState::default();
980 feed(&mut st, lines);
981 st.progress()
982 }
983
984 #[test]
985 fn extracts_a_todowrite_checklist() {
986 let p = run(&[todo_write(
987 "t1",
988 r#"[{"content":"Fix the bug","status":"in_progress"},{"content":"Write tests","status":"pending"}]"#,
989 )]);
990 assert_eq!(
991 p.todos,
992 vec![
993 TodoItem { text: "Fix the bug".into(), status: "in_progress".into() },
994 TodoItem { text: "Write tests".into(), status: "pending".into() },
995 ]
996 );
997 }
998
999 #[test]
1000 fn the_latest_full_list_supersedes_earlier_ones() {
1001 let p = run(&[
1002 todo_write("t1", r#"[{"content":"A","status":"pending"}, {"content":"B","status":"pending"}]"#),
1003 todo_write("t2", r#"[{"content":"A","status":"completed"}]"#),
1004 ]);
1005 assert_eq!(p.todos, vec![TodoItem { text: "A".into(), status: "completed".into() }]);
1006 }
1007
1008 #[test]
1009 fn task_style_calls_accumulate_and_update_in_place() {
1010 let p = run(&[
1011 task_call("c1", "TaskCreate", "k1", "First", "pending"),
1012 task_call("c2", "TaskCreate", "k2", "Second", "pending"),
1013 task_call("u1", "TaskUpdate", "k1", "First", "completed"),
1014 ]);
1015 assert_eq!(
1016 p.todos,
1017 vec![
1018 TodoItem { text: "First".into(), status: "completed".into() },
1019 TodoItem { text: "Second".into(), status: "pending".into() },
1020 ],
1021 "an update revises its own row and preserves creation order",
1022 );
1023 }
1024
1025 #[test]
1030 fn a_task_created_in_an_earlier_tick_survives_later_unrelated_output() {
1031 let mut st = BlockState::default();
1032 feed(&mut st, &[task_call("c1", "TaskCreate", "k1", "Long-lived task", "pending")]);
1033
1034 let noise: Vec<String> = (0..50).map(|i| tool_use(&format!("n{i}"), "Read")).collect();
1036 feed(&mut st, &noise);
1037
1038 assert_eq!(
1039 st.progress().todos,
1040 vec![TodoItem { text: "Long-lived task".into(), status: "pending".into() }],
1041 "the task is still open and must still be listed",
1042 );
1043 }
1044
1045 #[test]
1046 fn an_update_arriving_a_tick_later_still_finds_its_row() {
1047 let mut st = BlockState::default();
1048 feed(&mut st, &[task_call("c1", "TaskCreate", "k1", "Task", "pending")]);
1049 feed(&mut st, &[tool_use("n1", "Bash"), tool_result("n1")]);
1050 feed(&mut st, &[task_call("u1", "TaskUpdate", "k1", "Task", "completed")]);
1051
1052 assert_eq!(st.progress().todos, vec![TodoItem { text: "Task".into(), status: "completed".into() }]);
1053 }
1054
1055 #[test]
1056 fn current_tool_is_the_outstanding_tool_use() {
1057 let p = run(&[tool_use("a", "Read"), tool_result("a"), tool_use("b", "Bash")]);
1058 assert_eq!(p.current_tool.as_deref(), Some("Bash"));
1059 }
1060
1061 #[test]
1062 fn no_current_tool_once_every_call_has_returned() {
1063 let p = run(&[tool_use("a", "Read"), tool_result("a")]);
1064 assert_eq!(p.current_tool, None);
1065 }
1066
1067 #[test]
1070 fn a_result_in_a_later_tick_clears_the_call_from_an_earlier_one() {
1071 let mut st = BlockState::default();
1072 feed(&mut st, &[tool_use("a", "Bash")]);
1073 assert_eq!(st.progress().current_tool.as_deref(), Some("Bash"));
1074 feed(&mut st, &[tool_result("a")]);
1075 assert_eq!(st.progress().current_tool, None);
1076 }
1077
1078 #[test]
1079 fn an_explicitly_cleared_checklist_reads_as_empty() {
1080 let p = run(&[
1081 todo_write("t1", r#"[{"content":"A","status":"pending"}]"#),
1082 todo_write("t2", "[]"),
1083 ]);
1084 assert!(p.todos.is_empty());
1085 }
1086
1087 #[test]
1088 fn truncates_a_pathological_list_and_reports_how_many_were_dropped() {
1089 let entries: Vec<String> = (0..MAX_TODOS + 5)
1090 .map(|i| format!(r#"{{"content":"item {i}","status":"pending"}}"#))
1091 .collect();
1092 let p = run(&[todo_write("t1", &format!("[{}]", entries.join(",")))]);
1093 assert_eq!(p.todos.len(), MAX_TODOS);
1094 assert_eq!(p.todos_truncated, 5);
1095 }
1096
1097 #[test]
1100 fn a_tail_seeded_block_reports_its_checklist_as_partial() {
1101 let mut st = BlockState { partial: true, ..Default::default() };
1102 feed(&mut st, &[task_call("c1", "TaskCreate", "k1", "Only what we saw", "pending")]);
1103 let p = st.progress();
1104 assert!(p.todos_partial, "item-at-a-time rows may be missing earlier entries");
1105 assert_eq!(p.todos_truncated, 0, "nothing was dropped by the cap");
1106 }
1107
1108 #[test]
1111 fn a_full_list_call_clears_the_partial_flag() {
1112 let mut st = BlockState { partial: true, ..Default::default() };
1113 feed(&mut st, &[todo_write("t1", r#"[{"content":"Whole list","status":"pending"}]"#)]);
1114 assert!(!st.progress().todos_partial);
1115 }
1116
1117 #[test]
1118 fn ignores_malformed_lines_instead_of_giving_up_on_the_whole_batch() {
1119 let p = run(&[
1120 "not json at all".to_string(),
1121 String::new(),
1122 todo_write("t1", r#"[{"content":"Survives","status":"pending"}]"#),
1123 ]);
1124 assert_eq!(p.todos.len(), 1);
1125 }
1126
1127 #[test]
1128 fn a_non_todo_tool_never_contributes_checklist_rows() {
1129 let p = run(&[tool_use("a", "Read"), tool_use("b", "Bash")]);
1130 assert!(p.todos.is_empty());
1131 }
1132
1133 #[test]
1134 fn recognizes_todo_tools_by_name_across_provider_vocabularies() {
1135 assert!(is_todo_tool("TodoWrite"));
1136 assert!(is_todo_tool("TaskCreate"));
1137 assert!(is_todo_tool("TaskUpdate"));
1138 assert!(is_todo_tool("mcp__planner__TodoSync"));
1140 assert!(!is_todo_tool("Read"));
1141 assert!(!is_todo_tool("Task"), "the subagent-spawning Task tool is not a checklist");
1142 }
1143
1144 #[test]
1148 fn read_style_task_tools_are_not_claimed_as_supported() {
1149 assert!(
1150 !TODO_TOOL_NAMES.contains(&"TaskList"),
1151 "TaskList's tasks live in its tool_result, not its input",
1152 );
1153 assert!(parse_todo_array(&serde_json::json!({})).is_none());
1157 }
1158
1159 #[test]
1160 fn the_open_tool_list_stays_bounded_when_results_go_missing() {
1161 let mut st = BlockState::default();
1162 let calls: Vec<String> = (0..MAX_OPEN_TOOLS + 20)
1163 .map(|i| tool_use(&format!("t{i}"), "Read"))
1164 .collect();
1165 feed(&mut st, &calls);
1166 assert_eq!(st.open.len(), MAX_OPEN_TOOLS);
1167 }
1168
1169 #[test]
1173 fn a_status_only_update_moves_the_row_without_resending_its_text() {
1174 let mut st = BlockState::default();
1175 feed(&mut st, &[task_call("c1", "TaskCreate", "k1", "Ship it", "pending")]);
1176 feed(
1177 &mut st,
1178 &[format!(
1179 r#"{{"type":"assistant","message":{{"content":[{{"type":"tool_use","id":"u1","name":"TaskUpdate","input":{{"task_id":"k1","status":"completed"}}}}]}}}}"#
1180 )],
1181 );
1182 assert_eq!(
1183 st.progress().todos,
1184 vec![TodoItem { text: "Ship it".into(), status: "completed".into() }],
1185 "text is preserved from the create, status taken from the update",
1186 );
1187 }
1188
1189 #[test]
1191 fn a_text_only_update_preserves_the_existing_status() {
1192 let mut st = BlockState::default();
1193 feed(&mut st, &[task_call("c1", "TaskCreate", "k1", "Old wording", "in_progress")]);
1194 feed(
1195 &mut st,
1196 &[format!(
1197 r#"{{"type":"assistant","message":{{"content":[{{"type":"tool_use","id":"u1","name":"TaskUpdate","input":{{"task_id":"k1","title":"New wording"}}}}]}}}}"#
1198 )],
1199 );
1200 assert_eq!(
1201 st.progress().todos,
1202 vec![TodoItem { text: "New wording".into(), status: "in_progress".into() }],
1203 );
1204 }
1205
1206 #[test]
1209 fn a_status_only_update_for_an_unknown_task_creates_no_row() {
1210 let mut st = BlockState::default();
1211 feed(
1212 &mut st,
1213 &[format!(
1214 r#"{{"type":"assistant","message":{{"content":[{{"type":"tool_use","id":"u1","name":"TaskUpdate","input":{{"task_id":"ghost","status":"completed"}}}}]}}}}"#
1215 )],
1216 );
1217 assert!(st.progress().todos.is_empty());
1218 }
1219
1220 #[test]
1224 fn parses_provider_native_top_level_tool_frames() {
1225 let mut st = BlockState::default();
1226 feed(
1227 &mut st,
1228 &[r#"{"type":"tool_use","tool_name":"Bash","tool_id":"g1","parameters":{}}"#.to_string()],
1229 );
1230 assert_eq!(st.progress().current_tool.as_deref(), Some("Bash"));
1231
1232 feed(
1233 &mut st,
1234 &[r#"{"type":"tool_result","tool_id":"g1","status":"success"}"#.to_string()],
1235 );
1236 assert_eq!(st.progress().current_tool, None, "a tool_id result clears its call");
1237 }
1238
1239 #[test]
1240 fn parses_a_todo_checklist_from_a_top_level_frame() {
1241 let mut st = BlockState::default();
1242 feed(
1243 &mut st,
1244 &[r#"{"type":"tool_use","tool_name":"TodoWrite","tool_id":"g2","parameters":{"todos":[{"content":"From Gemini","status":"pending"}]}}"#.to_string()],
1245 );
1246 assert_eq!(
1247 st.progress().todos,
1248 vec![TodoItem { text: "From Gemini".into(), status: "pending".into() }],
1249 );
1250 }
1251
1252 #[test]
1254 fn a_plain_line_is_not_treated_as_a_tool_frame() {
1255 let mut st = BlockState::default();
1256 feed(&mut st, &[r#"{"type":"system","subtype":"init","name":"TodoWrite"}"#.to_string()]);
1257 assert!(st.progress().todos.is_empty());
1258 assert_eq!(st.progress().current_tool, None);
1259 }
1260
1261 #[test]
1266 fn two_idless_creates_with_the_same_title_stay_two_rows() {
1267 let idless_create = |id: &str, title: &str| {
1268 format!(
1269 r#"{{"type":"assistant","message":{{"content":[{{"type":"tool_use","id":"{id}","name":"TaskCreate","input":{{"title":"{title}","status":"pending"}}}}]}}}}"#
1270 )
1271 };
1272 let p = run(&[idless_create("c1", "Retry the flaky step"), idless_create("c2", "Retry the flaky step")]);
1273 assert_eq!(p.todos.len(), 2, "two distinct tasks must not collapse into one row");
1274 assert!(p.todos.iter().all(|t| t.text == "Retry the flaky step"));
1275 }
1276
1277 #[test]
1280 fn an_idless_update_still_matches_its_row_by_text() {
1281 let mut st = BlockState::default();
1282 feed(&mut st, &[task_call("c1", "TaskCreate", "k1", "Ship it", "pending")]);
1283 feed(
1284 &mut st,
1285 &[format!(
1286 r#"{{"type":"assistant","message":{{"content":[{{"type":"tool_use","id":"u1","name":"TaskUpdate","input":{{"title":"Ship it","status":"completed"}}}}]}}}}"#
1287 )],
1288 );
1289 assert_eq!(
1290 st.progress().todos,
1291 vec![TodoItem { text: "Ship it".into(), status: "completed".into() }],
1292 );
1293 }
1294
1295 #[test]
1298 fn ids_take_precedence_over_text_for_identity() {
1299 let p = run(&[
1300 task_call("c1", "TaskCreate", "k1", "Same title", "pending"),
1301 task_call("c2", "TaskCreate", "k2", "Same title", "pending"),
1302 task_call("u1", "TaskUpdate", "k2", "Same title", "completed"),
1303 ]);
1304 assert_eq!(
1305 p.todos,
1306 vec![
1307 TodoItem { text: "Same title".into(), status: "pending".into() },
1308 TodoItem { text: "Same title".into(), status: "completed".into() },
1309 ],
1310 );
1311 }
1312
1313 #[test]
1314 fn create_style_tool_names_are_recognized() {
1315 assert!(is_create_call("TaskCreate"));
1316 assert!(is_create_call("TodoAdd"));
1317 assert!(!is_create_call("TaskUpdate"));
1318 assert!(!is_create_call("TodoWrite"), "a whole-list write never reaches the single-item path");
1319 }
1320
1321 #[test]
1322 fn empty_progress_is_recognized_so_idle_agents_publish_nothing() {
1323 assert!(AgentProgress::default().is_empty());
1324 let p = run(&[tool_use("a", "Read")]);
1325 assert!(!p.is_empty(), "an in-flight tool is worth publishing on its own");
1326 }
1327
1328 #[test]
1329 fn a_pr_create_is_recognized_in_any_shell_command() {
1330 assert!(is_pr_create("gh-agent pr create --title x --body y"));
1331 assert!(is_pr_create("cd /w/agentmux && gh pr create --fill"));
1332 assert!(is_pr_create("/usr/local/bin/gh-agent pr create -t x"));
1333 assert!(is_pr_create("C:\\npm\\gh-agent.cmd pr create -t x"));
1334 assert!(!is_pr_create("gh-agent pr view 12"));
1335 assert!(!is_pr_create("echo gh pr list; grep create"));
1336 assert!(!is_pr_create("git commit -m 'gh-agent pr: create'"));
1337 assert!(is_shell_tool("Bash") && is_shell_tool("mcp__agentmux__Shell") && !is_shell_tool("Edit"));
1338 assert!(is_pr_create("pwsh.exe -NoProfile -Command 'gh-agent pr create -t x'"));
1339 }
1340
1341 #[test]
1344 fn a_pr_create_with_flags_and_its_head_and_base() {
1345 assert!(is_pr_create("gh --repo owner/repo pr create -t x"));
1346 assert!(is_pr_create("gh pr -R owner/repo create -t x"));
1347 assert!(!is_pr_create("gh --repo pr view create"), "the value of --repo is not a word");
1348 assert!(!is_pr_create("gh pr view 1 && echo create"));
1349 let pr = |c: &str| pr_create(c).unwrap();
1350 assert_eq!(pr("gh-agent pr create -H agenty/x -B release"), NewPrCommand {
1351 head: Some("agenty/x".into()),
1352 base: Some("release".into())
1353 });
1354 assert_eq!(pr("gh pr create --head=me:feature --title t").head.as_deref(), Some("me:feature"));
1355 assert_eq!(pr("gh pr create -t x && git push --base y"), NewPrCommand::default(), "flags of the next command are not its");
1356 }
1357
1358 #[test]
1361 fn a_codex_pr_create_is_handed_to_the_overlap_note() {
1362 let started = r#"{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"pwsh.exe -NoProfile -Command 'gh-agent pr create -t x'","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#;
1363 let completed = started.replace("item.started", "item.completed");
1364 let mut st = BlockState::default();
1365 apply_lines(&mut st, &[started, completed.as_str()], Some(1_000));
1366 assert_eq!(st.new_prs.len(), 1, "the start counts, its completion does not");
1367 }
1368
1369 #[test]
1370 fn only_a_live_pr_create_is_handed_to_the_overlap_note() {
1371 let line = |cmd: &str| format!(
1372 r#"{{"type":"assistant","message":{{"content":[{{"type":"tool_use","id":"p1","name":"Bash","input":{{"command":"{cmd}"}}}}]}}}}"#
1373 );
1374 let mut backlog = BlockState::default();
1375 apply_lines(&mut backlog, &[line("gh-agent pr create -t x").as_str()], None);
1376 assert!(backlog.new_prs.is_empty(), "backlog is history");
1377 let mut live = BlockState::default();
1378 apply_lines(&mut live, &[line("gh-agent pr create -t x").as_str(), line("ls").as_str()], Some(1_000));
1379 assert_eq!(live.new_prs, vec![NewPrCommand::default()]);
1380 assert_eq!(live.open.len(), 2, "both calls still count as in flight");
1381 }
1382}