Skip to main content

microclaw_engine/
messages.rs

1//! Bilingual (en/zh) catalog for user-facing runtime messages.
2//!
3//! Refusals, approval prompts, and recovery notices are product surface,
4//! not log lines — they should read like a person wrote them, in the
5//! operator's language. `user_message_language` selects `en` (default,
6//! preserves historical output), `zh`, or `bilingual` (both blocks).
7//! Approval reply keywords are always recognized in both languages
8//! regardless of the render language.
9
10use crate::config::UserMessageLanguage;
11
12/// Structured approval prompt: what is waiting, an optional command
13/// preview, an optional advisory verdict from the aux reviewer, and the
14/// numbered option card.
15pub fn approval_prompt(
16    lang: UserMessageLanguage,
17    tool_name: &str,
18    preview: Option<&str>,
19    advisory: Option<&str>,
20) -> String {
21    let preview_block = preview
22        .map(|p| format!("\n\n```\n{p}\n```"))
23        .unwrap_or_default();
24    let advisory_en = advisory
25        .map(|a| format!("\nRisk reviewer (advisory only): {a}"))
26        .unwrap_or_default();
27    let advisory_zh = advisory
28        .map(|a| format!("\n风险复核(仅供参考):{a}"))
29        .unwrap_or_default();
30    let en = format!(
31        "High-risk tool '{tool_name}' is waiting for your confirmation.{preview_block}{advisory_en}\n\n\
32         1. Approve once (reply \"1\" or \"approve\" / \"批准\")\n\
33         2. Always allow '{tool_name}' in this chat (reply \"2\" or \"always\")\n\
34         3. Deny (reply \"3\", \"deny\", or any other instruction)"
35    );
36    let zh = format!(
37        "高风险工具 '{tool_name}' 正在等待你的确认。{preview_block}{advisory_zh}\n\n\
38         1. 批准本次(回复 \"1\" 或 \"批准\")\n\
39         2. 本聊天始终允许 '{tool_name}'(回复 \"2\" 或 \"总是\")\n\
40         3. 拒绝(回复 \"3\"、\"拒绝\",或发送其他指令)"
41    );
42    match lang {
43        UserMessageLanguage::En => en,
44        UserMessageLanguage::Zh => zh,
45        UserMessageLanguage::Bilingual => format!("{en}\n\n{zh}"),
46    }
47}
48
49/// Labels for the interactive approval buttons (approve once / always
50/// allow / deny), matching the numbered option card in [`approval_prompt`].
51pub fn approval_button_labels(lang: UserMessageLanguage) -> [String; 3] {
52    match lang {
53        UserMessageLanguage::Zh => ["✅ 批准本次".into(), "🔁 始终允许".into(), "❌ 拒绝".into()],
54        _ => [
55            "✅ Approve once".into(),
56            "🔁 Always allow".into(),
57            "❌ Deny".into(),
58        ],
59    }
60}
61
62/// Short prompt line shown on the interactive approval button message.
63pub fn approval_buttons_hint(lang: UserMessageLanguage) -> &'static str {
64    match lang {
65        UserMessageLanguage::Zh => "请选择:",
66        UserMessageLanguage::Bilingual => "Choose an option / 请选择:",
67        UserMessageLanguage::En => "Choose an option:",
68    }
69}
70
71/// Confirmation line the approval button message is edited to after a
72/// choice is made.
73pub fn approval_choice_ack(lang: UserMessageLanguage, label: &str) -> String {
74    match lang {
75        UserMessageLanguage::Zh => format!("已选择:{label}"),
76        _ => format!("Selected: {label}"),
77    }
78}
79
80/// Suffix appended after the (stable, test-guarded) budget refusal prefix.
81pub fn budget_refusal_body(lang: UserMessageLanguage, used: i64, budget: i64) -> String {
82    let en = format!(
83        " for this chat ({used} of {budget} tokens in the last 24h). I'll be available again \
84         once usage rolls out of the window. An operator can raise \
85         `token_budget.daily_per_chat` in the config."
86    );
87    let zh = format!(
88        "本聊天已用 {used} / {budget} tokens(近 24 小时)。用量滚出窗口后我会恢复可用;\
89         管理员可在配置中调高 `token_budget.daily_per_chat`。"
90    );
91    match lang {
92        UserMessageLanguage::En => en,
93        UserMessageLanguage::Zh => format!(" — {zh}"),
94        UserMessageLanguage::Bilingual => format!("{en}\n{zh}"),
95    }
96}
97
98/// Notice sent when a restart interrupted a turn at an uncertain tool
99/// boundary and the run was deliberately not replayed.
100pub fn interruption_notice(
101    lang: UserMessageLanguage,
102    progress: Option<&str>,
103    tool_summary: Option<&str>,
104) -> String {
105    let tool_summary = tool_summary.filter(|value| !value.trim().is_empty());
106    let progress = progress.filter(|value| !value.trim().is_empty());
107
108    let en = {
109        let mut notice = "⚠️ I restarted while a tool operation was in progress. I did not \
110                          replay it because the external side effect may already have happened."
111            .to_string();
112        if let Some(summary) = tool_summary {
113            notice.push_str("\n\nOperation at the uncertain boundary: ");
114            notice.push_str(summary);
115            notice.push('.');
116        }
117        if let Some(progress) = progress {
118            notice.push_str("\nLast durable progress: ");
119            notice.push_str(progress);
120            notice.push('.');
121        }
122        notice.push_str(
123            "\n\nPlease verify the external state, then tell me to continue. This run was \
124             stopped deliberately to avoid duplicating a write, message, command, or other \
125             side effect.",
126        );
127        notice
128    };
129    let zh = {
130        let mut notice =
131            "⚠️ 我在一次工具操作进行中重启了。因为外部副作用可能已经发生,我没有重放这次操作。"
132                .to_string();
133        if let Some(summary) = tool_summary {
134            notice.push_str("\n\n不确定边界上的操作:");
135            notice.push_str(summary);
136            notice.push('。');
137        }
138        if let Some(progress) = progress {
139            notice.push_str("\n最后一次持久进度:");
140            notice.push_str(progress);
141            notice.push('。');
142        }
143        notice.push_str(
144            "\n\n请先核实外部状态,然后告诉我继续。这次运行是有意停下的,以避免重复写入、发消息、执行命令等副作用。",
145        );
146        notice
147    };
148    match lang {
149        UserMessageLanguage::En => en,
150        UserMessageLanguage::Zh => zh,
151        UserMessageLanguage::Bilingual => format!("{en}\n\n{zh}"),
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn approval_prompt_renders_options_in_each_language() {
161        let en = approval_prompt(UserMessageLanguage::En, "bash", Some("rm -rf /tmp/x"), None);
162        assert!(en.contains("1. Approve once"));
163        assert!(en.contains("rm -rf /tmp/x"));
164        assert!(en.contains("批准")); // zh keyword stays discoverable in en
165
166        let zh = approval_prompt(UserMessageLanguage::Zh, "bash", None, Some("looks safe"));
167        assert!(zh.contains("批准本次"));
168        assert!(zh.contains("风险复核"));
169
170        let both = approval_prompt(UserMessageLanguage::Bilingual, "bash", None, None);
171        assert!(both.contains("Approve once") && both.contains("批准本次"));
172    }
173
174    #[test]
175    fn budget_refusal_en_matches_historical_wording() {
176        let body = budget_refusal_body(UserMessageLanguage::En, 10, 5);
177        assert!(body.starts_with(" for this chat (10 of 5 tokens"));
178    }
179}