Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SystemTime>, Option<String>)>,
/// Current operating mode. Updated on `ChangeMode` and `SendMessage`.
current_mode: AppMode,
}

// === Internal tool helpers ===
Expand Down Expand Up @@ -621,6 +623,7 @@ impl Engine {
slop_ledger_gate_cache: None,
workshop_vars,
sandbox_backend,
current_mode: AppMode::Agent,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 current_mode is hardcoded to AppMode::Agent at construction time, regardless of the mode the host app was actually configured with. If the app starts in YOLO or Plan mode and an Op::ChangeMode arrives before the first Op::SendMessage (e.g. to sync initial UI state), previous_mode will wrongly be Agent, a spurious "Agent → X" runtime message will be injected into the session, and the conversation history will look like the user explicitly switched modes when they never did. Accepting the initial mode from EngineConfig (or from the first SendMessage) would prevent this.

Suggested change
current_mode: AppMode::Agent,
current_mode: AppMode::Agent, // TODO: initialise from EngineConfig so ChangeMode before first SendMessage doesn't inject a spurious transition event

Fix in Codex Fix in Claude Code Fix in Cursor

};
engine.rehydrate_latest_canonical_state();

Expand Down Expand Up @@ -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);
Comment on lines +1059 to +1060
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid persisting mode-change sentinels as user retry drafts

When a user changes mode after an assistant response and then closes/reloads the session before sending another prompt, this appends a role: "user" runtime event as the final transcript message. apply_loaded_session() always calls recover_interrupted_user_tail(), which treats any trailing user message as an interrupted prompt, pops it, and queues its text as the composer draft; there is no filter for <codewhale:runtime_event>, so the user can be shown the internal XML and the intended mode-change event is removed from the model context on reload.

Useful? React with 👍 / 👎.

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 } => {
Expand Down Expand Up @@ -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.",
),
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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!(
"<codewhale:runtime_event kind=\"mode_change\" visibility=\"internal\">\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\
</codewhale:runtime_event>",
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;
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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,
Comment on lines 1293 to 1297
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Sync active-turn mode before annotating steers

For mode changes made while a turn is still running, Op::ChangeMode sits on the engine op queue because handle_send_message() is still awaiting handle_deepseek_turn(), while steer input is delivered on the separate rx_steer channel and calls this helper immediately. In that scenario self.current_mode has not been updated yet, so the <turn_meta> on the steered user message still reports the old mode even though the UI has already switched modes; this misses the advertised fix for mid-turn steer metadata.

Useful? React with 👍 / 👎.

self.session.auto_model,
self.session.reasoning_effort.as_deref(),
Expand All @@ -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>,
Expand All @@ -1254,6 +1315,7 @@ impl Engine {
content: vec![
self.turn_metadata_block(
routed_model,
mode,
auto_model,
reasoning_effort,
reasoning_effort_auto,
Expand Down Expand Up @@ -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() {}

Expand Down Expand Up @@ -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(),
Expand Down
144 changes: 144 additions & 0 deletions crates/tui/src/core/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}"#;
Expand Down Expand Up @@ -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"),
Expand All @@ -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");
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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");
Expand Down
18 changes: 16 additions & 2 deletions crates/tui/src/core/engine/turn_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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,
Comment on lines +346 to +350
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't count the frozen-prefix notice as a cache check

On the first request, the prefix-stability block just above this already sends a PrefixCacheChange event for the actual check_and_update() result, and the UI increments app.prefix_checks_total for every such event. Emitting this extra changed: false PrefixCacheChange for the one-shot frozen-prefix notice therefore makes /cache and the footer report an additional stable cache check that never happened, skewing the stability percentage and stable-check count from the first turn onward.

Useful? React with 👍 / 👎.

changed: false,
pinned_combined_hash: frozen.hash().to_string(),
})
.await;
self.session.frozen_prefix = Some(frozen);
}
}

Expand Down
Loading