use gate4agent_node_protocol::SessionMode;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BackendAction {
Spawn,
Model,
Effort,
Prompt,
Resume,
Wake,
Status,
Stop,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Support {
Native,
Degraded,
None,
}
pub fn support(provider: &str, action: BackendAction) -> (Support, Support) {
use BackendAction::*;
use Support::*;
let p = normalize(provider);
match (p, action) {
(_, Spawn) => (Native, Native),
("claude" | "codex" | "grok" | "kimi", Model) => (Native, Native),
("claude" | "codex" | "grok", Effort) => (Native, Native),
("kimi", Effort) => (None, Native),
(_, Prompt) => (Degraded, Native),
("claude" | "codex" | "grok" | "kimi", Resume) => (Native, Native),
(_, Wake) => (Degraded, Native),
(_, Status) => (Degraded, Native),
(_, Stop) => (Native, Native),
_ => (None, None),
}
}
fn normalize(provider: &str) -> &str {
match provider {
"claude-code" => "claude",
other => other,
}
}
fn on(mode: SessionMode, s: (Support, Support)) -> Support {
match mode {
SessionMode::Acp => s.1,
_ => s.0,
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum LaunchOptionsPlan {
None,
PtyFlags,
AcpAfterSpawn,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PathRefusal {
pub message: String,
}
pub fn plan_launch_options(
provider: &str,
mode: SessionMode,
model: bool,
effort: bool,
) -> Result<LaunchOptionsPlan, PathRefusal> {
if !model && !effort {
return Ok(LaunchOptionsPlan::None);
}
for (wanted, action, name) in [(model, BackendAction::Model, "model"), (effort, BackendAction::Effort, "effort")] {
if !wanted {
continue;
}
if on(mode, support(provider, action)) == Support::None {
let other = match mode {
SessionMode::Acp => "pty",
_ => "acp",
};
let other_mode = if other == "acp" { SessionMode::Acp } else { SessionMode::Pty };
let hint = if on(other_mode, support(provider, action)) != Support::None {
format!("; use the {other} path")
} else {
String::new()
};
return Err(PathRefusal {
message: format!("{provider}: {name} is not settable over {}{hint}", match mode {
SessionMode::Acp => "acp",
_ => "pty",
}),
});
}
}
Ok(match mode {
SessionMode::Acp => LaunchOptionsPlan::AcpAfterSpawn,
_ => LaunchOptionsPlan::PtyFlags,
})
}
pub fn preferred_mode(provider: &str, interactive: bool, needs_effort: bool) -> SessionMode {
if interactive {
if needs_effort && support(provider, BackendAction::Effort).0 == Support::None {
return SessionMode::Acp;
}
return SessionMode::Pty;
}
SessionMode::Acp
}
pub fn acp_option_writes(
advertised: &[(String, Option<String>, Vec<String>)],
model: Option<&str>,
effort: Option<&str>,
) -> (Vec<(String, String)>, bool, bool) {
let find = |category: &str, ids: &[&str]| {
advertised
.iter()
.find(|(_, c, _)| c.as_deref() == Some(category))
.or_else(|| advertised.iter().find(|(id, _, _)| ids.contains(&id.as_str())))
};
let mut writes = Vec::new();
let mut model_via_set_model = false;
let mut effort_unapplied = false;
if let Some(m) = model {
match find("model", &["model"]) {
Some((id, _, _)) => writes.push((id.clone(), json_str(m))),
None => model_via_set_model = true,
}
}
if let Some(e) = effort {
match find("thought_level", &["effort", "reasoning_effort", "thought_level"]) {
Some((id, _, choices)) if choices.is_empty() || choices.iter().any(|c| c == &json_str(e)) => {
writes.push((id.clone(), json_str(e)))
}
_ => effort_unapplied = true,
}
}
(writes, model_via_set_model, effort_unapplied)
}
fn json_str(s: &str) -> String {
serde_json::to_string(s).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pty_flags_for_all_fleet_models_and_kimi_effort_routes_to_acp() {
for p in ["claude", "codex", "grok", "kimi"] {
assert_eq!(plan_launch_options(p, SessionMode::Pty, true, false), Ok(LaunchOptionsPlan::PtyFlags));
assert_eq!(plan_launch_options(p, SessionMode::Acp, true, true), Ok(LaunchOptionsPlan::AcpAfterSpawn));
}
let refused = plan_launch_options("kimi", SessionMode::Pty, true, true).unwrap_err();
assert!(refused.message.contains("use the acp path"), "{refused:?}");
assert_eq!(preferred_mode("kimi", true, true), SessionMode::Acp);
assert_eq!(preferred_mode("claude", true, true), SessionMode::Pty);
assert_eq!(preferred_mode("codex", false, false), SessionMode::Acp);
}
#[test]
fn acp_writes_match_by_category_then_id_and_report_gaps() {
let adv = vec![
("model".to_owned(), Some("model".to_owned()), vec![]),
("reasoning".to_owned(), Some("thought_level".to_owned()), vec!["\"low\"".to_owned(), "\"high\"".to_owned()]),
];
let (w, set_model, gap) = acp_option_writes(&adv, Some("opus"), Some("high"));
assert_eq!(w, vec![("model".into(), "\"opus\"".into()), ("reasoning".into(), "\"high\"".into())]);
assert!(!set_model && !gap);
let (w, set_model, gap) = acp_option_writes(&[], Some("opus"), Some("max"));
assert!(w.is_empty() && set_model && gap);
let (_, _, gap) = acp_option_writes(&adv, None, Some("max"));
assert!(gap, "unadvertised choice is reported, not forced");
}
}