1use chrono::Utc;
8use meerkat_core::lifecycle::InputId;
9
10use crate::identifiers::{InputKind, LogicalRuntimeId, SupersessionKey};
11use crate::input::{Input, PeerConvention};
12
13pub 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#[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 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#[derive(Debug)]
47pub enum CoalescingResult {
48 Standalone,
50 Supersedes {
52 superseded_id: InputId,
54 },
55}
56
57pub 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 CoalescingResult::Supersedes {
70 superseded_id: existing_input.id().clone(),
71 }
72 }
73 _ => CoalescingResult::Standalone,
74 }
75}
76
77pub 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#[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 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 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}