Skip to main content

agentmux_srv/backend/notify/
sources.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! srv-side notification sources that need no mounted pane โ€”
5//! `SPEC_OS_NOTIFICATIONS_SYSTEM_2026_09_24.md` ยง10 Phase 5.
6//!
7//! The persistent Claude controller already receives every AskUserQuestion as
8//! a control-protocol `can_use_tool` request (it parks it in
9//! `pending_questions`, `persistent/input.rs`). Detecting it here โ€” rather
10//! than in the renderer's reducer โ€” is what lets "an agent needs your input"
11//! reach you with zero windows open (background mode).
12
13/// An `AskUserQuestion` call: the first question's text (when readable) and
14/// how many questions the call asks.
15#[derive(Debug, PartialEq, Eq)]
16pub struct AskedQuestion {
17    pub text: Option<String>,
18    pub count: usize,
19}
20
21/// If `frame` is a `control_request` asking to run `AskUserQuestion`, return
22/// what it asks (`text: None` when the frame has no readable text). `None`
23/// for every other frame.
24pub fn ask_user_question(frame: &serde_json::Value) -> Option<AskedQuestion> {
25    if frame.get("type").and_then(|v| v.as_str()) != Some("control_request") {
26        return None;
27    }
28    let req = frame.get("request")?;
29    if req.get("subtype").and_then(|v| v.as_str()) != Some("can_use_tool") {
30        return None;
31    }
32    if req.get("tool_name").and_then(|v| v.as_str()) != Some("AskUserQuestion") {
33        return None;
34    }
35    let questions = req.get("input").and_then(|i| i.get("questions")).and_then(|q| q.as_array());
36    Some(AskedQuestion {
37        text: questions
38            .and_then(|q| q.first())
39            .and_then(|q| q.get("question"))
40            .and_then(|t| t.as_str())
41            .map(str::to_string),
42        count: questions.map_or(0, Vec::len),
43    })
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49    use serde_json::json;
50
51    #[test]
52    fn detects_ask_user_question_and_extracts_text() {
53        let f = json!({"type":"control_request","request_id":"r1","request":{
54            "subtype":"can_use_tool","tool_name":"AskUserQuestion","tool_use_id":"t1",
55            "input":{"questions":[{"question":"Which branch?","header":"Branch","options":[]}]}}});
56        assert_eq!(ask_user_question(&f), Some(AskedQuestion { text: Some("Which branch?".to_string()), count: 1 }));
57    }
58
59    #[test]
60    fn counts_every_question_in_the_call() {
61        let f = json!({"type":"control_request","request":{"subtype":"can_use_tool","tool_name":"AskUserQuestion",
62            "input":{"questions":[{"question":"Which branch?"},{"question":"Squash?"},{"question":"Tag it?"}]}}});
63        assert_eq!(ask_user_question(&f), Some(AskedQuestion { text: Some("Which branch?".to_string()), count: 3 }));
64    }
65
66    #[test]
67    fn tolerates_missing_text_and_ignores_other_frames() {
68        let no_text = json!({"type":"control_request","request":{"subtype":"can_use_tool","tool_name":"AskUserQuestion","input":{}}});
69        assert_eq!(ask_user_question(&no_text), Some(AskedQuestion { text: None, count: 0 }));
70        let bash = json!({"type":"control_request","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{}}});
71        assert_eq!(ask_user_question(&bash), None);
72        let other = json!({"type":"control_request","request":{"subtype":"interrupt"}});
73        assert_eq!(ask_user_question(&other), None);
74        assert_eq!(ask_user_question(&json!({"type":"control_response"})), None);
75        assert_eq!(ask_user_question(&json!({"type":"assistant"})), None);
76    }
77}