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 let session = ctx.session_runtime.as_ref().ok_or_else(|| {
664 RuntimeError::ToolFailed("has_pending_injections: no session".into())
665 })?;
666 let turn_id = ctx.turn_id.clone().ok_or_else(|| {
667 RuntimeError::ToolFailed("has_pending_injections: no turn id".into())
668 })?;
669 let has_pending = session.list_pending_injections().iter().any(|inj| {
670 inj.turn_id == turn_id && inj.state == crate::injection::InjectionState::Pending
671 });
672 Ok(Value::Bool(has_pending))
673 })
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680
681 #[test]
682 fn watch_hub_register_and_unregister() {
683 let hub = WatchHub::new();
684 let id = hub.register(
685 WatchSource::Terminal {
686 handle: "t1".into(),
687 },
688 "$ ".into(),
689 WatchMode::Once,
690 Duration::from_secs(10),
691 );
692 assert!(hub.has_active_watchers());
693 assert!(hub.unregister(&id));
694 assert!(!hub.has_active_watchers());
695 }
696
697 #[test]
698 fn watch_hub_list_watchers_for_handle() {
699 let hub = WatchHub::new();
700 hub.register(
701 WatchSource::Terminal {
702 handle: "t1".into(),
703 },
704 "$ ".into(),
705 WatchMode::Once,
706 Duration::from_secs(10),
707 );
708 hub.register(
709 WatchSource::Terminal {
710 handle: "t1".into(),
711 },
712 "error".into(),
713 WatchMode::Persist,
714 Duration::from_secs(30),
715 );
716 hub.register(
717 WatchSource::Bash {
718 handle: "b1".into(),
719 },
720 "done".into(),
721 WatchMode::Once,
722 Duration::from_secs(10),
723 );
724 let t1_watchers = hub.list_watchers_for_handle("t1");
725 assert_eq!(t1_watchers.len(), 2);
726 let b1_watchers = hub.list_watchers_for_handle("b1");
727 assert_eq!(b1_watchers.len(), 1);
728 assert!(hub.list_watchers_for_handle("nonexistent").is_empty());
729 }
730
731 #[test]
732 fn watch_hub_enqueue_event_removes_once_mode() {
733 let hub = WatchHub::new();
734 let id = hub.register(
735 WatchSource::Bash {
736 handle: "b1".into(),
737 },
738 "done".into(),
739 WatchMode::Once,
740 Duration::from_secs(10),
741 );
742 hub.enqueue_event(WatchEvent {
743 watcher_id: id.clone(),
744 source: WatchSource::Bash {
745 handle: "b1".into(),
746 },
747 pattern: "done".into(),
748 row: Some(0),
749 col: Some(0),
750 text: "done".into(),
751 timestamp: chrono::Utc::now(),
752 timed_out: false,
753 exited: false,
754 timeout: Duration::from_secs(10),
755 });
756 assert!(
757 !hub.has_active_watchers(),
758 "once-mode watcher should be auto-removed after event"
759 );
760 }
761
762 #[test]
763 fn watch_hub_enqueue_event_keeps_persist_mode() {
764 let hub = WatchHub::new();
765 let id = hub.register(
766 WatchSource::Bash {
767 handle: "b1".into(),
768 },
769 "done".into(),
770 WatchMode::Persist,
771 Duration::from_secs(10),
772 );
773 hub.enqueue_event(WatchEvent {
774 watcher_id: id,
775 source: WatchSource::Bash {
776 handle: "b1".into(),
777 },
778 pattern: "done".into(),
779 row: Some(0),
780 col: Some(0),
781 text: "done".into(),
782 timestamp: chrono::Utc::now(),
783 timed_out: false,
784 exited: false,
785 timeout: Duration::from_secs(10),
786 });
787 assert!(
788 hub.has_active_watchers(),
789 "persist-mode watcher should remain after event"
790 );
791 }
792
793 #[test]
794 fn format_event_text_includes_watcher_id_and_pattern() {
795 let evt = WatchEvent {
796 watcher_id: "w_abc123".into(),
797 source: WatchSource::Terminal {
798 handle: "term_x".into(),
799 },
800 pattern: "$ ".into(),
801 row: Some(10),
802 col: Some(0),
803 text: "$ ls".into(),
804 timestamp: chrono::Utc::now(),
805 timed_out: false,
806 exited: false,
807 timeout: Duration::from_secs(120),
808 };
809 let text = format_watch_event_text(&evt);
810 assert!(text.contains("w_abc123"));
811 assert!(text.contains("terminal"));
812 assert!(text.contains("term_x"));
813 assert!(text.contains("$ "));
814 }
815
816 #[test]
817 fn format_event_text_timeout_includes_suggestion() {
818 let evt = WatchEvent {
819 watcher_id: "w_abc".into(),
820 source: WatchSource::Bash {
821 handle: "b1".into(),
822 },
823 pattern: "done".into(),
824 row: None,
825 col: None,
826 text: String::new(),
827 timestamp: chrono::Utc::now(),
828 timed_out: true,
829 exited: false,
830 timeout: Duration::from_secs(120),
831 };
832 let text = format_watch_event_text(&evt);
833 assert!(text.contains("not detected"));
834 assert!(text.contains("capture or"));
835 }
836
837 #[tokio::test]
838 async fn wait_for_event_returns_none_when_no_watchers() {
839 let hub = WatchHub::new();
840 let result = hub.wait_for_event(Duration::from_millis(100)).await;
841 assert!(result.is_none());
842 }
843
844 #[tokio::test]
845 async fn wait_for_event_returns_pending_event() {
846 let hub = Arc::new(WatchHub::new());
847 hub.register(
848 WatchSource::Terminal {
849 handle: "t1".into(),
850 },
851 "$ ".into(),
852 WatchMode::Once,
853 Duration::from_secs(10),
854 );
855 hub.enqueue_event(WatchEvent {
856 watcher_id: "w_test".into(),
857 source: WatchSource::Terminal {
858 handle: "t1".into(),
859 },
860 pattern: "$ ".into(),
861 row: Some(0),
862 col: Some(0),
863 text: "$ ".into(),
864 timestamp: chrono::Utc::now(),
865 timed_out: false,
866 exited: false,
867 timeout: Duration::from_secs(10),
868 });
869 let evt = hub.wait_for_event(Duration::from_millis(100)).await;
870 assert!(evt.is_some());
871 assert_eq!(evt.unwrap().pattern, "$ ");
872 }
873
874 #[tokio::test]
875 async fn wait_for_event_returns_source_exited_event() {
876 let hub = Arc::new(WatchHub::new());
880 let wid = hub.register(
881 WatchSource::Agent {
882 handle: "a1".into(),
883 },
884 "done".into(),
885 WatchMode::Once,
886 Duration::from_secs(10),
887 );
888 hub.enqueue_event(WatchEvent {
890 watcher_id: wid.clone(),
891 source: WatchSource::Agent {
892 handle: "a1".into(),
893 },
894 pattern: "done".into(),
895 row: None,
896 col: None,
897 text: String::new(),
898 timestamp: chrono::Utc::now(),
899 timed_out: false,
900 exited: true,
901 timeout: Duration::from_secs(10),
902 });
903 hub.unregister(&wid);
904 assert!(!hub.has_active_watchers());
905 let evt = hub.wait_for_event(Duration::from_millis(100)).await;
907 assert!(
908 evt.is_some(),
909 "exited event must be consumable via wait_for_event"
910 );
911 let evt = evt.unwrap();
912 assert!(evt.exited);
913 assert_eq!(evt.pattern, "done");
914 }
915
916 #[test]
917 fn format_event_text_exited_includes_already_exited() {
918 let evt = WatchEvent {
919 watcher_id: "w_xyz".into(),
920 source: WatchSource::Agent {
921 handle: "a1".into(),
922 },
923 pattern: "done".into(),
924 row: None,
925 col: None,
926 text: String::new(),
927 timestamp: chrono::Utc::now(),
928 timed_out: false,
929 exited: true,
930 timeout: Duration::from_secs(120),
931 };
932 let text = format_watch_event_text(&evt);
933 assert!(text.contains("already exited"));
934 assert!(text.contains("a1"));
935 assert!(text.contains("done"));
936 }
937
938 struct ScriptedWatchable {
939 results: Vec<WatchResult>,
940 idx: Mutex<usize>,
941 }
942
943 impl Watchable for ScriptedWatchable {
944 fn watch_output(
945 self: Arc<Self>,
946 _pattern: String,
947 _cancel: CancellationToken,
948 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = WatchResult> + Send>> {
949 let mut idx = self.idx.lock().unwrap();
950 let i = *idx;
951 *idx += 1;
952 let result = self
953 .results
954 .get(i)
955 .cloned()
956 .unwrap_or(WatchResult::SourceExited);
957 Box::pin(async move {
958 tokio::time::sleep(Duration::from_millis(10)).await;
959 result
960 })
961 }
962 }
963
964 #[tokio::test]
965 async fn persist_watcher_fires_on_every_match_then_cleans_up() {
966 let hub = Arc::new(WatchHub::new());
967 let wid = hub.register(
968 WatchSource::Bash {
969 handle: "b1".into(),
970 },
971 "done".into(),
972 WatchMode::Persist,
973 Duration::from_secs(10),
974 );
975 let watchable: Arc<dyn Watchable> = Arc::new(ScriptedWatchable {
976 results: vec![
977 WatchResult::Matched {
978 row: None,
979 col: Some(0),
980 text: "done".into(),
981 },
982 WatchResult::Matched {
983 row: None,
984 col: Some(4),
985 text: "done".into(),
986 },
987 WatchResult::SourceExited,
988 ],
989 idx: Mutex::new(0),
990 });
991 spawn_watcher(
992 Arc::clone(&hub),
993 wid,
994 WatchSource::Bash {
995 handle: "b1".into(),
996 },
997 "done".into(),
998 WatchMode::Persist,
999 Duration::from_secs(10),
1000 watchable,
1001 );
1002
1003 let e1 = hub
1004 .wait_for_event(Duration::from_secs(2))
1005 .await
1006 .expect("first match event");
1007 assert!(!e1.exited && !e1.timed_out, "first event should be a match");
1008 let e2 = hub
1009 .wait_for_event(Duration::from_secs(2))
1010 .await
1011 .expect("second match event");
1012 assert!(
1013 !e2.exited && !e2.timed_out,
1014 "second event should be a match"
1015 );
1016 let e3 = hub
1017 .wait_for_event(Duration::from_secs(2))
1018 .await
1019 .expect("exited event");
1020 assert!(e3.exited, "third event should be source-exited");
1021
1022 tokio::time::sleep(Duration::from_millis(50)).await;
1023 assert!(
1024 !hub.has_active_watchers(),
1025 "persist watcher must be removed after source exits"
1026 );
1027 }
1028
1029 #[tokio::test]
1030 async fn persist_watcher_timeout_removes_watcher() {
1031 struct NeverMatch;
1032 impl Watchable for NeverMatch {
1033 fn watch_output(
1034 self: Arc<Self>,
1035 _pattern: String,
1036 cancel: CancellationToken,
1037 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = WatchResult> + Send>>
1038 {
1039 Box::pin(async move {
1040 cancel.cancelled().await;
1041 WatchResult::Cancelled
1042 })
1043 }
1044 }
1045
1046 let hub = Arc::new(WatchHub::new());
1047 let wid = hub.register(
1048 WatchSource::Bash {
1049 handle: "b1".into(),
1050 },
1051 "done".into(),
1052 WatchMode::Persist,
1053 Duration::from_millis(50),
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_millis(50),
1064 Arc::new(NeverMatch) as Arc<dyn Watchable>,
1065 );
1066
1067 let evt = hub
1068 .wait_for_event(Duration::from_secs(2))
1069 .await
1070 .expect("timeout event");
1071 assert!(evt.timed_out, "should receive a timeout event");
1072
1073 tokio::time::sleep(Duration::from_millis(50)).await;
1074 assert!(
1075 !hub.has_active_watchers(),
1076 "persist watcher must be removed after timeout, not linger forever"
1077 );
1078 }
1079
1080 #[tokio::test]
1081 async fn persist_watcher_stops_on_cancel() {
1082 struct NeverMatch;
1083 impl Watchable for NeverMatch {
1084 fn watch_output(
1085 self: Arc<Self>,
1086 _pattern: String,
1087 cancel: CancellationToken,
1088 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = WatchResult> + Send>>
1089 {
1090 Box::pin(async move {
1091 cancel.cancelled().await;
1092 WatchResult::Cancelled
1093 })
1094 }
1095 }
1096
1097 let hub = Arc::new(WatchHub::new());
1098 let wid = hub.register(
1099 WatchSource::Bash {
1100 handle: "b1".into(),
1101 },
1102 "done".into(),
1103 WatchMode::Persist,
1104 Duration::from_secs(10),
1105 );
1106 spawn_watcher(
1107 Arc::clone(&hub),
1108 wid.clone(),
1109 WatchSource::Bash {
1110 handle: "b1".into(),
1111 },
1112 "done".into(),
1113 WatchMode::Persist,
1114 Duration::from_secs(10),
1115 Arc::new(NeverMatch) as Arc<dyn Watchable>,
1116 );
1117
1118 hub.unregister(&wid);
1119
1120 tokio::time::sleep(Duration::from_millis(100)).await;
1121 assert!(
1122 !hub.has_active_watchers(),
1123 "persist watcher must stop and be removed when cancelled"
1124 );
1125 }
1126}