1use crate::input::{Input, PeerConvention};
12use meerkat_core::types::HandlingMode;
13
14#[derive(Debug, Clone, thiserror::Error)]
16#[non_exhaustive]
17pub enum PeerHandlingModeError {
18 #[error("handling_mode is forbidden on ResponseProgress peer inputs")]
20 ForbiddenForResponseProgress,
21 #[error("steer handling_mode cannot carry injected context on peer inputs")]
25 SteerCannotCarryInjectedContext,
26}
27
28pub fn validate_peer_handling_mode(input: &Input) -> Result<(), PeerHandlingModeError> {
30 let Input::Peer(peer) = input else {
31 return Ok(());
32 };
33 if peer.handling_mode == Some(HandlingMode::Steer) && !peer.injected_context.is_empty() {
34 return Err(PeerHandlingModeError::SteerCannotCarryInjectedContext);
35 }
36 if peer.handling_mode.is_none() {
37 return Ok(());
38 }
39 match &peer.convention {
40 Some(PeerConvention::ResponseProgress { .. }) => {
41 Err(PeerHandlingModeError::ForbiddenForResponseProgress)
42 }
43 _ => Ok(()),
44 }
45}
46
47#[cfg(test)]
48#[allow(clippy::unwrap_used)]
49mod tests {
50 use super::*;
51 use crate::input::*;
52 use chrono::Utc;
53 use meerkat_core::lifecycle::InputId;
54 use meerkat_core::types::HandlingMode;
55
56 fn make_header() -> InputHeader {
57 InputHeader {
58 id: InputId::new(),
59 timestamp: Utc::now(),
60 source: InputOrigin::Peer {
61 peer_id: "peer-1".into(),
62 display_identity: None,
63 runtime_id: None,
64 },
65 durability: InputDurability::Durable,
66 visibility: InputVisibility::default(),
67 idempotency_key: None,
68 supersession_key: None,
69 correlation_id: None,
70 }
71 }
72
73 #[test]
74 fn response_progress_with_handling_mode_rejected() {
75 let input = Input::Peer(PeerInput {
76 injected_context: Vec::new(),
77 sender_taint: None,
78 header: make_header(),
79 convention: Some(PeerConvention::ResponseProgress {
80 request_id: "r".into(),
81 phase: ResponseProgressPhase::InProgress,
82 }),
83 content: "working".into(),
84 payload: Some(serde_json::json!({"progress": "working"})),
85 handling_mode: Some(HandlingMode::Queue),
86 });
87 let err = validate_peer_handling_mode(&input).unwrap_err();
88 assert!(matches!(
89 err,
90 PeerHandlingModeError::ForbiddenForResponseProgress
91 ));
92 }
93
94 #[test]
95 fn response_terminal_with_handling_mode_accepted() {
96 let input = Input::Peer(PeerInput {
97 injected_context: Vec::new(),
98 sender_taint: None,
99 header: make_header(),
100 convention: Some(PeerConvention::ResponseTerminal {
101 request_id: "r".into(),
102 status: ResponseTerminalStatus::Completed,
103 }),
104 content: "done".into(),
105 payload: Some(serde_json::json!({"ok": true})),
106 handling_mode: Some(HandlingMode::Steer),
107 });
108 assert!(validate_peer_handling_mode(&input).is_ok());
109 }
110
111 #[test]
112 fn response_terminal_with_queue_handling_mode_accepted() {
113 let input = Input::Peer(PeerInput {
114 injected_context: Vec::new(),
115 sender_taint: None,
116 header: make_header(),
117 convention: Some(PeerConvention::ResponseTerminal {
118 request_id: "r".into(),
119 status: ResponseTerminalStatus::Completed,
120 }),
121 content: "done".into(),
122 payload: Some(serde_json::json!({"ok": true})),
123 handling_mode: Some(HandlingMode::Queue),
124 });
125 assert!(validate_peer_handling_mode(&input).is_ok());
126 }
127
128 #[test]
129 fn message_with_handling_mode_accepted() {
130 let input = Input::Peer(PeerInput {
131 injected_context: Vec::new(),
132 sender_taint: None,
133 header: make_header(),
134 convention: Some(PeerConvention::Message),
135 content: "hi".into(),
136 payload: None,
137 handling_mode: Some(HandlingMode::Queue),
138 });
139 assert!(validate_peer_handling_mode(&input).is_ok());
140 }
141
142 #[test]
143 fn request_with_handling_mode_accepted() {
144 let input = Input::Peer(PeerInput {
145 injected_context: Vec::new(),
146 sender_taint: None,
147 header: make_header(),
148 convention: Some(PeerConvention::Request {
149 request_id: "r".into(),
150 intent: "i".into(),
151 }),
152 content: "do it".into(),
153 payload: Some(serde_json::json!({"subject": "x"})),
154 handling_mode: Some(HandlingMode::Steer),
155 });
156 assert!(validate_peer_handling_mode(&input).is_ok());
157 }
158
159 #[test]
160 fn no_convention_with_handling_mode_accepted() {
161 let input = Input::Peer(PeerInput {
162 injected_context: Vec::new(),
163 sender_taint: None,
164 header: make_header(),
165 convention: None,
166 content: "hi".into(),
167 payload: None,
168 handling_mode: Some(HandlingMode::Queue),
169 });
170 assert!(validate_peer_handling_mode(&input).is_ok());
171 }
172
173 #[test]
176 fn steer_with_injected_context_rejected() {
177 let input = Input::Peer(PeerInput {
178 injected_context: vec![meerkat_core::types::ContentInput::Text(
179 "ambient".to_string(),
180 )],
181 sender_taint: None,
182 header: make_header(),
183 convention: Some(PeerConvention::Message),
184 content: "urgent".into(),
185 payload: None,
186 handling_mode: Some(HandlingMode::Steer),
187 });
188 let err = validate_peer_handling_mode(&input).unwrap_err();
189 assert!(matches!(
190 err,
191 PeerHandlingModeError::SteerCannotCarryInjectedContext
192 ));
193 }
194
195 #[test]
198 fn queue_with_injected_context_accepted() {
199 let input = Input::Peer(PeerInput {
200 injected_context: vec![meerkat_core::types::ContentInput::Text(
201 "ambient".to_string(),
202 )],
203 sender_taint: None,
204 header: make_header(),
205 convention: Some(PeerConvention::Message),
206 content: "work".into(),
207 payload: None,
208 handling_mode: Some(HandlingMode::Queue),
209 });
210 assert!(validate_peer_handling_mode(&input).is_ok());
211 }
212
213 #[test]
214 fn peer_without_handling_mode_always_accepted() {
215 for convention in [
216 Some(PeerConvention::Message),
217 Some(PeerConvention::Request {
218 request_id: "r".into(),
219 intent: "i".into(),
220 }),
221 Some(PeerConvention::ResponseProgress {
222 request_id: "r".into(),
223 phase: ResponseProgressPhase::InProgress,
224 }),
225 Some(PeerConvention::ResponseTerminal {
226 request_id: "r".into(),
227 status: ResponseTerminalStatus::Completed,
228 }),
229 None,
230 ] {
231 let input = Input::Peer(PeerInput {
232 injected_context: Vec::new(),
233 sender_taint: None,
234 header: make_header(),
235 convention,
236 content: "hi".into(),
237 payload: None,
238 handling_mode: None,
239 });
240 assert!(
241 validate_peer_handling_mode(&input).is_ok(),
242 "should accept peer without handling_mode"
243 );
244 }
245 }
246
247 #[test]
248 fn non_peer_input_always_accepted() {
249 let input = Input::Prompt(PromptInput {
250 injected_context: Vec::new(),
251 header: InputHeader {
252 id: InputId::new(),
253 timestamp: Utc::now(),
254 source: InputOrigin::Operator,
255 durability: InputDurability::Durable,
256 visibility: InputVisibility::default(),
257 idempotency_key: None,
258 supersession_key: None,
259 correlation_id: None,
260 },
261 content: "hi".into(),
262 typed_turn_appends: Vec::new(),
263 turn_metadata: None,
264 });
265 assert!(validate_peer_handling_mode(&input).is_ok());
266 }
267}