Skip to main content

meerkat_runtime/
coalescing.rs

1//! §16 Coalescing — merging compatible inputs into aggregates.
2//!
3//! Only ExternalEventInput and PeerInput(ResponseProgress) are coalescing-eligible.
4//! Supersession scoped by (LogicalRuntimeId, variant, SupersessionKey).
5//! Cross-kind forbidden.
6
7use chrono::Utc;
8use meerkat_core::lifecycle::InputId;
9
10use crate::identifiers::{InputKind, LogicalRuntimeId, SupersessionKey};
11use crate::input::{Input, PeerConvention};
12
13/// Whether an input is eligible for coalescing.
14pub fn is_coalescing_eligible(input: &Input) -> bool {
15    matches!(
16        input,
17        Input::ExternalEvent(_)
18            | Input::Peer(crate::input::PeerInput {
19                convention: Some(PeerConvention::ResponseProgress { .. }),
20                ..
21            })
22    )
23}
24
25/// Scope key for supersession — inputs with the same scope can supersede each other.
26#[derive(Debug, Clone, PartialEq, Eq, Hash)]
27pub struct SupersessionScope {
28    pub runtime_id: LogicalRuntimeId,
29    pub kind: InputKind,
30    pub supersession_key: SupersessionKey,
31}
32
33impl SupersessionScope {
34    /// Extract the supersession scope from an input (if it has a supersession key).
35    pub fn from_input(input: &Input, runtime_id: &LogicalRuntimeId) -> Option<Self> {
36        let key = input.header().supersession_key.as_ref()?;
37        Some(Self {
38            runtime_id: runtime_id.clone(),
39            kind: input.kind(),
40            supersession_key: key.clone(),
41        })
42    }
43}
44
45/// Result of a coalescing check.
46#[derive(Debug)]
47pub enum CoalescingResult {
48    /// No coalescing needed (input is standalone).
49    Standalone,
50    /// The input supersedes an existing input.
51    Supersedes {
52        /// ID of the superseded input.
53        superseded_id: InputId,
54    },
55}
56
57/// Check if a new input supersedes an existing input with the same scope.
58pub fn check_supersession(
59    new_input: &Input,
60    existing_input: &Input,
61    runtime_id: &LogicalRuntimeId,
62) -> CoalescingResult {
63    let new_scope = SupersessionScope::from_input(new_input, runtime_id);
64    let existing_scope = SupersessionScope::from_input(existing_input, runtime_id);
65
66    match (new_scope, existing_scope) {
67        (Some(ns), Some(es)) if ns == es => {
68            // Same scope — new supersedes existing
69            CoalescingResult::Supersedes {
70                superseded_id: existing_input.id().clone(),
71            }
72        }
73        _ => CoalescingResult::Standalone,
74    }
75}
76
77/// Create an aggregate input from multiple coalesced inputs.
78pub fn create_aggregate_input(
79    sources: &[&Input],
80    aggregate_id: InputId,
81) -> Option<AggregateDescriptor> {
82    if sources.is_empty() {
83        return None;
84    }
85
86    let source_ids: Vec<InputId> = sources.iter().map(|i| i.id().clone()).collect();
87    let summary = format!("{} coalesced inputs", sources.len());
88
89    Some(AggregateDescriptor {
90        aggregate_id,
91        source_ids,
92        summary,
93        created_at: Utc::now(),
94    })
95}
96
97/// Describes a coalesced aggregate (the caller creates the actual Input).
98#[derive(Debug, Clone)]
99pub struct AggregateDescriptor {
100    pub aggregate_id: InputId,
101    pub source_ids: Vec<InputId>,
102    pub summary: String,
103    pub created_at: chrono::DateTime<Utc>,
104}
105
106#[cfg(test)]
107#[allow(clippy::unwrap_used)]
108mod tests {
109    use super::*;
110    use crate::input::*;
111    use chrono::Utc;
112    use meerkat_core::types::HandlingMode;
113
114    fn make_header_with_supersession(key: Option<&str>) -> InputHeader {
115        InputHeader {
116            id: InputId::new(),
117            timestamp: Utc::now(),
118            source: InputOrigin::External {
119                source_name: "test".into(),
120            },
121            durability: InputDurability::Ephemeral,
122            visibility: InputVisibility::default(),
123            idempotency_key: None,
124            supersession_key: key.map(SupersessionKey::new),
125            correlation_id: None,
126        }
127    }
128
129    #[test]
130    fn external_event_is_coalescing_eligible() {
131        let input = Input::ExternalEvent(ExternalEventInput {
132            objective_id: None,
133            header: make_header_with_supersession(None),
134            event_type: "webhook".into(),
135            payload: serde_json::json!({}),
136            blocks: None,
137            handling_mode: HandlingMode::Queue,
138            render_metadata: None,
139        });
140        assert!(is_coalescing_eligible(&input));
141    }
142
143    #[test]
144    fn response_progress_is_coalescing_eligible() {
145        let input = Input::Peer(PeerInput {
146            directed_interaction_id: None,
147            objective_id: None,
148            system_prompts: Vec::new(),
149            injected_context: Vec::new(),
150            sender_taint: None,
151            header: make_header_with_supersession(None),
152            convention: Some(PeerConvention::ResponseProgress {
153                request_id: "req-1".into(),
154                phase: ResponseProgressPhase::InProgress,
155            }),
156            content: "progress".into(),
157            payload: Some(serde_json::json!({"progress": "working"})),
158            handling_mode: None,
159        });
160        assert!(is_coalescing_eligible(&input));
161    }
162
163    #[test]
164    fn prompt_not_coalescing_eligible() {
165        let input = Input::Prompt(PromptInput {
166            injected_context: Vec::new(),
167            header: make_header_with_supersession(None),
168            content: "hello".into(),
169            typed_turn_appends: Vec::new(),
170            turn_metadata: None,
171        });
172        assert!(!is_coalescing_eligible(&input));
173    }
174
175    #[test]
176    fn peer_message_not_coalescing_eligible() {
177        let input = Input::Peer(PeerInput {
178            directed_interaction_id: None,
179            objective_id: None,
180            system_prompts: Vec::new(),
181            injected_context: Vec::new(),
182            sender_taint: None,
183            header: make_header_with_supersession(None),
184            convention: Some(PeerConvention::Message),
185            content: "hello".into(),
186            payload: None,
187            handling_mode: None,
188        });
189        assert!(!is_coalescing_eligible(&input));
190    }
191
192    #[test]
193    fn supersession_same_scope() {
194        let runtime = LogicalRuntimeId::new("agent-1");
195        let input1 = Input::ExternalEvent(ExternalEventInput {
196            objective_id: None,
197            header: make_header_with_supersession(Some("status")),
198            event_type: "status_update".into(),
199            payload: serde_json::json!({"v": 1}),
200            blocks: None,
201            handling_mode: HandlingMode::Queue,
202            render_metadata: None,
203        });
204        let input2 = Input::ExternalEvent(ExternalEventInput {
205            objective_id: None,
206            header: make_header_with_supersession(Some("status")),
207            event_type: "status_update".into(),
208            payload: serde_json::json!({"v": 2}),
209            blocks: None,
210            handling_mode: HandlingMode::Queue,
211            render_metadata: None,
212        });
213        let result = check_supersession(&input2, &input1, &runtime);
214        assert!(matches!(result, CoalescingResult::Supersedes { .. }));
215    }
216
217    #[test]
218    fn supersession_different_key() {
219        let runtime = LogicalRuntimeId::new("agent-1");
220        let input1 = Input::ExternalEvent(ExternalEventInput {
221            objective_id: None,
222            header: make_header_with_supersession(Some("status-a")),
223            event_type: "status_update".into(),
224            payload: serde_json::json!({}),
225            blocks: None,
226            handling_mode: HandlingMode::Queue,
227            render_metadata: None,
228        });
229        let input2 = Input::ExternalEvent(ExternalEventInput {
230            objective_id: None,
231            header: make_header_with_supersession(Some("status-b")),
232            event_type: "status_update".into(),
233            payload: serde_json::json!({}),
234            blocks: None,
235            handling_mode: HandlingMode::Queue,
236            render_metadata: None,
237        });
238        let result = check_supersession(&input2, &input1, &runtime);
239        assert!(matches!(result, CoalescingResult::Standalone));
240    }
241
242    #[test]
243    fn supersession_no_key() {
244        let runtime = LogicalRuntimeId::new("agent-1");
245        let input1 = Input::ExternalEvent(ExternalEventInput {
246            objective_id: None,
247            header: make_header_with_supersession(None),
248            event_type: "status_update".into(),
249            payload: serde_json::json!({}),
250            blocks: None,
251            handling_mode: HandlingMode::Queue,
252            render_metadata: None,
253        });
254        let input2 = Input::ExternalEvent(ExternalEventInput {
255            objective_id: None,
256            header: make_header_with_supersession(None),
257            event_type: "status_update".into(),
258            payload: serde_json::json!({}),
259            blocks: None,
260            handling_mode: HandlingMode::Queue,
261            render_metadata: None,
262        });
263        let result = check_supersession(&input2, &input1, &runtime);
264        assert!(matches!(result, CoalescingResult::Standalone));
265    }
266
267    #[test]
268    fn cross_kind_supersession_forbidden() {
269        let runtime = LogicalRuntimeId::new("agent-1");
270        let input1 = Input::ExternalEvent(ExternalEventInput {
271            objective_id: None,
272            header: make_header_with_supersession(Some("same-key")),
273            event_type: "type-a".into(),
274            payload: serde_json::json!({}),
275            blocks: None,
276            handling_mode: HandlingMode::Queue,
277            render_metadata: None,
278        });
279        // Different kind (Prompt vs ExternalEvent) but same supersession key
280        let input2 = Input::Prompt(PromptInput {
281            injected_context: Vec::new(),
282            header: make_header_with_supersession(Some("same-key")),
283            content: "hello".into(),
284            typed_turn_appends: Vec::new(),
285            turn_metadata: None,
286        });
287        let result = check_supersession(&input2, &input1, &runtime);
288        // Different kinds → different scope → no supersession
289        assert!(matches!(result, CoalescingResult::Standalone));
290    }
291
292    #[test]
293    fn create_aggregate_from_sources() {
294        let sources: Vec<Input> = (0..3)
295            .map(|_| {
296                Input::ExternalEvent(ExternalEventInput {
297                    objective_id: None,
298                    header: make_header_with_supersession(None),
299                    event_type: "test".into(),
300                    payload: serde_json::json!({}),
301                    blocks: None,
302                    handling_mode: HandlingMode::Queue,
303                    render_metadata: None,
304                })
305            })
306            .collect();
307        let source_refs: Vec<&Input> = sources.iter().collect();
308        let agg = create_aggregate_input(&source_refs, InputId::new()).unwrap();
309        assert_eq!(agg.source_ids.len(), 3);
310        assert!(agg.summary.contains('3'));
311    }
312
313    #[test]
314    fn create_aggregate_empty_returns_none() {
315        let result = create_aggregate_input(&[], InputId::new());
316        assert!(result.is_none());
317    }
318}