//! Plan mode state machine and prompt text generation. //! //! This module contains the [`PlanModeTracker`] struct that manages //! the full plan mode lifecycle for a session. It is designed to be //! testable in isolation — no references to `SessionActor`, conversation //! history, or async I/O. Pure state machine logic. //! //! The `SessionActor` owns one `PlanModeTracker` (behind a `Mutex`) and //! calls its methods at the appropriate points (`handle_session_mode`, //! `handle_prompt`, `handle_completion`, `run_compact`). use std::path::{Path, PathBuf}; /// Tracks plan mode lifecycle on the SessionActor. /// /// Lives alongside `session_yolo_mode` and `active_agent_type` — /// it is session-scoped mutable state, not part of AgentDefinition. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum PlanModeState { /// Normal operating mode. No plan mode constraints. Inactive, /// Client toggled plan mode ON, but no prompt has been sent yet. /// The model does not know about plan mode yet. No tool call has /// been made, no system-reminder injected. /// /// Transitions: /// -> Active (first user prompt triggers injection) /// -> Inactive (client toggles off before any prompt) Pending, /// Plan mode is active. The model has received plan mode instructions /// (either via system-reminder injection or via EnterPlanMode tool result). /// Write tools are blocked except for the plan file. /// /// Transitions: /// -> Inactive (ExitPlanMode approved, or user toggles off when idle) /// -> ExitPending (user toggles off while a turn is in-flight) Active, /// Client toggled plan mode OFF while Active and a model turn is /// in-flight. We need to wait for the current turn to finish (or /// cancel it), then cleanly exit. /// /// Transitions: /// -> Inactive (after turn completes, exit attachment injected) ExitPending, } /// Tracks the full plan mode lifecycle for a session. See the module docs /// for the ownership and testing contract. pub struct PlanModeTracker { /// Current state in the lifecycle. state: PlanModeState, /// Whether plan mode has already been active earlier in this session. /// Used for reentry detection — if true and we enter Active again, /// inject the reentry reminder instead of the standard one. was_previously_active: bool, /// Counter for full/sparse reminder alternation. /// Even = full reminder, odd = sparse. Reset on compaction. reminder_count: u32, /// Flag: inject a plan_mode_exit reminder on the next turn. /// Set only when the model has no in-context exit signal: user-initiated /// exits (toggle) and exits armed via [`Self::queue_exit_reminder`]. pending_exit_reminder: bool, /// `exit_plan_mode` approval UI is outstanding (client has not answered). /// Persisted so resume can restore approval chrome. awaiting_plan_approval: bool, /// Rendered activation reminder buffered by a mid-turn toggle /// ([`Self::activate_mid_turn`]), awaiting delivery at the running turn's /// next safe drain point. While set, the model has NOT seen plan mode yet: /// a toggle-off withdraws it and rolls the activation back instead of /// deferring an exit the model never knew about. Not persisted — a restart /// loses the buffer, and the next turn's Active-state injection covers it. pending_activation: Option, /// Absolute path to the plan file on disk. /// Lives inside the session directory: /// `~/.kigi/sessions///plan.md` plan_file_path: PathBuf, } /// A buffered mid-turn activation reminder plus the state needed to roll the /// activation back if it is withdrawn before delivery. struct PendingActivation { /// Pre-wrapped `` text, ready to push verbatim. text: String, /// `was_previously_active` before this activation, restored on withdrawal /// so a rolled-back activation doesn't fake a reentry. prior_was_previously_active: bool, } /// Serializable snapshot of plan mode lifecycle state. /// /// Persisted to `plan_mode.json` in the session directory and restored on /// session reload/resume so plan mode survives process restarts. /// The `plan_file_path` is NOT persisted — it is recomputed from session metadata. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct PlanModeSnapshot { pub state: PlanModeState, pub was_previously_active: bool, pub reminder_count: u32, pub pending_exit_reminder: bool, /// Client was shown `exit_plan_mode` approval but has not answered yet. /// Survives process restart so the pager can restore approval chrome /// without treating every Active+plan.md session as pending. #[serde(default)] pub awaiting_plan_approval: bool, } impl PlanModeTracker { /// Create a new tracker. `session_dir` is the session's storage /// directory (e.g., `~/.kigi/sessions///`). pub fn new(session_dir: PathBuf) -> Self { Self { state: PlanModeState::Inactive, was_previously_active: false, reminder_count: 0, pending_exit_reminder: false, awaiting_plan_approval: false, pending_activation: None, plan_file_path: session_dir.join("plan.md"), } } /// Restore a tracker from a persisted snapshot. /// /// `session_dir` is used to recompute `plan_file_path`. /// If the snapshot has a transient state (`Pending` or `ExitPending`), /// it is collapsed: `Pending` → `Inactive`, `ExitPending` → `Inactive` /// (with exit reminder set), since those states depend on in-flight /// client/turn interactions that don't survive a restart. pub fn from_snapshot(session_dir: PathBuf, mut snapshot: PlanModeSnapshot) -> Self { match snapshot.state { PlanModeState::Pending => { snapshot.state = PlanModeState::Inactive; } PlanModeState::ExitPending => { snapshot.state = PlanModeState::Inactive; snapshot.pending_exit_reminder = true; } _ => {} } Self { state: snapshot.state, was_previously_active: snapshot.was_previously_active, reminder_count: snapshot.reminder_count, pending_exit_reminder: snapshot.pending_exit_reminder, awaiting_plan_approval: snapshot.awaiting_plan_approval, pending_activation: None, plan_file_path: session_dir.join("plan.md"), } } /// Mark that the client is waiting on plan approval (`exit_plan_mode` parked). pub fn set_awaiting_plan_approval(&mut self, awaiting: bool) { self.awaiting_plan_approval = awaiting; } /// Whether approval is outstanding (also true after resume from snapshot). pub fn is_awaiting_plan_approval(&self) -> bool { self.awaiting_plan_approval } /// Capture the current lifecycle state as a persistable snapshot. pub fn snapshot(&self) -> PlanModeSnapshot { PlanModeSnapshot { state: self.state, was_previously_active: self.was_previously_active, awaiting_plan_approval: self.awaiting_plan_approval, reminder_count: self.reminder_count, pending_exit_reminder: self.pending_exit_reminder, } } pub fn state(&self) -> PlanModeState { self.state } pub fn is_active(&self) -> bool { self.state == PlanModeState::Active } pub fn plan_file_path(&self) -> &Path { &self.plan_file_path } /// Returns `true` if plan mode is active and the given edit path /// targets the plan file. Used to bypass the permission prompt for /// plan file edits during plan mode. pub fn should_auto_approve_edit(&self, edit_path: &Path) -> bool { self.is_active() && is_plan_file_write(edit_path, &self.plan_file_path) } /// Whether the next reminder should be the full variant. /// Even count = full, odd count = sparse. pub fn should_use_full_reminder(&self) -> bool { self.reminder_count.is_multiple_of(2) } pub fn has_pending_exit_reminder(&self) -> bool { self.pending_exit_reminder } /// Whether this is a reentry (plan mode was active earlier in this session). pub fn is_reentry(&self) -> bool { self.was_previously_active && self.state == PlanModeState::Pending } /// Client toggled plan mode ON. /// /// Returns true if state actually changed. Handles re-entry from /// `ExitPending` by cancelling the deferred exit and returning /// directly to `Active` (the model already has plan mode context). pub fn enter_pending(&mut self) -> bool { match self.state { PlanModeState::Inactive => { self.state = PlanModeState::Pending; self.pending_exit_reminder = false; true } PlanModeState::ExitPending => { self.state = PlanModeState::Active; self.pending_exit_reminder = false; true } _ => false, } } /// First user prompt while Pending — activate plan mode. /// Returns true if state actually changed. pub fn activate(&mut self) -> bool { if self.state != PlanModeState::Pending { return false; } self.state = PlanModeState::Active; self.was_previously_active = true; self.reminder_count = 0; true } /// Mid-turn toggle: activate immediately and buffer the pre-rendered /// activation reminder for delivery at the running turn's next safe /// drain point. Only valid from `Pending` (an `ExitPending → Active` /// re-entry needs no reminder). Returns true if activated. /// /// The reminder is recorded (alternation counter) at delivery /// ([`Self::take_pending_activation`]), not here, so a withdrawn or /// restart-lost buffer doesn't advance the full/sparse cycle. pub fn activate_mid_turn(&mut self, rendered_reminder: String) -> bool { if self.state != PlanModeState::Pending { return false; } let prior_was_previously_active = self.was_previously_active; self.state = PlanModeState::Active; self.was_previously_active = true; self.reminder_count = 0; self.pending_activation = Some(PendingActivation { text: rendered_reminder, prior_was_previously_active, }); true } /// Take the buffered mid-turn activation reminder for delivery. /// The caller pushes it into the conversation and then calls /// [`Self::record_reminder_injected`]. pub fn take_pending_activation(&mut self) -> Option { self.pending_activation.take().map(|p| p.text) } pub fn has_pending_activation(&self) -> bool { self.pending_activation.is_some() } /// Agent called EnterPlanMode tool \u{2014} go directly to Active. /// Returns true if state actually changed. pub fn activate_from_tool(&mut self) -> bool { if self.state != PlanModeState::Inactive { return false; } self.state = PlanModeState::Active; self.was_previously_active = true; self.reminder_count = 0; self.pending_exit_reminder = false; true } /// ExitPlanMode approved (agent-initiated exit). /// Returns true if state actually changed. /// /// Does NOT set `pending_exit_reminder`: callers must ensure the model gets /// an in-context exit signal — either by pushing a tool result that states /// the exit, or by explicitly arming [`Self::queue_exit_reminder`] when the /// result text carries no such signal. A reminder armed here would only /// drain at the next turn start, arriving a turn late and stale. pub fn deactivate_approved(&mut self) -> bool { if self.state != PlanModeState::Active { return false; } self.state = PlanModeState::Inactive; self.reminder_count = 0; self.awaiting_plan_approval = false; self.pending_activation = None; true } /// Client toggled plan mode OFF. /// `turn_in_flight`: whether a model turn is currently running. pub fn user_exit(&mut self, turn_in_flight: bool) { self.awaiting_plan_approval = false; if let Some(pending) = self.pending_activation.take() && self.state == PlanModeState::Active { self.state = PlanModeState::Inactive; self.was_previously_active = pending.prior_was_previously_active; return; } match self.state { PlanModeState::Pending => { self.state = PlanModeState::Inactive; } PlanModeState::Active => { if turn_in_flight { self.state = PlanModeState::ExitPending; } else { self.state = PlanModeState::Inactive; self.pending_exit_reminder = true; } } _ => {} } } /// Current turn completed while in ExitPending. pub fn complete_deferred_exit(&mut self) { if self.state != PlanModeState::ExitPending { return; } self.state = PlanModeState::Inactive; self.pending_exit_reminder = true; } /// Arm the one-shot exit reminder for the next turn. /// /// For exit paths whose tool result carries no exit signal (the compat /// harness — policy and rationale live on the bridge's /// `queue_exit_reminder_on_approved_exit` flag). pub fn queue_exit_reminder(&mut self) { self.pending_exit_reminder = true; } /// Called after injecting a per-turn reminder. Advances the counter. pub fn record_reminder_injected(&mut self) { self.reminder_count += 1; } /// Called after injecting the exit reminder. Clears the flag. pub fn clear_pending_exit_reminder(&mut self) { self.pending_exit_reminder = false; } /// Called after compaction. Resets reminder counter so next /// injection is the full variant. pub fn reset_after_compaction(&mut self) { if self.state == PlanModeState::Active { self.reminder_count = 0; self.pending_activation = None; } } } /// Full plan mode reminder template (plan-file write rules + turn-ending tools). /// /// Returns a MiniJinja template string with `${{ tools.by_kind.X }}` and /// `${{ plan_path }}` / `${{ plan_has_content }}` placeholders. The caller must /// render it via `TemplateRenderer::render_with_extra()` passing: /// /// ```json /// { "plan_path": "/path/to/plan.md", "plan_has_content": true } /// ``` /// /// Tool name placeholders (`${{ tools.by_kind.edit }}`, etc.) are resolved /// automatically from the registry's `ToolKind` \u{2192} client-facing name mapping. pub fn plan_mode_reminder_full_template() -> &'static str { "\ Plan mode is active. Do not make any edits or writes to the system. ## Plan File: ${%- if plan_has_content %} A plan file exists at ${{ plan_path }}. \ You can read it and make edits using the ${{ tools.by_kind.edit }} tool. ${%- else %} No plan written yet. Write your plan to ${{ plan_path }} \ using the ${{ tools.by_kind.edit }} tool. ${%- endif %} You should build your plan by writing to or editing this file. \ Note that this is the only file you are allowed to edit. Your turn should only end with either ${{ tools.by_kind.ask_user }} to clarify \ requirements or ${{ tools.by_kind.exit_plan }} to present your plan to the user." } /// Sparse plan mode reminder template. /// /// Static string for alternating turns (when `reminder_count` is odd) to save /// tokens. No MiniJinja placeholders — plan path and tool names are only in the /// full reminder. pub fn plan_mode_reminder_sparse_template() -> &'static str { "Plan mode is still active. Do not make any edits or writes to the system except for the plan file." } /// Reentry reminder template. /// /// Returns a MiniJinja template string injected when entering plan mode for /// the second+ time in the same session. Render via /// `TemplateRenderer::render_with_extra()` with `{ "plan_path": "..." }`. pub fn plan_mode_reentry_reminder_template() -> &'static str { "\ ## Returning to Plan Mode You are entering plan mode again after having previously exited it. \ A plan file exists at ${{ plan_path }} from your previous planning session. Your turn should only end with either ${{ tools.by_kind.ask_user }} to clarify requirements or ${{ tools.by_kind.exit_plan }} to present your plan to the user." } /// Rejection message for an edit outside the plan file while plan mode is /// active. Returned as the tool result so the model knows the only editable /// path. /// /// Render via `TemplateRenderer::render_with_extra()` with /// `{ "plan_path": "..." }`. pub fn plan_mode_edit_rejected_template() -> &'static str { "Rejected: file edits are not allowed in plan mode - the only editable file is the plan file (${{ plan_path }})." } /// Exit reminder template. /// /// Returns a MiniJinja template string injected once after exiting plan mode /// (user-initiated exit via toggle). Contains no placeholders. pub fn plan_mode_exit_reminder_template() -> &'static str { "\ You have exited plan mode. You can now make edits, run tools, and take actions." } /// Check if a write target matches the plan file. /// /// `target_path` is the absolute path the tool is trying to write to. /// `plan_file` is the absolute path from [`PlanModeTracker::plan_file_path`]. pub fn is_plan_file_write(target_path: &Path, plan_file: &Path) -> bool { target_path == plan_file } /// Whether the path's final component ends with a markdown suffix (case-insensitive). /// /// Suffixes align with client / workspace `MARKDOWN_SUFFIXES`: /// `.md`, `.markdown`, `.mdown`, `.mkd`, `.mkdn`, `.mdx`. /// /// In plan mode the shell rejects `Write` and `StrReplace` when this is /// false while plan mode is active (see `prepare_tool_call` in `acp_session.rs`). pub fn is_markdown_file_path(path: &Path) -> bool { const MARKDOWN_SUFFIXES: &[&str] = &[".md", ".markdown", ".mdown", ".mkd", ".mkdn", ".mdx"]; let Some(name) = path.file_name().and_then(|n| n.to_str()) else { return false; }; let bytes = name.as_bytes(); MARKDOWN_SUFFIXES.iter().any(|suffix| { let suffix = suffix.as_bytes(); bytes.len() >= suffix.len() && bytes[bytes.len() - suffix.len()..].eq_ignore_ascii_case(suffix) }) } /// True if a plan file exists at `path` with non-zero size. An empty /// pre-seeded plan file (created by enter_plan_mode) reports false so the /// reminder still tells the model to write its plan. /// /// Divergence: uses `metadata().len() > 0` (cheap per-turn stat), so a /// whitespace-only file counts as content here whereas `exit_plan_mode` trims /// and treats it as empty; harmless because the seed is always `b""`. pub(crate) async fn plan_file_has_content(path: &std::path::Path) -> bool { tokio::fs::metadata(path) .await .map(|m| m.len() > 0) .unwrap_or(false) } /// The prompt mode sent by the client in `_meta.mode`. /// /// Determines whether the prompt expects tool use / file edits (`Agent`) or /// is read-only (`Ask` / `Plan`). Used to decide whether a forked session /// needs worktrees or can run in read-only mode. #[derive( Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize, strum::Display, )] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum PromptMode { /// Full agent with tool use and file edits. #[default] Agent, /// Question-answering only, no tool use. Ask, /// Planning/reasoning only, no tool use. Plan, } impl PromptMode { /// Parse from the `_meta.mode` string. Unknown values default to `Agent`. pub fn from_meta_str(s: &str) -> Self { match s { "ask" => Self::Ask, "plan" => Self::Plan, _ => Self::Agent, } } /// Whether this mode is read-only (no file mutations expected). pub fn is_read_only(self) -> bool { matches!(self, Self::Ask | Self::Plan) } } #[cfg(test)] mod tests { use super::*; fn test_tracker() -> PlanModeTracker { PlanModeTracker::new(PathBuf::from("/tmp/test-session")) } #[test] fn user_initiated_lifecycle() { let mut t = test_tracker(); assert_eq!(t.state(), PlanModeState::Inactive); assert!(t.enter_pending()); assert_eq!(t.state(), PlanModeState::Pending); assert!(t.activate()); assert_eq!(t.state(), PlanModeState::Active); assert!(t.deactivate_approved()); assert_eq!(t.state(), PlanModeState::Inactive); } #[test] fn user_exit_while_turn_in_flight() { let mut t = test_tracker(); t.enter_pending(); t.activate(); t.user_exit(true); assert_eq!(t.state(), PlanModeState::ExitPending); t.complete_deferred_exit(); assert_eq!(t.state(), PlanModeState::Inactive); assert!(t.has_pending_exit_reminder()); } #[test] fn pending_cancel_is_clean() { let mut t = test_tracker(); t.enter_pending(); t.user_exit(false); assert_eq!(t.state(), PlanModeState::Inactive); assert!(!t.has_pending_exit_reminder()); } #[test] fn agent_initiated_skips_pending() { let mut t = test_tracker(); assert!(t.activate_from_tool()); assert_eq!(t.state(), PlanModeState::Active); } #[test] fn reentry_detected() { let mut t = test_tracker(); t.enter_pending(); t.activate(); t.deactivate_approved(); t.enter_pending(); assert!(t.is_reentry()); } #[test] fn reminder_alternation() { let mut t = test_tracker(); t.enter_pending(); t.activate(); assert!(t.should_use_full_reminder()); t.record_reminder_injected(); assert!(!t.should_use_full_reminder()); t.record_reminder_injected(); assert!(t.should_use_full_reminder()); } #[test] fn plan_file_in_session_dir() { let t = PlanModeTracker::new(PathBuf::from("/home/user/.kigi/sessions/proj/abc-123")); assert_eq!( t.plan_file_path(), Path::new("/home/user/.kigi/sessions/proj/abc-123/plan.md") ); } #[test] fn compaction_resets_to_full_reminder() { let mut t = test_tracker(); t.enter_pending(); t.activate(); t.record_reminder_injected(); t.reset_after_compaction(); assert!(t.should_use_full_reminder()); } #[test] fn midturn_activation_buffers_and_delivers_exactly_once() { let mut t = test_tracker(); t.enter_pending(); assert!(t.activate_mid_turn("reminder text".into())); assert_eq!(t.state(), PlanModeState::Active); assert!(t.has_pending_activation()); assert!(t.should_use_full_reminder()); assert_eq!( t.take_pending_activation().as_deref(), Some("reminder text") ); assert!(!t.has_pending_activation()); t.record_reminder_injected(); assert!(!t.should_use_full_reminder()); assert_eq!(t.take_pending_activation(), None); assert_eq!(t.take_pending_activation(), None); } #[test] fn midturn_activation_requires_pending() { let mut t = test_tracker(); assert!(!t.activate_mid_turn("x".into())); t.enter_pending(); t.activate(); assert!(!t.activate_mid_turn("dup".into())); assert!(!t.has_pending_activation()); t.user_exit(true); assert!(!t.activate_mid_turn("x".into())); assert!(!t.has_pending_activation()); } #[test] fn user_exit_withdraws_undelivered_activation() { let mut t = test_tracker(); t.enter_pending(); t.activate_mid_turn("reminder text".into()); t.user_exit(true); assert_eq!(t.state(), PlanModeState::Inactive); assert!(!t.has_pending_activation()); assert!(!t.has_pending_exit_reminder()); t.enter_pending(); assert!(!t.is_reentry()); } #[test] fn user_exit_after_delivery_defers_exit_normally() { let mut t = test_tracker(); t.enter_pending(); t.activate_mid_turn("reminder text".into()); t.take_pending_activation(); t.record_reminder_injected(); t.user_exit(true); assert_eq!(t.state(), PlanModeState::ExitPending); } #[test] fn withdrawal_preserves_real_reentry_flag() { let mut t = test_tracker(); t.enter_pending(); t.activate(); t.deactivate_approved(); t.enter_pending(); t.activate_mid_turn("reminder text".into()); t.user_exit(true); t.enter_pending(); assert!(t.is_reentry()); } #[test] fn compaction_drops_undelivered_activation() { let mut t = test_tracker(); t.enter_pending(); t.activate_mid_turn("reminder text".into()); t.reset_after_compaction(); assert!(!t.has_pending_activation()); assert_eq!(t.state(), PlanModeState::Active); } use kigi_tools::types::template_renderer::TemplateRenderer; use kigi_tools::types::tool::ToolKind; use std::collections::HashMap; /// Build a test TemplateRenderer with standard Kigi tool mappings. fn test_renderer() -> TemplateRenderer { let tools: HashMap = [ (ToolKind::Edit, "search_replace".to_owned()), (ToolKind::Read, "read_file".to_owned()), (ToolKind::List, "list_dir".to_owned()), (ToolKind::Search, "grep".to_owned()), (ToolKind::AskUser, "ask_user_question".to_owned()), (ToolKind::ExitPlan, "exit_plan_mode".to_owned()), ] .into(); TemplateRenderer::new(tools, HashMap::new()) } /// Build a test TemplateRenderer that includes the Task tool. fn test_renderer_with_task() -> TemplateRenderer { let tools: HashMap = [ (ToolKind::Edit, "search_replace".to_owned()), (ToolKind::Read, "read_file".to_owned()), (ToolKind::List, "list_dir".to_owned()), (ToolKind::Search, "grep".to_owned()), (ToolKind::AskUser, "ask_user_question".to_owned()), (ToolKind::ExitPlan, "exit_plan_mode".to_owned()), (ToolKind::Task, "task".to_owned()), ] .into(); TemplateRenderer::new(tools, HashMap::new()) } /// Build a TemplateRenderer with custom (non-default) tool names. fn custom_renderer() -> TemplateRenderer { let tools: HashMap = [ (ToolKind::Edit, "EditFile".to_owned()), (ToolKind::Read, "ReadFile".to_owned()), (ToolKind::List, "ListFiles".to_owned()), (ToolKind::Search, "SearchContent".to_owned()), (ToolKind::AskUser, "AskUser".to_owned()), (ToolKind::ExitPlan, "FinishPlan".to_owned()), ] .into(); TemplateRenderer::new(tools, HashMap::new()) } fn render( renderer: &TemplateRenderer, template: &str, plan_path: &str, plan_has_content: bool, ) -> String { let extra = serde_json::json!( { "plan_path" : plan_path, "plan_has_content" : plan_has_content, } ); renderer.render_with_extra(template, &extra).unwrap() } #[test] fn full_reminder_with_existing_plan() { let r = test_renderer(); let text = render( &r, plan_mode_reminder_full_template(), "/tmp/session/plan.md", true, ); assert!(text.contains("A plan file exists at /tmp/session/plan.md")); assert!(text.contains("search_replace tool")); assert!(text.contains("Plan mode is active")); assert!(text.contains("## Plan File:")); assert!(text.contains("only file you are allowed to edit")); assert!(!text.contains("No plan written yet")); } #[test] fn full_reminder_without_plan() { let r = test_renderer(); let text = render( &r, plan_mode_reminder_full_template(), "/tmp/session/plan.md", false, ); assert!(text.contains("No plan written yet")); assert!(text.contains("/tmp/session/plan.md")); assert!(text.contains("search_replace tool")); assert!(text.contains("Plan mode is active")); assert!(!text.contains("A plan file exists at")); } #[test] fn full_reminder_resolves_all_tool_names() { let r = test_renderer(); let text = render(&r, plan_mode_reminder_full_template(), "/tmp/plan.md", true); assert!(text.contains("search_replace tool")); assert!(text.contains("ask_user_question to clarify requirements")); assert!(text.contains("exit_plan_mode to present your plan to the user")); assert!( !text.contains("${{"), "unresolved template placeholder found" ); } #[test] fn full_reminder_with_custom_tool_names() { let r = custom_renderer(); let text = render(&r, plan_mode_reminder_full_template(), "/tmp/plan.md", true); assert!(text.contains("EditFile tool")); assert!(text.contains("AskUser to clarify requirements")); assert!(text.contains("FinishPlan to present your plan to the user")); assert!(!text.contains("search_replace")); assert!(!text.contains("ask_user_question")); assert!(!text.contains("exit_plan_mode")); } #[test] fn full_reminder_has_no_subagent_guidance() { let r = test_renderer_with_task(); let text = render(&r, plan_mode_reminder_full_template(), "/tmp/plan.md", true); assert!( !text.contains("subagent_type"), "full reminder should not include subagent guidance: {text}" ); let r = test_renderer(); let text = render(&r, plan_mode_reminder_full_template(), "/tmp/plan.md", true); assert!(!text.contains("subagent_type")); } #[test] fn full_reminder_has_no_phase_workflow() { let r = test_renderer(); let text = render(&r, plan_mode_reminder_full_template(), "/tmp/plan.md", true); assert!(!text.contains("Phase 1:")); assert!(!text.contains("Plan Workflow")); assert!(!text.contains("Iterative Planning Workflow")); assert!(!text.contains("The Loop")); } #[test] fn sparse_reminder_is_static_read_only_nudge() { let r = test_renderer(); let text = render( &r, plan_mode_reminder_sparse_template(), "/tmp/plan.md", false, ); assert_eq!( text, "Plan mode is still active. Do not make any edits or writes to the system except for the plan file." ); assert!(!text.contains("/tmp/plan.md")); assert!(!text.contains("exit_plan_mode")); assert!(!text.contains("${{")); } #[test] fn sparse_reminder_ignores_custom_tool_names() { let r = custom_renderer(); let text = render( &r, plan_mode_reminder_sparse_template(), "/tmp/plan.md", false, ); assert!(!text.contains("AskUser")); assert!(!text.contains("FinishPlan")); assert!(text.contains("Plan mode is still active")); } #[test] fn reentry_reminder_renders() { let r = test_renderer(); let text = render( &r, plan_mode_reentry_reminder_template(), "/tmp/plan.md", false, ); assert!(text.contains("Returning to Plan Mode")); assert!(text.contains("/tmp/plan.md")); assert!(text.contains("entering plan mode again")); assert!(text.contains("exit_plan_mode")); assert!(text.contains("ask_user_question")); assert!(!text.contains("${{")); } #[test] fn reentry_reminder_with_custom_names() { let r = custom_renderer(); let text = render( &r, plan_mode_reentry_reminder_template(), "/tmp/plan.md", false, ); assert!(text.contains("FinishPlan to present your plan to the user")); assert!(text.contains("AskUser to clarify requirements")); assert!(!text.contains("exit_plan_mode")); assert!(!text.contains("ask_user_question")); } #[test] fn exit_reminder_renders() { let r = test_renderer(); let text = render( &r, plan_mode_exit_reminder_template(), "/tmp/plan.md", false, ); assert_eq!( text, "You have exited plan mode. You can now make edits, run tools, and take actions." ); assert!(!text.contains("/tmp/plan.md")); assert!(!text.contains("/implement")); assert!(!text.contains("${{")); } #[test] fn edit_rejected_template_renders() { let r = test_renderer(); let text = render( &r, plan_mode_edit_rejected_template(), "/tmp/session/plan.md", false, ); assert_eq!( text, "Rejected: file edits are not allowed in plan mode - the only editable file is the plan file (/tmp/session/plan.md)." ); } #[test] fn templates_are_static_with_no_hardcoded_tool_names() { let hardcoded_names = [ "search_replace", "read_file", "list_dir", "grep", "ask_user_question", "exit_plan_mode", ]; let templates = [ plan_mode_reminder_full_template(), plan_mode_reminder_sparse_template(), plan_mode_reentry_reminder_template(), plan_mode_exit_reminder_template(), plan_mode_edit_rejected_template(), ]; for template in &templates { for name in &hardcoded_names { assert!( !template.contains(name), "template contains hardcoded tool name '{name}': {template:.80}..." ); } } } #[test] fn is_plan_file_write_exact_match() { let plan = Path::new("/home/user/.kigi/sessions/proj/abc/plan.md"); let target = Path::new("/home/user/.kigi/sessions/proj/abc/plan.md"); assert!(is_plan_file_write(target, plan)); } #[test] fn is_plan_file_write_different_path() { let plan = Path::new("/home/user/.kigi/sessions/proj/abc/plan.md"); let target = Path::new("/home/user/project/src/main.rs"); assert!(!is_plan_file_write(target, plan)); } #[test] fn is_markdown_file_path_recognizes_extensions() { assert!(is_markdown_file_path(Path::new("/x/plan.md"))); assert!(is_markdown_file_path(Path::new("notes.MDX"))); assert!(is_markdown_file_path(Path::new("readme.markdown"))); assert!(is_markdown_file_path(Path::new("/a/guide.mdown"))); assert!(is_markdown_file_path(Path::new("x.mkd"))); assert!(is_markdown_file_path(Path::new("x.MKDN"))); assert!(!is_markdown_file_path(Path::new("/src/lib.rs"))); assert!(!is_markdown_file_path(Path::new("/no-extension"))); assert!(!is_markdown_file_path(Path::new("/src/notmd.rs"))); assert!(!is_markdown_file_path(Path::new("企业AI决策清单.html"))); assert!(is_markdown_file_path(Path::new("企业AI决策清单.md"))); assert!(is_markdown_file_path(Path::new("计划.markdown"))); assert!(!is_markdown_file_path(Path::new("md"))); assert!(!is_markdown_file_path(Path::new("x"))); } #[test] fn auto_approve_edit_when_active_and_plan_file() { let mut t = test_tracker(); t.enter_pending(); t.activate(); let plan = t.plan_file_path().to_path_buf(); assert!(t.should_auto_approve_edit(&plan)); } #[test] fn no_auto_approve_edit_when_active_but_different_file() { let mut t = test_tracker(); t.enter_pending(); t.activate(); assert!(!t.should_auto_approve_edit(Path::new("/some/other/file.rs"))); } #[test] fn no_auto_approve_edit_when_inactive() { let t = test_tracker(); let plan = t.plan_file_path().to_path_buf(); assert!(!t.should_auto_approve_edit(&plan)); } #[test] fn no_auto_approve_edit_when_pending() { let mut t = test_tracker(); t.enter_pending(); let plan = t.plan_file_path().to_path_buf(); assert!(!t.should_auto_approve_edit(&plan)); } #[test] fn double_enter_pending_is_noop() { let mut t = test_tracker(); assert!(t.enter_pending()); assert!(!t.enter_pending()); assert_eq!(t.state(), PlanModeState::Pending); } #[test] fn activate_from_inactive_only() { let mut t = test_tracker(); assert!(!t.activate()); assert_eq!(t.state(), PlanModeState::Inactive); } #[test] fn activate_from_tool_when_already_active() { let mut t = test_tracker(); t.activate_from_tool(); assert!(!t.activate_from_tool()); assert_eq!(t.state(), PlanModeState::Active); } #[test] fn deactivate_when_not_active() { let mut t = test_tracker(); assert!(!t.deactivate_approved()); assert_eq!(t.state(), PlanModeState::Inactive); } #[test] fn user_exit_from_inactive_is_noop() { let mut t = test_tracker(); t.user_exit(false); assert_eq!(t.state(), PlanModeState::Inactive); assert!(!t.has_pending_exit_reminder()); } #[test] fn complete_deferred_exit_when_not_exit_pending_is_noop() { let mut t = test_tracker(); t.enter_pending(); t.activate(); t.complete_deferred_exit(); assert_eq!(t.state(), PlanModeState::Active); assert!(!t.has_pending_exit_reminder()); } #[test] fn user_exit_while_idle_sets_exit_reminder() { let mut t = test_tracker(); t.enter_pending(); t.activate(); t.user_exit(false); assert_eq!(t.state(), PlanModeState::Inactive); assert!(t.has_pending_exit_reminder()); t.clear_pending_exit_reminder(); assert!(!t.has_pending_exit_reminder()); } #[test] fn enter_pending_clears_pending_exit_reminder() { let mut t = test_tracker(); t.enter_pending(); t.activate(); t.user_exit(false); assert!(t.has_pending_exit_reminder()); t.enter_pending(); assert!(!t.has_pending_exit_reminder()); } #[test] fn activate_from_tool_clears_pending_exit_reminder() { let mut t = test_tracker(); t.enter_pending(); t.activate(); t.user_exit(false); assert!(t.has_pending_exit_reminder()); t.activate_from_tool(); assert!(!t.has_pending_exit_reminder()); } #[test] fn deactivate_approved_does_not_set_pending_exit_reminder() { let mut t = test_tracker(); t.enter_pending(); t.activate(); assert!(!t.has_pending_exit_reminder()); t.deactivate_approved(); assert!(!t.has_pending_exit_reminder()); } #[test] fn queue_exit_reminder_arms_flag() { let mut t = test_tracker(); t.enter_pending(); t.activate(); t.deactivate_approved(); assert!(!t.has_pending_exit_reminder()); t.queue_exit_reminder(); assert!(t.has_pending_exit_reminder()); t.clear_pending_exit_reminder(); assert!(!t.has_pending_exit_reminder()); } #[test] fn compaction_reset_only_when_active() { let mut t = test_tracker(); t.enter_pending(); t.activate(); t.record_reminder_injected(); t.deactivate_approved(); t.reset_after_compaction(); } #[test] fn snapshot_round_trip_active() { let mut t = test_tracker(); t.enter_pending(); t.activate(); t.record_reminder_injected(); let snap = t.snapshot(); assert_eq!(snap.state, PlanModeState::Active); assert!(snap.was_previously_active); assert_eq!(snap.reminder_count, 1); let restored = PlanModeTracker::from_snapshot(PathBuf::from("/tmp/test-session"), snap); assert_eq!(restored.state(), PlanModeState::Active); assert!(!restored.should_use_full_reminder()); } #[test] fn snapshot_pending_collapses_to_inactive() { let mut t = test_tracker(); t.enter_pending(); let snap = t.snapshot(); assert_eq!(snap.state, PlanModeState::Pending); let restored = PlanModeTracker::from_snapshot(PathBuf::from("/tmp/test-session"), snap); assert_eq!(restored.state(), PlanModeState::Inactive); } #[test] fn snapshot_exit_pending_collapses_to_inactive_with_reminder() { let mut t = test_tracker(); t.enter_pending(); t.activate(); t.user_exit(true); let snap = t.snapshot(); assert_eq!(snap.state, PlanModeState::ExitPending); let restored = PlanModeTracker::from_snapshot(PathBuf::from("/tmp/test-session"), snap); assert_eq!(restored.state(), PlanModeState::Inactive); assert!(restored.has_pending_exit_reminder()); } #[test] fn snapshot_inactive_restores_cleanly() { let t = test_tracker(); let snap = t.snapshot(); let restored = PlanModeTracker::from_snapshot(PathBuf::from("/tmp/test-session"), snap); assert_eq!(restored.state(), PlanModeState::Inactive); assert!(!restored.has_pending_exit_reminder()); } #[test] fn reenter_from_exit_pending_cancels_deferred_exit() { let mut t = test_tracker(); t.enter_pending(); t.activate(); t.user_exit(true); assert_eq!(t.state(), PlanModeState::ExitPending); assert!(t.enter_pending()); assert_eq!(t.state(), PlanModeState::Active); assert!(!t.has_pending_exit_reminder()); t.complete_deferred_exit(); assert_eq!(t.state(), PlanModeState::Active); } #[test] fn was_previously_active_persists_through_agent_exit() { let mut t = test_tracker(); t.activate_from_tool(); assert!(t.is_active()); t.deactivate_approved(); assert_eq!(t.state(), PlanModeState::Inactive); t.enter_pending(); assert!(t.is_reentry()); } #[test] fn full_lifecycle_with_exit_pending() { let mut t = test_tracker(); t.enter_pending(); t.activate(); assert!(t.should_use_full_reminder()); t.record_reminder_injected(); assert!(!t.should_use_full_reminder()); t.record_reminder_injected(); t.user_exit(true); assert_eq!(t.state(), PlanModeState::ExitPending); t.complete_deferred_exit(); assert_eq!(t.state(), PlanModeState::Inactive); assert!(t.has_pending_exit_reminder()); t.clear_pending_exit_reminder(); assert!(!t.has_pending_exit_reminder()); t.enter_pending(); assert!(t.is_reentry()); t.activate(); assert_eq!(t.state(), PlanModeState::Active); assert!(t.should_use_full_reminder()); } #[test] fn test_prompt_mode_from_meta_str_known_values() { assert_eq!(PromptMode::from_meta_str("ask"), PromptMode::Ask); assert_eq!(PromptMode::from_meta_str("plan"), PromptMode::Plan); assert_eq!(PromptMode::from_meta_str("agent"), PromptMode::Agent); } #[test] fn test_prompt_mode_from_meta_str_unknown_defaults_to_agent() { assert_eq!(PromptMode::from_meta_str(""), PromptMode::Agent); assert_eq!(PromptMode::from_meta_str("unknown"), PromptMode::Agent); assert_eq!(PromptMode::from_meta_str("ASK"), PromptMode::Agent); assert_eq!(PromptMode::from_meta_str("Plan"), PromptMode::Agent); assert_eq!(PromptMode::from_meta_str("code"), PromptMode::Agent); } #[test] fn test_prompt_mode_is_read_only() { assert!(!PromptMode::Agent.is_read_only()); assert!(PromptMode::Ask.is_read_only()); assert!(PromptMode::Plan.is_read_only()); } #[test] fn test_prompt_mode_default_is_agent() { assert_eq!(PromptMode::default(), PromptMode::Agent); } #[test] fn test_prompt_mode_serde_round_trip() { for mode in [PromptMode::Agent, PromptMode::Ask, PromptMode::Plan] { let json = serde_json::to_string(&mode).unwrap(); let deserialized: PromptMode = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized, mode, "round-trip failed for {json}"); } } #[test] fn test_prompt_mode_serde_snake_case() { assert_eq!( serde_json::to_string(&PromptMode::Agent).unwrap(), r#""agent""# ); assert_eq!(serde_json::to_string(&PromptMode::Ask).unwrap(), r#""ask""#); assert_eq!( serde_json::to_string(&PromptMode::Plan).unwrap(), r#""plan""# ); } #[test] fn awaiting_plan_approval_survives_snapshot_round_trip() { let mut t = test_tracker(); t.enter_pending(); t.activate(); t.set_awaiting_plan_approval(true); assert!(t.is_awaiting_plan_approval()); let restored = PlanModeTracker::from_snapshot(PathBuf::from("/tmp/test-session"), t.snapshot()); assert_eq!(restored.state(), PlanModeState::Active); assert!(restored.is_awaiting_plan_approval()); } #[test] fn deactivate_approved_clears_awaiting_flag() { let mut t = test_tracker(); t.enter_pending(); t.activate(); t.set_awaiting_plan_approval(true); t.deactivate_approved(); assert!(!t.is_awaiting_plan_approval()); } #[test] fn user_exit_clears_awaiting_flag() { let mut t = test_tracker(); t.enter_pending(); t.activate(); t.set_awaiting_plan_approval(true); t.user_exit(false); assert!(!t.is_awaiting_plan_approval()); } #[test] fn snapshot_without_awaiting_field_defaults_false() { let legacy = r#"{ "state": "Active", "was_previously_active": true, "reminder_count": 0, "pending_exit_reminder": false }"#; let snapshot: PlanModeSnapshot = serde_json::from_str(legacy).unwrap(); assert!(!snapshot.awaiting_plan_approval); let restored = PlanModeTracker::from_snapshot(PathBuf::from("/tmp/test-session"), snapshot); assert!(!restored.is_awaiting_plan_approval()); } }