use crate::texts::tr;
use serde_json::{json, Value};
pub const PLAN_MODE: &str = "<escouade_plan_mode>\nYou are in plan mode: study what is asked and propose a plan, without changing anything (no file written, no command that writes). End your turn with the whole plan, in Markdown, as your last words: the user approves it before any change.\n</escouade_plan_mode>";
pub const PLAN_GO: &str = "The plan is approved: carry it out now.";
pub const PERMANENT: &str = "allow_always_permanent";
pub const RATE_LIMITED: i64 = -31001;
pub const RATE_RESUME_MS: i64 = 60_000;
const UNSHOWN: [&str; 3] = ["ui.ask_user_question", "vibe.todo", "search_tool_functions"];
pub fn agent_of(agent: &str, fenced: bool) -> String {
if agent == "auto-approve" && fenced {
"ask".into()
} else {
agent.into()
}
}
fn mcp_tool(name: &str) -> Option<(&str, &str)> {
name.strip_prefix("mcp_")?.split_once('.')
}
pub fn normalized(u: &Value, cwd: &str) -> Option<Value> {
let tool = u["_meta"]["tool_name"].as_str().unwrap_or_default();
if UNSHOWN.contains(&tool) {
return None;
}
let mut n = u.clone();
let raw = &u["rawInput"];
if tool == "agents.spawn" {
n["kind"] = json!("other");
if raw.is_object() {
n["rawInput"] = json!({
"_toolName": "task", "description": raw["task"], "prompt": raw["task"],
"subagentType": raw["agent"],
});
}
} else if let Some((server, name)) = mcp_tool(tool) {
if raw.is_object() {
n["rawInput"] =
json!({ "providerIdentifier": server, "toolName": name, "args": raw.clone() });
}
} else if !tool.is_empty()
&& !matches!(
u["kind"].as_str(),
Some("execute" | "edit" | "read" | "search" | "fetch" | "delete" | "think")
)
&& raw.is_object()
{
n["rawInput"]["_toolName"] = json!(tool);
}
if let Some(content) = n["content"].as_array_mut() {
for c in content.iter_mut().filter(|c| c["type"] == "diff") {
if let Some(p) = c["path"].as_str() {
c["path"] = json!(crate::tools::absolute(p, cwd));
}
}
}
if let Some(code) = n["rawOutput"]["returncode"].as_i64() {
n["rawOutput"]["exitCode"] = json!(code);
}
Some(n)
}
pub fn diff_in(content: &Value) -> Option<&Value> {
content.as_array()?.iter().find(|c| c["type"] == "diff")
}
pub fn merged(call: &mut Value, update: &Value) {
if !call.is_object() {
*call = json!({});
}
for (k, v) in update.as_object().into_iter().flatten() {
if !v.is_null() && k != "sessionUpdate" {
call[k] = v.clone();
}
}
}
pub fn option_value<'a>(options: &'a Value, id: &str) -> Option<&'a str> {
options
.as_array()?
.iter()
.find(|o| o["id"] == id)?
.get("currentValue")?
.as_str()
}
pub fn option_values(options: &Value, id: &str) -> Vec<String> {
options
.as_array()
.into_iter()
.flatten()
.find(|o| o["id"] == id)
.and_then(|o| o["options"].as_array())
.into_iter()
.flatten()
.filter_map(|v| v["value"].as_str().map(str::to_string))
.collect()
}
pub fn thinking_of(effort: &str, levels: &[String]) -> Option<String> {
if effort.is_empty() || levels.is_empty() {
return None;
}
match effort {
"low" => levels.iter().find(|l| *l == "off").cloned(),
_ => levels.iter().rev().find(|l| *l != "off").cloned(),
}
}
pub fn elicitation_questions(params: &Value) -> Vec<(String, Value)> {
let schema = ¶ms["requestedSchema"];
let mut keys: Vec<(&String, &Value)> = schema["properties"]
.as_object()
.into_iter()
.flatten()
.collect();
keys.sort_by_key(|(k, _)| {
(
k.trim_start_matches('q').parse::<u32>().unwrap_or(u32::MAX),
k.to_string(),
)
});
keys.into_iter()
.map(|(key, p)| {
let multi = p["type"] == "array";
let choices = if multi {
&p["items"]["anyOf"]
} else {
&p["oneOf"]
};
let options: Vec<Value> = choices
.as_array()
.into_iter()
.flatten()
.map(|c| {
let label = c["title"].as_str().or(c["const"].as_str()).unwrap_or("");
json!({ "label": label, "description": "" })
})
.collect();
let text = p["description"]
.as_str()
.or(p["title"].as_str())
.or(params["message"].as_str())
.unwrap_or_default();
(
key.clone(),
json!({
"question": text, "header": "", "multiSelect": multi, "options": options,
}),
)
})
.collect()
}
pub fn elicitation_content(params: &Value, answers: &Value) -> Value {
let props = ¶ms["requestedSchema"]["properties"];
let mut content = serde_json::Map::new();
for (key, q) in elicitation_questions(params) {
let text = q["question"].as_str().unwrap_or_default();
let answer = answers[text]
.as_str()
.unwrap_or_default()
.trim()
.to_string();
let p = &props[&key];
let multi = p["type"] == "array";
let choices = if multi {
&p["items"]["anyOf"]
} else {
&p["oneOf"]
};
let value_of = |label: &str| {
choices
.as_array()
.into_iter()
.flatten()
.find(|c| c["title"].as_str() == Some(label) || c["const"].as_str() == Some(label))
.and_then(|c| c["const"].as_str())
.unwrap_or(label)
.to_string()
};
let value = if multi {
json!(answer
.split(", ")
.filter(|a| !a.is_empty())
.map(value_of)
.collect::<Vec<_>>())
} else {
json!(value_of(&answer))
};
content.insert(key, value);
}
Value::Object(content)
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Spent {
pub cost: f64,
pub prompt: u64,
pub cached: u64,
pub completion: u64,
}
impl Spent {
pub fn of(u: &Value) -> Spent {
let n = |k: &str| u["_meta"][k].as_u64().unwrap_or(0);
Spent {
cost: u["cost"]["amount"].as_f64().unwrap_or(0.0),
prompt: n("promptTokens"),
cached: n("cachedTokens"),
completion: n("completionTokens"),
}
}
pub fn since(self, before: Spent) -> Spent {
Spent {
cost: (self.cost - before.cost).max(0.0),
prompt: self.prompt.saturating_sub(before.prompt),
cached: self.cached.saturating_sub(before.cached),
completion: self.completion.saturating_sub(before.completion),
}
}
}
pub fn worded(message: &str) -> bool {
let m = message.trim();
!m.is_empty()
&& !REFUSED.contains(&m)
&& !m.ends_with("via Escouade.")
&& !m.ends_with("through Escouade.")
}
const REFUSED: [&str; 2] = [
"L'utilisateur a refusé cette action.",
"The user refused this action.",
];
pub fn rate_limited(lang: &str, said: Option<&str>) -> String {
let said = said.unwrap_or("Rate limit exceeded");
tr(
lang,
format!("Limite de débit de Mistral atteinte : {said}"),
format!("Mistral’s rate limit reached: {said}"),
)
}
pub fn session_refused(lang: &str, e: &str) -> String {
tr(
lang,
format!("Mistral Vibe n’a pas ouvert la session de l’agent : {e}. S’il n’a pas de clé d’API, lance vibe --setup dans un terminal."),
format!("Mistral Vibe didn’t open the agent’s session: {e}. If it has no API key, run vibe --setup in a terminal."),
)
}
pub fn protocol_refused(lang: &str, version: &str) -> String {
let ours = crate::PROTOCOL;
tr(
lang,
format!("Mistral Vibe parle ACP {version}, Escouade ACP {ours} : mets Mistral Vibe à jour (uv tool upgrade mistral-vibe)."),
format!("Mistral Vibe speaks ACP {version}, Escouade ACP {ours}: update Mistral Vibe (uv tool upgrade mistral-vibe)."),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_question_is_answered_by_the_keys_and_values_of_its_form() {
let single = json!({ "requestedSchema": { "properties": {
"q0": { "description": "blue or red?", "oneOf": [ { "const": "blue", "title": "blue" }, { "const": "red", "title": "red" } ] },
} } });
let qs = elicitation_questions(&single);
assert_eq!(qs[0].0, "q0");
assert_eq!(qs[0].1["options"][1]["label"], "red");
assert_eq!(
elicitation_content(&single, &json!({ "blue or red?": "red" })),
json!({ "q0": "red" })
);
assert_eq!(
elicitation_content(&single, &json!({ "blue or red?": "vert" })),
json!({ "q0": "vert" })
);
let multi = json!({ "requestedSchema": { "properties": {
"q0": { "description": "Fruits ?", "type": "array",
"items": { "anyOf": [ { "const": "p", "title": "Pomme" }, { "const": "o", "title": "Orange" } ] } },
} } });
assert_eq!(elicitation_questions(&multi)[0].1["multiSelect"], true);
assert_eq!(
elicitation_content(&multi, &json!({ "Fruits ?": "Pomme, Orange" })),
json!({ "q0": ["p", "o"] })
);
}
#[test]
fn the_thinking_of_an_effort_and_the_agent_of_a_mode() {
let levels = vec!["off".to_string(), "high".to_string()];
assert_eq!(thinking_of("low", &levels).as_deref(), Some("off"));
assert_eq!(thinking_of("medium", &levels).as_deref(), Some("high"));
assert_eq!(thinking_of("", &levels), None);
assert_eq!(agent_of("auto-approve", true), "ask");
assert_eq!(agent_of("auto-approve", false), "auto-approve");
assert_eq!(agent_of("accept-edits", true), "accept-edits");
}
#[test]
fn its_calls_are_put_in_cursors_form() {
let spawn = normalized(
&json!({ "_meta": { "tool_name": "agents.spawn" }, "rawInput": { "task": "t", "agent": "explore" } }),
"C:\\repo",
)
.unwrap();
assert_eq!(spawn["rawInput"]["_toolName"], "task");
assert_eq!(spawn["rawInput"]["subagentType"], "explore");
for tool in UNSHOWN {
assert!(normalized(&json!({ "_meta": { "tool_name": tool } }), "C:\\repo").is_none());
}
let w = normalized(
&json!({ "kind": "edit", "_meta": { "tool_name": "write_file" },
"content": [{ "type": "diff", "path": "b.txt", "newText": "x" }],
"rawOutput": { "returncode": 1 } }),
"C:\\repo",
)
.unwrap();
assert_eq!(w["content"][0]["path"], "C:\\repo\\b.txt");
assert_eq!(w["rawOutput"]["exitCode"], 1);
assert!(!worded("Refusé par Guillaume via Escouade."));
assert!(!worded("L'utilisateur a refusé cette action."));
assert!(worded("Pas ce fichier"));
}
}