Skip to main content

git_paw/coordination/
tell.rs

1//! Pure logic for the supervisor `/tell` routing command: argument parsing
2//! and delivery-mode selection (design D3).
3//!
4//! The keystroke send and broker publish themselves are driven by the
5//! supervisor skill (shell + `tmux send-keys` + the broker publish helper);
6//! the decisions that govern them are factored here so they are unit-testable
7//! and reusable by future consumers.
8
9use crate::config::TellMode;
10
11use super::inventory::Mode;
12
13/// A parsed `/tell <agent_id> <prompt>` directive.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct TellCommand {
16    /// The target agent identifier (first whitespace-delimited token).
17    pub target_id: String,
18    /// The remainder of the directive — the prompt to route, verbatim
19    /// (multi-line content preserved).
20    pub prompt: String,
21}
22
23/// Parses a `/tell` directive typed in the supervisor pane.
24///
25/// The agent identifier is the first whitespace-delimited token after
26/// `/tell`; the prompt is the rest of the input (which may span multiple
27/// lines). Returns `None` when the input is not a `/tell` directive, names no
28/// target, or carries no prompt body.
29#[must_use]
30pub fn parse_tell(input: &str) -> Option<TellCommand> {
31    let trimmed = input.trim_start();
32    let rest = trimmed.strip_prefix("/tell")?;
33    // Require a separator between `/tell` and the target so `/tellfoo` does
34    // not parse.
35    if !rest.starts_with(char::is_whitespace) {
36        return None;
37    }
38    let rest = rest.trim_start();
39    // Split the target off the first whitespace run; the prompt is everything
40    // after, with only the leading separator trimmed (trailing newlines in a
41    // multi-line prompt are preserved up to a final trim).
42    let mut chars = rest.char_indices();
43    let target_end = chars
44        .find(|(_, c)| c.is_whitespace())
45        .map_or(rest.len(), |(i, _)| i);
46    let target_id = rest[..target_end].to_string();
47    if target_id.is_empty() {
48        return None;
49    }
50    let prompt = rest[target_end..].trim().to_string();
51    if prompt.is_empty() {
52        return None;
53    }
54    Some(TellCommand { target_id, prompt })
55}
56
57/// The delivery channel `/tell` resolves to for a given config + target mode.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum DeliveryDecision {
60    /// Publish an `agent.feedback` broker message (the safe default).
61    Feedback,
62    /// Inject the prompt into the target pane via `tmux send-keys`.
63    SendKeys,
64    /// Configured `send-keys` but the target is not in accept-edits mode, so
65    /// fall back to `agent.feedback` and emit a stderr note.
66    FeedbackFallback,
67}
68
69impl DeliveryDecision {
70    /// Whether this decision delivers via `agent.feedback` (either the plain
71    /// default or the fallback path).
72    #[must_use]
73    pub fn uses_feedback(self) -> bool {
74        matches!(self, Self::Feedback | Self::FeedbackFallback)
75    }
76
77    /// Whether this decision is the send-keys → feedback fallback (design
78    /// D3.3), which the caller pairs with a stderr note.
79    #[must_use]
80    pub fn is_fallback(self) -> bool {
81        matches!(self, Self::FeedbackFallback)
82    }
83
84    /// The label recorded for this delivery in the learnings routing log.
85    #[must_use]
86    pub fn learnings_label(self) -> &'static str {
87        match self {
88            Self::Feedback | Self::FeedbackFallback => "feedback",
89            Self::SendKeys => "send-keys",
90        }
91    }
92}
93
94/// Selects the `/tell` delivery mode per design D3's precedence:
95///
96/// 1. configured `send-keys` AND target detected `accept-edits` → `send-keys`;
97/// 2. configured `feedback` (default) → `agent.feedback`;
98/// 3. configured `send-keys` but target `interactive`/`unknown` → fall back
99///    to `agent.feedback` (caller emits a stderr note).
100#[must_use]
101pub fn select_delivery_mode(configured: TellMode, detected: Mode) -> DeliveryDecision {
102    match configured {
103        TellMode::SendKeys => match detected {
104            Mode::AcceptEdits => DeliveryDecision::SendKeys,
105            Mode::Interactive | Mode::Unknown => DeliveryDecision::FeedbackFallback,
106        },
107        TellMode::Feedback => DeliveryDecision::Feedback,
108    }
109}
110
111/// The stderr-side note emitted when `send-keys` falls back to feedback
112/// because the target's mode is not `accept-edits` (design D3.3 / task 6.4).
113#[must_use]
114pub fn fallback_note(target_id: &str, detected: Mode) -> String {
115    format!(
116        "note: [supervisor.tell] mode = \"send-keys\" but target `{target_id}` detected mode is \
117         `{detected}`; falling back to agent.feedback delivery. Check the agent's mode if you \
118         expected direct keystroke injection."
119    )
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn parse_tell_basic() {
128        let cmd = parse_tell("/tell feat/auth rebase onto main").unwrap();
129        assert_eq!(cmd.target_id, "feat/auth");
130        assert_eq!(cmd.prompt, "rebase onto main");
131    }
132
133    #[test]
134    fn parse_tell_multiline_prompt_preserved() {
135        let cmd = parse_tell("/tell feat-api\nrun the migration\nthen restart").unwrap();
136        assert_eq!(cmd.target_id, "feat-api");
137        assert_eq!(cmd.prompt, "run the migration\nthen restart");
138    }
139
140    #[test]
141    fn parse_tell_rejects_non_directive() {
142        assert!(parse_tell("hello there").is_none());
143        assert!(parse_tell("/tellfoo bar").is_none());
144    }
145
146    #[test]
147    fn parse_tell_rejects_missing_prompt_or_target() {
148        assert!(parse_tell("/tell feat-auth").is_none());
149        assert!(parse_tell("/tell    ").is_none());
150        assert!(parse_tell("/tell").is_none());
151    }
152
153    #[test]
154    fn default_feedback_mode_uses_feedback() {
155        for detected in [Mode::AcceptEdits, Mode::Interactive, Mode::Unknown] {
156            assert_eq!(
157                select_delivery_mode(TellMode::Feedback, detected),
158                DeliveryDecision::Feedback
159            );
160        }
161    }
162
163    #[test]
164    fn send_keys_mode_targets_accept_edits() {
165        assert_eq!(
166            select_delivery_mode(TellMode::SendKeys, Mode::AcceptEdits),
167            DeliveryDecision::SendKeys
168        );
169    }
170
171    #[test]
172    fn send_keys_falls_back_for_non_accept_edits() {
173        assert_eq!(
174            select_delivery_mode(TellMode::SendKeys, Mode::Interactive),
175            DeliveryDecision::FeedbackFallback
176        );
177        assert_eq!(
178            select_delivery_mode(TellMode::SendKeys, Mode::Unknown),
179            DeliveryDecision::FeedbackFallback
180        );
181    }
182
183    #[test]
184    fn decision_helpers() {
185        assert!(DeliveryDecision::Feedback.uses_feedback());
186        assert!(DeliveryDecision::FeedbackFallback.uses_feedback());
187        assert!(!DeliveryDecision::SendKeys.uses_feedback());
188        assert!(DeliveryDecision::FeedbackFallback.is_fallback());
189        assert!(!DeliveryDecision::Feedback.is_fallback());
190        assert_eq!(DeliveryDecision::SendKeys.learnings_label(), "send-keys");
191        assert_eq!(
192            DeliveryDecision::FeedbackFallback.learnings_label(),
193            "feedback"
194        );
195    }
196
197    #[test]
198    fn fallback_note_names_target_and_mode() {
199        let note = fallback_note("feat-auth", Mode::Unknown);
200        assert!(note.contains("feat-auth"));
201        assert!(note.contains("unknown"));
202        assert!(note.contains("agent.feedback"));
203    }
204}