locode_engine/queue.rs
1//! The mid-run input queue (ADR-0028).
2//!
3//! A **handle taken before the run**, not a command. [`Session::run`] takes
4//! `&mut self` and the caller awaits it, so a frontend's command loop is not
5//! polled while a run is in flight — the same constraint that made
6//! `cancel_handle()` a pre-run clone (ADR-0018). A frontend clones this handle
7//! before starting a run and pushes into it; the loop drains it between
8//! dispatch and the next sample.
9//!
10//! [`Session::run`]: crate::Session::run
11
12use std::sync::{Arc, Mutex, PoisonError};
13
14/// The preamble that marks text as having arrived **mid-run**.
15///
16/// A mid-run message is not the same speech act as a fresh prompt: it may amend
17/// an earlier instruction, be an aside, or be an instruction for after the
18/// current work ("when you finish this, also…"). The model can only act
19/// correctly — including changing course inside the running loop — if it knows
20/// which. Unmarked, it reads as though it had been there from the start.
21///
22/// Grok Build ships the same marker and the same wording
23/// (`INTERJECTION_WIRE_PREFIX`), and its PTY tests assert the distinction this
24/// module preserves: the mid-run path carries the preamble, and a queued
25/// message that lands as an ordinary next-turn prompt does not.
26pub const MID_RUN_PREAMBLE: &str = "The user sent a message while you were working";
27
28/// A clonable handle to one session's pending mid-run input.
29///
30/// Clones share one queue. Cheap to clone and safe to hold across an await —
31/// the lock is only ever taken inside these methods, never returned.
32#[derive(Debug, Clone, Default)]
33pub struct InputQueue {
34 items: Arc<Mutex<Vec<String>>>,
35}
36
37impl InputQueue {
38 /// An empty queue.
39 #[must_use]
40 pub fn new() -> Self {
41 Self::default()
42 }
43
44 /// Queue a message typed while the run is in flight.
45 pub fn push(&self, text: impl Into<String>) {
46 self.lock().push(text.into());
47 }
48
49 /// The pending items, for a frontend to render. Does not consume.
50 #[must_use]
51 pub fn pending(&self) -> Vec<String> {
52 self.lock().clone()
53 }
54
55 /// Whether anything is waiting.
56 #[must_use]
57 pub fn is_empty(&self) -> bool {
58 self.lock().is_empty()
59 }
60
61 /// Take everything, leaving the queue empty.
62 #[must_use]
63 pub fn take_all(&self) -> Vec<String> {
64 std::mem::take(&mut *self.lock())
65 }
66
67 /// Drop every pending item.
68 ///
69 /// Called on cancel: the message was written for a run the user then
70 /// abandoned, so delivering it into the next turn would be surprising. This
71 /// covers **undelivered** items only — anything already drained is in
72 /// `history` and cancel rolls nothing back (ADR-0028).
73 pub fn clear(&self) {
74 self.lock().clear();
75 }
76
77 /// The queued text as one marked block, or `None` when nothing is waiting.
78 ///
79 /// Multiple items concatenate rather than arriving as separate turns: the
80 /// common case is a user typing several lines before sending, not issuing
81 /// competing instructions.
82 #[must_use]
83 pub(crate) fn take_marked(&self) -> Option<String> {
84 let items = self.take_all();
85 if items.is_empty() {
86 return None;
87 }
88 Some(format!("{MID_RUN_PREAMBLE}:\n\n{}", items.join("\n\n")))
89 }
90
91 fn lock(&self) -> std::sync::MutexGuard<'_, Vec<String>> {
92 // The guard only ever protects a Vec and is never held across an await,
93 // so recovering from a poisoned lock is strictly better than panicking
94 // inside the loop (denied lints forbid unwrap here anyway).
95 self.items.lock().unwrap_or_else(PoisonError::into_inner)
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn clones_share_one_queue() {
105 let a = InputQueue::new();
106 let b = a.clone();
107 a.push("from a");
108 assert_eq!(b.pending(), vec!["from a".to_string()]);
109 assert!(!b.is_empty());
110 }
111
112 #[test]
113 fn multiple_items_concatenate_under_one_marker() {
114 let q = InputQueue::new();
115 q.push("first line");
116 q.push("second line");
117 let marked = q.take_marked().expect("something queued");
118 assert!(marked.starts_with(MID_RUN_PREAMBLE));
119 assert!(marked.contains("first line"));
120 assert!(marked.contains("second line"));
121 assert_eq!(
122 marked.matches(MID_RUN_PREAMBLE).count(),
123 1,
124 "one marker for the whole batch, not one per item"
125 );
126 assert!(q.is_empty(), "taking consumes");
127 }
128
129 #[test]
130 fn an_empty_queue_yields_nothing_to_inject() {
131 assert!(InputQueue::new().take_marked().is_none());
132 }
133
134 #[test]
135 fn clear_drops_undelivered_items() {
136 let q = InputQueue::new();
137 q.push("abandoned");
138 q.clear();
139 assert!(q.is_empty());
140 assert!(q.take_marked().is_none());
141 }
142}