Skip to content

fix: prevent ordinary prompt text from being interpreted as a mode switch#3455

Open
mvanhorn wants to merge 2 commits into
Hmbown:mainfrom
mvanhorn:fix/3387-prompt-mode-switch-boundary
Open

fix: prevent ordinary prompt text from being interpreted as a mode switch#3455
mvanhorn wants to merge 2 commits into
Hmbown:mainfrom
mvanhorn:fix/3387-prompt-mode-switch-boundary

Conversation

@mvanhorn

Copy link
Copy Markdown
Contributor

Summary

Mode changes were able to leak from ordinary prompt text. On the runtime turn-start path, parse_mode resolved a mode string with a broad catch-all (_ => AppMode::Agent), so any unrecognized value silently became a valid mode instead of being rejected. Because start_turn prefers StartTurnRequest.mode over the thread's persisted mode, a stray prompt-like override (e.g. enter yolo mode) that should be ignored was coerced into Agent, silently changing a Plan/YOLO thread's mode without an explicit request (#3387).

This draws a clear boundary between prompt-submission text and mode-command parsing:

  • Adds a strict parse_mode_opt(mode) -> Option<AppMode> that resolves only the explicit mode tokens, matching the /mode command's own accepted set: agent/plan/yolo plus the numeric aliases 1/2/3. Anything else returns None.
  • parse_mode stays an infallible wrapper (parse_mode_opt(..).unwrap_or(AppMode::Agent)), so the persisted/default mode path is unchanged (AppMode::as_setting() always yields a recognized token).
  • At the start_turn call site, an unrecognized per-turn override now falls back to the thread's persisted mode rather than coercing to Agent, so an invalid override never crosses the mode boundary.

Mode transitions continue to originate only from explicit sources: the Tab cycle, /mode, the mode picker, and config/startup defaults. The composer/submit path (looks_like_slash_command_input, which requires a leading /) was already strict and is left untouched.

Why this matters

Root cause: the lone runtime mode resolver treated every non-token string as a valid mode, and the turn-start call preferred the request override unconditionally, so free-form prompt text could silently change approval/sandbox/trust posture.

The failing case now fixed: starting a turn on a Plan or YOLO thread with a request mode such as enter yolo mode (or any non-token fragment) no longer flips the thread to Agent; the unrecognized override is ignored and the persisted mode is preserved. Regression tests assert that prompt fragments (plan a trip to Tokyo, switch the agent on, enter yolo mode, agent of chaos, mode) never resolve to a mode, while exact tokens and numeric aliases still do.

Testing

  • cargo fmt clean on the changed crate
  • cargo test -p codewhale-tui parse_mode — all parse_mode / parse_mode_opt regression tests pass

(Note: the full cargo test --workspace build currently fails before reaching these tests due to a pre-existing non-exhaustive match in crates/tui/src/main.rs doctor_api_key_source_labelApiKeySource::Command / ApiKeySource::Secret are uncovered on main, unrelated to this change. The parse_mode tests were verified passing under a local workaround for that build error.)

Fixes #3387

Checklist

  • Updated docs or comments as needed
  • Added or updated tests where relevant
  • No UI changes (runtime mode-resolver logic only); no manual TUI verification needed
  • Harvested/co-authored credit uses a GitHub numeric noreply address

mvanhorn and others added 2 commits June 22, 2026 23:51
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 (Hmbown#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 Hmbown#3387

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzUHiFSX6tAEdQtQGA3hDx
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 (Hmbown#3387).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzUHiFSX6tAEdQtQGA3hDx
@Hmbown

Hmbown commented Jun 23, 2026

Copy link
Copy Markdown
Owner

Summary

Mode changes were able to leak from ordinary prompt text. On the runtime turn-start path, parse_mode resolved a mode string with a broad catch-all (_ => AppMode::Agent), so any unrecognized value silently became a valid mode instead of being rejected. Because start_turn prefers StartTurnRequest.mode over the thread's persisted mode, a stray prompt-like override (e.g. enter yolo mode) that should be ignored was coerced into Agent, silently changing a Plan/YOLO thread's mode without an explicit request (#3387).

This draws a clear boundary between prompt-submission text and mode-command parsing:

  • Adds a strict parse_mode_opt(mode) -> Option<AppMode> that resolves only the explicit mode tokens, matching the /mode command's own accepted set: agent/plan/yolo plus the numeric aliases 1/2/3. Anything else returns None.
  • parse_mode stays an infallible wrapper (parse_mode_opt(..).unwrap_or(AppMode::Agent)), so the persisted/default mode path is unchanged (AppMode::as_setting() always yields a recognized token).
  • At the start_turn call site, an unrecognized per-turn override now falls back to the thread's persisted mode rather than coercing to Agent, so an invalid override never crosses the mode boundary.

Mode transitions continue to originate only from explicit sources: the Tab cycle, /mode, the mode picker, and config/startup defaults. The composer/submit path (looks_like_slash_command_input, which requires a leading /) was already strict and is left untouched.

Why this matters

Root cause: the lone runtime mode resolver treated every non-token string as a valid mode, and the turn-start call preferred the request override unconditionally, so free-form prompt text could silently change approval/sandbox/trust posture.

The failing case now fixed: starting a turn on a Plan or YOLO thread with a request mode such as enter yolo mode (or any non-token fragment) no longer flips the thread to Agent; the unrecognized override is ignored and the persisted mode is preserved. Regression tests assert that prompt fragments (plan a trip to Tokyo, switch the agent on, enter yolo mode, agent of chaos, mode) never resolve to a mode, while exact tokens and numeric aliases still do.

Testing

  • cargo fmt clean on the changed crate
  • cargo test -p codewhale-tui parse_mode — all parse_mode / parse_mode_opt regression tests pass

(Note: the full cargo test --workspace build currently fails before reaching these tests due to a pre-existing non-exhaustive match in crates/tui/src/main.rs doctor_api_key_source_labelApiKeySource::Command / ApiKeySource::Secret are uncovered on main, unrelated to this change. The parse_mode tests were verified passing under a local workaround for that build error.)

Fixes #3387

Checklist

  • Updated docs or comments as needed
  • Added or updated tests where relevant
  • No UI changes (runtime mode-resolver logic only); no manual TUI verification needed
  • Harvested/co-authored credit uses a GitHub numeric noreply address

This is awesome - thanks so much Matt!! Been frustrated with this behavior myself and have other attempts at solving this so will fold this in if merging directly doesn't make sense

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Prompt text can be misinterpreted as a mode switch

2 participants