agentmux_srv\backend/
wps.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Wave Pub/Sub system: event brokering with scoped subscriptions.
5//! Port of Go's pkg/wps/wps.go + wpstypes.go.
6
7//!
8//! The Broker supports:
9//! - All-scope subscriptions (receive all events of a type)
10//! - Exact-scope subscriptions (e.g., "block:uuid")
11//! - Star-scope subscriptions (e.g., "block:*")
12//! - Event persistence (history/replay)
13
14use std::collections::{HashMap, HashSet};
15use std::sync::Mutex;
16
17use serde::{Deserialize, Serialize};
18
19// ---- Event type constants (match Go) ----
20
21#[allow(dead_code)]
22pub const EVENT_BLOCK_CLOSE: &str = "blockclose";
23#[allow(dead_code)]
24pub const EVENT_CONN_CHANGE: &str = "connchange";
25pub const EVENT_SYS_INFO: &str = "sysinfo";
26pub const EVENT_CONTROLLER_STATUS: &str = "controllerstatus";
27pub const EVENT_WAVE_OBJ_UPDATE: &str = "waveobj:update";
28pub const EVENT_BLOCK_FILE: &str = "blockfile";
29pub const EVENT_INSTALL_PROGRESS: &str = "install_progress";
30#[allow(dead_code)]
31pub const EVENT_CONFIG: &str = "config";
32#[allow(dead_code)]
33pub const EVENT_USER_INPUT: &str = "userinput";
34/// Fired by `SubprocessController::spawn_turn` when a user message is
35/// picked up (either direct-spawn or queue drain). Frontend uses this to
36/// promote pending `PendingMessage` entries into the conversation
37/// document. Payload: `{ block_id, message_id }`.
38pub const EVENT_AGENT_MESSAGE_ACCEPTED: &str = "agent-message-accepted";
39#[allow(dead_code)]
40pub const EVENT_ROUTE_GONE: &str = "route:gone";
41pub const EVENT_BLOCK_STATS: &str = "blockstats";
42pub const EVENT_AGENT_HEALTH: &str = "agenthealth";
43/// Fired when an agent subprocess exits non-zero (or reports an error on its
44/// terminal `result` frame). Carries the classified `AgentFailure` so the pane
45/// shows the real cause instead of a bare exit code.
46pub const EVENT_AGENT_FAILURE: &str = "agentfailure";
47/// Fired by `handle_shell_create` when a persistent shell is launched.
48/// Frontend creates the ShellNode row on receipt.
49/// Payload: `{ shell_id, cmd, cwd?, title, timestamp }`.
50pub const EVENT_SHELL_NODE_CREATE: &str = "shell_node_create";
51/// Fired per stdout/stderr line and on process exit by `ShellNodeRunner`.
52/// `op: "chunk"` carries `{ shell_id, kind, content, timestamp }`;
53/// `op: "exit"` carries `{ shell_id, exit_code, timestamp }`.
54pub const EVENT_SHELL_CHUNK: &str = "shell_chunk";
55/// Fired by the agent-pane PTY read loop when an OSC 0/2 window-title sequence
56/// from Claude Code is extracted. Carries the normalised conversation-topic string
57/// so the frontend can surface it as a `term:activity` tab label.
58/// Payload: `{ "blockId": "...", "activity": "auth refactor" }`.
59pub const EVENT_BLOCK_ACTIVITY: &str = "block:activity";
60
61// File operation constants
62#[allow(dead_code)]
63pub const FILE_OP_CREATE: &str = "create";
64#[allow(dead_code)]
65pub const FILE_OP_DELETE: &str = "delete";
66pub const FILE_OP_APPEND: &str = "append";
67pub const FILE_OP_TRUNCATE: &str = "truncate";
68#[allow(dead_code)]
69pub const FILE_OP_INVALIDATE: &str = "invalidate";
70
71const MAX_PERSIST: usize = 4096;
72const REMAKE_ARR_THRESHOLD: usize = 10 * 1024;
73
74// ---- Types ----
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct WaveEvent {
78    pub event: String,
79    #[serde(skip_serializing_if = "Vec::is_empty", default)]
80    pub scopes: Vec<String>,
81    #[serde(skip_serializing_if = "String::is_empty", default)]
82    pub sender: String,
83    #[serde(skip_serializing_if = "is_zero", default)]
84    pub persist: usize,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub data: Option<serde_json::Value>,
87}
88
89fn is_zero(v: &usize) -> bool {
90    *v == 0
91}
92
93impl WaveEvent {
94    #[allow(dead_code)]
95    pub fn has_scope(&self, scope: &str) -> bool {
96        self.scopes.iter().any(|s| s == scope)
97    }
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct SubscriptionRequest {
102    pub event: String,
103    #[serde(skip_serializing_if = "Vec::is_empty", default)]
104    pub scopes: Vec<String>,
105    #[serde(default)]
106    pub allscopes: bool,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct WSFileEventData {
111    pub zoneid: String,
112    pub filename: String,
113    pub fileop: String,
114    #[serde(skip_serializing_if = "String::is_empty", default)]
115    pub data64: String,
116}
117
118// ---- Client trait ----
119
120/// Trait for event delivery to connected clients.
121pub trait WpsClient: Send + Sync {
122    fn send_event(&self, route_id: &str, event: WaveEvent);
123}
124
125// ---- Subscription internals ----
126
127#[derive(Default)]
128struct BrokerSubscription {
129    /// Route IDs subscribed to all scopes for this event.
130    all_subs: Vec<String>,
131    /// Exact scope → route IDs.
132    scope_subs: HashMap<String, Vec<String>>,
133    /// Star/wildcard scope → route IDs.
134    star_subs: HashMap<String, Vec<String>>,
135}
136
137impl BrokerSubscription {
138    fn is_empty(&self) -> bool {
139        self.all_subs.is_empty() && self.scope_subs.is_empty() && self.star_subs.is_empty()
140    }
141}
142
143#[derive(Hash, Eq, PartialEq, Clone)]
144struct PersistKey {
145    event: String,
146    scope: String,
147}
148
149struct PersistEventWrap {
150    arr_total_adds: usize,
151    events: Vec<WaveEvent>,
152}
153
154// ---- Broker ----
155
156/// The central pub/sub broker for WaveEvents.
157pub struct Broker {
158    inner: Mutex<BrokerInner>,
159}
160
161/// Tracks `(route_id, event_name, scope)` tuples whose persisted
162/// history has already been replayed to a given route. Skipping
163/// replay on resubscribe prevents the frontend's `eventsub`
164/// flushes (sent on every listener add/remove, once per conn_id)
165/// from re-emitting completed bash logs on every pane mount or
166/// tab switch. Codex P2 on PR #817; route keying updated PR #1418.
167type ReplayKey = (String, String, String);
168
169struct BrokerInner {
170    client: Option<Box<dyn WpsClient>>,
171    sub_map: HashMap<String, BrokerSubscription>,
172    persist_map: HashMap<PersistKey, PersistEventWrap>,
173    replayed: HashSet<ReplayKey>,
174}
175
176impl Broker {
177    pub fn new() -> Self {
178        Self {
179            inner: Mutex::new(BrokerInner {
180                client: None,
181                sub_map: HashMap::new(),
182                persist_map: HashMap::new(),
183                replayed: HashSet::new(),
184            }),
185        }
186    }
187
188    pub fn set_client(&self, client: Box<dyn WpsClient>) {
189        let mut inner = self.inner.lock().unwrap();
190        inner.client = Some(client);
191    }
192
193    /// Subscribe a route to an event, optionally scoped.
194    ///
195    /// **Replay-on-subscribe**: after registering the route, immediately
196    /// deliver any persisted events that match the subscription. Lets
197    /// late subscribers catch up on the most recent state without
198    /// waiting for the next publish — closes the race for live-log
199    /// streaming where the frontend learns the tool_use_id only after
200    /// the wrapper has already finished publishing.
201    pub fn subscribe(&self, route_id: &str, sub: SubscriptionRequest) {
202        if sub.event.is_empty() {
203            return;
204        }
205        let mut inner = self.inner.lock().unwrap();
206        // Remove existing subscription first (re-subscribe)
207        Self::unsubscribe_nolock(&mut inner, route_id, &sub.event);
208
209        let bs = inner
210            .sub_map
211            .entry(sub.event.clone())
212            .or_default();
213
214        if sub.allscopes {
215            add_unique(&mut bs.all_subs, route_id);
216        } else {
217            for scope in &sub.scopes {
218                if scope_has_star(scope) {
219                    add_to_scope_map(&mut bs.star_subs, scope, route_id);
220                } else {
221                    add_to_scope_map(&mut bs.scope_subs, scope, route_id);
222                }
223            }
224        }
225
226        Self::replay_to_route(&mut inner, route_id, &sub);
227    }
228
229    /// Deliver any persisted events matching `sub` to `route_id`.
230    /// Called inside `subscribe` so replay happens atomically under
231    /// the broker lock — no live event published mid-replay can
232    /// interleave.
233    ///
234    /// **Once-per-(route, event, scope).** The frontend
235    /// (`frontend/app/store/wps.ts`) flushes `eventsub` on every
236    /// listener add/remove; each WebSocket connection has its own
237    /// `conn_id` as the route key (PR #1418). Replaying persisted
238    /// history on each of those flushes would re-emit completed bash
239    /// logs every pane mount / tab switch / sibling subscription. The
240    /// `replayed` set tracks tuples that already received their backfill
241    /// and short-circuits subsequent resubscribes. Cleared per-route in
242    /// `unsubscribe_all` so a true reconnect (route dropped +
243    /// re-registered) gets a fresh replay.
244    ///
245    /// Star-scope replay is intentionally not implemented (rare,
246    /// requires scanning every persist key; can add later if needed).
247    fn replay_to_route(
248        inner: &mut BrokerInner,
249        route_id: &str,
250        sub: &SubscriptionRequest,
251    ) {
252        let client = match &inner.client {
253            Some(c) => c,
254            None => return,
255        };
256
257        let mut scopes_to_deliver: Vec<String> = Vec::new();
258        if sub.allscopes {
259            // "" key holds the global history per persist_event's scope_set.
260            scopes_to_deliver.push(String::new());
261        } else {
262            for scope in &sub.scopes {
263                if !scope_has_star(scope) {
264                    scopes_to_deliver.push(scope.clone());
265                }
266            }
267        }
268
269        let mut to_send: Vec<WaveEvent> = Vec::new();
270        for scope in scopes_to_deliver {
271            let key = (route_id.to_string(), sub.event.clone(), scope.clone());
272            if inner.replayed.contains(&key) {
273                continue;
274            }
275            let pkey = PersistKey {
276                event: sub.event.clone(),
277                scope: scope.clone(),
278            };
279            if let Some(pe) = inner.persist_map.get(&pkey) {
280                for event in &pe.events {
281                    to_send.push(event.clone());
282                }
283            }
284            inner.replayed.insert(key);
285        }
286        for event in to_send {
287            client.send_event(route_id, event);
288        }
289    }
290
291    /// Unsubscribe a route from a specific event.
292    pub fn unsubscribe(&self, route_id: &str, event_name: &str) {
293        let mut inner = self.inner.lock().unwrap();
294        Self::unsubscribe_nolock(&mut inner, route_id, event_name);
295    }
296
297    /// Unsubscribe a route from all events.
298    ///
299    /// Also clears the `replayed` tracker for this route so a future
300    /// reconnect (route registers again from scratch) gets a fresh
301    /// replay of persisted history. Without this, a transient
302    /// WebSocket drop would silently lose all subsequent replay.
303    pub fn unsubscribe_all(&self, route_id: &str) {
304        let mut inner = self.inner.lock().unwrap();
305        let events: Vec<String> = inner.sub_map.keys().cloned().collect();
306        for event in events {
307            Self::unsubscribe_nolock(&mut inner, route_id, &event);
308        }
309        inner.replayed.retain(|(r, _, _)| r != route_id);
310    }
311
312    fn unsubscribe_nolock(inner: &mut BrokerInner, route_id: &str, event_name: &str) {
313        let bs = match inner.sub_map.get_mut(event_name) {
314            Some(bs) => bs,
315            None => return,
316        };
317        bs.all_subs.retain(|s| s != route_id);
318        remove_from_all_scopes(&mut bs.scope_subs, route_id);
319        remove_from_all_scopes(&mut bs.star_subs, route_id);
320        if bs.is_empty() {
321            inner.sub_map.remove(event_name);
322        }
323    }
324
325    /// Publish an event to all matching subscribers.
326    pub fn publish(&self, event: WaveEvent) {
327        let mut inner = self.inner.lock().unwrap();
328
329        // Persist if requested
330        if event.persist > 0 {
331            Self::persist_event(&mut inner, &event);
332        }
333
334        let client = match &inner.client {
335            Some(c) => c,
336            None => return,
337        };
338
339        let route_ids = Self::get_matching_routes(&inner, &event);
340        for route_id in route_ids {
341            client.send_event(&route_id, event.clone());
342        }
343    }
344
345    /// Read persisted event history.
346    pub fn read_event_history(
347        &self,
348        event_type: &str,
349        scope: &str,
350        max_items: usize,
351    ) -> Vec<WaveEvent> {
352        if max_items == 0 {
353            return Vec::new();
354        }
355        let inner = self.inner.lock().unwrap();
356        let key = PersistKey {
357            event: event_type.to_string(),
358            scope: scope.to_string(),
359        };
360        match inner.persist_map.get(&key) {
361            Some(pe) if !pe.events.is_empty() => {
362                let n = max_items.min(pe.events.len());
363                pe.events[pe.events.len() - n..].to_vec()
364            }
365            _ => Vec::new(),
366        }
367    }
368
369    fn persist_event(inner: &mut BrokerInner, event: &WaveEvent) {
370        let num_persist = event.persist.min(MAX_PERSIST);
371        let mut scope_set: Vec<String> = event.scopes.clone();
372        scope_set.push(String::new()); // "" scope for global persistence
373
374        for scope in scope_set {
375            let key = PersistKey {
376                event: event.event.clone(),
377                scope,
378            };
379            let pe = inner.persist_map.entry(key).or_insert_with(|| {
380                PersistEventWrap {
381                    arr_total_adds: 0,
382                    events: Vec::with_capacity(num_persist),
383                }
384            });
385            pe.events.push(event.clone());
386            pe.arr_total_adds += 1;
387            // Trim to max persist
388            if pe.events.len() > num_persist {
389                pe.events.drain(..pe.events.len() - num_persist);
390            }
391            // Compact if too many additions (reduce memory fragmentation)
392            if pe.arr_total_adds > REMAKE_ARR_THRESHOLD {
393                let compacted: Vec<WaveEvent> = pe.events.drain(..).collect();
394                pe.events = compacted;
395                pe.arr_total_adds = pe.events.len();
396            }
397        }
398    }
399
400    fn get_matching_routes(inner: &BrokerInner, event: &WaveEvent) -> Vec<String> {
401        let bs = match inner.sub_map.get(&event.event) {
402            Some(bs) => bs,
403            None => return Vec::new(),
404        };
405
406        let mut route_ids: HashMap<&str, ()> = HashMap::new();
407
408        // All-scope subscribers
409        for route_id in &bs.all_subs {
410            route_ids.insert(route_id, ());
411        }
412
413        // Exact-scope subscribers
414        for scope in &event.scopes {
415            if let Some(routes) = bs.scope_subs.get(scope) {
416                for route_id in routes {
417                    route_ids.insert(route_id, ());
418                }
419            }
420            // Star-scope subscribers
421            for (star_scope, routes) in &bs.star_subs {
422                if star_match(star_scope, scope, ":") {
423                    for route_id in routes {
424                        route_ids.insert(route_id, ());
425                    }
426                }
427            }
428        }
429
430        route_ids.keys().map(|s| s.to_string()).collect()
431    }
432}
433
434impl Default for Broker {
435    fn default() -> Self {
436        Self::new()
437    }
438}
439
440// ---- Helpers ----
441
442fn scope_has_star(scope: &str) -> bool {
443    scope.split(':').any(|part| part == "*" || part == "**")
444}
445
446/// Simple star matching: each segment separated by `sep` is compared.
447/// "*" matches any single segment, "**" matches any remaining segments.
448fn star_match(pattern: &str, value: &str, sep: &str) -> bool {
449    let pat_parts: Vec<&str> = pattern.split(sep).collect();
450    let val_parts: Vec<&str> = value.split(sep).collect();
451
452    let mut pi = 0;
453    let mut vi = 0;
454    while pi < pat_parts.len() && vi < val_parts.len() {
455        if pat_parts[pi] == "**" {
456            return true; // matches everything remaining
457        }
458        if pat_parts[pi] != "*" && pat_parts[pi] != val_parts[vi] {
459            return false;
460        }
461        pi += 1;
462        vi += 1;
463    }
464    pi == pat_parts.len() && vi == val_parts.len()
465}
466
467fn add_unique(vec: &mut Vec<String>, val: &str) {
468    if !vec.iter().any(|s| s == val) {
469        vec.push(val.to_string());
470    }
471}
472
473fn add_to_scope_map(map: &mut HashMap<String, Vec<String>>, scope: &str, route_id: &str) {
474    let entry = map.entry(scope.to_string()).or_default();
475    add_unique(entry, route_id);
476}
477
478fn remove_from_all_scopes(map: &mut HashMap<String, Vec<String>>, route_id: &str) {
479    let empty_scopes: Vec<String> = map
480        .iter_mut()
481        .filter_map(|(scope, routes)| {
482            routes.retain(|r| r != route_id);
483            if routes.is_empty() {
484                Some(scope.clone())
485            } else {
486                None
487            }
488        })
489        .collect();
490    for scope in empty_scopes {
491        map.remove(&scope);
492    }
493}
494
495/// Publish a single install-progress line to the frontend for a given block.
496/// The frontend subscribes to `install_progress` events scoped to `block:{block_id}`
497/// and displays each message as a log line in the agent presentation view.
498pub fn publish_install_progress(broker: &Broker, block_id: &str, message: &str) {
499    let scope = format!("block:{}", block_id);
500    broker.publish(WaveEvent {
501        event: EVENT_INSTALL_PROGRESS.to_string(),
502        scopes: vec![scope],
503        sender: String::new(),
504        persist: 0,
505        data: Some(serde_json::json!({ "message": message })),
506    });
507}
508
509/// Publish a Claude Code OSC window-title activity string to the frontend
510/// for a given agent-pane block. Frontend subscribes to `block:activity`
511/// events scoped to `block:{block_id}` and writes the payload to
512/// `term:activity` block metadata, which the tab label reads.
513pub fn publish_block_activity(broker: &Broker, block_id: &str, activity: &str) {
514    let scope = format!("block:{}", block_id);
515    broker.publish(WaveEvent {
516        event: EVENT_BLOCK_ACTIVITY.to_string(),
517        scopes: vec![scope],
518        sender: String::new(),
519        persist: 0,
520        data: Some(serde_json::json!({ "blockId": block_id, "activity": activity })),
521    });
522}
523
524// ====================================================================
525// Tests
526// ====================================================================
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use std::sync::Arc;
532
533    struct TestClient {
534        events: Mutex<Vec<(String, WaveEvent)>>,
535    }
536
537    impl TestClient {
538        fn new() -> Self {
539            Self {
540                events: Mutex::new(Vec::new()),
541            }
542        }
543
544        fn received_events(&self) -> Vec<(String, WaveEvent)> {
545            self.events.lock().unwrap().clone()
546        }
547    }
548
549    impl WpsClient for TestClient {
550        fn send_event(&self, route_id: &str, event: WaveEvent) {
551            self.events
552                .lock()
553                .unwrap()
554                .push((route_id.to_string(), event));
555        }
556    }
557
558    impl WpsClient for Arc<TestClient> {
559        fn send_event(&self, route_id: &str, event: WaveEvent) {
560            self.events
561                .lock()
562                .unwrap()
563                .push((route_id.to_string(), event));
564        }
565    }
566
567    #[test]
568    fn test_subscribe_all_scopes() {
569        let broker = Broker::new();
570        let client = Arc::new(TestClient::new());
571        broker.set_client(Box::new(Arc::clone(&client)));
572
573        broker.subscribe(
574            "route-1",
575            SubscriptionRequest {
576                event: EVENT_WAVE_OBJ_UPDATE.to_string(),
577                scopes: vec![],
578                allscopes: true,
579            },
580        );
581
582        broker.publish(WaveEvent {
583            event: EVENT_WAVE_OBJ_UPDATE.to_string(),
584            scopes: vec!["block:abc".to_string()],
585            sender: String::new(),
586            persist: 0,
587            data: None,
588        });
589
590        let events = client.received_events();
591        assert_eq!(events.len(), 1);
592        assert_eq!(events[0].0, "route-1");
593    }
594
595    #[test]
596    fn test_subscribe_exact_scope() {
597        let broker = Broker::new();
598        let client = Arc::new(TestClient::new());
599        broker.set_client(Box::new(Arc::clone(&client)));
600
601        broker.subscribe(
602            "route-1",
603            SubscriptionRequest {
604                event: EVENT_WAVE_OBJ_UPDATE.to_string(),
605                scopes: vec!["block:abc".to_string()],
606                allscopes: false,
607            },
608        );
609
610        // Should match
611        broker.publish(WaveEvent {
612            event: EVENT_WAVE_OBJ_UPDATE.to_string(),
613            scopes: vec!["block:abc".to_string()],
614            sender: String::new(),
615            persist: 0,
616            data: None,
617        });
618
619        // Should NOT match
620        broker.publish(WaveEvent {
621            event: EVENT_WAVE_OBJ_UPDATE.to_string(),
622            scopes: vec!["block:xyz".to_string()],
623            sender: String::new(),
624            persist: 0,
625            data: None,
626        });
627
628        let events = client.received_events();
629        assert_eq!(events.len(), 1);
630    }
631
632    #[test]
633    fn test_subscribe_star_scope() {
634        let broker = Broker::new();
635        let client = Arc::new(TestClient::new());
636        broker.set_client(Box::new(Arc::clone(&client)));
637
638        broker.subscribe(
639            "route-1",
640            SubscriptionRequest {
641                event: EVENT_WAVE_OBJ_UPDATE.to_string(),
642                scopes: vec!["block:*".to_string()],
643                allscopes: false,
644            },
645        );
646
647        broker.publish(WaveEvent {
648            event: EVENT_WAVE_OBJ_UPDATE.to_string(),
649            scopes: vec!["block:abc".to_string()],
650            sender: String::new(),
651            persist: 0,
652            data: None,
653        });
654
655        broker.publish(WaveEvent {
656            event: EVENT_WAVE_OBJ_UPDATE.to_string(),
657            scopes: vec!["tab:xyz".to_string()],
658            sender: String::new(),
659            persist: 0,
660            data: None,
661        });
662
663        let events = client.received_events();
664        assert_eq!(events.len(), 1); // only block:* matches block:abc
665    }
666
667    #[test]
668    fn test_unsubscribe() {
669        let broker = Broker::new();
670        let client = Arc::new(TestClient::new());
671        broker.set_client(Box::new(Arc::clone(&client)));
672
673        broker.subscribe(
674            "route-1",
675            SubscriptionRequest {
676                event: EVENT_BLOCK_CLOSE.to_string(),
677                scopes: vec![],
678                allscopes: true,
679            },
680        );
681
682        broker.unsubscribe("route-1", EVENT_BLOCK_CLOSE);
683
684        broker.publish(WaveEvent {
685            event: EVENT_BLOCK_CLOSE.to_string(),
686            scopes: vec![],
687            sender: String::new(),
688            persist: 0,
689            data: None,
690        });
691
692        assert!(client.received_events().is_empty());
693    }
694
695    #[test]
696    fn test_unsubscribe_all() {
697        let broker = Broker::new();
698        let client = Arc::new(TestClient::new());
699        broker.set_client(Box::new(Arc::clone(&client)));
700
701        broker.subscribe(
702            "route-1",
703            SubscriptionRequest {
704                event: EVENT_BLOCK_CLOSE.to_string(),
705                scopes: vec![],
706                allscopes: true,
707            },
708        );
709        broker.subscribe(
710            "route-1",
711            SubscriptionRequest {
712                event: EVENT_CONFIG.to_string(),
713                scopes: vec![],
714                allscopes: true,
715            },
716        );
717
718        broker.unsubscribe_all("route-1");
719
720        broker.publish(WaveEvent {
721            event: EVENT_BLOCK_CLOSE.to_string(),
722            scopes: vec![],
723            sender: String::new(),
724            persist: 0,
725            data: None,
726        });
727        broker.publish(WaveEvent {
728            event: EVENT_CONFIG.to_string(),
729            scopes: vec![],
730            sender: String::new(),
731            persist: 0,
732            data: None,
733        });
734
735        assert!(client.received_events().is_empty());
736    }
737
738    /// Regression: replay-on-subscribe delivers persisted events that
739    /// were published BEFORE the route subscribed. This closes the
740    /// late-subscribe race for tool_chunk streaming.
741    #[test]
742    fn test_replay_on_subscribe_exact_scope() {
743        let broker = Broker::new();
744        let client = Arc::new(TestClient::new());
745        broker.set_client(Box::new(Arc::clone(&client)));
746
747        // Publish 5 persisted events BEFORE any subscriber exists.
748        for i in 0..5 {
749            broker.publish(WaveEvent {
750                event: "tool_chunk".to_string(),
751                scopes: vec!["block:abc".to_string()],
752                sender: String::new(),
753                persist: 10,
754                data: Some(serde_json::json!({"tool_id": "t1", "n": i})),
755            });
756        }
757        assert!(
758            client.received_events().is_empty(),
759            "no subscriber yet, no delivery"
760        );
761
762        // Subscribe to the matching scope — replay should fire.
763        broker.subscribe(
764            "route-1",
765            SubscriptionRequest {
766                event: "tool_chunk".to_string(),
767                scopes: vec!["block:abc".to_string()],
768                allscopes: false,
769            },
770        );
771
772        let events = client.received_events();
773        assert_eq!(events.len(), 5, "all 5 persisted events replayed");
774        assert_eq!(events[0].1.data, Some(serde_json::json!({"tool_id": "t1", "n": 0})));
775        assert_eq!(events[4].1.data, Some(serde_json::json!({"tool_id": "t1", "n": 4})));
776    }
777
778    /// Regression: re-subscribing the SAME route to the SAME
779    /// (event, scope) does NOT replay again. The frontend's
780    /// `eventsub` flushes happen on every listener add/remove, so
781    /// the broker has to be idempotent across resubscribe calls.
782    /// Codex P2 on PR #817.
783    #[test]
784    fn test_replay_on_resubscribe_is_idempotent() {
785        let broker = Broker::new();
786        let client = Arc::new(TestClient::new());
787        broker.set_client(Box::new(Arc::clone(&client)));
788
789        for i in 0..3 {
790            broker.publish(WaveEvent {
791                event: "tool_chunk".to_string(),
792                scopes: vec!["block:abc".to_string()],
793                sender: String::new(),
794                persist: 10,
795                data: Some(serde_json::json!({"n": i})),
796            });
797        }
798
799        let sub = SubscriptionRequest {
800            event: "tool_chunk".to_string(),
801            scopes: vec!["block:abc".to_string()],
802            allscopes: false,
803        };
804
805        broker.subscribe("route-1", sub.clone());
806        assert_eq!(
807            client.received_events().len(),
808            3,
809            "first subscribe replays all 3 persisted events"
810        );
811
812        // Re-subscribe (same route, same event+scope) — must not
813        // replay a second time.
814        broker.subscribe("route-1", sub.clone());
815        assert_eq!(
816            client.received_events().len(),
817            3,
818            "re-subscribe is a no-op for replay; received count stays at 3"
819        );
820
821        // Live publish after re-subscribe still delivers.
822        broker.publish(WaveEvent {
823            event: "tool_chunk".to_string(),
824            scopes: vec!["block:abc".to_string()],
825            sender: String::new(),
826            persist: 10,
827            data: Some(serde_json::json!({"n": "live"})),
828        });
829        assert_eq!(
830            client.received_events().len(),
831            4,
832            "live publish after resubscribe is delivered exactly once"
833        );
834
835        // Disconnect (unsubscribe_all) + reconnect — fresh replay.
836        broker.unsubscribe_all("route-1");
837        broker.subscribe("route-1", sub.clone());
838        assert_eq!(
839            client.received_events().len(),
840            8,
841            "reconnect after unsubscribe_all clears the replayed tracker; \
842             gets all 4 persisted events again"
843        );
844    }
845
846    /// Regression: replay does NOT cross-pollute scopes. Subscriber to
847    /// `block:abc` must not receive events persisted for `block:xyz`.
848    #[test]
849    fn test_replay_on_subscribe_scope_isolation() {
850        let broker = Broker::new();
851        let client = Arc::new(TestClient::new());
852        broker.set_client(Box::new(Arc::clone(&client)));
853
854        broker.publish(WaveEvent {
855            event: "tool_chunk".to_string(),
856            scopes: vec!["block:xyz".to_string()],
857            sender: String::new(),
858            persist: 10,
859            data: Some(serde_json::json!({"tool_id": "other"})),
860        });
861
862        broker.subscribe(
863            "route-1",
864            SubscriptionRequest {
865                event: "tool_chunk".to_string(),
866                scopes: vec!["block:abc".to_string()],
867                allscopes: false,
868            },
869        );
870
871        let events = client.received_events();
872        assert_eq!(events.len(), 0, "scope:abc must not get block:xyz events");
873    }
874
875    #[test]
876    fn test_event_persistence() {
877        let broker = Broker::new();
878        let client = Arc::new(TestClient::new());
879        broker.set_client(Box::new(Arc::clone(&client)));
880
881        // Subscribe so events are dispatched
882        broker.subscribe(
883            "route-1",
884            SubscriptionRequest {
885                event: EVENT_SYS_INFO.to_string(),
886                scopes: vec![],
887                allscopes: true,
888            },
889        );
890
891        // Publish persistent events
892        for i in 0..5 {
893            broker.publish(WaveEvent {
894                event: EVENT_SYS_INFO.to_string(),
895                scopes: vec!["cpu".to_string()],
896                sender: String::new(),
897                persist: 3, // keep last 3
898                data: Some(serde_json::json!(i)),
899            });
900        }
901
902        // Read history (global scope "")
903        let history = broker.read_event_history(EVENT_SYS_INFO, "", 10);
904        assert_eq!(history.len(), 3);
905        assert_eq!(history[0].data, Some(serde_json::json!(2)));
906        assert_eq!(history[2].data, Some(serde_json::json!(4)));
907
908        // Read scoped history
909        let scoped = broker.read_event_history(EVENT_SYS_INFO, "cpu", 2);
910        assert_eq!(scoped.len(), 2);
911    }
912
913    #[test]
914    fn test_star_match() {
915        assert!(star_match("block:*", "block:abc", ":"));
916        assert!(star_match("*:abc", "block:abc", ":"));
917        assert!(!star_match("block:*", "tab:abc", ":"));
918        assert!(star_match("**", "block:abc:xyz", ":"));
919        assert!(!star_match("block:*", "block:abc:xyz", ":")); // * matches one segment only
920    }
921
922    #[test]
923    fn test_wave_event_serialization() {
924        let event = WaveEvent {
925            event: "test".to_string(),
926            scopes: vec!["scope1".to_string()],
927            sender: String::new(),
928            persist: 0,
929            data: Some(serde_json::json!({"key": "value"})),
930        };
931        let json = serde_json::to_string(&event).unwrap();
932        let parsed: WaveEvent = serde_json::from_str(&json).unwrap();
933        assert_eq!(parsed.event, "test");
934        assert_eq!(parsed.scopes, vec!["scope1"]);
935        // Empty sender and zero persist should be omitted
936        assert!(!json.contains("\"sender\""));
937        assert!(!json.contains("\"persist\""));
938    }
939
940    #[test]
941    fn test_subscription_request_serialization() {
942        let req = SubscriptionRequest {
943            event: "blockclose".to_string(),
944            scopes: vec!["block:123".to_string()],
945            allscopes: false,
946        };
947        let json = serde_json::to_string(&req).unwrap();
948        let parsed: SubscriptionRequest = serde_json::from_str(&json).unwrap();
949        assert_eq!(parsed.event, "blockclose");
950    }
951
952    #[test]
953    fn test_no_client_publish_does_not_panic() {
954        let broker = Broker::new();
955        // No client set — should not panic
956        broker.publish(WaveEvent {
957            event: "test".to_string(),
958            scopes: vec![],
959            sender: String::new(),
960            persist: 0,
961            data: None,
962        });
963    }
964
965    #[test]
966    fn test_double_star_scope() {
967        let broker = Broker::new();
968        let client = Arc::new(TestClient::new());
969        broker.set_client(Box::new(Arc::clone(&client)));
970
971        broker.subscribe(
972            "route-1",
973            SubscriptionRequest {
974                event: EVENT_WAVE_OBJ_UPDATE.to_string(),
975                scopes: vec!["**".to_string()],
976                allscopes: false,
977            },
978        );
979
980        broker.publish(WaveEvent {
981            event: EVENT_WAVE_OBJ_UPDATE.to_string(),
982            scopes: vec!["block:abc:def".to_string()],
983            sender: String::new(),
984            persist: 0,
985            data: None,
986        });
987
988        assert_eq!(client.received_events().len(), 1);
989    }
990}