diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 54ba0c243..54af77db4 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -375,6 +375,8 @@ pub struct Engine { /// This keeps prompt refreshes cheap while still noticing append/update /// writes from slop ledger tools during the same session. slop_ledger_gate_cache: Option<(Option, Option)>, + /// Current operating mode. Updated on `ChangeMode` and `SendMessage`. + current_mode: AppMode, } // === Internal tool helpers === @@ -621,6 +623,7 @@ impl Engine { slop_ledger_gate_cache: None, workshop_vars, sandbox_backend, + current_mode: AppMode::Agent, }; engine.rehydrate_latest_canonical_state(); @@ -1046,11 +1049,23 @@ impl Engine { let _ = self.tx_event.send(Event::AgentList { agents }).await; } Op::ChangeMode { mode } => { + let previous_mode = self.current_mode; + self.current_mode = mode; self.refresh_system_prompt(mode); self.emit_session_updated().await; + // Notify the agent that the mode has changed so it can re-evaluate + // any operations that were blocked by the previous mode's policy. + if previous_mode != mode { + let msg = Self::mode_change_runtime_message(previous_mode, mode); + self.session.add_message(msg); + self.emit_session_updated().await; + } let _ = self .tx_event - .send(Event::status(format!("Mode changed to: {mode:?}"))) + .send(Event::status(format!( + "Mode changed to: {}", + mode.description() + ))) .await; } Op::SetModel { model, mode } => { @@ -1190,6 +1205,47 @@ impl Engine { .await; } + /// Build a runtime event message notifying the agent that the operating mode has changed. + /// This lets the agent re-evaluate any operations that were blocked by the previous mode. + fn mode_change_runtime_message(previous_mode: AppMode, new_mode: AppMode) -> Message { + let (policy_note, re_eval_hint) = match new_mode { + AppMode::Yolo => ( + "all operations run automatically without requiring user approval", + "Re-evaluate any previously blocked write, patch, or shell operations \ + — they are now auto-approved.", + ), + AppMode::Agent => ( + "read-only operations run silently; writes, patches, and shell \ + commands require user approval", + "Any operations you ran automatically under YOLO mode now require \ + explicit user approval before executing.", + ), + AppMode::Plan => ( + "all writes and patches are blocked; shell and code execution are unavailable", + "Any previously planned operations that require writes or shell access \ + must wait until the mode changes back to Agent or YOLO.", + ), + }; + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: format!( + "\n\ +This is an internal runtime event, not user input. The operating mode has changed \ +from {previous} mode to {new} mode.\n\n\ +In {new} mode: {policy}\n\n\ +{re_eval}\n\ +", + previous = previous_mode.description(), + new = new_mode.description(), + policy = policy_note, + re_eval = re_eval_hint, + ), + cache_control: None, + }], + } + } + async fn add_session_message(&mut self, message: Message) { self.session.add_message(message); self.emit_session_updated().await; @@ -1198,11 +1254,13 @@ impl Engine { fn turn_metadata_block( &self, routed_model: &str, + mode: AppMode, auto_model: bool, reasoning_effort: Option<&str>, reasoning_effort_auto: bool, ) -> ContentBlock { let today = chrono::Local::now().format("%Y-%m-%d").to_string(); + let mode_label = mode.description(); let working_set_summary = self .session .working_set @@ -1212,6 +1270,7 @@ impl Engine { let mut lines = vec![ format!("Current local date: {today}"), + format!("Current mode: {mode_label}"), format!("Current model: {routed_model}"), ]; if auto_model { @@ -1234,6 +1293,7 @@ impl Engine { fn user_text_message_with_turn_metadata(&self, text: String) -> Message { self.user_text_message_with_turn_metadata_for_route( text, + self.current_mode, &self.session.model, self.session.auto_model, self.session.reasoning_effort.as_deref(), @@ -1244,6 +1304,7 @@ impl Engine { fn user_text_message_with_turn_metadata_for_route( &self, text: String, + mode: AppMode, routed_model: &str, auto_model: bool, reasoning_effort: Option<&str>, @@ -1254,6 +1315,7 @@ impl Engine { content: vec![ self.turn_metadata_block( routed_model, + mode, auto_model, reasoning_effort, reasoning_effort_auto, @@ -1289,6 +1351,9 @@ impl Engine { // Reset cancel token for fresh turn (in case previous was cancelled) self.reset_cancel_token(); + // Track current mode so mid-turn messages include the right mode in turn metadata. + self.current_mode = mode; + // Drain stale steer messages from previous turns. while self.rx_steer.try_recv().is_ok() {} @@ -1368,6 +1433,7 @@ impl Engine { // Add user message to session let user_msg = self.user_text_message_with_turn_metadata_for_route( content, + mode, &model, auto_model, reasoning_effort.as_deref(), diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 48491277e..d058a00e0 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -1617,6 +1617,61 @@ async fn change_mode_refreshes_session_prompt_and_updates_session() { assert!(prompt.contains("Approval Policy: Auto")); } +#[tokio::test] +async fn change_mode_op_injects_runtime_event_into_session_messages() { + let tmp = tempdir().expect("tempdir"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + model: "deepseek-v4-pro".to_string(), + ..Default::default() + }; + let (engine, handle) = Engine::new(config, &Config::default()); + + let run = tokio::spawn(engine.run()); + // Switch from default Agent → YOLO + handle + .send(Op::ChangeMode { + mode: AppMode::Yolo, + }) + .await + .expect("send change mode"); + + // Collect session-updated events until we see the injected message + let messages = { + let mut rx = handle.rx_event.write().await; + loop { + let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("session update after mode switch") + .expect("event"); + if let Event::SessionUpdated { messages, .. } = event { + // The last message should be our runtime event + if let Some(last) = messages.last() + && let ContentBlock::Text { text, .. } = + last.content.first().expect("text block") + && text.contains("kind=\"mode_change\"") + { + break messages; + } + } + } + }; + run.abort(); + + let last = messages.last().expect("at least one message"); + let ContentBlock::Text { text, .. } = last.content.first().expect("text block") else { + panic!("expected text block"); + }; + assert!( + text.contains("Agent mode") && text.contains("YOLO mode"), + "should contain both previous and new mode: {text}" + ); + assert!( + text.contains("Re-evaluate"), + "should tell agent to re-evaluate: {text}" + ); +} + #[test] fn detects_context_length_errors_from_provider_payloads() { let msg = r#"SSE stream request failed: HTTP 400 Bad Request: {"error":{"message":"This model's maximum context length is 131072 tokens. However, you requested 153056 tokens (148960 in the messages, 4096 in the completion).","type":"invalid_request_error"}}"#; @@ -2042,6 +2097,7 @@ fn turn_metadata_includes_auto_model_route() { let user_msg = engine.user_text_message_with_turn_metadata_for_route( "debug this regression".to_string(), + AppMode::Agent, "deepseek-v4-pro", true, Some("max"), @@ -2058,6 +2114,94 @@ fn turn_metadata_includes_auto_model_route() { assert!(!text.contains("debug this regression")); } +#[test] +fn turn_metadata_includes_current_mode() { + let tmp = tempdir().expect("tempdir"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + ..Default::default() + }; + let (engine, _handle) = Engine::new(config, &Config::default()); + + let user_msg = engine.user_text_message_with_turn_metadata_for_route( + "test mode metadata".to_string(), + AppMode::Yolo, + "deepseek-v4-flash", + false, + None, + false, + ); + let first_block = user_msg.content.first().expect("turn metadata block"); + let ContentBlock::Text { text, .. } = first_block else { + panic!("expected text metadata block"); + }; + + assert!( + text.contains("Current mode: YOLO mode - full tool access without approvals"), + "turn metadata should include the current mode label, got: {text}" + ); +} + +#[test] +fn turn_metadata_mode_updates_with_change_mode_op() { + let tmp = tempdir().expect("tempdir"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + ..Default::default() + }; + let (mut engine, _handle) = Engine::new(config, &Config::default()); + + // In agent mode by default + let msg = engine.user_text_message_with_turn_metadata("hello".to_string()); + let first_block = msg.content.first().expect("turn metadata block"); + let ContentBlock::Text { text, .. } = first_block else { + panic!("expected text metadata block"); + }; + assert!( + text.contains("Agent mode"), + "initial mode should be Agent, got: {text}" + ); + + // Switch to YOLO — user_text_message_with_turn_metadata should reflect the new mode + engine.current_mode = AppMode::Yolo; + let msg = engine.user_text_message_with_turn_metadata("hello again".to_string()); + let first_block = msg.content.first().expect("turn metadata block"); + let ContentBlock::Text { text, .. } = first_block else { + panic!("expected text metadata block"); + }; + assert!( + text.contains("YOLO mode"), + "mode after change should be YOLO, got: {text}" + ); +} + +#[test] +fn mode_change_runtime_message_format() { + let msg = Engine::mode_change_runtime_message(AppMode::Agent, AppMode::Yolo); + + assert_eq!(msg.role, "user"); + let ContentBlock::Text { text, .. } = msg.content.first().expect("text block") else { + panic!("expected text block"); + }; + + assert!( + text.contains("codewhale:runtime_event"), + "should be a runtime event message" + ); + assert!( + text.contains("kind=\"mode_change\""), + "should have mode_change kind" + ); + assert!( + text.contains("Agent mode") && text.contains("YOLO mode"), + "should mention both previous and new mode: {text}" + ); + assert!( + text.contains("Re-evaluate"), + "should tell agent to re-evaluate blocked operations: {text}" + ); +} + #[test] fn user_text_message_keeps_current_turn_input_after_turn_metadata() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 6b7ffd1ff..472bc9bc7 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -314,7 +314,9 @@ impl Engine { // Three-zone prefix contract (#2264): freeze baseline on first // turn, verify against it on subsequent turns. Operates alongside // PrefixStabilityManager as an independent diagnostic layer. - // Phase 2: warn-only, auto-re-freeze on drift. + // Phase 3: emit a one-shot 'frozen' event on first turn. + // Drift is logged (tracing::debug!) but not re-emitted — + // PrefixStabilityManager already reports the change above. let system_text = crate::prefix_cache::system_prompt_text(self.session.system_prompt.as_ref()); let current_tools: &[crate::models::Tool] = active_tools.as_deref().unwrap_or_default(); @@ -338,7 +340,19 @@ impl Engine { self.session.system_prompt.as_ref(), current_tools.to_vec(), ); - self.session.frozen_prefix = Some(pinned.freeze()); + let frozen = pinned.freeze(); + let _ = self + .tx_event + .send(Event::PrefixCacheChange { + description: format!("frozen: {}", frozen.short_id()), + system_prompt_changed: false, + tools_changed: false, + stability_pct: 100, + changed: false, + pinned_combined_hash: frozen.hash().to_string(), + }) + .await; + self.session.frozen_prefix = Some(frozen); } }