#![allow(clippy::all)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PriorMode {
Default,
}
impl Default for PriorMode {
fn default() -> Self {
PriorMode::Default
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PlanModeReason {
ModelRequested {
reason: Option<String>,
},
OperatorRequested,
AutoDestructive {
tripped_check: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum PlanModeState {
#[default]
Off,
On {
entered_at: i64,
reason: PlanModeReason,
prior_mode: PriorMode,
},
}
impl PlanModeState {
pub fn is_on(&self) -> bool {
matches!(self, PlanModeState::On { .. })
}
pub fn is_off(&self) -> bool {
matches!(self, PlanModeState::Off)
}
pub fn on(entered_at: i64, reason: PlanModeReason) -> Self {
PlanModeState::On {
entered_at,
reason,
prior_mode: PriorMode::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolKind {
Bash,
FileEdit,
Outbound,
Delegate,
Dispatch,
Schedule,
Config,
ReadOnly,
}
impl ToolKind {
pub fn is_mutating(self) -> bool {
!matches!(self, ToolKind::ReadOnly)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlanModeRefusal {
pub tool_name: String,
pub tool_kind: ToolKind,
pub hint: &'static str,
pub entered_at: i64,
pub entered_reason: PlanModeReason,
}
impl PlanModeRefusal {
pub const HINT: &'static str = "Call ExitPlanMode { final_plan } when the plan is ready.";
}
pub const PLAN_MODE_SYSTEM_HINT: &str = "[plan-mode] Active. Read-only exploration. Mutating tools refuse with PlanModeRefusal. Call ExitPlanMode { final_plan } when ready.";
pub fn plan_mode_system_hint(state: &PlanModeState) -> Option<&'static str> {
state.is_on().then_some(PLAN_MODE_SYSTEM_HINT)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcceptanceOutcome {
Pass,
Fail,
}
impl AcceptanceOutcome {
fn as_str(self) -> &'static str {
match self {
AcceptanceOutcome::Pass => "pass",
AcceptanceOutcome::Fail => "fail",
}
}
}
pub fn format_notify_entered(entered_at: i64, reason: &PlanModeReason) -> String {
let ts = chrono::DateTime::from_timestamp(entered_at, 0)
.map(|d| d.to_rfc3339())
.unwrap_or_else(|| entered_at.to_string());
let reason_str = match reason {
PlanModeReason::ModelRequested { reason: Some(r) } => format!("model: {r}"),
PlanModeReason::ModelRequested { reason: None } => "model".to_string(),
PlanModeReason::OperatorRequested => "operator".to_string(),
PlanModeReason::AutoDestructive { tripped_check } => {
format!("auto-destructive: {tripped_check}")
}
};
format!("[plan-mode] entered at {ts} — reason: {reason_str}")
}
pub fn format_notify_exited(plan: &str, turn_log_index: u64) -> String {
let snippet: String = plan.chars().take(200).collect();
let ellipsis = if plan.chars().count() > 200 {
"…"
} else {
""
};
format!(
"[plan-mode] exited — plan: {snippet}{ellipsis} (full plan in turn log #{turn_log_index})"
)
}
pub fn format_notify_acceptance(outcome: AcceptanceOutcome, summary: &str) -> String {
format!(
"[plan-mode] acceptance: {} ({})",
outcome.as_str(),
summary.trim()
)
}
pub fn format_notify_refused(refusal: &PlanModeRefusal) -> String {
let kind = match refusal.tool_kind {
ToolKind::Bash => "bash",
ToolKind::FileEdit => "file_edit",
ToolKind::Outbound => "outbound",
ToolKind::Delegate => "delegate",
ToolKind::Dispatch => "dispatch",
ToolKind::Schedule => "schedule",
ToolKind::Config => "config",
ToolKind::ReadOnly => "read_only",
};
format!(
"[plan-mode] refused tool={tool} kind={kind}",
tool = refusal.tool_name
)
}
pub const MUTATING_TOOLS: &[&str] = &[
"Bash",
"FileWrite",
"FileEdit",
"NotebookEdit",
"program_phase",
"delegate_to",
"dispatch_followup",
"ScheduleCron",
"cron_create",
"cron_delete",
"cron_pause",
"cron_resume",
"start_followup",
"cancel_followup",
"RemoteTrigger",
"Repl",
"Config",
"TeamCreate",
"TeamDelete",
"TeamSendMessage",
"memory_snapshot",
];
pub const READ_ONLY_TOOLS: &[&str] = &[
"FileRead",
"Glob",
"Grep",
"WebSearch",
"WebFetch",
"ListMcpResources",
"ReadMcpResource",
"list_mcp_resources",
"read_mcp_resource",
"ToolSearch",
"AskUserQuestion",
"Sleep",
"EnterPlanMode",
"ExitPlanMode",
"TodoWrite",
"SyntheticOutput",
"cron_list",
"check_followup",
"Lsp",
"config_changes_tail",
"TeamList",
"TeamStatus",
"memory_search",
"agent_query",
"agent_turns_tail",
"session_logs",
"what_do_i_know",
"who_am_i",
"my_stats",
];
pub fn classify_tool(tool_name: &str) -> Option<ToolKind> {
if MUTATING_TOOLS.contains(&tool_name) {
return Some(match tool_name {
"Bash" | "Repl" => ToolKind::Bash,
"FileWrite" | "FileEdit" | "NotebookEdit" => ToolKind::FileEdit,
"delegate_to" | "TeamCreate" | "TeamDelete" | "TeamSendMessage" => ToolKind::Delegate,
"program_phase" | "dispatch_followup" => ToolKind::Dispatch,
"ScheduleCron" | "start_followup" | "cancel_followup" => ToolKind::Schedule,
"RemoteTrigger" => ToolKind::Outbound,
"Config" => ToolKind::Config,
_ => ToolKind::Outbound, });
}
if READ_ONLY_TOOLS.contains(&tool_name) {
return Some(ToolKind::ReadOnly);
}
if tool_name.contains('.') {
return Some(ToolKind::Outbound);
}
None
}
pub fn gate_tool_call(
state: &PlanModeState,
tool_name: &str,
bash_is_mutating: Option<bool>,
) -> Option<PlanModeRefusal> {
gate_tool_call_with_args(state, tool_name, bash_is_mutating, &serde_json::Value::Null)
}
pub fn gate_tool_call_with_args(
state: &PlanModeState,
tool_name: &str,
bash_is_mutating: Option<bool>,
args: &serde_json::Value,
) -> Option<PlanModeRefusal> {
let PlanModeState::On {
entered_at,
reason,
prior_mode: _,
} = state
else {
return None;
};
let kind = classify_tool(tool_name)?;
if tool_name == "Config" {
if let Some(op) = args.get("op").and_then(|v| v.as_str()) {
if op == "read" {
return None;
}
}
}
let blocked = match kind {
ToolKind::Bash => bash_is_mutating.unwrap_or(true),
ToolKind::ReadOnly => false,
_ => true,
};
if !blocked {
return None;
}
Some(PlanModeRefusal {
tool_name: tool_name.to_string(),
tool_kind: kind,
hint: PlanModeRefusal::HINT,
entered_at: *entered_at,
entered_reason: reason.clone(),
})
}
pub fn unclassified_tools<I, S>(registered: I) -> Vec<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
registered
.into_iter()
.filter(|name| classify_tool(name.as_ref()).is_none())
.map(|name| name.as_ref().to_string())
.collect()
}
pub fn assert_registry_classified<I, S>(registered: I)
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let bad = unclassified_tools(registered);
if !bad.is_empty() {
panic!(
"plan_mode: {} tool(s) registered without mutating/read-only \
classification: {:?}. Add them to MUTATING_TOOLS or \
READ_ONLY_TOOLS in crates/core/src/plan_mode.rs, or use the \
`<channel>.<verb>` outbound convention.",
bad.len(),
bad
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_default_off() {
assert!(PlanModeState::default().is_off());
}
#[test]
fn state_serde_roundtrip_on() {
let state = PlanModeState::on(
1_700_000_000,
PlanModeReason::ModelRequested {
reason: Some("explore auth flow".into()),
},
);
let json = serde_json::to_string(&state).unwrap();
let back: PlanModeState = serde_json::from_str(&json).unwrap();
assert_eq!(state, back);
}
#[test]
fn state_serde_roundtrip_auto_destructive() {
let state = PlanModeState::on(
1_700_000_000,
PlanModeReason::AutoDestructive {
tripped_check: "rm -rf $HOME".into(),
},
);
let json = serde_json::to_string(&state).unwrap();
let back: PlanModeState = serde_json::from_str(&json).unwrap();
assert_eq!(state, back);
}
#[test]
fn classify_known_mutators() {
assert_eq!(classify_tool("Bash"), Some(ToolKind::Bash));
assert_eq!(classify_tool("FileEdit"), Some(ToolKind::FileEdit));
assert_eq!(classify_tool("delegate_to"), Some(ToolKind::Delegate));
assert_eq!(classify_tool("ScheduleCron"), Some(ToolKind::Schedule));
assert_eq!(classify_tool("Config"), Some(ToolKind::Config));
}
#[test]
fn classify_known_read_only() {
assert_eq!(classify_tool("FileRead"), Some(ToolKind::ReadOnly));
assert_eq!(classify_tool("EnterPlanMode"), Some(ToolKind::ReadOnly));
assert_eq!(classify_tool("ExitPlanMode"), Some(ToolKind::ReadOnly));
assert_eq!(classify_tool("AskUserQuestion"), Some(ToolKind::ReadOnly));
}
#[test]
fn classify_outbound_dotted_convention() {
assert_eq!(classify_tool("whatsapp.send"), Some(ToolKind::Outbound));
assert_eq!(classify_tool("browser.click"), Some(ToolKind::Outbound));
}
#[test]
fn classify_unknown_returns_none() {
assert_eq!(classify_tool("totally_unregistered_thing"), None);
}
#[test]
fn lsp_is_classified_as_read_only() {
assert!(READ_ONLY_TOOLS.contains(&"Lsp"));
let state = PlanModeState::on(123, PlanModeReason::ModelRequested { reason: None });
assert!(gate_tool_call(&state, "Lsp", None).is_none());
}
#[test]
fn config_read_passes_plan_mode() {
let state = PlanModeState::on(123, PlanModeReason::ModelRequested { reason: None });
let args = serde_json::json!({ "op": "read", "key": "model.model" });
assert!(gate_tool_call_with_args(&state, "Config", None, &args).is_none());
}
#[test]
fn config_propose_blocked_by_plan_mode() {
let state = PlanModeState::on(123, PlanModeReason::ModelRequested { reason: None });
let args = serde_json::json!({ "op": "propose", "key": "model.model", "value": "claude-opus-4-7" });
let refusal = gate_tool_call_with_args(&state, "Config", None, &args).unwrap();
assert_eq!(refusal.tool_kind, ToolKind::Config);
}
#[test]
fn config_apply_blocked_by_plan_mode() {
let state = PlanModeState::on(123, PlanModeReason::ModelRequested { reason: None });
let args = serde_json::json!({ "op": "apply", "patch_id": "01J7" });
let refusal = gate_tool_call_with_args(&state, "Config", None, &args).unwrap();
assert_eq!(refusal.tool_kind, ToolKind::Config);
}
#[test]
fn config_changes_tail_is_read_only() {
assert!(READ_ONLY_TOOLS.contains(&"config_changes_tail"));
let state = PlanModeState::on(123, PlanModeReason::ModelRequested { reason: None });
assert!(gate_tool_call(&state, "config_changes_tail", None).is_none());
}
#[test]
fn team_create_classified_as_delegate() {
assert_eq!(classify_tool("TeamCreate"), Some(ToolKind::Delegate));
}
#[test]
fn team_delete_classified_as_delegate() {
assert_eq!(classify_tool("TeamDelete"), Some(ToolKind::Delegate));
}
#[test]
fn team_send_message_classified_as_delegate() {
assert_eq!(classify_tool("TeamSendMessage"), Some(ToolKind::Delegate));
}
#[test]
fn team_create_blocked_under_plan_mode() {
let state = PlanModeState::on(123, PlanModeReason::ModelRequested { reason: None });
let refusal = gate_tool_call(&state, "TeamCreate", None).unwrap();
assert_eq!(refusal.tool_kind, ToolKind::Delegate);
}
#[test]
fn team_list_passes_plan_mode() {
assert!(READ_ONLY_TOOLS.contains(&"TeamList"));
let state = PlanModeState::on(123, PlanModeReason::ModelRequested { reason: None });
assert!(gate_tool_call(&state, "TeamList", None).is_none());
}
#[test]
fn team_status_passes_plan_mode() {
assert!(READ_ONLY_TOOLS.contains(&"TeamStatus"));
let state = PlanModeState::on(123, PlanModeReason::ModelRequested { reason: None });
assert!(gate_tool_call(&state, "TeamStatus", None).is_none());
}
#[test]
fn gate_off_lets_everything_through() {
let state = PlanModeState::Off;
assert!(gate_tool_call(&state, "FileEdit", None).is_none());
assert!(gate_tool_call(&state, "Bash", Some(true)).is_none());
}
#[test]
fn gate_on_blocks_mutators() {
let state = PlanModeState::on(123, PlanModeReason::ModelRequested { reason: None });
let refusal = gate_tool_call(&state, "FileEdit", None).unwrap();
assert_eq!(refusal.tool_kind, ToolKind::FileEdit);
assert_eq!(refusal.entered_at, 123);
}
#[test]
fn gate_on_allows_read_only() {
let state = PlanModeState::on(123, PlanModeReason::ModelRequested { reason: None });
assert!(gate_tool_call(&state, "FileRead", None).is_none());
assert!(gate_tool_call(&state, "ExitPlanMode", None).is_none());
}
#[test]
fn gate_bash_with_classifier_unknown_blocks() {
let state = PlanModeState::on(123, PlanModeReason::ModelRequested { reason: None });
let refusal = gate_tool_call(&state, "Bash", None).unwrap();
assert_eq!(refusal.tool_kind, ToolKind::Bash);
}
#[test]
fn gate_bash_read_only_passes() {
let state = PlanModeState::on(123, PlanModeReason::ModelRequested { reason: None });
assert!(gate_tool_call(&state, "Bash", Some(false)).is_none());
}
#[test]
fn gate_bash_destructive_blocks() {
let state = PlanModeState::on(123, PlanModeReason::ModelRequested { reason: None });
let refusal = gate_tool_call(&state, "Bash", Some(true)).unwrap();
assert_eq!(refusal.tool_kind, ToolKind::Bash);
}
#[test]
fn unclassified_tools_reports_missing() {
let names = ["FileEdit", "weird_new_tool", "Glob"];
let bad = unclassified_tools(names);
assert_eq!(bad, vec!["weird_new_tool".to_string()]);
}
#[test]
fn assert_passes_for_known_registry() {
let names: Vec<&str> = MUTATING_TOOLS
.iter()
.chain(READ_ONLY_TOOLS.iter())
.copied()
.collect();
assert_registry_classified(names);
}
#[test]
#[should_panic(expected = "plan_mode:")]
fn assert_panics_on_unclassified() {
assert_registry_classified(["totally_unregistered_thing"]);
}
#[test]
fn system_hint_returns_string_when_on() {
let state = PlanModeState::on(1, PlanModeReason::ModelRequested { reason: None });
assert_eq!(plan_mode_system_hint(&state), Some(PLAN_MODE_SYSTEM_HINT));
}
#[test]
fn system_hint_returns_none_when_off() {
assert_eq!(plan_mode_system_hint(&PlanModeState::Off), None);
}
#[test]
fn system_hint_is_a_frozen_static() {
assert!(PLAN_MODE_SYSTEM_HINT.contains("[plan-mode]"));
assert!(PLAN_MODE_SYSTEM_HINT.contains("ExitPlanMode"));
assert!(PLAN_MODE_SYSTEM_HINT.contains("PlanModeRefusal"));
}
#[test]
fn notify_entered_model_no_reason() {
let s = format_notify_entered(
1_700_000_000,
&PlanModeReason::ModelRequested { reason: None },
);
assert!(s.starts_with("[plan-mode] entered at "));
assert!(s.ends_with(" — reason: model"));
}
#[test]
fn notify_entered_model_with_reason() {
let s = format_notify_entered(
1_700_000_000,
&PlanModeReason::ModelRequested {
reason: Some("auth flow".into()),
},
);
assert!(s.contains("reason: model: auth flow"));
}
#[test]
fn notify_entered_operator() {
let s = format_notify_entered(1_700_000_000, &PlanModeReason::OperatorRequested);
assert!(s.ends_with("reason: operator"));
}
#[test]
fn notify_entered_auto_destructive_carries_check() {
let s = format_notify_entered(
1_700_000_000,
&PlanModeReason::AutoDestructive {
tripped_check: "rm -rf $HOME".into(),
},
);
assert!(s.contains("auto-destructive: rm -rf $HOME"));
}
#[test]
fn notify_exited_truncates_long_plan() {
let plan = "x".repeat(300);
let s = format_notify_exited(&plan, 7);
assert!(s.contains("xxx"));
assert!(s.contains('…'));
assert!(s.ends_with("(full plan in turn log #7)"));
}
#[test]
fn notify_exited_short_plan_no_ellipsis() {
let s = format_notify_exited("1. read auth\n2. patch", 42);
assert!(!s.contains('…'));
assert!(s.ends_with("(full plan in turn log #42)"));
}
#[test]
fn notify_acceptance_pass_and_fail() {
let p = format_notify_acceptance(AcceptanceOutcome::Pass, "12 tests");
assert_eq!(p, "[plan-mode] acceptance: pass (12 tests)");
let f = format_notify_acceptance(AcceptanceOutcome::Fail, "build red");
assert_eq!(f, "[plan-mode] acceptance: fail (build red)");
}
#[test]
fn notify_refused_renders_tool_and_kind() {
let refusal = PlanModeRefusal {
tool_name: "FileEdit".into(),
tool_kind: ToolKind::FileEdit,
hint: PlanModeRefusal::HINT,
entered_at: 1,
entered_reason: PlanModeReason::ModelRequested { reason: None },
};
assert_eq!(
format_notify_refused(&refusal),
"[plan-mode] refused tool=FileEdit kind=file_edit"
);
}
}