Skip to main content

agentmux_srv/backend/reactive/
progress_watcher.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pushed per-agent **progress**: the agent's current todo checklist and the
5//! tool it is running right now, published as an `agent:progress` MuxEvent so
6//! the Swarm pane can list them under the agent's name.
7//!
8//! Sibling of [`super::activity_watcher`], and deliberately a separate loop:
9//! that one spends a Haiku call per agent to write an English one-liner and is
10//! throttled accordingly. This one only reads NEW bytes of a block's `output`
11//! file and parses JSON, so it costs nothing per tick and can run far more
12//! often — which is the point, since a checklist that lags 20s behind the agent
13//! is worse than no checklist.
14//!
15//! Why the backend and not the frontend: `agent-document-store` already holds
16//! every tool call, but only for panes that are currently MOUNTED. The Swarm
17//! pane's whole value is seeing agents you are not looking at, so the data has
18//! to come from the same place the shell/cron/summary rows come from.
19//!
20//! **Why state is carried across ticks** (reagent P1 on PR #2952): the
21//! checklist cannot be rebuilt from a fixed tail window. `TodoWrite`-style
22//! calls are immune — one call carries the whole list — but the vocabulary
23//! AgentMux actually ships against (`TaskCreate`/`TaskUpdate`) describes ONE
24//! item per call, so reconstructing the list needs every still-open task's
25//! original `TaskCreate` to be inside the window. A verbose `Bash` or a large
26//! `Read` pushes those out within seconds, and the task would then vanish from
27//! the checklist while still open, silently. So each block keeps an accumulated
28//! [`BlockState`] and each tick only consumes the bytes appended since the last
29//! one.
30
31use 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
44/// How often to sweep registered agents. Much tighter than the summary loop's
45/// 20s: this is an incremental read + JSON parse, with no model call to bill.
46const SWEEP_INTERVAL_SECS: u64 = 3;
47
48/// Most bytes consumed in one tick, and the most read when first catching up on
49/// a block. A block seen from its first byte is exact; one that already had
50/// more than this when we first saw it (srv restarted mid-session) is seeded
51/// from its tail and flagged — see [`BlockState::partial`].
52const MAX_READ_BYTES: i64 = 4 * 1024 * 1024;
53
54/// Cap on rows published per agent, so one pathological checklist can't flood
55/// the Swarm tree. Truncation is reported rather than silent — see
56/// [`AgentProgress::todos_truncated`].
57const MAX_TODOS: usize = 24;
58
59/// Bound on remembered in-flight tool calls. Real concurrency is a handful; a
60/// larger number means results are being missed, and the oldest entries are the
61/// ones least likely to still be running.
62const MAX_OPEN_TOOLS: usize = 32;
63
64pub const EVENT_AGENT_PROGRESS: &str = "agent:progress";
65
66/// Republish an unchanged payload every N ticks (~15s at a 3s sweep).
67///
68/// The change-suppression below is what keeps this loop quiet, but on its own
69/// it strands a LATE subscriber (codex P1 on PR #2952): open the Swarm after an
70/// agent's checklist has settled and there is no further change to deliver, so
71/// the pane shows nothing indefinitely for a perfectly active agent. The event
72/// is also published with `persist: 1` so the broker replays the latest one to
73/// a freshly-subscribed route; this periodic republish is the belt to that
74/// suspenders, and covers any subscriber the replay path does not.
75const REPUBLISH_EVERY_TICKS: u64 = 5;
76
77/// Tool names whose **call arguments** carry checklist state.
78///
79/// Deliberately a list rather than the single `TodoWrite` everyone expects:
80/// Claude Code 2.1.177 — the version AgentMux ships against today — advertises
81/// `TaskCreate`/`TaskUpdate` and no `TodoWrite` at all, and other providers
82/// differ again. Matching by a known set plus the `Todo` substring below means
83/// a version bump that renames the tool degrades to "no rows" rather than to
84/// wrong rows.
85///
86/// READ-style tools (`TaskList`, `TodoRead`) are deliberately absent. Their
87/// task data lives in the tool RESULT, not the call's `input`, and this parser
88/// only reads `input` — so listing them would advertise support that silently
89/// contributes nothing (reagent P2 on PR #2952). Supporting them means parsing
90/// tool_result payloads, which is its own change.
91const 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
97/// Tools whose input names a file the agent is changing. Their paths become
98/// the agent's recent files, which `backend::work_facts` compares across
99/// agents (docs/specs/SPEC_AGENT_OVERLAP_AWARENESS_2026_10_10.md §3.1).
100const FILE_EDIT_TOOL_NAMES: &[&str] = &["Edit", "Write", "MultiEdit", "NotebookEdit"];
101
102/// A tool that runs a shell command (Claude's `Bash`, a provider's `shell`,
103/// AgentMux's own `Shell`), by name.
104fn is_shell_tool(name: &str) -> bool {
105    let lower = name.to_ascii_lowercase();
106    lower.contains("bash") || lower.ends_with("shell")
107}
108
109/// Whether a shell command opens a PR: `gh-agent pr create` or `gh pr create`,
110/// anywhere in it (`cd x && gh-agent pr create --title …`, or inside a quoted
111/// shell call like Codex's `pwsh -Command 'gh-agent pr create …'`), the
112/// program named by its path or with `.cmd`/`.exe` too.
113#[cfg(test)]
114fn is_pr_create(command: &str) -> bool {
115    pr_create(command).is_some()
116}
117
118/// The PR being opened in a shell command (see [`is_pr_create`]), with the
119/// `--head` and `--base` it names, if any. Flags may sit between the words
120/// (`gh --repo o/r pr create`, `gh pr -R o/r create`); the command ends at a
121/// shell separator.
122fn 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        // `pr create`, skipping flags (`-R`/`--repo` take a value).
143        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/// The head and base a PR-create command names (`--head`, `--base`).
187#[derive(Debug, Clone, Default, PartialEq, Eq)]
188struct NewPrCommand {
189    head: Option<String>,
190    base: Option<String>,
191}
192
193/// PR creations kept per sweep, at most.
194const MAX_NEW_PRS: usize = 4;
195
196/// Most recent files kept per block, distinct paths, newest first.
197pub const MAX_RECENT_FILES: usize = 50;
198
199/// One file an agent changed, as its tool call named it.
200#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
201pub struct RecentFile {
202    /// The path exactly as the tool input wrote it (usually absolute).
203    pub path: String,
204    pub tool: String,
205    /// When the call was made: the transcript line's own `timestamp`, else
206    /// the moment this watcher read it live. A line without a timestamp read
207    /// as backlog (see [`apply_lines`]) is not recorded at all, so a block
208    /// first seen mid-session never reads as "edited just now".
209    pub ts_ms: u64,
210}
211
212/// A transcript line's own time, when it carries one (`timestamp`, RFC 3339
213/// or Unix ms, as Claude's session lines do).
214fn 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/// What the watcher currently knows about one block, for readers outside
225/// the sweep loop.
226#[derive(Debug, Clone, Default)]
227pub struct WatchedWork {
228    pub progress: AgentProgress,
229    pub recent_files: Vec<RecentFile>,
230}
231
232/// The latest [`WatchedWork`] per block id, replaced after every sweep.
233fn 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
239/// Read access to the watcher's state for one block: its checklist, current
240/// tool and recent files. `None` until the watcher has swept it once.
241pub fn watched_work(block_id: &str) -> Option<WatchedWork> {
242    snapshot().read().get(block_id).cloned()
243}
244
245/// Record that `path` was just changed by `tool`: moved to the front if it
246/// was already listed, and the list kept to [`MAX_RECENT_FILES`].
247fn 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/// One checklist entry as the Swarm renders it.
254#[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    /// `pending` | `in_progress` | `completed`. Passed through as the provider
259    /// wrote it where possible; unknown values are kept verbatim rather than
260    /// coerced, so a new provider status shows up as itself instead of
261    /// silently becoming "pending".
262    pub status: String,
263}
264
265/// What we currently believe about one agent.
266#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
267pub struct AgentProgress {
268    pub todos: Vec<TodoItem>,
269    /// How many rows [`MAX_TODOS`] dropped, so the UI can say "+N more"
270    /// instead of quietly showing a partial list as if it were the whole one.
271    pub todos_truncated: usize,
272    /// True when this block was first seen mid-stream, so item-at-a-time
273    /// (`TaskCreate`) calls from before that point were never observed and the
274    /// checklist may be missing entries. Distinct from `todos_truncated`,
275    /// which is a cap we chose; this is history we could not see.
276    pub todos_partial: bool,
277    /// The tool this agent is running right now — the oldest `tool_use` with no
278    /// matching `tool_result` yet. `None` when the agent is between tools.
279    pub current_tool: Option<String>,
280}
281
282impl AgentProgress {
283    /// Nothing worth publishing — avoids a MuxEvent per tick per idle agent.
284    fn is_empty(&self) -> bool {
285        self.todos.is_empty() && self.current_tool.is_none()
286    }
287}
288
289/// Everything we accumulate for one block across ticks.
290#[derive(Debug, Default)]
291struct BlockState {
292    /// Byte offset of the next unread line in the block's `output`.
293    next_offset: i64,
294    /// Item-at-a-time accumulation (`TaskCreate`/`TaskUpdate`), keyed so an
295    /// update revises its own row. Insertion order is the agent's own creation
296    /// order, the only ordering it ever expressed.
297    tasks: Vec<(String, TodoItem)>,
298    /// Last whole-list call. Supersedes `tasks` outright when present, because
299    /// that is the tool's own replace-everything semantics.
300    list: Option<Vec<TodoItem>>,
301    /// In-flight `tool_use` calls, oldest first.
302    open: Vec<(String, String)>,
303    /// See [`AgentProgress::todos_partial`].
304    partial: bool,
305    /// Files named by edit tools, newest first (see [`note_recent_file`]).
306    recent_files: Vec<RecentFile>,
307    /// Edits read live since the sweep last took them, oldest first: what
308    /// the overlap notes check (`work_facts::overlap_notes`).
309    new_edits: Vec<RecentFile>,
310    /// PRs it started opening, read live since the sweep last took them: what
311    /// the PR overlap note checks (`work_facts::overlap_notes::on_new_prs`).
312    new_prs: Vec<NewPrCommand>,
313    /// This block's output has been looked at before.
314    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            // A whole-list call is self-contained, so once one has been seen
329            // the checklist is complete regardless of what we missed earlier.
330            todos_partial: self.partial && self.list.is_none(),
331            current_tool: self.open.first().map(|(_, name)| name.clone()),
332        }
333    }
334}
335
336/// Fold newly-appended transcript lines into `state`.
337///
338/// Pure w.r.t. I/O so it can be unit-tested against real transcript shapes
339/// without a FileStore. Called once per tick with only the lines appended since
340/// the previous call — never the whole file.
341///
342/// A recent file takes its line's own timestamp. Without one it takes
343/// `live_now_ms`: the read time when these lines were just written, `None`
344/// when they are backlog read on first sight of the block (srv restarted, or
345/// the block predates the watcher), whose read time says nothing about when
346/// the edit happened. Such an edit is not recorded.
347fn 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        // Resolve finished calls first: a tool_use and its result never share a
354        // line, so ordering within the line is not a concern.
355        for id in tool_result_ids(&v) {
356            state.open.retain(|(open_id, _)| *open_id != id);
357        }
358
359        // Codex reports a shell command as an `item.started` event of type
360        // `command_execution`, not a `tool_use`.
361        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                // Drop the oldest if we're clearly missing results, rather than
380                // letting this grow for the life of the block.
381                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                        // Backlog is history, not something the agent is
403                        // doing now: only live edits are checked for overlap.
404                        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            // A whole-list call, once seen, owns the checklist — an
423            // item-at-a-time call after it would be describing a different
424            // vocabulary than the one currently in force.
425            if state.list.is_none() {
426                merge_single_task(&mut state.tasks, input, name);
427            }
428        }
429    }
430}
431
432/// Every frame in a transcript line that could be a tool call or result.
433///
434/// Covers two genuinely different provider shapes (codex P2 on PR #2952):
435///   * Claude stream-json — blocks inside `message.content` (or a bare
436///     top-level `content` array).
437///   * Gemini and friends — the LINE ITSELF is the frame:
438///     `{"type":"tool_use","tool_name":…,"tool_id":…,"parameters":{…}}`
439///     (see `frontend/app/view/agent/providers/gemini-translator.ts`).
440///
441/// Without the second, every non-Claude agent silently showed no in-flight
442/// tool and no todos at all, since the array lookup just yielded nothing.
443fn 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    // A bare frame is only a frame if it declares one of the two types we
452    // care about — otherwise every ordinary line would look like one.
453    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
459/// Tool name under either vocabulary (`name` for Claude, `tool_name` for
460/// Gemini-shaped frames).
461fn 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
468/// Call id under either vocabulary.
469fn 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
476/// Call arguments under either vocabulary.
477fn 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        // `tool_use_id` (Claude) or `tool_id` (Gemini) — a result names the
491        // call it belongs to under whichever key its provider uses.
492        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
503/// `input.todos` (or `input.items`) as a whole checklist.
504fn 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    // An empty array is a real state ("checklist cleared"), so return it as
510    // such rather than falling through to the single-item path.
511    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
520/// Fold a Task-style call describing a SINGLE item into the accumulated rows.
521///
522/// Keyed by whatever id the tool uses so a later call revises the same row,
523/// falling back to the text for tools that hand back no id.
524///
525/// Fields are merged, not replaced (codex P1 on PR #2952). A `TaskUpdate`
526/// legitimately carries only `task_id` + the changed `status` — it has no
527/// reason to resend text that did not change. Requiring a complete item meant
528/// such a call parsed to nothing and the row sat at `pending` forever, which is
529/// precisely the case a checklist exists to show moving.
530/// Does this tool name describe CREATING an item, as opposed to revising one?
531///
532/// Only consulted for calls that carry no id (reagent P2 on PR #2952). With an
533/// id the key is unambiguous; without one, text is the only identity available,
534/// and keying on it alone silently collapses two genuinely distinct in-flight
535/// tasks that happen to share a title. Two visible duplicate rows are a much
536/// smaller harm than one invisibly missing row, so an idless CREATE always
537/// appends and only an idless UPDATE matches by text.
538fn 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    // An idless create is its own row, always — see `is_create_call`.
548    if id.is_none() && is_create_call(tool_name) {
549        let Some(text) = text else { return };
550        items.push((
551            // Unique so no later call can match it by key; an idless tool
552            // gives us nothing to correlate a future update against anyway.
553            format!("{}#{}", text, items.len()),
554            TodoItem { text, status: status.unwrap_or_else(|| "pending".to_string()) },
555        ));
556        return;
557    }
558
559    // Find the row this call refers to. An id matches the key it was stored
560    // under; an idless update has only its text, and must compare against each
561    // row's TEXT rather than its key — a row created WITH an id is keyed by
562    // that id, so a key comparison would miss it and append a duplicate.
563    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        // Neither an id nor any text — nothing identifies a row to touch.
582        return;
583    };
584
585    // Creating a row still needs something to display; an update for a task we
586    // never saw created (seeded mid-stream) has no text to show.
587    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
598/// Consume whatever has been appended to this block's `output` since the last
599/// tick, folding it into `state`.
600///
601/// Returns false when there was nothing to do, so the caller can skip the rest
602/// of the work for this block.
603fn 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    // Whatever is already there the first time this block is looked at is
610    // backlog; only what appears after that was written live (see
611    // `apply_lines` on stamping recent files).
612    let first_sight = !state.seen;
613    state.seen = true;
614    if size <= 0 {
615        return false;
616    }
617
618    // The file shrank — a new session reusing the block, or a truncation. Our
619    // accumulated state describes a transcript that no longer exists, so start
620    // over rather than mixing two conversations' checklists.
621    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        // First sight of a block that is already long (srv restarted
627        // mid-session). Seed from the tail and admit the list may be missing
628        // item-at-a-time entries from before this point.
629        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    // Only consume up to the last complete line: a read boundary lands
646    // anywhere, and half a JSON object would be dropped as malformed and then
647    // never seen again in its complete form.
648    let consumed = match bytes.iter().rposition(|b| *b == b'\n') {
649        Some(idx) => idx + 1,
650        // No newline anywhere in the chunk — an unterminated line longer than
651        // the read. Skip it wholesale rather than stalling forever on it.
652        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
667/// Run the progress sweep loop. Never returns.
668/// One block's worth of work decided by a sweep — what to publish, if anything.
669struct SweepOutcome {
670    agent_id: String,
671    block_id: String,
672    progress: AgentProgress,
673}
674
675/// One sweep's result: the states back, what to publish, and the edits and
676/// PRs the overlap notes check.
677type Swept = (HashMap<String, BlockState>, Vec<SweepOutcome>, (Vec<NewEdit>, Vec<NewPr>));
678
679/// The whole file-reading half of a sweep, run OFF the async runtime.
680///
681/// `FileStore::stat`/`read_at` are synchronous, mutex-guarded SQLite reads, and
682/// this loop runs them for every live agent at a 3s cadence (reagent P2 on PR
683/// #2952). Doing that inline would park a Tokio worker thread on disk I/O
684/// several times a second — tolerable at `activity_watcher`'s 20s, much less so
685/// here. `states` is moved in and back out rather than shared behind a lock:
686/// only this loop ever touches it, and one owner is simpler than one mutex.
687fn 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        // Same gate as the summary loop: an idle or non-agent pane has no
700        // progress to report and shouldn't be read every tick.
701        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        // Nothing new AND nothing already published for this block — an agent
722        // that has never had progress costs no events at all.
723        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
738/// Run the progress sweep loop. Never returns.
739pub 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    // Last payload published per block, so an unchanged checklist doesn't
744    // re-publish every 3s.
745    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        // Both maps are bounded by the live agent count, not by every block_id
760        // ever seen in the process's lifetime.
761        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        // A panic inside the blocking closure loses that tick's accumulated
773        // state. Start clean rather than killing the loop: the next sweep
774        // re-seeds from the tail (flagged partial), which is degraded but
775        // recoverable, whereas returning here would end progress reporting for
776        // the life of the process.
777        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        // Hands off and returns at once: the check runs on its own task.
786        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            // persist 1: replayed to a freshly-subscribed route, so a Swarm opened
810            // mid-session sees the current checklist rather than waiting
811            // for the agent's next change — see REPUBLISH_EVERY_TICKS.
812            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    /// One tick's worth of lines.
846    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    /// An srv restart (or any first sight of a block) reads the backlog: an
864    /// edit there without its own timestamp must not read as "edited now",
865    /// one with a timestamp keeps it, and what is written afterwards is live.
866    #[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    /// A block first seen while still empty: everything after is live.
892    #[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    /// A fake output stream with Edit / Write / MultiEdit / NotebookEdit
911    /// calls fills recent files, newest first, with the read time.
912    #[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        // The checklist and current tool are unchanged by the new bookkeeping.
938        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    /// THE reagent P1 regression: a TaskCreate seen in an EARLIER tick must
1026    /// survive once its line has scrolled out of any fixed tail window. A
1027    /// stateless, tail-only parser drops it silently while the task is still
1028    /// open — the failure this whole accumulate-across-ticks design exists for.
1029    #[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        // A later tick carrying only unrelated chatter — no todo tool in sight.
1035        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    /// A result arriving in a later tick than its call must still clear it —
1068    /// the common case now that ticks are 3s and tools are slower than that.
1069    #[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    /// `partial` is about history we could not see; `todos_truncated` is a cap
1098    /// we chose. They are different claims and must not be conflated.
1099    #[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    /// …but a whole-list call is self-contained, so seeing one makes the
1109    /// checklist complete regardless of what came before.
1110    #[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        // Substring fallback for a renamed/namespaced variant.
1139        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    /// Read-style tools return their data in the RESULT, which this parser
1145    /// never reads — so claiming to support them would be advertising a
1146    /// no-op (reagent P2 on PR #2952).
1147    #[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        // TodoRead matches only via the deliberate `Todo` substring fallback,
1154        // and still contributes nothing because its input carries no items —
1155        // which is correct behavior, not a supported path.
1156        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    /// codex P1: a TaskUpdate legitimately carries only the id and the changed
1170    /// status. Requiring a complete item meant the row sat at `pending`
1171    /// forever — precisely the transition a checklist exists to show.
1172    #[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    /// The mirror: a text-only edit must not reset a status back to pending.
1190    #[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    /// An update for a task we never saw created (seeded mid-stream) has no
1207    /// text to display, so it must not invent a blank row.
1208    #[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    /// codex P2: Gemini-shaped transcripts put the frame at the TOP LEVEL with
1221    /// `tool_name`/`tool_id`/`parameters`. Before this, every non-Claude agent
1222    /// showed no in-flight tool and no todos at all.
1223    #[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    /// An ordinary transcript line must not be mistaken for a bare tool frame.
1253    #[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    /// reagent P2: with no id, text is the only identity available, and keying
1262    /// on it alone silently merged two genuinely distinct in-flight tasks that
1263    /// happened to share a title. One invisibly missing row is a worse failure
1264    /// than two visible duplicates, so an idless CREATE always appends.
1265    #[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    /// …but an idless UPDATE still has to find its row, since matching by text
1278    /// is the only correlation available and appending would be a duplicate.
1279    #[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    /// An id, when present, is always the key — two same-titled tasks with
1296    /// distinct ids stay distinct, and an update by id finds its own row.
1297    #[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    /// gh's inherited flags can sit between the words, and the PR's head and
1342    /// base can be named.
1343    #[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    /// Codex's shell commands are `item.started` events, as its 0.154.0
1359    /// fixture records them (frontend/test/fixtures/providers/codex/0.154.0/command.jsonl).
1360    #[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}