Skip to main content

sparrow_config/
humanize.rs

1//! v0.9 Pilier 2 — « zéro jargon ».
2//!
3//! One table, used by every surface (CLI, TUI, console, gateways), that turns
4//! each engine [`Event`] into a plain-language status line for **simple mode**.
5//! The transcript should read like a sentence — *tu as dit · je fais · voilà* —
6//! never `run a3f2 · route … · tier T1`.
7//!
8//! ## The anti-regression lock
9//! [`humanize`] matches **every** `Event` variant with no wildcard arm. Adding
10//! a new variant to the contract therefore fails to compile here until someone
11//! gives it a human phrase (or an explicit `None`). That is stronger than a
12//! test: you cannot ship an un-humanized event.
13//!
14//! Events that are pure telemetry or internal continuity (token counts, cost
15//! deltas, opaque reasoning) return `None` — in simple mode they belong to the
16//! HUD, not the conversation.
17
18use sparrow_core::event::{AgentStatus, AutonomyLevel, Decision, Event, RiskLevel};
19
20/// Display language for the human layer. Only two are shipped on purpose
21/// (the structure allows more); see PLAN_v0.9.0 §8.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Lang {
24    Fr,
25    En,
26}
27
28impl Lang {
29    /// Resolve from a config/locale string. Anything not clearly English
30    /// falls back to French (Sparrow's primary language today).
31    pub fn from_code(code: &str) -> Self {
32        let c = code.trim().to_lowercase();
33        if c.starts_with("en") {
34            Lang::En
35        } else {
36            Lang::Fr
37        }
38    }
39}
40
41/// Pick the language string. Tiny helper to keep the big match readable.
42fn t(lang: Lang, fr: &str, en: &str) -> String {
43    match lang {
44        Lang::Fr => fr.to_string(),
45        Lang::En => en.to_string(),
46    }
47}
48
49/// Short, human verb phrase for a tool the agent is about to use.
50/// Falls back to a generic phrasing for unknown tools.
51fn tool_phrase(name: &str, args: &serde_json::Value, lang: Lang) -> String {
52    // The file/path argument under any of the common keys.
53    let target = ["path", "file_path", "file", "filename"]
54        .iter()
55        .find_map(|k| args.get(*k).and_then(|v| v.as_str()))
56        .unwrap_or("");
57    let with = |fr: &str, en: &str| {
58        if target.is_empty() {
59            t(lang, fr, en)
60        } else {
61            match lang {
62                Lang::Fr => format!("{fr} {target}…"),
63                Lang::En => format!("{en} {target}…"),
64            }
65        }
66    };
67    match name {
68        "fs_read" | "read" | "read_file" | "file_search" => with("Je lis", "Reading"),
69        "fs_write" | "write" | "write_file" => with("Je crée", "Creating"),
70        "edit" | "multi_edit" | "str_replace" => with("Je modifie", "Editing"),
71        "exec" | "bash" | "shell" | "code_exec" | "run_command" => {
72            t(lang, "Je lance une commande…", "Running a command…")
73        }
74        "search" | "grep" | "ripgrep" => t(lang, "Je cherche dans le code…", "Searching the code…"),
75        "web_search" => t(lang, "Je cherche sur internet…", "Searching the web…"),
76        "web_fetch" | "fetch" => t(lang, "Je consulte une page web…", "Fetching a web page…"),
77        _ => match lang {
78            Lang::Fr => format!("Je m'apprête à utiliser l'outil « {name} »…"),
79            Lang::En => format!("About to use the “{name}” tool…"),
80        },
81    }
82}
83
84/// Translate an engine event into a plain-language status line for simple mode.
85///
86/// Returns `None` for events that should not appear as a status line in the
87/// conversation (telemetry, internal continuity, or content that the renderer
88/// already prints verbatim, like streamed assistant text).
89pub fn humanize(ev: &Event, lang: Lang) -> Option<String> {
90    match ev {
91        Event::RunStarted { .. } => Some(t(lang, "C'est parti, je m'occupe de ça.", "On it.")),
92
93        // Routing/continuity details belong to the HUD, not the conversation.
94        Event::RouteSelected { .. } => None,
95        Event::TokenUsage { .. } => None,
96        Event::TokenUsageEstimated { .. } => None,
97        Event::CostUpdate { .. } => None,
98        // Streamed assistant text and opaque reasoning are rendered (or hidden)
99        // by the normal text path — not as a status line.
100        Event::ThinkingDelta { .. } => None,
101        Event::ReasoningDelta { .. } => None,
102        // Per-agent lane chatter shows in the cockpit lanes, not the simple feed.
103        Event::AgentStatus { .. } => None,
104        // Skill bookkeeping is invisible in simple mode.
105        Event::SkillLearned { .. } => None,
106
107        Event::ModelSwitched { reason, .. } => {
108            // Honest, non-technical framing of an escalation/fallback.
109            if reason.contains("escalat") || reason.contains("verify") {
110                Some(t(
111                    lang,
112                    "C'est plus coriace que prévu, je passe la vitesse supérieure.",
113                    "Tougher than expected — stepping up to a stronger model.",
114                ))
115            } else {
116                Some(t(
117                    lang,
118                    "Je change de modèle pour continuer.",
119                    "Switching model to keep going.",
120                ))
121            }
122        }
123
124        // Conversation content. The router line is hidden in simple mode and
125        // other roles are printed verbatim by the renderer, so the humanize
126        // table emits no status line for any Message.
127        Event::Message { .. } => None,
128
129        Event::ToolUseProposed { name, args, .. } => Some(tool_phrase(name, args, lang)),
130        // The "started" echo would duplicate the proposed line.
131        Event::ToolUseStarted { .. } => None,
132        Event::ToolOutput { .. } => None,
133
134        Event::ApprovalRequested { tool, .. } => {
135            let what = tool.as_deref().unwrap_or("");
136            if what.is_empty() {
137                Some(t(
138                    lang,
139                    "J'ai besoin de ton accord pour continuer.",
140                    "I need your go-ahead to continue.",
141                ))
142            } else {
143                Some(match lang {
144                    Lang::Fr => format!("J'ai besoin de ton accord pour « {what} »."),
145                    Lang::En => format!("I need your go-ahead for “{what}”."),
146                })
147            }
148        }
149        Event::ApprovalResolved { decision, .. } => Some(match decision {
150            Decision::Allow
151            | Decision::AllowOnce
152            | Decision::AllowSession
153            | Decision::AllowAlways => t(lang, "D'accord, j'y vais.", "Got it, going ahead."),
154            Decision::Deny => t(
155                lang,
156                "Compris, je n'y touche pas.",
157                "Understood, leaving it alone.",
158            ),
159            Decision::AskUser => t(lang, "J'attends ta réponse.", "Waiting for your answer."),
160        }),
161
162        Event::DiffProposed {
163            file, plus, minus, ..
164        } => Some(match lang {
165            Lang::Fr => format!("J'ai préparé une modification de {file} (+{plus} / −{minus})."),
166            Lang::En => format!("Prepared a change to {file} (+{plus} / −{minus})."),
167        }),
168        Event::DiffApplied { file, .. } => Some(match lang {
169            Lang::Fr => format!("{file} mis à jour."),
170            Lang::En => format!("{file} updated."),
171        }),
172
173        Event::TestResult { passed, failed, .. } => Some(if *failed == 0 {
174            match lang {
175                Lang::Fr => format!("Tests : {passed} réussis. ✅"),
176                Lang::En => format!("Tests: {passed} passing. ✅"),
177            }
178        } else {
179            match lang {
180                Lang::Fr => format!("Tests : {passed} réussis, {failed} en échec."),
181                Lang::En => format!("Tests: {passed} passing, {failed} failing."),
182            }
183        }),
184
185        Event::AgentSpawned { role, .. } => Some(match lang {
186            Lang::Fr => format!("Je fais appel à un assistant ({role})."),
187            Lang::En => format!("Bringing in a helper ({role})."),
188        }),
189
190        Event::CheckpointCreated { .. } => Some(t(
191            lang,
192            "Point de sauvegarde fait — on peut tout annuler.",
193            "Checkpoint saved — everything is undoable.",
194        )),
195
196        Event::AutonomyChanged { level, .. } => Some(match level {
197            AutonomyLevel::Supervised => t(
198                lang,
199                "Je te demande avant chaque action.",
200                "I'll ask before each action.",
201            ),
202            AutonomyLevel::Trusted => t(
203                lang,
204                "J'agis seul, mais je te montre tout.",
205                "I'll act on my own and show you everything.",
206            ),
207            AutonomyLevel::Autonomous => {
208                t(lang, "Je travaille en autonomie.", "Working autonomously.")
209            }
210        }),
211
212        Event::RunFinished { outcome, .. } => {
213            let files = outcome.diffs.len();
214            Some(match outcome.status.as_str() {
215                "completed" => match lang {
216                    Lang::Fr if files > 0 => {
217                        format!("Terminé ! {files} fichier(s) modifié(s).")
218                    }
219                    Lang::Fr => "Terminé !".to_string(),
220                    Lang::En if files > 0 => format!("Done! {files} file(s) changed."),
221                    Lang::En => "Done!".to_string(),
222                },
223                "waiting_for_approval" => t(
224                    lang,
225                    "En attente de ton accord pour continuer.",
226                    "Waiting for your approval to continue.",
227                ),
228                "no actions taken" => t(lang, "Rien n'a été modifié.", "Nothing was changed."),
229                other => match lang {
230                    Lang::Fr => format!("Fin : {other}."),
231                    Lang::En => format!("Finished: {other}."),
232                },
233            })
234        }
235
236        Event::Error { message, .. } => {
237            // Phase 2.3 — errors that reassure: never dump a raw API/JSON blob
238            // in simple mode. Lead with a calm sentence and always offer an
239            // exit door. Keep a short hint only when the message is itself
240            // short and human.
241            let m = message.to_lowercase();
242            let calm = if m.contains("api error") || m.contains("400") || m.contains("{\"") {
243                t(
244                    lang,
245                    "Un modèle a refusé la requête. Je réessaie autrement — si ça persiste, tape « sparrow doctor ».",
246                    "A model refused the request. I'll try another way — if it persists, run “sparrow doctor”.",
247                )
248            } else if m.contains("connect") || m.contains("network") || m.contains("timeout") {
249                t(
250                    lang,
251                    "Je n'arrive pas à joindre internet. Je peux continuer avec un modèle local si tu veux.",
252                    "I can't reach the internet. I can keep going with a local model if you like.",
253                )
254            } else if message.len() <= 120 {
255                match lang {
256                    Lang::Fr => {
257                        format!("Quelque chose a coincé : {message}. Rien n'a été modifié.")
258                    }
259                    Lang::En => format!("Something went wrong: {message}. Nothing was changed."),
260                }
261            } else {
262                t(
263                    lang,
264                    "Quelque chose a coincé, mais rien n'a été modifié. Tape « sparrow doctor » pour un diagnostic.",
265                    "Something went wrong, but nothing was changed. Run “sparrow doctor” for a checkup.",
266                )
267            };
268            Some(calm)
269        }
270
271        Event::Compacted { .. } => Some(t(
272            lang,
273            "J'ai fait de la place dans ma mémoire de travail.",
274            "Freed up room in my working memory.",
275        )),
276
277        Event::UpdateAvailable { latest, .. } => Some(match lang {
278            Lang::Fr => format!("Une nouvelle version de Sparrow est disponible ({latest})."),
279            Lang::En => format!("A new version of Sparrow is available ({latest})."),
280        }),
281    }
282}
283
284/// Plain-language label for a risk level (used in approval contracts).
285pub fn risk_phrase(risk: &RiskLevel, lang: Lang) -> String {
286    match risk {
287        RiskLevel::ReadOnly => t(lang, "lecture seule", "read-only"),
288        RiskLevel::Mutating => t(lang, "modifie des fichiers", "changes files"),
289        RiskLevel::Exec => t(lang, "exécute une commande", "runs a command"),
290        RiskLevel::Destructive => t(lang, "action irréversible", "irreversible action"),
291        RiskLevel::Network => t(lang, "accède à internet", "accesses the internet"),
292    }
293}
294
295/// Plain-language label for an agent status (used by the cockpit's simple view).
296pub fn status_phrase(status: &AgentStatus, lang: Lang) -> String {
297    match status {
298        AgentStatus::Idle => t(lang, "au repos", "idle"),
299        AgentStatus::Thinking => t(lang, "réfléchit…", "thinking…"),
300        AgentStatus::Working => t(lang, "travaille…", "working…"),
301        AgentStatus::WaitingForApproval => t(lang, "attend ton accord", "awaiting your go-ahead"),
302        AgentStatus::Done => t(lang, "terminé", "done"),
303        AgentStatus::Error => t(lang, "a rencontré un souci", "hit a problem"),
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use sparrow_core::event::{OutcomeSummary, RunId, TokenUsage};
311    use serde_json::json;
312
313    fn run() -> RunId {
314        RunId("test".into())
315    }
316
317    #[test]
318    fn experience_config_resolves_mode_and_lang() {
319        use crate::config::ExperienceConfig;
320        let pro = ExperienceConfig {
321            mode: "pro".into(),
322            language: "en".into(),
323        };
324        assert!(!pro.is_simple());
325        assert_eq!(pro.lang(), Lang::En);
326
327        let auto = ExperienceConfig {
328            mode: "auto".into(),
329            language: "fr".into(),
330        };
331        assert!(auto.is_simple(), "auto resolves to simple (human-first)");
332        assert_eq!(auto.lang(), Lang::Fr);
333
334        let builder = ExperienceConfig {
335            mode: "builder".into(),
336            language: "fr".into(),
337        };
338        assert!(builder.is_builder());
339        assert!(!builder.is_simple());
340
341        // Default is auto/auto → simple.
342        assert!(ExperienceConfig::default().is_simple());
343    }
344
345    #[test]
346    fn lang_from_code_defaults_to_french() {
347        assert_eq!(Lang::from_code("fr-FR"), Lang::Fr);
348        assert_eq!(Lang::from_code("en_US"), Lang::En);
349        assert_eq!(Lang::from_code("de"), Lang::Fr); // unknown → primary lang
350        assert_eq!(Lang::from_code(""), Lang::Fr);
351    }
352
353    #[test]
354    fn run_started_is_human_in_both_languages() {
355        let ev = Event::RunStarted {
356            run: run(),
357            task: "x".into(),
358            agent: "sparrow".into(),
359        };
360        assert_eq!(
361            humanize(&ev, Lang::Fr).unwrap(),
362            "C'est parti, je m'occupe de ça."
363        );
364        assert_eq!(humanize(&ev, Lang::En).unwrap(), "On it.");
365    }
366
367    #[test]
368    fn tool_proposed_names_the_file_in_plain_words() {
369        let ev = Event::ToolUseProposed {
370            run: run(),
371            id: "1".into(),
372            name: "fs_write".into(),
373            args: json!({"path": "poeme.txt", "content": "x"}),
374            risk: RiskLevel::Mutating,
375        };
376        assert_eq!(humanize(&ev, Lang::Fr).unwrap(), "Je crée poeme.txt…");
377    }
378
379    #[test]
380    fn telemetry_events_have_no_status_line() {
381        for ev in [
382            Event::TokenUsage {
383                run: run(),
384                input: 10,
385                output: 5,
386            },
387            Event::CostUpdate {
388                run: run(),
389                usd: 0.01,
390            },
391            Event::ReasoningDelta {
392                run: run(),
393                text: "…".into(),
394            },
395            Event::RouteSelected {
396                run: run(),
397                chain: vec![],
398                context_window: 1,
399            },
400        ] {
401            assert!(
402                humanize(&ev, Lang::Fr).is_none(),
403                "telemetry must be silent in simple mode"
404            );
405        }
406    }
407
408    #[test]
409    fn no_jargon_leaks_into_simple_mode() {
410        // A sweep of representative events must never surface raw identifiers
411        // or technical tokens in their human phrasing.
412        let banned = ["run ", "tier", "T1", "tok", "route ", "↑", "↓", "$0.0"];
413        let samples = [
414            Event::RunStarted {
415                run: run(),
416                task: "t".into(),
417                agent: "sparrow".into(),
418            },
419            Event::CheckpointCreated {
420                run: run(),
421                id: sparrow_core::event::CheckpointId("c".into()),
422                label: "l".into(),
423            },
424            Event::RunFinished {
425                run: run(),
426                outcome: OutcomeSummary {
427                    status: "completed".into(),
428                    diffs: vec![],
429                    cost_usd: 0.0,
430                    tokens: TokenUsage {
431                        input: 0,
432                        output: 0,
433                    },
434                    cost_comparison: String::new(),
435                    duration_ms: None,
436                },
437            },
438        ];
439        for ev in samples {
440            if let Some(phrase) = humanize(&ev, Lang::Fr) {
441                for bad in banned {
442                    assert!(
443                        !phrase.contains(bad),
444                        "simple-mode phrase `{phrase}` leaked jargon `{bad}`"
445                    );
446                }
447            }
448        }
449    }
450
451    #[test]
452    fn error_is_reassuring_and_hides_raw_blobs() {
453        let ev = Event::Error {
454            run: run(),
455            message: "OpenAI-compatible API error 400: {\"error\":{\"message\":\"…\"}}".into(),
456        };
457        let line = humanize(&ev, Lang::Fr).unwrap();
458        assert!(!line.contains('{'), "raw JSON must not leak in simple mode");
459        assert!(line.contains("doctor"), "must offer an exit door");
460    }
461
462    #[test]
463    fn run_finished_reports_files_changed() {
464        let ev = Event::RunFinished {
465            run: run(),
466            outcome: OutcomeSummary {
467                status: "completed".into(),
468                diffs: vec![sparrow_core::event::FileDiff {
469                    file: "a.txt".into(),
470                    plus: 1,
471                    minus: 0,
472                }],
473                cost_usd: 0.0,
474                tokens: TokenUsage {
475                    input: 0,
476                    output: 0,
477                },
478                cost_comparison: String::new(),
479                duration_ms: None,
480            },
481        };
482        assert_eq!(
483            humanize(&ev, Lang::Fr).unwrap(),
484            "Terminé ! 1 fichier(s) modifié(s)."
485        );
486    }
487}