Skip to content
Closed
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
28 changes: 23 additions & 5 deletions crates/tui/src/runtime_threads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2026,7 +2026,15 @@ impl RuntimeThreadManager {
touch_lru(&mut active.lru, thread_id);
}

let mode = parse_mode(req.mode.as_deref().unwrap_or(&thread.mode));
// A requested mode override only takes effect when it is an explicit,
// recognized mode token. An unrecognized override (e.g. a stray prompt
// fragment) must NOT silently change the mode: fall back to the
// thread's persisted mode rather than coercing to Agent (#3387).
let mode = req
.mode
.as_deref()
.and_then(parse_mode_opt)
.unwrap_or_else(|| parse_mode(&thread.mode));
let requested_model = req.model.unwrap_or_else(|| thread.model.clone());
let auto_model = requested_model.trim().eq_ignore_ascii_case("auto");
let (provider, model, reasoning_effort) = if auto_model {
Expand Down Expand Up @@ -3740,14 +3748,24 @@ fn enforce_lru_capacity(
evicted
}

fn parse_mode(mode: &str) -> AppMode {
/// Resolves only explicit mode tokens to an app mode. Free-form prompt text is
/// never a valid mode token: `parse_mode_opt` returns `None` unless the input is
/// exactly `agent`/`plan`/`yolo` or the numeric aliases `1`/`2`/`3`. Mode
/// changes originate from the Tab cycle, `/mode`, the mode picker, or
/// config/startup defaults, not from submitted natural-language prompt text.
fn parse_mode_opt(mode: &str) -> Option<AppMode> {
match mode.trim().to_ascii_lowercase().as_str() {
"plan" => AppMode::Plan,
"yolo" => AppMode::Yolo,
_ => AppMode::Agent,
"agent" | "1" => Some(AppMode::Agent),
"plan" | "2" => Some(AppMode::Plan),
"yolo" | "3" => Some(AppMode::Yolo),
_ => None,
}
}

fn parse_mode(mode: &str) -> AppMode {
parse_mode_opt(mode).unwrap_or(AppMode::Agent)
}

fn tool_kind_for_name(name: &str) -> TurnItemKind {
let lower = name.to_ascii_lowercase();
if lower == "exec_shell" || lower == "exec_shell_wait" || lower == "exec_shell_interact" {
Expand Down
32 changes: 32 additions & 0 deletions crates/tui/src/runtime_threads/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2466,6 +2466,38 @@ fn parse_mode_defaults_to_agent() {
assert_eq!(parse_mode("plan"), AppMode::Plan);
}

#[test]
fn parse_mode_opt_resolves_explicit_tokens_and_aliases() {
assert_eq!(parse_mode_opt("agent"), Some(AppMode::Agent));
assert_eq!(parse_mode_opt("1"), Some(AppMode::Agent));
assert_eq!(parse_mode_opt("plan"), Some(AppMode::Plan));
assert_eq!(parse_mode_opt("2"), Some(AppMode::Plan));
assert_eq!(parse_mode_opt("yolo"), Some(AppMode::Yolo));
assert_eq!(parse_mode_opt("3"), Some(AppMode::Yolo));
assert_eq!(parse_mode_opt(" PLAN "), Some(AppMode::Plan));
}

#[test]
fn parse_mode_opt_rejects_prompt_fragments() {
for input in [
"plan a trip to Tokyo",
"switch the agent on",
"enter yolo mode",
"agent of chaos",
"mode",
] {
assert_eq!(parse_mode_opt(input), None);
}
}

#[test]
fn parse_mode_wrapper_defaults_and_resolves_numeric_aliases() {
assert_eq!(parse_mode("plan a trip to Tokyo"), AppMode::Agent);
assert_eq!(parse_mode("1"), AppMode::Agent);
assert_eq!(parse_mode("2"), AppMode::Plan);
assert_eq!(parse_mode("3"), AppMode::Yolo);
}

fn rebind_event(event: &str, agent_id: &str, seq: u64) -> RuntimeEventRecord {
RuntimeEventRecord {
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
Expand Down
Loading