use crate::{
core::{
self,
extensions::{
CliFlagDef, DockLine, DockSpan, Extension, ExtensionCommand, ExtensionHook,
ExtensionKeybinding, ExtensionMode, ExtensionTool, ExtensionUiRequest, RichSpan,
SelectOption, UiNotifyLevel, UserPromptAction, request_show_dock, request_ui,
},
provider::AgentMessage,
session_manager::Session,
tools::ToolError,
},
error::Result,
extensions::{EXTENSION_FACTORIES, ExtensionFactory, PRIORITY_PLAN_MODE, command_arg},
modes::interactive::{
agent_actor::AgentCommand,
app::{App, MsgLevel, SysSpan},
handlers::{register_extension_keybinding, register_slash_command},
},
utils::glyphs::{DEF_CHECKBOX_EMPTY, DEF_DONE},
};
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
const CUSTOM_TYPE: &str = "plan-mode";
const PLAN_MODE_MARKER: &str = "[PLAN MODE ACTIVE]";
const EXECUTING_MARKER: &str = "[EXECUTING PLAN";
const PLAN_MODE_PROMPT: &str = "[PLAN MODE ACTIVE]
You are in plan mode - a read-only exploration mode for safe code analysis.
Restrictions:
- Built-in edit and write tools are disabled
- Bash is restricted to an allowlist of read-only commands
- External tools (MCP, extensions, etc.) remain available
Create a detailed numbered plan under a \"Plan:\" header:
Plan:
1. First step description
2. Second step description
...
Do NOT attempt to make changes - just describe what you would do.";
const EXEC_MODE_PROMPT_HEAD: &str = "[EXECUTING PLAN - Full tool access enabled]
Remaining steps:
";
const EXEC_MODE_PROMPT_TAIL: &str = "
Execute each step in order.
After completing a step, include a [DONE:n] tag in your response.
Steps are also auto-completed after each turn with tool calls.";
const EXECUTE_FOLLOWUP_HEAD: &str = "Execute the plan.
Remaining steps:
";
const EXECUTE_FOLLOWUP_TAIL: &str = "
After completing a step, include a [DONE:n] tag in your response.
Steps are also auto-completed after each turn with tool calls.";
const DESTRUCTIVE_PATTERNS: &[&str] = &[
r"\brm\b",
r"\brmdir\b",
r"\bmv\b",
r"\bcp\b",
r"\bmkdir\b",
r"\btouch\b",
r"\bchmod\b",
r"\bchown\b",
r"\bchgrp\b",
r"\bln\b",
r"\btee\b",
r"\btruncate\b",
r"\bdd\b",
r"\bshred\b",
r"(?:^|[^<])>([^>]|$)",
r">>",
r"\bnpm\s+(install|uninstall|update|ci|link|publish)",
r"\byarn\s+(add|remove|install|publish)",
r"\bpnpm\s+(add|remove|install|publish)",
r"\bpip\s+(install|uninstall)",
r"\bapt(-get)?\s+(install|remove|purge|update|upgrade)",
r"\bbrew\s+(install|uninstall|upgrade)",
r"\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)",
r"\bsudo\b",
r"\bsu\b",
r"\bkill\b",
r"\bpkill\b",
r"\bkillall\b",
r"\breboot\b",
r"\bshutdown\b",
r"\bsystemctl\s+(start|stop|restart|enable|disable)",
r"\bservice\s+\S+\s+(start|stop|restart)",
r"\b(vim?|nano|emacs|code|subl)\b",
];
const SAFE_PATTERNS: &[&str] = &[
r"^\s*cat\b",
r"^\s*head\b",
r"^\s*tail\b",
r"^\s*less\b",
r"^\s*more\b",
r"^\s*grep\b",
r"^\s*find\b",
r"^\s*ls\b",
r"^\s*pwd\b",
r"^\s*echo\b",
r"^\s*printf\b",
r"^\s*wc\b",
r"^\s*sort\b",
r"^\s*uniq\b",
r"^\s*diff\b",
r"^\s*file\b",
r"^\s*stat\b",
r"^\s*du\b",
r"^\s*df\b",
r"^\s*tree\b",
r"^\s*which\b",
r"^\s*whereis\b",
r"^\s*type\b",
r"^\s*env\b",
r"^\s*printenv\b",
r"^\s*uname\b",
r"^\s*whoami\b",
r"^\s*id\b",
r"^\s*date\b",
r"^\s*cal\b",
r"^\s*uptime\b",
r"^\s*ps\b",
r"^\s*top\b",
r"^\s*htop\b",
r"^\s*free\b",
r"^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)",
r"^\s*git\s+ls-",
r"^\s*npm\s+(list|ls|view|info|search|outdated|audit)",
r"^\s*yarn\s+(list|info|why|audit)",
r"^\s*node\s+--version",
r"^\s*python\s+--version",
r"^\s*curl\s",
r"^\s*wget\s+-O\s*-",
r"^\s*jq\b",
r"^\s*sed\s+-n",
r"^\s*awk\b",
r"^\s*rg\b",
r"^\s*fd\b",
r"^\s*bat\b",
r"^\s*eza\b",
];
#[linkme::distributed_slice(EXTENSION_FACTORIES)]
static PLAN_MODE_FACTORY: ExtensionFactory = ExtensionFactory {
priority: PRIORITY_PLAN_MODE,
make: || Arc::new(PlanMode::new()),
};
fn compile_patterns(patterns: &[&str]) -> Vec<Regex> {
patterns
.iter()
.map(|p| Regex::new(&format!("(?i){p}")).expect("plan-mode pattern"))
.collect()
}
fn destructive_patterns() -> &'static Vec<Regex> {
static P: OnceLock<Vec<Regex>> = OnceLock::new();
P.get_or_init(|| compile_patterns(DESTRUCTIVE_PATTERNS))
}
fn safe_patterns() -> &'static Vec<Regex> {
static P: OnceLock<Vec<Regex>> = OnceLock::new();
P.get_or_init(|| compile_patterns(SAFE_PATTERNS))
}
pub fn is_safe_command(command: &str) -> bool {
let is_destructive = destructive_patterns().iter().any(|p| p.is_match(command));
let is_safe = safe_patterns().iter().any(|p| p.is_match(command));
!is_destructive && is_safe
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct TodoItem {
pub step: usize,
pub text: String,
pub completed: bool,
}
pub fn clean_step_text(text: &str) -> String {
let bold = Regex::new(r"\*{1,2}([^*]+)\*{1,2}").expect("bold");
let code = Regex::new(r"`([^`]+)`").expect("code");
let verb = Regex::new(
r"^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?",
)
.expect("verb");
let ws = Regex::new(r"\s+").expect("ws");
let mut cleaned = bold.replace_all(text, "$1").to_string();
cleaned = code.replace_all(&cleaned, "$1").to_string();
cleaned = verb.replace_all(&cleaned, "").to_string();
cleaned = ws.replace_all(&cleaned, " ").to_string();
let cleaned = cleaned.trim().to_string();
let mut cleaned = if cleaned.is_empty() {
cleaned
} else {
let mut chars = cleaned.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => cleaned,
}
};
if cleaned.chars().count() > 50 {
let truncated: String = cleaned.chars().take(47).collect();
cleaned = format!("{truncated}...");
}
cleaned
}
pub fn extract_todo_items(message: &str) -> Vec<TodoItem> {
let mut items = Vec::new();
let header = Regex::new(r"(?i)\*{0,2}Plan:\*{0,2}\s*\n").expect("header");
let Some(hit) = header.find(message) else {
return items;
};
let plan_section = &message[hit.end()..];
let numbered = Regex::new(r"(?m)^\s*(\d+)[.)]\s+([^\n]+)").expect("numbered");
for cap in numbered.captures_iter(plan_section) {
let text = cap
.get(2)
.map(|m| m.as_str().trim().trim_end_matches(['*']).trim().to_string())
.unwrap_or_default();
if text.len() > 5
&& !text.starts_with('`')
&& !text.starts_with('/')
&& !text.starts_with('-')
{
let cleaned = clean_step_text(&text);
if cleaned.chars().count() > 3 {
items.push(TodoItem {
step: items.len() + 1,
text: cleaned,
completed: false,
});
}
}
}
items
}
pub fn extract_done_steps(message: &str) -> Vec<usize> {
let done = Regex::new(r"(?i)\[DONE:(\d+)\]").expect("done");
done.captures_iter(message)
.filter_map(|c| c.get(1).and_then(|m| m.as_str().parse().ok()))
.collect()
}
pub fn mark_completed_steps(text: &str, items: &mut [TodoItem]) -> usize {
let done_steps = extract_done_steps(text);
for step in &done_steps {
if let Some(item) = items.iter_mut().find(|t| t.step == *step) {
item.completed = true;
}
}
done_steps.len()
}
fn remaining_todo_lines(todos: &[TodoItem]) -> String {
todos
.iter()
.filter(|t| !t.completed)
.map(|t| format!("{}. {}", t.step, t.text))
.collect::<Vec<_>>()
.join("\n")
}
fn todo_list_display(todos: &[TodoItem]) -> String {
todos
.iter()
.map(|t| {
format!(
"{}. {} {}",
t.step,
if t.completed {
DEF_DONE
} else {
DEF_CHECKBOX_EMPTY
},
t.text
)
})
.collect::<Vec<_>>()
.join("\n")
}
#[derive(Debug, Default)]
struct State {
enabled: bool,
executing: bool,
todos: Vec<TodoItem>,
refine_pending: bool,
select_id: Option<u64>,
dock_shown: bool,
dirty_payload: Option<Value>,
write_in_flight: bool,
cleanup_pending: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct PersistedState {
#[serde(default = "persist_version")]
version: u32,
enabled: bool,
executing: bool,
todos: Vec<TodoItem>,
}
fn persist_version() -> u32 {
1
}
#[derive(Default)]
struct StateLock(Mutex<State>);
impl StateLock {
fn lock(&self) -> MutexGuard<'_, State> {
self.0.lock().unwrap_or_else(|e| e.into_inner())
}
}
fn state() -> &'static StateLock {
static S: OnceLock<StateLock> = OnceLock::new();
S.get_or_init(Default::default)
}
fn interest_for(st: &State) -> Vec<ExtensionHook> {
let mut out = Vec::new();
if st.enabled || st.executing || st.cleanup_pending {
out.push(ExtensionHook::TransformContext);
}
if st.enabled {
out.push(ExtensionHook::BeforeToolCall);
}
if st.dock_shown && !st.todos.is_empty() {
out.push(ExtensionHook::Dock);
}
if st.refine_pending {
out.push(ExtensionHook::UserPrompt);
}
if st.enabled
|| st.executing
|| !st.todos.is_empty()
|| st.refine_pending
|| st.select_id.is_some()
|| st.write_in_flight
|| st.dirty_payload.is_some()
{
out.push(ExtensionHook::AgentEvent);
}
out
}
fn persist_value() -> Option<Value> {
let st = state().lock();
serde_json::to_value(PersistedState {
version: persist_version(),
enabled: st.enabled,
executing: st.executing,
todos: st.todos.clone(),
})
.ok()
}
fn persist() {
let Some(payload) = persist_value() else {
return;
};
let mut st = state().lock();
st.dirty_payload = Some(payload);
if !st.write_in_flight {
st.write_in_flight = true;
let data = st.dirty_payload.take().unwrap_or(Value::Null);
core::extensions::request_ui(ExtensionUiRequest::PersistSessionEntry {
custom_type: CUSTOM_TYPE.to_string(),
data,
});
}
}
fn on_persisted(custom_type: &str) {
if custom_type != CUSTOM_TYPE {
return;
}
let mut st = state().lock();
st.write_in_flight = false;
if let Some(data) = st.dirty_payload.take() {
st.write_in_flight = true;
core::extensions::request_ui(ExtensionUiRequest::PersistSessionEntry {
custom_type: CUSTOM_TYPE.to_string(),
data,
});
}
}
fn latest_from_session(session: Option<&Session>) -> Option<PersistedState> {
for v in session?.get_entries().iter().rev() {
if v.get("customType").and_then(|c| c.as_str()) != Some(CUSTOM_TYPE) {
continue;
}
if let Some(data) = v.get("data")
&& let Ok(p) = serde_json::from_value::<PersistedState>(data.clone())
{
return Some(p);
}
}
None
}
fn todos_from_history(messages: &[AgentMessage]) -> Vec<TodoItem> {
let mut todos = Vec::new();
for m in messages.iter().rev() {
if m.role != "assistant" {
continue;
}
let extracted = extract_todo_items(&m.text());
if !extracted.is_empty() {
todos = extracted;
break;
}
}
let all = messages
.iter()
.map(|m| m.text())
.collect::<Vec<_>>()
.join("\n");
mark_completed_steps(&all, &mut todos);
todos
}
fn restore_from_session(
session: Option<&Session>,
messages: &[AgentMessage],
request_rebuild: bool,
) {
let persisted = latest_from_session(session);
let mut restored = false;
{
let mut st = state().lock();
match persisted {
Some(p) => {
st.enabled = p.enabled;
st.executing = p.executing;
st.todos = p.todos;
restored = true;
}
None => {
let todos = todos_from_history(messages);
if !todos.is_empty() {
st.executing = true;
st.enabled = false;
st.todos = todos;
restored = true;
}
}
}
st.dock_shown = false;
st.refine_pending = false;
st.select_id = None;
}
let rescan_changed = {
let all = messages
.iter()
.map(|m| m.text())
.collect::<Vec<_>>()
.join("\n");
let mut st = state().lock();
let before: Vec<bool> = st.todos.iter().map(|t| t.completed).collect();
mark_completed_steps(&all, &mut st.todos);
before != st.todos.iter().map(|t| t.completed).collect::<Vec<_>>()
};
let pending = state().lock().todos.iter().filter(|t| !t.completed).count();
if restored && rescan_changed {
persist();
}
if pending > 0 {
state().lock().dock_shown = true;
request_show_dock();
}
emit_plan_changed(is_enabled());
if request_rebuild {
request_ui(ExtensionUiRequest::RebuildTools);
}
}
fn emit_plan_changed(enabled: bool) {
static LAST: OnceLock<Mutex<Option<bool>>> = OnceLock::new();
let mut last = LAST.get_or_init(|| Mutex::new(None)).lock().unwrap();
if *last == Some(enabled) {
return;
}
*last = Some(enabled);
core::extensions::dispatch_agent_event(
&serde_json::json!({ "type": "plan-mode:changed", "enabled": enabled }),
);
}
pub fn toggle_enabled() -> bool {
if !core::extensions::is_extension_enabled("plan-mode") {
return is_enabled();
}
let mut st = state().lock();
st.enabled = !st.enabled;
if !st.enabled {
st.executing = false;
st.todos.clear();
st.refine_pending = false;
st.select_id = None;
st.cleanup_pending = true;
}
let enabled = st.enabled;
drop(st);
emit_plan_changed(enabled);
persist();
enabled
}
pub fn set_enabled(enabled: bool) {
if enabled && !core::extensions::is_extension_enabled("plan-mode") {
return;
}
let mut st = state().lock();
if st.enabled == enabled {
return;
}
st.enabled = enabled;
if !enabled {
st.executing = false;
st.todos.clear();
st.refine_pending = false;
st.select_id = None;
st.cleanup_pending = true;
}
drop(st);
emit_plan_changed(enabled);
persist();
}
pub fn is_enabled() -> bool {
state().lock().enabled
}
fn toggle_plan_ui(st: &mut App) {
if !core::extensions::is_extension_enabled("plan-mode") {
return;
}
let enabled = toggle_enabled();
apply_plan_mode_ui(st, enabled);
}
fn enable_plan_ui(st: &mut App) {
if !core::extensions::is_extension_enabled("plan-mode") || is_enabled() {
return;
}
set_enabled(true);
apply_plan_mode_ui(st, true);
}
fn apply_plan_mode_ui(st: &mut App, enabled: bool) {
st.worker.send(AgentCommand::RebuildTools);
st.push_msg(
if enabled {
"Plan mode enabled. Built-in write tools disabled.".to_string()
} else {
"Plan mode disabled. Full access restored.".to_string()
},
MsgLevel::Info,
);
}
pub fn todo_list_spans() -> Option<Vec<SysSpan>> {
let st = state().lock();
if st.todos.is_empty() {
return None;
}
let mut spans = vec![SysSpan::plain("Plan Progress:\n")];
for item in &st.todos {
spans.push(SysSpan::plain(&format!("{}. ", item.step)));
if item.completed {
spans.push(SysSpan::fg("success", &format!("{DEF_DONE} ")));
spans.push(SysSpan::fg("muted", &format!("{}\n", item.text)));
} else {
spans.push(SysSpan::plain(&format!("{DEF_CHECKBOX_EMPTY} ")));
spans.push(SysSpan::plain(&format!("{}\n", item.text)));
}
}
Some(spans)
}
pub fn is_dock_shown() -> bool {
state().lock().dock_shown
}
pub fn show_dock_section() {
state().lock().dock_shown = true;
}
pub fn hide_dock_section() {
state().lock().dock_shown = false;
}
fn todo_count() -> usize {
state().lock().todos.len()
}
#[derive(Default)]
pub struct PlanMode;
impl PlanMode {
pub fn new() -> Self {
Self
}
}
pub fn ensure_registered() {
static REG: std::sync::Once = std::sync::Once::new();
REG.call_once(|| {
core::extensions::register_extension(PlanMode::new());
});
}
impl Extension for PlanMode {
fn name(&self) -> &str {
"plan-mode"
}
fn description(&self) -> &str {
"Read-only exploration mode: disables edit/write, restricts bash to read-only commands, tracks numbered Plan: steps with [DONE:n] progress."
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Dev, ExtensionMode::Creator]
}
fn default_enabled(&self) -> bool {
false
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn commands(&self) -> Vec<ExtensionCommand> {
let mut commands = vec![ExtensionCommand {
name: "plan".to_string(),
description: "Toggle plan mode, or enter it with an initial prompt: /plan [text]"
.to_string(),
busy_safe: false, subcommands: Vec::new(),
}];
if todo_count() > 0 {
commands.push(ExtensionCommand {
name: "todos".to_string(),
description: "Show current plan todo list".to_string(),
busy_safe: true, subcommands: Vec::new(),
});
}
commands
}
fn cli_flags(&self) -> Vec<CliFlagDef> {
vec![CliFlagDef {
takes_value: false,
name: "plan",
description: "Start in plan mode (read-only exploration; edit/write disabled, bash restricted to an allowlist)",
}]
}
fn apply_cli_flag(&self, name: &str, value: bool) {
if name == "plan" && value {
set_enabled(true);
}
}
fn keybindings(&self) -> Vec<ExtensionKeybinding> {
vec![ExtensionKeybinding {
id: "plan-mode.toggle".to_string(),
keys: vec!["alt+p".to_string()],
description: "Toggle plan mode (read-only exploration)".to_string(),
}]
}
fn hooks(&self) -> Vec<ExtensionHook> {
interest_for(&state().lock())
}
fn on_registered(&self) {
register_slash_command("plan-mode", "plan", command_plan);
register_slash_command("plan-mode", "todos", command_todos);
register_extension_keybinding("plan-mode", "plan-mode.toggle", toggle_plan_ui);
}
fn on_enabled_changed(&self, enabled: bool) {
if !enabled {
let mut st = state().lock();
st.enabled = false;
st.executing = false;
st.todos.clear();
st.refine_pending = false;
st.select_id = None;
st.cleanup_pending = true;
drop(st);
emit_plan_changed(false);
persist();
}
}
fn filter_tools(&self, selected: Vec<String>) -> Vec<String> {
if state().lock().enabled {
selected
.into_iter()
.filter(|t| !matches!(t.as_str(), "edit" | "write"))
.collect()
} else {
selected
}
}
fn before_tool_call(
&self,
name: &str,
args: &Value,
) -> std::result::Result<Option<Value>, ToolError> {
if !state().lock().enabled {
return Ok(None);
}
match name {
"edit" | "write" => Err(ToolError(format!(
"Plan mode: {name} is disabled (read-only exploration). Use /plan to disable plan mode first."
))),
"bash" | "powershell" => {
let command = args
.get("command")
.and_then(|v| v.as_str())
.unwrap_or_default();
if !is_safe_command(command) {
return Err(ToolError(format!(
"Plan mode: {name} command blocked (not allowlisted). Use /plan to disable plan mode first.\nCommand: {command}"
)));
}
Ok(None)
}
_ => Ok(None),
}
}
fn transform_context(&self, messages: &mut Vec<AgentMessage>) -> Result<()> {
let (enabled, executing, todos) = {
let mut st = state().lock();
st.cleanup_pending = false;
(st.enabled, st.executing, st.todos.clone())
};
messages.retain(|m| !is_injected_plan_context(m));
if enabled && !executing {
push_context(messages, PLAN_MODE_PROMPT);
} else if executing && !todos.is_empty() {
push_context(
messages,
&format!(
"{}{}{}",
EXEC_MODE_PROMPT_HEAD,
remaining_todo_lines(&todos),
EXEC_MODE_PROMPT_TAIL
),
);
}
Ok(())
}
fn on_session_start(&self, _cwd: &str, messages: &[AgentMessage], session: Option<&Session>) {
restore_from_session(session, messages, false);
}
fn on_session_switched(&self, session_path: Option<&str>, messages: &[AgentMessage]) {
let Some(path) = session_path.filter(|p| !p.is_empty()) else {
*state().lock() = State::default();
return;
};
match Session::open(path) {
Ok(sess) => restore_from_session(Some(&sess), messages, true),
Err(_) => *state().lock() = State::default(),
}
}
fn on_ui_choice(&self, id: u64, choice: Option<String>) -> Option<String> {
let mut st = state().lock();
if st.select_id != Some(id) {
return None;
}
st.select_id = None;
let Some(choice) = choice else {
return None; };
if choice.starts_with("Execute") {
st.enabled = false;
st.executing = true;
let first = st
.todos
.iter()
.find(|t| !t.completed)
.map(|t| t.text.clone());
let mut text = format!(
"{}{}{}",
EXECUTE_FOLLOWUP_HEAD,
remaining_todo_lines(&st.todos),
EXECUTE_FOLLOWUP_TAIL
);
if let Some(first) = first {
text.push_str(&format!("\n\nStart with: {first}")); }
st.dock_shown = true;
drop(st);
request_show_dock();
emit_plan_changed(false);
persist();
Some(text)
} else if choice.contains("Refine") {
st.refine_pending = true;
drop(st);
request_ui(core::extensions::ExtensionUiRequest::Notify {
text: "Plan mode: type your refinement below and press Enter to send".to_string(),
level: UiNotifyLevel::Info,
});
None
} else {
None }
}
fn on_user_prompt(&self, text: &str) -> Option<UserPromptAction> {
let mut st = state().lock();
if !st.refine_pending {
return None;
}
st.refine_pending = false;
let list = todo_list_display(&st.todos);
Some(UserPromptAction::Rewrite(format!(
"**Plan Steps ({}):**\n\n{}\n\nRefine the plan:\n{}",
st.todos.len(),
list,
text.trim()
)))
}
fn dock_lines(&self) -> Vec<DockLine> {
let st = state().lock();
if !st.dock_shown || st.todos.is_empty() {
return Vec::new();
}
let done = st.todos.iter().filter(|t| t.completed).count();
let mut out = vec![vec![DockSpan::plain(format!(
"Plan Progress ({}/{})",
done,
st.todos.len()
))]];
for item in &st.todos {
let mut line = vec![DockSpan::plain(format!("{}. ", item.step))];
if item.completed {
line.push(DockSpan::new("success", "#00ff00", format!("{DEF_DONE} ")));
line.push(DockSpan::new("muted", "#808080", item.text.clone()));
} else {
line.push(DockSpan::plain(format!("{DEF_CHECKBOX_EMPTY} ")));
line.push(DockSpan::plain(item.text.clone()));
}
out.push(line);
}
out
}
fn on_agent_event(&self, event: &Value) {
match event.get("type").and_then(|v| v.as_str()) {
Some("turn_end") => self.on_turn_end(event),
Some("agent_end") => self.on_agent_end(event),
Some("extension:entry_persisted")
if let Some(ct) = event.get("customType").and_then(|v| v.as_str()) =>
{
on_persisted(ct);
}
_ => {}
}
}
}
fn is_injected_plan_context(m: &AgentMessage) -> bool {
if m.role != "user" || m.entry_id.is_some() {
return false;
}
let text = m.text();
let text = text.trim_start();
text.starts_with(PLAN_MODE_MARKER) || text.starts_with(EXECUTING_MARKER)
}
fn push_context(messages: &mut Vec<AgentMessage>, text: &str) {
messages.push(AgentMessage::user_text(text));
}
fn command_plan(st: &mut App, cmd: &str) -> bool {
let arg = command_arg(cmd);
if arg.is_empty() {
toggle_plan_ui(st);
return false;
}
enable_plan_ui(st);
let text = arg.to_string();
st.messages.push(AgentMessage::user_text(&text));
st.status_start = Some(text);
false
}
fn command_todos(st: &mut App, _: &str) -> bool {
if !st.dock_visible {
show_dock_section();
st.dock_visible = true;
st.dirty = true;
} else if is_dock_shown() {
hide_dock_section();
st.dirty = true;
} else {
show_dock_section();
st.dirty = true;
}
false
}
impl PlanMode {
fn on_turn_end(&self, event: &Value) {
{
let st = state().lock();
if !st.executing || st.todos.is_empty() {
return;
}
}
let message: Option<AgentMessage> = event
.get("message")
.and_then(|m| serde_json::from_value(m.clone()).ok());
let Some(message) = message else {
return;
};
let text = message.text();
let progressed = {
let mut st = state().lock();
let before: Vec<bool> = st.todos.iter().map(|t| t.completed).collect();
let explicitly = mark_completed_steps(&text, &mut st.todos);
let had_tool_calls = event
.get("toolResults")
.and_then(|v| v.as_array())
.is_some_and(|a| !a.is_empty());
if explicitly == 0
&& had_tool_calls
&& let Some(next) = st.todos.iter_mut().find(|t| !t.completed)
{
next.completed = true;
}
before != st.todos.iter().map(|t| t.completed).collect::<Vec<_>>()
};
if progressed {
persist();
}
}
fn on_agent_end(&self, event: &Value) {
{
let mut st = state().lock();
if st.executing && !st.todos.is_empty() && st.todos.iter().all(|t| t.completed) {
let mut spans = vec![RichSpan::fg(
"success",
format!(
"Plan Complete! ({}/{}) {DEF_DONE}\n",
st.todos.len(),
st.todos.len()
),
)];
for item in &st.todos {
spans.push(RichSpan::plain(format!("{}. ", item.step)));
spans.push(RichSpan::fg("success", format!("{DEF_DONE} ")));
spans.push(RichSpan::fg("muted", format!("{}\n", item.text)));
}
st.executing = false;
st.todos.clear();
st.select_id = None;
st.cleanup_pending = true;
drop(st);
persist();
request_ui(ExtensionUiRequest::NotifyRich {
spans,
level: UiNotifyLevel::Success,
});
return;
}
if !st.enabled || st.executing {
return;
}
}
let extracted: Vec<TodoItem> = event
.get("messages")
.and_then(|v| v.as_array())
.map(|arr| {
let mut last_assistant: Option<String> = None;
for m in arr.iter().rev() {
if m.get("role").and_then(|v| v.as_str()) != Some("assistant") {
continue;
}
if let Ok(msg) = serde_json::from_value::<AgentMessage>(m.clone()) {
last_assistant = Some(msg.text());
break;
}
}
last_assistant
.map(|t| extract_todo_items(&t))
.unwrap_or_default()
})
.unwrap_or_default();
if extracted.is_empty() {
return;
}
state().lock().todos = extracted;
persist();
let id = core::extensions::next_ui_id();
state().lock().select_id = Some(id);
request_ui(ExtensionUiRequest::Select {
id,
title: "Plan mode - what next?".to_string(),
options: vec![
SelectOption::new("Execute the plan (track progress)"),
SelectOption::new("Stay in plan mode"),
SelectOption::new("Refine the plan"),
],
});
}
}
#[cfg(test)]
pub(crate) static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
pub(crate) fn reset_state_for_tests() {
*state().lock() = State::default();
}
#[cfg(test)]
mod tests {
use super::*;
struct TestEnv {
_auth_lock: std::sync::MutexGuard<'static, ()>,
_lock: std::sync::MutexGuard<'static, ()>,
_ad: crate::test_support::AgentDirGuard,
dir: tempfile::TempDir,
}
fn take_own_persists() -> usize {
let mut n = 0;
while let Some(r) = crate::core::extensions::take_pending_ui() {
if let core::extensions::ExtensionUiRequest::PersistSessionEntry { custom_type, .. } = r
&& custom_type == CUSTOM_TYPE
{
n += 1;
}
}
n
}
fn drain_ui_requests() {
while let Some(r) = crate::core::extensions::take_pending_ui() {
if matches!(r, ExtensionUiRequest::PersistSessionEntry { .. }) {
on_persisted(CUSTOM_TYPE);
}
}
}
fn ensure_registered() {
super::ensure_registered();
crate::core::extensions::set_extension_enabled("plan-mode", true);
}
fn setup() -> TestEnv {
let _auth_lock = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
let dir = tempfile::tempdir().expect("tempdir");
crate::core::extensions::set_extension_enabled("plan-mode", false);
*state().lock() = State::default();
drain_ui_requests();
TestEnv {
_auth_lock,
_lock,
_ad,
dir,
}
}
#[test]
fn safe_command_allows_readonly() {
let _g = setup();
for cmd in [
"ls -la",
"grep -r foo src/",
"cat Cargo.toml",
"git status",
"git log --oneline",
"rg pattern .",
"echo hello",
] {
assert!(is_safe_command(cmd), "should allow: {cmd}");
}
}
#[test]
fn safe_command_blocks_destructive() {
let _g = setup();
for cmd in [
"rm -rf target",
"mv a b",
"cp a b",
"touch x",
"mkdir x",
"git commit -m x",
"git push",
"npm install",
"pip install x",
"sudo ls",
"ls && rm x",
"echo hi > file",
"echo hi >> file",
"vim Cargo.toml",
"tee out.txt",
] {
assert!(!is_safe_command(cmd), "should block: {cmd}");
}
}
#[test]
fn todos_spans_no_duplicate_title_and_success_color() {
let _g = setup();
{
let mut st = state().lock();
st.todos = vec![
TodoItem {
step: 1,
text: "Done step".to_string(),
completed: true,
},
TodoItem {
step: 2,
text: "Pending step".to_string(),
completed: false,
},
];
}
let spans = todo_list_spans().expect("有 todos 应返回 spans");
let text = crate::modes::interactive::app::sys_spans_text(&spans);
assert_eq!(
text.matches("Plan Progress:").count(),
1,
"标题不得重复: {text:?}"
);
assert_eq!(
text, "Plan Progress:\n1. ✓ Done step\n2. ☐ Pending step\n",
"got: {text:?}"
);
let check = spans
.iter()
.find(|s| s.text == "✓ ")
.expect("应有已完成标记");
assert_eq!(check.fg.as_deref(), Some("success"));
let pending = spans
.iter()
.find(|s| s.text == "☐ ")
.expect("应有未完成标记");
assert_eq!(pending.fg, None, "未完成项不应覆盖颜色");
}
#[test]
fn extract_todos_from_plan_message() {
let _g = setup();
let msg = "Let me analyze this.\n\nPlan:\n1. **Read** the main file\n2. Check the `config`\n3. rm nothing (keep, not a verb prefix)\n4. Verify behavior\n\nThen some footer.";
let items = extract_todo_items(msg);
assert_eq!(items.len(), 4, "got: {items:?}");
assert_eq!(items[0].text, "Main file");
assert_eq!(items[0].step, 1);
assert!(!items[0].completed);
let msg2 = "Plan:\n- bullet\n\nDone.";
assert!(extract_todo_items(msg2).is_empty());
}
#[test]
fn done_markers_complete_steps() {
let _g = setup();
let mut items = vec![
TodoItem {
step: 1,
text: "A".to_string(),
completed: false,
},
TodoItem {
step: 2,
text: "B".to_string(),
completed: false,
},
TodoItem {
step: 3,
text: "C".to_string(),
completed: false,
},
];
assert_eq!(
mark_completed_steps("did [DONE:1] and [DONE:3]", &mut items),
2
);
assert!(items[0].completed);
assert!(!items[1].completed);
assert!(items[2].completed);
let mut items2 = vec![TodoItem {
step: 1,
text: "A".to_string(),
completed: false,
}];
assert_eq!(mark_completed_steps("[done:1]", &mut items2), 1);
assert!(items2[0].completed);
}
#[test]
fn filter_tools_removes_writes_in_plan_mode() {
let _g = setup();
let base = vec![
"read".to_string(),
"bash".to_string(),
"edit".to_string(),
"write".to_string(),
"grep".to_string(),
];
let ext = PlanMode::new();
assert_eq!(ext.filter_tools(base.clone()), base);
ensure_registered();
set_enabled(true);
let filtered = ext.filter_tools(base.clone());
assert!(!filtered.contains(&"edit".to_string()));
assert!(!filtered.contains(&"write".to_string()));
assert!(filtered.contains(&"read".to_string()));
set_enabled(false);
assert_eq!(ext.filter_tools(base.clone()), base);
}
#[test]
fn before_tool_call_intercepts_bash_and_writes() {
let _g = setup();
let ext = PlanMode::new();
ensure_registered();
set_enabled(true);
let write = ext.before_tool_call("write", &serde_json::json!({"path":"x","content":"y"}));
assert!(write.is_err(), "write tool blocked in plan mode");
let bad_bash = ext.before_tool_call("bash", &serde_json::json!({"command":"rm x"}));
assert!(bad_bash.is_err(), "destructive bash blocked");
let ok_bash = ext.before_tool_call("bash", &serde_json::json!({"command":"ls -la"}));
assert!(ok_bash.unwrap().is_none(), "read-only bash allowed");
let ps = ext.before_tool_call(
"powershell",
&serde_json::json!({"command":"Get-ChildItem"}),
);
assert!(ps.is_err(), "powershell 也受 plan mode 约束");
let ps_safe = ext.before_tool_call("powershell", &serde_json::json!({"command":"ls -la"}));
assert!(ps_safe.unwrap().is_none(), "白名单内命令放行");
let read = ext.before_tool_call("read", &serde_json::json!({"path":"x"}));
assert!(read.unwrap().is_none(), "other tools allowed");
set_enabled(false);
let write2 = ext.before_tool_call("write", &serde_json::json!({"path":"x","content":"y"}));
assert!(
write2.unwrap().is_none(),
"disabled plan mode: write allowed"
);
}
#[test]
fn transform_context_injects_and_cleans() {
let _g = setup();
let ext = PlanMode::new();
ensure_registered();
let mut msgs = vec![AgentMessage::user_text("hello")];
set_enabled(true);
ext.transform_context(&mut msgs).unwrap();
assert_eq!(msgs.len(), 2);
assert!(msgs[1].text().contains(PLAN_MODE_MARKER));
ext.transform_context(&mut msgs).unwrap();
assert_eq!(msgs.len(), 2, "old injection replaced, not duplicated");
{
let mut st = state().lock();
st.executing = true;
st.todos = vec![TodoItem {
step: 1,
text: "Do A".to_string(),
completed: false,
}];
}
ext.transform_context(&mut msgs).unwrap();
assert_eq!(msgs.len(), 2);
assert!(msgs[1].text().contains(EXECUTING_MARKER));
assert!(msgs[1].text().contains("1. Do A"));
set_enabled(false);
let mut st = state().lock();
st.executing = false;
st.todos.clear();
drop(st);
ext.transform_context(&mut msgs).unwrap();
assert_eq!(msgs.len(), 1);
assert!(!msgs[0].text().contains(PLAN_MODE_MARKER));
}
#[test]
fn transform_context_keeps_assistant_that_echoes_marker() {
let _g = setup();
let ext = PlanMode::new();
ensure_registered();
set_enabled(false);
{
let mut st = state().lock();
st.executing = false;
st.todos.clear();
}
let mut assistant = AgentMessage::user_text("");
assistant.role = "assistant".into();
assistant.entry_id = Some("entry-1".into());
assistant.content = vec![
crate::core::provider::ContentBlock::Text {
text: format!("continuing {EXECUTING_MARKER} now"),
text_signature: None,
},
crate::core::provider::ContentBlock::ToolCall {
id: "call_1".into(),
name: "bash".into(),
arguments: serde_json::json!({}),
thought_signature: None,
namespace: None,
},
];
let mut msgs = vec![assistant];
ext.transform_context(&mut msgs).unwrap();
assert_eq!(msgs.len(), 1, "带 tool_calls 的 assistant 不得被删");
assert_eq!(msgs[0].role, "assistant");
let injected = AgentMessage::user_text(&format!("{EXECUTING_MARKER} - Full tool access"));
msgs.push(injected);
ext.transform_context(&mut msgs).unwrap();
assert_eq!(msgs.len(), 1, "注入消息应被清理");
}
#[test]
fn turn_end_marks_done_and_auto_advances() {
let _g = setup();
let ext = PlanMode::new();
{
let mut st = state().lock();
st.enabled = false;
st.executing = true;
st.todos = vec![
TodoItem {
step: 1,
text: "A".to_string(),
completed: false,
},
TodoItem {
step: 2,
text: "B".to_string(),
completed: false,
},
];
}
ext.on_agent_event(&serde_json::json!({
"type": "turn_end",
"message": { "role": "assistant", "content": [{ "type": "text", "text": "done [DONE:1]" }] },
"toolResults": []
}));
let todos = state().lock().todos.clone();
assert!(todos[0].completed);
assert!(!todos[1].completed);
ext.on_agent_event(&serde_json::json!({
"type": "turn_end",
"message": { "role": "assistant", "content": [{ "type": "text", "text": "next" }] },
"toolResults": [{ "x": 1 }]
}));
let todos = state().lock().todos.clone();
assert!(todos[1].completed, "auto-advanced");
}
#[test]
fn agent_end_requests_select_then_execute() {
let _g = setup();
let ext = PlanMode::new();
ensure_registered();
set_enabled(true);
ext.on_agent_event(&serde_json::json!({
"type": "agent_end",
"messages": [
{ "role": "user", "content": [{ "type": "text", "text": "hi" }] },
{ "role": "assistant", "content": [{ "type": "text", "text": "Plan:\n1. Inspect code\n2. Verify result" }] }
]
}));
let (id, title, options) = loop {
match crate::core::extensions::take_pending_ui() {
Some(core::extensions::ExtensionUiRequest::Select { id, title, options }) => {
break (
id,
title,
options.into_iter().map(|o| o.text).collect::<Vec<_>>(),
);
}
Some(_) => continue,
None => panic!("expected select request"),
}
};
assert_eq!(title, "Plan mode - what next?");
assert_eq!(options.len(), 3);
{
let st = state().lock();
assert_eq!(st.todos.len(), 2);
}
let follow = ext.on_ui_choice(id, Some(options[0].clone()));
let text = follow.expect("execute follow-up");
assert!(text.starts_with("Execute the plan."));
assert!(text.contains("1. Inspect code"));
assert!(text.contains("Start with: Inspect code"));
assert!(!is_enabled(), "execute closes plan mode");
assert!(state().lock().executing);
let mut show_dock = false;
while let Some(req) = crate::core::extensions::take_pending_ui() {
if matches!(req, core::extensions::ExtensionUiRequest::ShowDock) {
show_dock = true;
}
}
assert!(show_dock, "execute 应请求显示停靠面板");
assert!(ext.on_ui_choice(id, Some("x".to_string())).is_none());
}
#[test]
fn stay_then_retoggle_agent_end_still_requests_select() {
let _g = setup();
let ext = PlanMode::new();
ensure_registered();
set_enabled(true);
let plan_event = serde_json::json!({
"type": "agent_end",
"messages": [
{ "role": "user", "content": [{ "type": "text", "text": "hi" }] },
{ "role": "assistant", "content": [{ "type": "text", "text": "Plan:\n1. Inspect code\n2. Verify result" }] }
]
});
ext.on_agent_event(&plan_event);
let id1 = loop {
match crate::core::extensions::take_pending_ui() {
Some(core::extensions::ExtensionUiRequest::Select { id, .. }) => break id,
Some(_) => continue,
None => panic!("expected first select"),
}
};
assert!(
ext.on_ui_choice(id1, Some("Stay in plan mode".to_string()))
.is_none(),
"Stay 不应触发 follow-up"
);
assert!(is_enabled());
set_enabled(false);
assert!(!is_enabled());
set_enabled(true);
assert!(is_enabled());
ext.on_agent_event(&plan_event);
let (id2, options2) = loop {
match crate::core::extensions::take_pending_ui() {
Some(core::extensions::ExtensionUiRequest::Select { id, options, .. }) => {
break (id, options.into_iter().map(|o| o.text).collect::<Vec<_>>());
}
Some(_) => continue,
None => panic!("expected second select after re-enter"),
}
};
assert_eq!(options2.len(), 3);
assert_ne!(id2, id1, "新请求应使用新 id");
while crate::core::extensions::take_pending_ui().is_some() {}
}
#[test]
fn agent_end_notify_on_completion() {
let _g = setup();
let ext = PlanMode::new();
{
let mut st = state().lock();
st.enabled = false;
st.executing = true;
st.todos = vec![TodoItem {
step: 1,
text: "A".to_string(),
completed: true,
}];
}
ext.on_agent_event(&serde_json::json!({ "type": "agent_end", "messages": [] }));
let spans = loop {
match crate::core::extensions::take_pending_ui() {
Some(core::extensions::ExtensionUiRequest::NotifyRich { spans, .. }) => {
break spans;
}
Some(_) => continue,
None => panic!("expected completion notify"),
}
};
let text: String = spans.iter().map(|s| s.text.as_str()).collect();
assert!(text.contains("Plan Complete!"));
assert!(text.contains("1. "));
assert!(text.contains("A"));
assert!(!text.contains("**"), "不得含字面粗体标记: {text:?}");
assert!(!text.contains("~~"), "不得含字面删除线标记: {text:?}");
assert_eq!(spans[0].fg.as_deref(), Some("success"));
assert_eq!(spans[2].fg.as_deref(), Some("success"));
assert_eq!(spans[3].fg.as_deref(), Some("muted"));
{
let st = state().lock();
assert!(!st.executing);
assert!(st.todos.is_empty());
}
}
#[test]
fn refine_flow_requests_input_then_merges() {
let _g = setup();
let ext = PlanMode::new();
ensure_registered();
set_enabled(true);
assert!(
ext.on_user_prompt("plain text").is_none(),
"非精炼待定不认领"
);
ext.on_agent_event(&serde_json::json!({
"type": "agent_end",
"messages": [
{ "role": "user", "content": [{ "type": "text", "text": "hi" }] },
{ "role": "assistant", "content": [{ "type": "text", "text": "Plan:\n1. Inspect code" }] }
]
}));
let select_id = loop {
match crate::core::extensions::take_pending_ui() {
Some(core::extensions::ExtensionUiRequest::Select { id, options, .. }) => {
let refine = options
.iter()
.map(|o| o.text.clone())
.find(|o| o.contains("Refine"))
.unwrap();
assert!(ext.on_ui_choice(id, Some(refine)).is_none());
break id;
}
Some(_) => continue,
None => panic!("expected select request"),
}
};
assert!(
ext.on_ui_choice(select_id.wrapping_add(1000), Some("Execute".to_string()))
.is_none()
);
let notify_text = loop {
match crate::core::extensions::take_pending_ui() {
Some(core::extensions::ExtensionUiRequest::Notify { text, .. }) => break text,
Some(_) => continue,
None => panic!("expected refine hint notify"),
}
};
assert!(notify_text.contains("refinement"));
let merged = ext.on_user_prompt("make it faster").expect("refine merge");
let core::extensions::UserPromptAction::Rewrite(merged) = merged else {
panic!("refine 应返回 Rewrite");
};
assert!(merged.contains("**Plan Steps (1):**"));
assert!(merged.contains("Refine the plan:\nmake it faster"));
assert!(ext.on_user_prompt("again").is_none());
}
#[test]
fn disable_via_extension_panel_no_deadlock() {
let _g = setup();
ensure_registered();
set_enabled(true);
assert!(is_enabled());
assert!(
crate::core::extensions::set_extension_enabled("plan-mode", false),
"面板禁用应返回成功"
);
assert!(!is_enabled(), "禁用扩展应强制关闭 plan mode");
assert!(!toggle_enabled(), "扩展禁用时 toggle 不应开启");
assert!(!is_enabled());
assert!(crate::core::extensions::set_extension_enabled(
"plan-mode",
true
));
assert!(toggle_enabled(), "重新启用后 toggle 生效");
set_enabled(false);
}
#[test]
fn agent_tools_filtered_when_plan_enabled() {
let _g = setup();
ensure_registered();
let ext = PlanMode::new();
ext.apply_cli_flag("plan", true);
assert!(is_enabled(), "apply_cli_flag 开启 plan mode");
let entry = crate::core::model_resolver::find_model("deepseek", "deepseek-flash").unwrap();
let a = crate::core::agent_session::Agent::new(
entry,
"/tmp".to_string(),
crate::core::agent_session::ToolSelection::tools(vec![
"read".to_string(),
"bash".to_string(),
"edit".to_string(),
"write".to_string(),
"grep".to_string(),
"find".to_string(),
"ls".to_string(),
]),
Vec::new(),
None,
None,
None,
Vec::new(),
None,
None,
false,
Vec::new(),
)
.unwrap();
assert!(!a.tools.iter().any(|(n, _, _)| n == "edit"));
assert!(!a.tools.iter().any(|(n, _, _)| n == "write"));
assert!(a.tools.iter().any(|(n, _, _)| n == "read"));
set_enabled(false);
let mut a = a;
a.rebuild_tools();
assert!(a.tools.iter().any(|(n, _, _)| n == "edit"));
assert!(a.tools.iter().any(|(n, _, _)| n == "write"));
}
#[test]
fn turn_end_persists_only_on_progress() {
let _g = setup();
ensure_registered();
drain_ui_requests();
let ext = PlanMode::new();
{
let mut st = state().lock();
st.executing = true;
st.todos = vec![
TodoItem {
step: 1,
text: "A".to_string(),
completed: false,
},
TodoItem {
step: 2,
text: "B".to_string(),
completed: false,
},
];
}
ext.on_agent_event(&serde_json::json!({
"type": "turn_end",
"message": { "role": "assistant", "content": [{ "type": "text", "text": "looking..." }] },
"toolResults": []
}));
assert_eq!(take_own_persists(), 0, "无进度回合不应落盘");
ext.on_agent_event(&serde_json::json!({
"type": "turn_end",
"message": { "role": "assistant", "content": [{ "type": "text", "text": "[DONE:1] done" }] },
"toolResults": []
}));
assert_eq!(take_own_persists(), 1, "步骤完成应落盘");
on_persisted(CUSTOM_TYPE);
ext.on_agent_event(&serde_json::json!({
"type": "turn_end",
"message": { "role": "assistant", "content": [{ "type": "text", "text": "next" }] },
"toolResults": [{ "x": 1 }]
}));
assert_eq!(take_own_persists(), 1, "工具调用自动完成应落盘");
on_persisted(CUSTOM_TYPE);
ext.on_agent_event(&serde_json::json!({
"type": "turn_end",
"message": { "role": "assistant", "content": [{ "type": "text", "text": "[DONE:1] again" }] },
"toolResults": []
}));
assert_eq!(take_own_persists(), 0, "重复标记已完成步骤不应落盘");
}
#[test]
fn persist_ack_does_not_echo_loop() {
let _g = setup();
ensure_registered();
{
let mut st = state().lock();
st.enabled = true;
st.executing = true;
st.todos = vec![TodoItem {
step: 1,
text: "X".to_string(),
completed: false,
}];
}
persist();
assert_eq!(take_own_persists(), 1, "首次 persist 应入队一次写入");
on_persisted(CUSTOM_TYPE);
assert_eq!(take_own_persists(), 0, "回执后不应再排队写入(回声循环)");
assert!(!state().lock().write_in_flight);
}
fn session_with_plan_entry(dir: &std::path::Path) -> Session {
let sess_dir = dir.join("sessions-pm");
let _ = std::fs::create_dir_all(&sess_dir);
let mut s = Session::create("/tmp", Some(sess_dir.clone()), true).unwrap();
s.append_custom_entry(
CUSTOM_TYPE,
Some(serde_json::json!({
"version": 1,
"enabled": true,
"executing": true,
"todos": [
{ "step": 1, "text": "Inspect", "completed": false },
{ "step": 2, "text": "Verify", "completed": false }
]
})),
);
s
}
#[test]
fn session_persist_restore_roundtrip() {
let env = setup();
let mut s = session_with_plan_entry(env.dir.path());
s.append_message(&AgentMessage::user_text("did [DONE:1]"));
let msgs = s.build_context_messages();
let ext = PlanMode::new();
ext.on_session_start("/tmp", &msgs, Some(&s));
assert!(is_enabled(), "restored from session entry");
{
let st = state().lock();
assert!(st.executing);
assert_eq!(st.todos.len(), 2);
assert!(st.todos[0].completed, "历史 [DONE:1] 重扫修正");
assert!(!st.todos[1].completed, "未完成步骤保留");
assert!(st.dock_shown, "resume 自动显示停靠面板");
}
assert!(
{
let mut dock = false;
while let Some(req) = crate::core::extensions::take_pending_ui() {
if matches!(req, core::extensions::ExtensionUiRequest::ShowDock) {
dock = true;
}
}
dock
},
"应有 ShowDock 请求"
);
*state().lock() = State::default();
}
#[test]
fn session_restore_falls_back_to_history_plan() {
let env = setup();
let sess_dir = env.dir.path().join("sessions-pm");
let _ = std::fs::create_dir_all(&sess_dir);
let s = Session::create("/tmp", Some(sess_dir.clone()), true).unwrap();
let msgs = vec![
AgentMessage::user_text("analyze"),
serde_json::from_value::<AgentMessage>(serde_json::json!({
"role": "assistant",
"content": [{ "type": "text", "text": "Plan:\n1. Inspect code\n2. Verify result" }]
}))
.unwrap(),
AgentMessage::user_text("[DONE:1]"),
];
let ext = PlanMode::new();
ext.on_session_start("/tmp", &msgs, Some(&s));
{
let st = state().lock();
assert!(st.executing, "历史兜底视为执行中");
assert!(!st.enabled);
assert_eq!(st.todos.len(), 2);
assert!(st.todos[0].completed, "[DONE:1] 重扫");
assert!(!st.todos[1].completed);
assert!(st.dock_shown, "未完成 todo 自动显示");
}
while crate::core::extensions::take_pending_ui().is_some() {}
*state().lock() = State::default();
}
#[test]
fn fresh_session_keeps_cli_plan_flag() {
let env = setup();
let sess_dir = env.dir.path().join("sessions-pm");
let _ = std::fs::create_dir_all(&sess_dir);
let s = Session::create("/tmp", Some(sess_dir.clone()), true).unwrap();
ensure_registered();
set_enabled(true); let ext = PlanMode::new();
ext.on_session_start("/tmp", &[], Some(&s));
assert!(is_enabled(), "--plan 开启的会话不得被恢复逻辑关闭");
set_enabled(false);
ext.on_session_start("/tmp", &[], Some(&s));
assert!(!is_enabled(), "普通全新会话保持关闭");
while crate::core::extensions::take_pending_ui().is_some() {}
*state().lock() = State::default();
}
#[test]
fn session_switch_restores_or_clears() {
let env = setup();
let ext = PlanMode::new();
set_enabled(true);
{
let mut st = state().lock();
st.todos = vec![TodoItem {
step: 1,
text: "X".to_string(),
completed: false,
}];
}
ext.on_session_switched(None, &[]);
assert!(!is_enabled());
assert!(state().lock().todos.is_empty());
assert!(!is_dock_shown());
let s = session_with_plan_entry(env.dir.path());
let file = s.get_session_file().unwrap().to_string_lossy().to_string();
let msgs = s.build_context_messages();
ext.on_session_switched(Some(&file), &msgs);
assert!(is_enabled());
assert_eq!(state().lock().todos.len(), 2);
assert!(is_dock_shown());
while crate::core::extensions::take_pending_ui().is_some() {}
*state().lock() = State::default();
}
#[test]
fn cli_flag_and_keybinding_declared() {
let _g = setup();
ensure_registered();
let ext = PlanMode::new();
let flags = ext.cli_flags();
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].name, "plan");
assert!(!flags[0].description.is_empty());
assert!(crate::core::extensions::set_extension_enabled(
"plan-mode",
false
));
ext.apply_cli_flag("plan", true);
assert!(!is_enabled(), "扩展禁用时 apply_cli_flag 不应开启");
crate::core::extensions::set_extension_enabled("plan-mode", true);
ext.apply_cli_flag("plan", true);
assert!(is_enabled());
set_enabled(false);
let kbs = ext.keybindings();
assert_eq!(kbs.len(), 1);
assert_eq!(kbs[0].id, "plan-mode.toggle");
assert!(kbs[0].keys.contains(&"alt+p".to_string()));
}
#[test]
fn interest_predicate_tracks_state() {
let _g = setup();
assert!(
PlanMode::new().hooks().is_empty(),
"空闲状态不应订阅任何回调:{:?}",
PlanMode::new().hooks()
);
{
let mut st = state().lock();
st.enabled = true;
}
let hooks = PlanMode::new().hooks();
assert!(hooks.contains(&ExtensionHook::TransformContext));
assert!(hooks.contains(&ExtensionHook::BeforeToolCall));
assert!(hooks.contains(&ExtensionHook::AgentEvent));
assert!(
!hooks.contains(&ExtensionHook::Dock),
"未显示停靠段不应订阅 Dock"
);
{
let mut st = state().lock();
st.enabled = false;
st.executing = true;
st.todos = vec![TodoItem {
step: 1,
text: "A".to_string(),
completed: false,
}];
st.dock_shown = true;
}
let hooks = PlanMode::new().hooks();
assert!(hooks.contains(&ExtensionHook::TransformContext));
assert!(
!hooks.contains(&ExtensionHook::BeforeToolCall),
"执行模式恢复完整工具"
);
assert!(hooks.contains(&ExtensionHook::AgentEvent));
assert!(hooks.contains(&ExtensionHook::Dock));
}
#[test]
fn interest_cleanup_is_one_shot_after_disable() {
let _g = setup();
let ext = PlanMode::new();
ensure_registered();
set_enabled(true);
assert!(ext.hooks().contains(&ExtensionHook::TransformContext));
set_enabled(false);
assert!(
state().lock().cleanup_pending,
"关闭 plan 模式应置一次性清理标志"
);
assert!(
ext.hooks().contains(&ExtensionHook::TransformContext),
"清理回合仍需订阅 TransformContext"
);
let mut msgs = vec![AgentMessage::user_text("hello")];
ext.transform_context(&mut msgs).unwrap();
assert!(!state().lock().cleanup_pending, "清理后应清位");
assert!(
!ext.hooks().contains(&ExtensionHook::TransformContext),
"清理回合结束后应退订 TransformContext:{:?}",
ext.hooks()
);
assert!(state().lock().write_in_flight, "关闭触发落盘 → 在途");
assert!(ext.hooks().contains(&ExtensionHook::AgentEvent));
while state().lock().write_in_flight {
on_persisted(CUSTOM_TYPE);
}
assert!(
ext.hooks().is_empty(),
"回执排空后彻底空闲:{:?}",
ext.hooks()
);
}
#[test]
fn interest_keeps_agent_event_while_persist_in_flight() {
let _g = setup();
persist();
assert!(state().lock().write_in_flight);
assert!(
PlanMode::new().hooks().contains(&ExtensionHook::AgentEvent),
"在途持久化必须订阅 AgentEvent"
);
on_persisted(CUSTOM_TYPE);
assert!(!state().lock().write_in_flight);
assert!(
PlanMode::new().hooks().is_empty(),
"回执后无其它状态应回到空闲:{:?}",
PlanMode::new().hooks()
);
}
#[test]
fn interest_dock_requires_todos() {
let _g = setup();
{
let mut st = state().lock();
st.dock_shown = true; }
assert!(!PlanMode::new().hooks().contains(&ExtensionHook::Dock));
{
let mut st = state().lock();
st.todos = vec![TodoItem {
step: 1,
text: "A".to_string(),
completed: false,
}];
}
assert!(PlanMode::new().hooks().contains(&ExtensionHook::Dock));
}
}