use gate4agent_node_protocol::SessionMode;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BackendAction {
Spawn,
Model,
Effort,
Prompt,
Resume,
Wake,
Status,
Stop,
Interrupt,
ListSessions,
Ready,
Limit,
Login,
ToolPermission,
McpAttach,
ModeSwitch,
}
pub const ALL_ACTIONS: &[BackendAction] = &[
BackendAction::Spawn,
BackendAction::Model,
BackendAction::Effort,
BackendAction::Prompt,
BackendAction::Resume,
BackendAction::Wake,
BackendAction::Status,
BackendAction::Stop,
BackendAction::Interrupt,
BackendAction::ListSessions,
BackendAction::Ready,
BackendAction::Limit,
BackendAction::Login,
BackendAction::ToolPermission,
BackendAction::McpAttach,
BackendAction::ModeSwitch,
];
#[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),
("gemini" | "opencode", Model | Resume | McpAttach | ModeSwitch) => (Degraded, Native),
("gemini" | "opencode", Effort | ListSessions) => (None, Degraded),
("cursor", Model | Resume | ModeSwitch | McpAttach) => (Native, None),
("cursor", _) => (Degraded, None),
("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),
(_, Interrupt) => (Degraded, Native),
("claude" | "codex", ListSessions) => (Native, Degraded),
("grok" | "kimi", ListSessions) => (Degraded, Degraded),
(_, Ready) => (Degraded, Native),
(_, Limit) => (Degraded, Degraded),
(_, Login) => (Degraded, Native),
(_, ToolPermission) => (Degraded, Native),
("claude" | "codex" | "kimi", McpAttach) => (Native, Native),
("grok", McpAttach) => (Degraded, Native),
("claude" | "codex" | "kimi", ModeSwitch) => (Native, Native),
("grok", ModeSwitch) => (Degraded, 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 plan_action(provider: &str, mode: SessionMode, action: BackendAction) -> Result<Support, PathRefusal> {
let s = support(provider, action);
let here = on(mode, s);
if here != Support::None {
return Ok(here);
}
let (name, other, other_s) = match mode {
SessionMode::Acp => ("acp", "pty", s.0),
_ => ("pty", "acp", s.1),
};
let hint = if other_s != Support::None { format!("; use the {other} path") } else { String::new() };
Err(PathRefusal { message: format!("{provider}: {action:?} is not available over {name}{hint}") })
}
pub fn best_path(provider: &str, action: BackendAction, prefer: SessionMode) -> Option<SessionMode> {
let (pty, acp) = support(provider, action);
let rank = |s: Support| match s {
Support::Native => 2,
Support::Degraded => 1,
Support::None => 0,
};
match (rank(pty), rank(acp)) {
(0, 0) => None,
(p, a) if p > a => Some(SessionMode::Pty),
(p, a) if a > p => Some(SessionMode::Acp),
_ => Some(prefer),
}
}
pub fn prompt_allowed_on_transport(is_wake: bool) -> bool {
is_wake
}
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");
}
}
#[cfg(test)]
mod action_table_tests {
use super::*;
#[test]
fn every_listed_provider_has_a_path_for_core_actions() {
for p in ["claude", "codex", "grok", "kimi"] {
for a in ALL_ACTIONS {
assert!(best_path(p, *a, SessionMode::Pty).is_some(), "{p} {a:?}");
}
}
}
#[test]
fn selector_picks_native_and_refuses_with_hint() {
assert_eq!(best_path("claude", BackendAction::ToolPermission, SessionMode::Pty), Some(SessionMode::Acp));
assert_eq!(best_path("codex", BackendAction::ListSessions, SessionMode::Acp), Some(SessionMode::Pty));
assert_eq!(best_path("claude", BackendAction::Stop, SessionMode::Pty), Some(SessionMode::Pty));
let e = plan_action("kimi", SessionMode::Pty, BackendAction::Effort).unwrap_err();
assert!(e.message.contains("use the acp path"));
assert!(plan_action("cursor", SessionMode::Acp, BackendAction::Interrupt).is_err());
assert!(!prompt_allowed_on_transport(false));
}
}