From 17d34a6bf7ae9764a7519cca1f4cdcf8090c57ff Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:56:19 -0700 Subject: [PATCH 1/2] fix: ignore unrecognized mode override, preserve persisted thread mode Round-2 review: when StartTurnRequest.mode carries a non-token value, fall back to the thread's persisted mode via parse_mode_opt instead of coercing to Agent, so an invalid override never crosses the mode boundary (#3387). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HzUHiFSX6tAEdQtQGA3hDx --- crates/tui/src/runtime_threads.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 9ce453393..4f4bfa11e 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -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 { From 2142d36c351c4da6f80db7e3140e0f5033eed187 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:51:31 -0700 Subject: [PATCH 2/2] fix: only resolve explicit tokens to a mode, never prompt text parse_mode's broad catch-all silently coerced any unrecognized string into a valid AppMode, so a stray prompt fragment reaching the runtime turn-start resolver could enter Plan/Agent/YOLO mode with no explicit request (#3387). Split out a strict parse_mode_opt that resolves only the exact explicit tokens (agent/plan/yolo plus the numeric aliases 1/2/3, matching the /mode command's parse_mode_arg) and returns None otherwise; parse_mode stays an infallible wrapper defaulting unknown input to Agent. Adds regression coverage asserting prompt fragments like "plan a trip to Tokyo" and "enter yolo mode" never coerce into a mode. Fixes #3387 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HzUHiFSX6tAEdQtQGA3hDx --- crates/tui/src/runtime_threads.rs | 18 ++++++++++---- crates/tui/src/runtime_threads/tests.rs | 32 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 4f4bfa11e..6275d07ff 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -3748,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 { 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" { diff --git a/crates/tui/src/runtime_threads/tests.rs b/crates/tui/src/runtime_threads/tests.rs index 7e53f6ab4..dcde15b21 100644 --- a/crates/tui/src/runtime_threads/tests.rs +++ b/crates/tui/src/runtime_threads/tests.rs @@ -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,