1use anyhow::Result;
14use std::path::{Path, PathBuf};
15
16pub const MAX_HOOK_PAYLOAD_CHARS: usize = 10_000;
21
22#[derive(Debug, Clone, PartialEq)]
23pub struct InboxMessage {
24 pub id: String,
25 pub text: String,
26 pub sent_at: i64,
27}
28
29pub fn write_message(dir: &Path, session_id: &str, text: &str) -> Result<InboxMessage> {
32 use std::sync::atomic::{AtomicU32, Ordering};
33 static SEQ: AtomicU32 = AtomicU32::new(0);
34
35 let sent_at = std::time::SystemTime::now()
36 .duration_since(std::time::UNIX_EPOCH)
37 .map(|d| d.as_millis() as i64)
38 .unwrap_or(0);
39 let message = InboxMessage {
46 id: format!(
47 "msg-{sent_at}-{}-{:010}",
48 std::process::id(),
49 SEQ.fetch_add(1, Ordering::Relaxed),
50 ),
51 text: text.to_string(),
52 sent_at,
53 };
54
55 let dir = inbox_dir(dir, session_id);
56 std::fs::create_dir_all(&dir)?;
57 let path = dir.join(format!("{}.json", message.id));
58 let body = serde_json::json!({
59 "id": message.id,
60 "text": message.text,
61 "sentAt": message.sent_at,
62 });
63 let tmp = path.with_extension("tmp");
64 std::fs::write(&tmp, serde_json::to_string_pretty(&body)?)?;
65 std::fs::rename(&tmp, &path)?;
66 Ok(message)
67}
68
69pub fn read_pending_messages(dir: &Path, session_id: &str) -> Result<Vec<InboxMessage>> {
71 let dir = inbox_dir(dir, session_id);
72 let entries = match std::fs::read_dir(&dir) {
73 Ok(e) => e,
74 Err(_) => return Ok(Vec::new()), };
76 let mut messages: Vec<InboxMessage> = entries
77 .filter_map(|e| e.ok())
78 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
79 .filter_map(|e| {
80 let raw = std::fs::read_to_string(e.path()).ok()?;
81 let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
82 Some(InboxMessage {
83 id: v.get("id")?.as_str()?.to_string(),
84 text: v.get("text")?.as_str()?.to_string(),
85 sent_at: v.get("sentAt").and_then(|t| t.as_i64()).unwrap_or(0),
86 })
87 })
88 .collect();
89 messages.sort_by(|a, b| a.sent_at.cmp(&b.sent_at).then(a.id.cmp(&b.id)));
90 Ok(messages)
91}
92
93pub fn mark_messages_delivered(dir: &Path, session_id: &str, ids: &[String]) -> Result<()> {
98 let dir = inbox_dir(dir, session_id);
99 let mut first_err: Option<std::io::Error> = None;
100 for id in ids {
101 let path = dir.join(format!("{id}.json"));
102 if path.exists() {
103 let delivered = dir.join(format!("{id}.json.delivered"));
104 if let Err(e) = std::fs::rename(&path, &delivered) {
105 first_err.get_or_insert(e);
106 }
107 }
108 }
109 match first_err {
110 Some(e) => Err(e.into()),
111 None => Ok(()),
112 }
113}
114
115fn inbox_dir(dir: &Path, session_id: &str) -> PathBuf {
121 dir.join(format!("{session_id}.inbox"))
122}
123
124fn drain_batch(pending: Vec<InboxMessage>, max_chars: usize) -> (String, Vec<String>) {
131 let mut joined = String::new();
132 let mut ids = Vec::new();
133 for msg in pending {
134 let mut candidate = joined.clone();
135 if !candidate.is_empty() {
136 candidate.push_str("\n\n");
137 }
138 candidate.push_str(&msg.text);
139 if candidate.chars().count() > max_chars {
140 if ids.is_empty() {
141 joined = candidate.chars().take(max_chars).collect();
142 ids.push(msg.id);
143 }
144 break;
145 }
146 joined = candidate;
147 ids.push(msg.id);
148 }
149 (joined, ids)
150}
151
152pub fn drain_for_stop(dir: &Path, session_id: &str) -> Result<Option<serde_json::Value>> {
172 let pending = read_pending_messages(dir, session_id)?;
173 if pending.is_empty() {
174 return Ok(None);
175 }
176 let (reason, ids) = drain_batch(pending, MAX_HOOK_PAYLOAD_CHARS);
177 if let Err(e) = mark_messages_delivered(dir, session_id, &ids) {
180 tracing::warn!("failed to mark inbox messages delivered for {session_id}: {e}");
181 }
182 Ok(Some(serde_json::json!({ "decision": "block", "reason": reason })))
183}
184
185pub fn drain_for_prompt_submit(dir: &Path, session_id: &str) -> Result<Option<serde_json::Value>> {
191 let pending = read_pending_messages(dir, session_id)?;
192 if pending.is_empty() {
193 return Ok(None);
194 }
195 let (context, ids) = drain_batch(pending, MAX_HOOK_PAYLOAD_CHARS);
196 if let Err(e) = mark_messages_delivered(dir, session_id, &ids) {
199 tracing::warn!("failed to mark inbox messages delivered for {session_id}: {e}");
200 }
201 Ok(Some(serde_json::json!({
202 "hookSpecificOutput": {
203 "hookEventName": "UserPromptSubmit",
204 "additionalContext": context,
205 }
206 })))
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use tempfile::tempdir;
213
214 #[test]
215 fn write_then_read_returns_the_message() {
216 let dir = tempdir().unwrap();
217 let written = write_message(dir.path(), "sess-1", "hello worker").unwrap();
218 let pending = read_pending_messages(dir.path(), "sess-1").unwrap();
219 assert_eq!(pending, vec![written]);
220 }
221
222 #[test]
223 fn read_pending_on_missing_dir_returns_empty() {
224 let dir = tempdir().unwrap();
225 assert!(read_pending_messages(dir.path(), "no-such-session").unwrap().is_empty());
226 }
227
228 #[test]
229 fn messages_are_returned_oldest_first() {
230 let dir = tempdir().unwrap();
231 let a = write_message(dir.path(), "sess-1", "first").unwrap();
232 let b = write_message(dir.path(), "sess-1", "second").unwrap();
233 let pending = read_pending_messages(dir.path(), "sess-1").unwrap();
234 assert_eq!(pending, vec![a, b]);
235 }
236
237 #[test]
238 fn delivered_messages_are_no_longer_pending() {
239 let dir = tempdir().unwrap();
240 let msg = write_message(dir.path(), "sess-1", "hello").unwrap();
241 mark_messages_delivered(dir.path(), "sess-1", std::slice::from_ref(&msg.id)).unwrap();
242 assert!(read_pending_messages(dir.path(), "sess-1").unwrap().is_empty());
243 let dir_path = inbox_dir(dir.path(), "sess-1");
245 assert!(dir_path.join(format!("{}.json.delivered", msg.id)).exists());
246 }
247
248 #[test]
249 fn messages_for_different_sessions_do_not_collide() {
250 let dir = tempdir().unwrap();
251 write_message(dir.path(), "sess-1", "for one").unwrap();
252 write_message(dir.path(), "sess-2", "for two").unwrap();
253 assert_eq!(read_pending_messages(dir.path(), "sess-1").unwrap().len(), 1);
254 assert_eq!(read_pending_messages(dir.path(), "sess-2").unwrap().len(), 1);
255 }
256
257 #[test]
258 fn drain_for_stop_returns_none_when_nothing_pending() {
259 let dir = tempdir().unwrap();
260 assert!(drain_for_stop(dir.path(), "sess-1").unwrap().is_none());
261 }
262
263 #[test]
264 fn drain_for_stop_blocks_with_joined_reason_and_delivers() {
265 let dir = tempdir().unwrap();
266 write_message(dir.path(), "sess-1", "first").unwrap();
267 write_message(dir.path(), "sess-1", "second").unwrap();
268
269 let response = drain_for_stop(dir.path(), "sess-1").unwrap().unwrap();
270 assert_eq!(response["decision"], "block");
271 assert_eq!(response["reason"], "first\n\nsecond");
272 assert!(read_pending_messages(dir.path(), "sess-1").unwrap().is_empty());
273 }
274
275 #[test]
276 fn drain_for_stop_is_self_limiting_across_repeat_invocations() {
277 let dir = tempdir().unwrap();
281 write_message(dir.path(), "sess-1", "only message").unwrap();
282 assert!(drain_for_stop(dir.path(), "sess-1").unwrap().is_some());
283 assert!(drain_for_stop(dir.path(), "sess-1").unwrap().is_none());
284 }
285
286 #[test]
287 fn drain_for_prompt_submit_returns_none_when_nothing_pending() {
288 let dir = tempdir().unwrap();
289 assert!(drain_for_prompt_submit(dir.path(), "sess-1").unwrap().is_none());
290 }
291
292 #[test]
293 fn drain_for_prompt_submit_shapes_hook_specific_output_and_delivers() {
294 let dir = tempdir().unwrap();
295 write_message(dir.path(), "sess-1", "context for the next turn").unwrap();
296
297 let response = drain_for_prompt_submit(dir.path(), "sess-1").unwrap().unwrap();
298 assert_eq!(response["hookSpecificOutput"]["hookEventName"], "UserPromptSubmit");
299 assert_eq!(
300 response["hookSpecificOutput"]["additionalContext"],
301 "context for the next turn"
302 );
303 assert!(read_pending_messages(dir.path(), "sess-1").unwrap().is_empty());
304 }
305
306 #[test]
307 fn drain_for_prompt_submit_truncates_a_single_oversized_message_and_delivers_it() {
308 let dir = tempdir().unwrap();
309 let long = "x".repeat(MAX_HOOK_PAYLOAD_CHARS + 500);
310 write_message(dir.path(), "sess-1", &long).unwrap();
311
312 let response = drain_for_prompt_submit(dir.path(), "sess-1").unwrap().unwrap();
313 let context = response["hookSpecificOutput"]["additionalContext"].as_str().unwrap();
314 assert_eq!(context.chars().count(), MAX_HOOK_PAYLOAD_CHARS);
315 assert!(read_pending_messages(dir.path(), "sess-1").unwrap().is_empty());
318 }
319
320 #[test]
321 fn drain_for_prompt_submit_leaves_overflow_messages_pending_instead_of_discarding_them() {
322 let dir = tempdir().unwrap();
323 let big = "x".repeat(MAX_HOOK_PAYLOAD_CHARS);
324 write_message(dir.path(), "sess-1", &big).unwrap();
325 write_message(dir.path(), "sess-1", "second message").unwrap();
326
327 let response = drain_for_prompt_submit(dir.path(), "sess-1").unwrap().unwrap();
328 let context = response["hookSpecificOutput"]["additionalContext"].as_str().unwrap();
329 assert_eq!(context, big, "only the first message should be in this batch");
330
331 let still_pending = read_pending_messages(dir.path(), "sess-1").unwrap();
332 assert_eq!(still_pending.len(), 1, "the overflow message must stay pending, not be lost");
333 assert_eq!(still_pending[0].text, "second message");
334 }
335
336 #[test]
337 fn drain_for_stop_leaves_overflow_messages_pending_instead_of_discarding_them() {
338 let dir = tempdir().unwrap();
339 let big = "x".repeat(MAX_HOOK_PAYLOAD_CHARS);
340 write_message(dir.path(), "sess-1", &big).unwrap();
341 write_message(dir.path(), "sess-1", "second message").unwrap();
342
343 let response = drain_for_stop(dir.path(), "sess-1").unwrap().unwrap();
344 assert_eq!(response["reason"], big);
345
346 let still_pending = read_pending_messages(dir.path(), "sess-1").unwrap();
347 assert_eq!(still_pending.len(), 1, "the overflow message must stay pending, not be lost");
348 assert_eq!(still_pending[0].text, "second message");
349 }
350
351 #[test]
352 fn drain_batch_includes_everything_that_fits() {
353 let pending = vec![
354 InboxMessage { id: "a".into(), text: "first".into(), sent_at: 1 },
355 InboxMessage { id: "b".into(), text: "second".into(), sent_at: 2 },
356 ];
357 let (joined, ids) = drain_batch(pending, 100);
358 assert_eq!(joined, "first\n\nsecond");
359 assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
360 }
361
362 #[test]
363 fn drain_batch_stops_before_a_message_that_would_overflow() {
364 let pending = vec![
365 InboxMessage { id: "a".into(), text: "12345".into(), sent_at: 1 },
366 InboxMessage { id: "b".into(), text: "67890".into(), sent_at: 2 },
367 ];
368 let (joined, ids) = drain_batch(pending, 5);
370 assert_eq!(joined, "12345");
371 assert_eq!(ids, vec!["a".to_string()]);
372 }
373
374 #[test]
375 fn drain_batch_truncates_a_single_message_that_alone_exceeds_the_cap() {
376 let pending = vec![InboxMessage { id: "a".into(), text: "1234567890".into(), sent_at: 1 }];
377 let (joined, ids) = drain_batch(pending, 5);
378 assert_eq!(joined, "12345");
379 assert_eq!(ids, vec!["a".to_string()]);
380 }
381}