1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3use std::time::Duration;
4
5use tokio::sync::Notify;
6use tokio_util::sync::CancellationToken;
7
8use crate::error::RuntimeError;
9use crate::message::Message;
10use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
11use crate::value::Value;
12
13pub type WatcherId = String;
14
15#[derive(Debug, Clone)]
16pub enum WatchSource {
17 Terminal { handle: String },
18 Bash { handle: String },
19 Agent { handle: String },
20}
21
22impl WatchSource {
23 pub fn kind_str(&self) -> &'static str {
24 match self {
25 Self::Terminal { .. } => "terminal",
26 Self::Bash { .. } => "bash",
27 Self::Agent { .. } => "agent",
28 }
29 }
30
31 pub fn handle(&self) -> &str {
32 match self {
33 Self::Terminal { handle } | Self::Bash { handle } | Self::Agent { handle } => handle,
34 }
35 }
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum WatchMode {
40 Once,
41 Persist,
42}
43
44#[derive(Debug, Clone)]
45pub struct WatchEvent {
46 pub watcher_id: WatcherId,
47 pub source: WatchSource,
48 pub pattern: String,
49 pub row: Option<u16>,
50 pub col: Option<u16>,
51 pub text: String,
52 pub timestamp: chrono::DateTime<chrono::Utc>,
53 pub timed_out: bool,
54 pub exited: bool,
55 pub timeout: Duration,
56}
57
58#[derive(Debug, Clone)]
59pub enum WatchResult {
60 Matched {
61 row: Option<u16>,
62 col: Option<u16>,
63 text: String,
64 },
65 SourceExited,
66 Cancelled,
67}
68
69struct Watcher {
70 id: WatcherId,
71 source: WatchSource,
72 pattern: String,
73 mode: WatchMode,
74 timeout: Duration,
75 created_at: chrono::DateTime<chrono::Utc>,
76 cancel: CancellationToken,
77}
78
79#[derive(Debug, Clone)]
80pub struct WatcherInfo {
81 pub id: WatcherId,
82 pub source: WatchSource,
83 pub pattern: String,
84 pub mode: WatchMode,
85 pub age: Duration,
86 pub timeout: Duration,
87}
88
89pub struct WatchHub {
90 watchers: Mutex<HashMap<WatcherId, Watcher>>,
91 pending_events: Mutex<Vec<WatchEvent>>,
92 notify: Notify,
93}
94
95impl Default for WatchHub {
96 fn default() -> Self {
97 Self::new()
98 }
99}
100
101impl WatchHub {
102 pub fn new() -> Self {
103 Self {
104 watchers: Mutex::new(HashMap::new()),
105 pending_events: Mutex::new(Vec::new()),
106 notify: Notify::new(),
107 }
108 }
109
110 pub fn register(
111 &self,
112 source: WatchSource,
113 pattern: String,
114 mode: WatchMode,
115 timeout: Duration,
116 ) -> WatcherId {
117 let id = format!("w_{}", uuid::Uuid::now_v7().simple());
118 let cancel = CancellationToken::new();
119 let watcher = Watcher {
120 id: id.clone(),
121 source,
122 pattern,
123 mode,
124 timeout,
125 created_at: chrono::Utc::now(),
126 cancel,
127 };
128 self.watchers.lock().unwrap().insert(id.clone(), watcher);
129 id
130 }
131
132 pub fn unregister(&self, id: &WatcherId) -> bool {
133 if let Some(w) = self.watchers.lock().unwrap().remove(id) {
134 w.cancel.cancel();
135 true
136 } else {
137 false
138 }
139 }
140
141 pub fn has_active_watchers(&self) -> bool {
142 !self.watchers.lock().unwrap().is_empty()
143 }
144
145 pub fn list_active(&self) -> Vec<WatcherInfo> {
146 let now = chrono::Utc::now();
147 self.watchers
148 .lock()
149 .unwrap()
150 .values()
151 .map(|w| WatcherInfo {
152 id: w.id.clone(),
153 source: w.source.clone(),
154 pattern: w.pattern.clone(),
155 mode: w.mode,
156 age: (now - w.created_at).to_std().unwrap_or(Duration::ZERO),
157 timeout: w.timeout,
158 })
159 .collect()
160 }
161
162 pub fn list_watchers_for_handle(&self, handle: &str) -> Vec<WatcherInfo> {
163 self.list_active()
164 .into_iter()
165 .filter(|w| w.source.handle() == handle)
166 .collect()
167 }
168
169 pub fn get_cancel(&self, id: &WatcherId) -> CancellationToken {
170 self.watchers
171 .lock()
172 .unwrap()
173 .get(id)
174 .map(|w| w.cancel.clone())
175 .unwrap_or_default()
176 }
177
178 pub fn enqueue_event(&self, event: WatchEvent) {
179 let should_remove = {
180 let watchers = self.watchers.lock().unwrap();
181 watchers
182 .get(&event.watcher_id)
183 .map(|w| matches!(w.mode, WatchMode::Once))
184 .unwrap_or(false)
185 };
186 if should_remove {
187 if let Some(w) = self.watchers.lock().unwrap().remove(&event.watcher_id) {
188 w.cancel.cancel();
189 }
190 }
191 self.pending_events.lock().unwrap().push(event);
192 self.notify.notify_one();
193 }
194
195 pub async fn wait_for_event(&self, timeout: Duration) -> Option<WatchEvent> {
196 if let Some(evt) = self.pending_events.lock().unwrap().pop() {
197 return Some(evt);
198 }
199 if !self.has_active_watchers() {
200 return None;
201 }
202 let _ = tokio::time::timeout(timeout, self.notify.notified()).await;
203 self.pending_events.lock().unwrap().pop()
204 }
205}
206
207pub trait Watchable: Send + Sync {
208 fn watch_output(
209 self: Arc<Self>,
210 pattern: String,
211 cancel: CancellationToken,
212 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = WatchResult> + Send>>;
213}
214
215pub async fn handle_watch_result(
216 hub: Arc<WatchHub>,
217 wid: WatcherId,
218 source: WatchSource,
219 pattern: String,
220 timeout: Duration,
221 result: Result<WatchResult, tokio::time::error::Elapsed>,
222) {
223 match result {
224 Ok(WatchResult::Matched { row, col, text }) => {
225 hub.enqueue_event(WatchEvent {
226 watcher_id: wid,
227 source,
228 pattern,
229 row,
230 col,
231 text,
232 timestamp: chrono::Utc::now(),
233 timed_out: false,
234 exited: false,
235 timeout,
236 });
237 }
238 Ok(WatchResult::SourceExited) => {
239 hub.enqueue_event(WatchEvent {
240 watcher_id: wid.clone(),
241 source,
242 pattern,
243 row: None,
244 col: None,
245 text: String::new(),
246 timestamp: chrono::Utc::now(),
247 timed_out: false,
248 exited: true,
249 timeout,
250 });
251 hub.unregister(&wid);
252 }
253 Ok(WatchResult::Cancelled) => {
254 hub.unregister(&wid);
255 }
256 Err(_) => {
257 hub.enqueue_event(WatchEvent {
258 watcher_id: wid,
259 source,
260 pattern,
261 row: None,
262 col: None,
263 text: String::new(),
264 timestamp: chrono::Utc::now(),
265 timed_out: true,
266 exited: false,
267 timeout,
268 });
269 }
270 }
271}
272
273pub fn format_watch_event_text(evt: &WatchEvent) -> String {
274 let kind = evt.source.kind_str();
275 let handle = evt.source.handle();
276 if evt.exited {
277 format!(
278 "[watcher {}] {} '{}' has already exited. Pattern '{}' will never match.",
279 evt.watcher_id, kind, handle, evt.pattern
280 )
281 } else if evt.timed_out {
282 format!(
283 "[watcher {}] {} '{}' pattern '{}' not detected in {}s. \
284 Consider using {}.capture or {}.output to check current state.",
285 evt.watcher_id,
286 kind,
287 handle,
288 evt.pattern,
289 evt.timeout.as_secs(),
290 kind,
291 kind
292 )
293 } else {
294 format!(
295 "[watcher {}] {} '{}' matched '{}' at row {:?}, col {:?}: {}",
296 evt.watcher_id, kind, handle, evt.pattern, evt.row, evt.col, evt.text
297 )
298 }
299}
300
301fn watch_source_to_value(src: &WatchSource) -> Value {
302 Value::Struct(vec![
303 ("kind".into(), Value::Str(src.kind_str().into())),
304 ("handle".into(), Value::Str(src.handle().into())),
305 ])
306}
307
308fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
309 let value = match args.named(name) {
310 Some(v) => v,
311 None => args.positional(pos)?,
312 };
313 match value {
314 Value::Str(s) => Ok(s.clone()),
315 other => Err(RuntimeError::TypeMismatch {
316 expected: "string".into(),
317 actual: other.kind_name().into(),
318 }),
319 }
320}
321
322fn extract_optional_string(args: &ToolArgs, name: &str) -> Option<String> {
323 args.named(name).and_then(|v| {
324 if let Value::Str(s) = v {
325 Some(s.clone())
326 } else {
327 None
328 }
329 })
330}
331
332fn extract_optional_int(args: &ToolArgs, name: &str) -> Option<i64> {
333 args.named(name).and_then(|v| {
334 if let Value::Int(i) = v {
335 Some(*i)
336 } else {
337 None
338 }
339 })
340}
341
342pub struct Watch;
343impl Tool for Watch {
344 fn name(&self) -> &str {
345 "watch"
346 }
347 fn tier(&self) -> Tier {
348 Tier::Four
349 }
350 fn description(&self) -> Option<&str> {
351 Some(
352 "Register a background watcher on any running task (terminal, bash, or agent).\n\
353 When pattern appears in the task's output, the agent is woken up.\n\n\
354 Non-blocking: returns watcher_id immediately.\n\
355 mode: \"once\" (default) or \"persist\".\n\
356 timeout_ms: default 120000 (120s), max 600000 (10min).",
357 )
358 }
359 fn input_schema(&self) -> serde_json::Value {
360 serde_json::json!({
361 "type": "object",
362 "properties": {
363 "handle": {"type": "string"},
364 "pattern": {"type": "string"},
365 "mode": {"type": "string", "enum": ["once", "persist"], "default": "once"},
366 "timeout_ms": {"type": "integer", "default": 120000}
367 },
368 "required": ["handle", "pattern"]
369 })
370 }
371 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
372 Box::pin(async move {
373 let handle = extract_string(&args, "handle", 0)?;
374 let pattern = extract_string(&args, "pattern", 1)?;
375 let mode = match extract_optional_string(&args, "mode").as_deref() {
376 Some("persist") => WatchMode::Persist,
377 _ => WatchMode::Once,
378 };
379 let timeout_ms = extract_optional_int(&args, "timeout_ms")
380 .unwrap_or(120_000)
381 .clamp(1_000, 600_000) as u64;
382 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
383
384 let task_registry = ctx.task_registry.clone().ok_or_else(|| {
385 RuntimeError::ToolFailed("watch: task registry not available".into())
386 })?;
387 let snapshot = task_registry.lookup_by_handle(&handle).ok_or_else(|| {
388 RuntimeError::ToolFailed(format!("watch: handle '{handle}' not found"))
389 })?;
390
391 let watch_hub = ctx
392 .watch_hub
393 .clone()
394 .ok_or_else(|| RuntimeError::ToolFailed("watch: watch hub not available".into()))?;
395
396 let source = match snapshot.kind {
397 crate::task_registry::TaskKind::Terminal => WatchSource::Terminal {
398 handle: handle.clone(),
399 },
400 crate::task_registry::TaskKind::Bash => WatchSource::Bash {
401 handle: handle.clone(),
402 },
403 crate::task_registry::TaskKind::Flow => WatchSource::Agent {
404 handle: handle.clone(),
405 },
406 };
407
408 let watcher_id = watch_hub.register(
409 source.clone(),
410 pattern.clone(),
411 mode,
412 Duration::from_millis(timeout_ms),
413 );
414
415 match snapshot.kind {
416 crate::task_registry::TaskKind::Terminal => {
417 let reg = ctx.term_registry.clone().ok_or_else(|| {
418 RuntimeError::ToolFailed("watch: terminal registry not available".into())
419 })?;
420 let entry = reg.lookup(&handle, &session_id)?;
421 let watchable: Arc<dyn Watchable> = Arc::clone(&entry) as Arc<dyn Watchable>;
422 spawn_watcher(
423 watch_hub,
424 watcher_id.clone(),
425 source,
426 pattern,
427 mode,
428 Duration::from_millis(timeout_ms),
429 watchable,
430 );
431 }
432 crate::task_registry::TaskKind::Bash => {
433 let reg = ctx.bg_registry.clone().ok_or_else(|| {
434 RuntimeError::ToolFailed("watch: bash registry not available".into())
435 })?;
436 let entry = reg.lookup(&handle, &session_id)?;
437 let watchable: Arc<dyn Watchable> = Arc::clone(&entry) as Arc<dyn Watchable>;
438 spawn_watcher(
439 watch_hub,
440 watcher_id.clone(),
441 source,
442 pattern,
443 mode,
444 Duration::from_millis(timeout_ms),
445 watchable,
446 );
447 }
448 crate::task_registry::TaskKind::Flow => {
449 let reg = ctx.flow_registry.clone().ok_or_else(|| {
450 RuntimeError::ToolFailed("watch: agent registry not available".into())
451 })?;
452 let entry = reg.lookup(&handle)?;
453 let watchable: Arc<dyn Watchable> = Arc::clone(&entry) as Arc<dyn Watchable>;
454 spawn_watcher(
455 watch_hub,
456 watcher_id.clone(),
457 source,
458 pattern,
459 mode,
460 Duration::from_millis(timeout_ms),
461 watchable,
462 );
463 }
464 }
465
466 Ok(Value::Struct(vec![(
467 "watcher_id".into(),
468 Value::Str(watcher_id),
469 )]))
470 })
471 }
472}
473
474fn spawn_watcher(
475 hub: Arc<WatchHub>,
476 wid: WatcherId,
477 source: WatchSource,
478 pattern: String,
479 mode: WatchMode,
480 timeout: Duration,
481 watchable: Arc<dyn Watchable>,
482) {
483 let cancel = hub.get_cancel(&wid);
484 let w = Arc::clone(&watchable);
485 let pat = pattern.clone();
486 tokio::spawn(async move {
487 loop {
488 let result = tokio::time::timeout(
489 timeout,
490 Arc::clone(&w).watch_output(pat.clone(), cancel.clone()),
491 )
492 .await;
493 let matched = matches!(result, Ok(WatchResult::Matched { .. }));
494 handle_watch_result(
495 Arc::clone(&hub),
496 wid.clone(),
497 source.clone(),
498 pattern.clone(),
499 timeout,
500 result,
501 )
502 .await;
503 if !matched || mode == WatchMode::Once {
504 break;
505 }
506 }
507 if mode == WatchMode::Persist {
508 hub.unregister(&wid);
509 }
510 });
511}
512
513pub struct WatcherList;
514impl Tool for WatcherList {
515 fn name(&self) -> &str {
516 "watcher.list"
517 }
518 fn tier(&self) -> Tier {
519 Tier::Zero
520 }
521 fn description(&self) -> Option<&str> {
522 Some("List all active background watchers with their source, pattern, mode, and age.")
523 }
524 fn input_schema(&self) -> serde_json::Value {
525 serde_json::json!({"type": "object", "properties": {}})
526 }
527 fn call<'a>(&'a self, _args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
528 Box::pin(async move {
529 let hub = ctx.watch_hub.clone().ok_or_else(|| {
530 RuntimeError::ToolFailed("watcher.list: watch hub not available".into())
531 })?;
532 let list = hub.list_active();
533 let items: Vec<Value> = list
534 .iter()
535 .map(|w| {
536 Value::Struct(vec![
537 ("watcher_id".into(), Value::Str(w.id.clone())),
538 ("source".into(), watch_source_to_value(&w.source)),
539 ("pattern".into(), Value::Str(w.pattern.clone())),
540 (
541 "mode".into(),
542 Value::Str(
543 match w.mode {
544 WatchMode::Once => "once",
545 WatchMode::Persist => "persist",
546 }
547 .into(),
548 ),
549 ),
550 ("age_ms".into(), Value::Int(w.age.as_millis() as i64)),
551 (
552 "timeout_ms".into(),
553 Value::Int(w.timeout.as_millis() as i64),
554 ),
555 ])
556 })
557 .collect();
558 Ok(Value::List(items))
559 })
560 }
561}
562
563pub struct WatcherUnwatch;
564impl Tool for WatcherUnwatch {
565 fn name(&self) -> &str {
566 "watcher.unwatch"
567 }
568 fn tier(&self) -> Tier {
569 Tier::Zero
570 }
571 fn description(&self) -> Option<&str> {
572 Some("Cancel a background watcher by id. Works for any source (terminal/bash/agent).")
573 }
574 fn input_schema(&self) -> serde_json::Value {
575 serde_json::json!({
576 "type": "object",
577 "properties": {
578 "watcher_id": {"type": "string"}
579 },
580 "required": ["watcher_id"]
581 })
582 }
583 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
584 Box::pin(async move {
585 let id = extract_string(&args, "watcher_id", 0)?;
586 let hub = ctx.watch_hub.clone().ok_or_else(|| {
587 RuntimeError::ToolFailed("watcher.unwatch: watch hub not available".into())
588 })?;
589 if hub.unregister(&id) {
590 Ok(Value::Unit)
591 } else {
592 Err(RuntimeError::ToolFailed(format!(
593 "watcher.unwatch: '{id}' not found"
594 )))
595 }
596 })
597 }
598}
599
600pub struct WaitForWatcher;
601impl Tool for WaitForWatcher {
602 fn name(&self) -> &str {
603 "wait_for_watcher"
604 }
605 fn tier(&self) -> Tier {
606 Tier::Zero
607 }
608 fn description(&self) -> Option<&str> {
609 Some(
610 "Block until a registered watcher fires, or timeout.\n\
611 If no active watchers exist, returns immediately (Unit).\n\
612 Returns Str with event details on match, or Unit on timeout/no watchers.",
613 )
614 }
615 fn input_schema(&self) -> serde_json::Value {
616 serde_json::json!({
617 "type": "object",
618 "properties": {
619 "timeout_ms": {"type": "integer", "default": 30000}
620 }
621 })
622 }
623 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
624 Box::pin(async move {
625 let timeout_ms = extract_optional_int(&args, "timeout_ms")
626 .unwrap_or(30_000)
627 .clamp(100, 300_000) as u64;
628 let hub = ctx.watch_hub.clone().ok_or_else(|| {
629 RuntimeError::ToolFailed("wait_for_watcher: watch hub not available".into())
630 })?;
631 match hub.wait_for_event(Duration::from_millis(timeout_ms)).await {
632 Some(evt) => Ok(Value::Message(Message::system_text(
633 ctx.turn_id
634 .clone()
635 .unwrap_or_else(crate::event::TurnId::now),
636 format_watch_event_text(&evt),
637 ))),
638 None => Ok(Value::Unit),
639 }
640 })
641 }
642}
643
644pub struct HasPendingInjections;
645impl Tool for HasPendingInjections {
646 fn name(&self) -> &str {
647 "has_pending_injections"
648 }
649 fn tier(&self) -> Tier {
650 Tier::Zero
651 }
652 fn description(&self) -> Option<&str> {
653 Some(
654 "Check if there are pending user injections for the current turn.\n\
655 Returns true if any exist — the agent loop should continue so they get drained.",
656 )
657 }
658 fn input_schema(&self) -> serde_json::Value {
659 serde_json::json!({"type": "object", "properties": {}})
660 }
661 fn call<'a>(&'a self, _args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
662 Box::pin(async move {
663 if ctx
664 .agent_entry
665 .as_ref()
666 .is_some_and(|entry| !entry.pending_injections.lock().unwrap().is_empty())
667 {
668 return Ok(Value::Bool(true));
669 }
670 let Some(session) = ctx.session_runtime.as_ref() else {
671 return if ctx.agent_entry.is_some() {
672 Ok(Value::Bool(false))
673 } else {
674 Err(RuntimeError::ToolFailed(
675 "has_pending_injections: no current flow".into(),
676 ))
677 };
678 };
679 let turn_id = ctx.turn_id.clone().ok_or_else(|| {
680 RuntimeError::ToolFailed("has_pending_injections: no turn id".into())
681 })?;
682 let has_pending = session.list_pending_injections().iter().any(|inj| {
683 inj.turn_id == turn_id && inj.state == crate::injection::InjectionState::Pending
684 });
685 Ok(Value::Bool(has_pending))
686 })
687 }
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693
694 #[tokio::test]
695 async fn pending_injection_check_uses_the_current_flow_store() {
696 let session = Arc::new(crate::session::Session::open_ephemeral());
697 let turn_id = crate::event::TurnId::now();
698 session.begin_turn(crate::message::Message::user_text(turn_id.clone(), "root"));
699 session.enqueue_injection("root correction").unwrap();
700 let root_ctx = ToolCtx::new()
701 .with_anchors(Some(turn_id), None, None)
702 .with_session_runtime(session);
703 assert!(matches!(
704 HasPendingInjections
705 .call(ToolArgs::default(), &root_ctx)
706 .await
707 .unwrap(),
708 Value::Bool(true)
709 ));
710
711 let registry = crate::tools::agent_ctrl::FlowRegistry::new();
712 let entry = registry.create_entry(
713 "child".into(),
714 "goal".into(),
715 String::new(),
716 crate::event::FlowRunId::now(),
717 );
718 let child_ctx = ToolCtx::new().with_agent_entry(Arc::clone(&entry));
719 assert!(matches!(
720 HasPendingInjections
721 .call(ToolArgs::default(), &child_ctx)
722 .await
723 .unwrap(),
724 Value::Bool(false)
725 ));
726 entry
727 .pending_injections
728 .lock()
729 .unwrap()
730 .push(crate::injection::Injection::with_level(
731 crate::event::TurnId::now(),
732 "child correction",
733 crate::injection::InjectionLevel::L1Nudge,
734 None,
735 ));
736 assert!(matches!(
737 HasPendingInjections
738 .call(ToolArgs::default(), &child_ctx)
739 .await
740 .unwrap(),
741 Value::Bool(true)
742 ));
743 }
744
745 #[test]
746 fn watch_hub_register_and_unregister() {
747 let hub = WatchHub::new();
748 let id = hub.register(
749 WatchSource::Terminal {
750 handle: "t1".into(),
751 },
752 "$ ".into(),
753 WatchMode::Once,
754 Duration::from_secs(10),
755 );
756 assert!(hub.has_active_watchers());
757 assert!(hub.unregister(&id));
758 assert!(!hub.has_active_watchers());
759 }
760
761 #[test]
762 fn watch_hub_list_watchers_for_handle() {
763 let hub = WatchHub::new();
764 hub.register(
765 WatchSource::Terminal {
766 handle: "t1".into(),
767 },
768 "$ ".into(),
769 WatchMode::Once,
770 Duration::from_secs(10),
771 );
772 hub.register(
773 WatchSource::Terminal {
774 handle: "t1".into(),
775 },
776 "error".into(),
777 WatchMode::Persist,
778 Duration::from_secs(30),
779 );
780 hub.register(
781 WatchSource::Bash {
782 handle: "b1".into(),
783 },
784 "done".into(),
785 WatchMode::Once,
786 Duration::from_secs(10),
787 );
788 let t1_watchers = hub.list_watchers_for_handle("t1");
789 assert_eq!(t1_watchers.len(), 2);
790 let b1_watchers = hub.list_watchers_for_handle("b1");
791 assert_eq!(b1_watchers.len(), 1);
792 assert!(hub.list_watchers_for_handle("nonexistent").is_empty());
793 }
794
795 #[test]
796 fn watch_hub_enqueue_event_removes_once_mode() {
797 let hub = WatchHub::new();
798 let id = hub.register(
799 WatchSource::Bash {
800 handle: "b1".into(),
801 },
802 "done".into(),
803 WatchMode::Once,
804 Duration::from_secs(10),
805 );
806 hub.enqueue_event(WatchEvent {
807 watcher_id: id.clone(),
808 source: WatchSource::Bash {
809 handle: "b1".into(),
810 },
811 pattern: "done".into(),
812 row: Some(0),
813 col: Some(0),
814 text: "done".into(),
815 timestamp: chrono::Utc::now(),
816 timed_out: false,
817 exited: false,
818 timeout: Duration::from_secs(10),
819 });
820 assert!(
821 !hub.has_active_watchers(),
822 "once-mode watcher should be auto-removed after event"
823 );
824 }
825
826 #[test]
827 fn watch_hub_enqueue_event_keeps_persist_mode() {
828 let hub = WatchHub::new();
829 let id = hub.register(
830 WatchSource::Bash {
831 handle: "b1".into(),
832 },
833 "done".into(),
834 WatchMode::Persist,
835 Duration::from_secs(10),
836 );
837 hub.enqueue_event(WatchEvent {
838 watcher_id: id,
839 source: WatchSource::Bash {
840 handle: "b1".into(),
841 },
842 pattern: "done".into(),
843 row: Some(0),
844 col: Some(0),
845 text: "done".into(),
846 timestamp: chrono::Utc::now(),
847 timed_out: false,
848 exited: false,
849 timeout: Duration::from_secs(10),
850 });
851 assert!(
852 hub.has_active_watchers(),
853 "persist-mode watcher should remain after event"
854 );
855 }
856
857 #[test]
858 fn format_event_text_includes_watcher_id_and_pattern() {
859 let evt = WatchEvent {
860 watcher_id: "w_abc123".into(),
861 source: WatchSource::Terminal {
862 handle: "term_x".into(),
863 },
864 pattern: "$ ".into(),
865 row: Some(10),
866 col: Some(0),
867 text: "$ ls".into(),
868 timestamp: chrono::Utc::now(),
869 timed_out: false,
870 exited: false,
871 timeout: Duration::from_secs(120),
872 };
873 let text = format_watch_event_text(&evt);
874 assert!(text.contains("w_abc123"));
875 assert!(text.contains("terminal"));
876 assert!(text.contains("term_x"));
877 assert!(text.contains("$ "));
878 }
879
880 #[test]
881 fn format_event_text_timeout_includes_suggestion() {
882 let evt = WatchEvent {
883 watcher_id: "w_abc".into(),
884 source: WatchSource::Bash {
885 handle: "b1".into(),
886 },
887 pattern: "done".into(),
888 row: None,
889 col: None,
890 text: String::new(),
891 timestamp: chrono::Utc::now(),
892 timed_out: true,
893 exited: false,
894 timeout: Duration::from_secs(120),
895 };
896 let text = format_watch_event_text(&evt);
897 assert!(text.contains("not detected"));
898 assert!(text.contains("capture or"));
899 }
900
901 #[tokio::test]
902 async fn wait_for_event_returns_none_when_no_watchers() {
903 let hub = WatchHub::new();
904 let result = hub.wait_for_event(Duration::from_millis(100)).await;
905 assert!(result.is_none());
906 }
907
908 #[tokio::test]
909 async fn wait_for_event_returns_pending_event() {
910 let hub = Arc::new(WatchHub::new());
911 hub.register(
912 WatchSource::Terminal {
913 handle: "t1".into(),
914 },
915 "$ ".into(),
916 WatchMode::Once,
917 Duration::from_secs(10),
918 );
919 hub.enqueue_event(WatchEvent {
920 watcher_id: "w_test".into(),
921 source: WatchSource::Terminal {
922 handle: "t1".into(),
923 },
924 pattern: "$ ".into(),
925 row: Some(0),
926 col: Some(0),
927 text: "$ ".into(),
928 timestamp: chrono::Utc::now(),
929 timed_out: false,
930 exited: false,
931 timeout: Duration::from_secs(10),
932 });
933 let evt = hub.wait_for_event(Duration::from_millis(100)).await;
934 assert!(evt.is_some());
935 assert_eq!(evt.unwrap().pattern, "$ ");
936 }
937
938 #[tokio::test]
939 async fn wait_for_event_returns_source_exited_event() {
940 let hub = Arc::new(WatchHub::new());
944 let wid = hub.register(
945 WatchSource::Agent {
946 handle: "a1".into(),
947 },
948 "done".into(),
949 WatchMode::Once,
950 Duration::from_secs(10),
951 );
952 hub.enqueue_event(WatchEvent {
954 watcher_id: wid.clone(),
955 source: WatchSource::Agent {
956 handle: "a1".into(),
957 },
958 pattern: "done".into(),
959 row: None,
960 col: None,
961 text: String::new(),
962 timestamp: chrono::Utc::now(),
963 timed_out: false,
964 exited: true,
965 timeout: Duration::from_secs(10),
966 });
967 hub.unregister(&wid);
968 assert!(!hub.has_active_watchers());
969 let evt = hub.wait_for_event(Duration::from_millis(100)).await;
971 assert!(
972 evt.is_some(),
973 "exited event must be consumable via wait_for_event"
974 );
975 let evt = evt.unwrap();
976 assert!(evt.exited);
977 assert_eq!(evt.pattern, "done");
978 }
979
980 #[test]
981 fn format_event_text_exited_includes_already_exited() {
982 let evt = WatchEvent {
983 watcher_id: "w_xyz".into(),
984 source: WatchSource::Agent {
985 handle: "a1".into(),
986 },
987 pattern: "done".into(),
988 row: None,
989 col: None,
990 text: String::new(),
991 timestamp: chrono::Utc::now(),
992 timed_out: false,
993 exited: true,
994 timeout: Duration::from_secs(120),
995 };
996 let text = format_watch_event_text(&evt);
997 assert!(text.contains("already exited"));
998 assert!(text.contains("a1"));
999 assert!(text.contains("done"));
1000 }
1001
1002 struct ScriptedWatchable {
1003 results: Vec<WatchResult>,
1004 idx: Mutex<usize>,
1005 }
1006
1007 impl Watchable for ScriptedWatchable {
1008 fn watch_output(
1009 self: Arc<Self>,
1010 _pattern: String,
1011 _cancel: CancellationToken,
1012 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = WatchResult> + Send>> {
1013 let mut idx = self.idx.lock().unwrap();
1014 let i = *idx;
1015 *idx += 1;
1016 let result = self
1017 .results
1018 .get(i)
1019 .cloned()
1020 .unwrap_or(WatchResult::SourceExited);
1021 Box::pin(async move {
1022 tokio::time::sleep(Duration::from_millis(10)).await;
1023 result
1024 })
1025 }
1026 }
1027
1028 #[tokio::test]
1029 async fn persist_watcher_fires_on_every_match_then_cleans_up() {
1030 let hub = Arc::new(WatchHub::new());
1031 let wid = hub.register(
1032 WatchSource::Bash {
1033 handle: "b1".into(),
1034 },
1035 "done".into(),
1036 WatchMode::Persist,
1037 Duration::from_secs(10),
1038 );
1039 let watchable: Arc<dyn Watchable> = Arc::new(ScriptedWatchable {
1040 results: vec![
1041 WatchResult::Matched {
1042 row: None,
1043 col: Some(0),
1044 text: "done".into(),
1045 },
1046 WatchResult::Matched {
1047 row: None,
1048 col: Some(4),
1049 text: "done".into(),
1050 },
1051 WatchResult::SourceExited,
1052 ],
1053 idx: Mutex::new(0),
1054 });
1055 spawn_watcher(
1056 Arc::clone(&hub),
1057 wid,
1058 WatchSource::Bash {
1059 handle: "b1".into(),
1060 },
1061 "done".into(),
1062 WatchMode::Persist,
1063 Duration::from_secs(10),
1064 watchable,
1065 );
1066
1067 let e1 = hub
1068 .wait_for_event(Duration::from_secs(2))
1069 .await
1070 .expect("first match event");
1071 assert!(!e1.exited && !e1.timed_out, "first event should be a match");
1072 let e2 = hub
1073 .wait_for_event(Duration::from_secs(2))
1074 .await
1075 .expect("second match event");
1076 assert!(
1077 !e2.exited && !e2.timed_out,
1078 "second event should be a match"
1079 );
1080 let e3 = hub
1081 .wait_for_event(Duration::from_secs(2))
1082 .await
1083 .expect("exited event");
1084 assert!(e3.exited, "third event should be source-exited");
1085
1086 tokio::time::sleep(Duration::from_millis(50)).await;
1087 assert!(
1088 !hub.has_active_watchers(),
1089 "persist watcher must be removed after source exits"
1090 );
1091 }
1092
1093 #[tokio::test]
1094 async fn persist_watcher_timeout_removes_watcher() {
1095 struct NeverMatch;
1096 impl Watchable for NeverMatch {
1097 fn watch_output(
1098 self: Arc<Self>,
1099 _pattern: String,
1100 cancel: CancellationToken,
1101 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = WatchResult> + Send>>
1102 {
1103 Box::pin(async move {
1104 cancel.cancelled().await;
1105 WatchResult::Cancelled
1106 })
1107 }
1108 }
1109
1110 let hub = Arc::new(WatchHub::new());
1111 let wid = hub.register(
1112 WatchSource::Bash {
1113 handle: "b1".into(),
1114 },
1115 "done".into(),
1116 WatchMode::Persist,
1117 Duration::from_millis(50),
1118 );
1119 spawn_watcher(
1120 Arc::clone(&hub),
1121 wid,
1122 WatchSource::Bash {
1123 handle: "b1".into(),
1124 },
1125 "done".into(),
1126 WatchMode::Persist,
1127 Duration::from_millis(50),
1128 Arc::new(NeverMatch) as Arc<dyn Watchable>,
1129 );
1130
1131 let evt = hub
1132 .wait_for_event(Duration::from_secs(2))
1133 .await
1134 .expect("timeout event");
1135 assert!(evt.timed_out, "should receive a timeout event");
1136
1137 tokio::time::sleep(Duration::from_millis(50)).await;
1138 assert!(
1139 !hub.has_active_watchers(),
1140 "persist watcher must be removed after timeout, not linger forever"
1141 );
1142 }
1143
1144 #[tokio::test]
1145 async fn persist_watcher_stops_on_cancel() {
1146 struct NeverMatch;
1147 impl Watchable for NeverMatch {
1148 fn watch_output(
1149 self: Arc<Self>,
1150 _pattern: String,
1151 cancel: CancellationToken,
1152 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = WatchResult> + Send>>
1153 {
1154 Box::pin(async move {
1155 cancel.cancelled().await;
1156 WatchResult::Cancelled
1157 })
1158 }
1159 }
1160
1161 let hub = Arc::new(WatchHub::new());
1162 let wid = hub.register(
1163 WatchSource::Bash {
1164 handle: "b1".into(),
1165 },
1166 "done".into(),
1167 WatchMode::Persist,
1168 Duration::from_secs(10),
1169 );
1170 spawn_watcher(
1171 Arc::clone(&hub),
1172 wid.clone(),
1173 WatchSource::Bash {
1174 handle: "b1".into(),
1175 },
1176 "done".into(),
1177 WatchMode::Persist,
1178 Duration::from_secs(10),
1179 Arc::new(NeverMatch) as Arc<dyn Watchable>,
1180 );
1181
1182 hub.unregister(&wid);
1183
1184 tokio::time::sleep(Duration::from_millis(100)).await;
1185 assert!(
1186 !hub.has_active_watchers(),
1187 "persist watcher must stop and be removed when cancelled"
1188 );
1189 }
1190}