Skip to content

feat(engine): inject mode-change runtime message and include mode in turn metadata#2577

Open
encyc wants to merge 4 commits into
Hmbown:mainfrom
encyc:feat/mode-change-system-message
Open

feat(engine): inject mode-change runtime message and include mode in turn metadata#2577
encyc wants to merge 4 commits into
Hmbown:mainfrom
encyc:feat/mode-change-system-message

Conversation

@encyc
Copy link
Copy Markdown
Contributor

@encyc encyc commented Jun 2, 2026

What

When the user switches modes mid-session (Agent → YOLO → Plan), the agent now receives a <codewhale:runtime_event kind="mode_change"> message in the conversation. This lets the agent re-evaluate any operations that were blocked by the previous mode approval policy.

Additionally, the current AppMode is now included in every turn <turn_meta> block so mid-turn messages (steer, REPL, LSP diagnostics) correctly report the active mode instead of always showing "Agent".

Why

Closes #2515. The core problem: users switch from Agent → YOLO mode with Alt+y, but the agent still says "I cannot do shell operations, you need to run them manually" — because it never received any signal that the mode changed.

Changes

engine.rs

  • New current_mode: AppMode field on Engine struct — tracks the active mode
  • Op::ChangeMode handling — injects a mode_change_runtime_message into session.messages, which uses <codewhale:runtime_event kind="mode_change"> XML (same pattern as sub-agent completion events). The message includes:
    • Policy description for the new mode
    • Explicit re-evaluation hint ("Re-evaluate any previously blocked operations")
  • turn_metadata_block — now includes Current mode: {mode_label} line
  • user_text_message_with_turn_metadata — uses self.current_mode instead of hardcoded AppMode::Agent
  • user_text_message_with_turn_metadata_for_route — accepts mode: AppMode parameter
  • handle_send_message — stores self.current_mode = mode early

engine/tests.rs

  • turn_metadata_includes_current_mode — verifies mode appears in turn_meta
  • turn_metadata_mode_updates_with_change_mode_op — verifies mid-turn messages pick up mode changes
  • mode_change_runtime_message_format — verifies runtime event format
  • Updated turn_metadata_includes_auto_model_route to pass AppMode::Agent

Verification

cargo test -p codewhale-tui  # 3356 passed, 0 failed
cargo fmt --all -- --check    # clean

Greptile Summary

This PR injects a <codewhale:runtime_event kind=\"mode_change\"> message into the conversation when the user switches modes mid-session, and adds the active AppMode to every turn's <turn_meta> block so the agent always has accurate context about its operating policy.

  • Engine gains a current_mode: AppMode field updated on both Op::ChangeMode and Op::SendMessage; mode_change_runtime_message constructs the runtime event using the same XML pattern as sub-agent completion events.
  • turn_metadata_block now accepts a mode: AppMode parameter and emits a Current mode: line; user_text_message_with_turn_metadata threads self.current_mode through automatically.
  • turn_loop.rs upgrades the prefix-freeze diagnostic from Phase 2 (warn-only) to Phase 3 (emitting a one-shot PrefixCacheChange event on the first turn freeze).

Confidence Score: 4/5

Safe to merge with one fix: Op::EditLastTurn hardcodes AppMode::Agent instead of self.current_mode, silently overwriting the engine's tracked mode and re-dispatching the edited turn under the wrong approval policy.

The Op::EditLastTurn handler passes a hardcoded AppMode::Agent to handle_send_message, which immediately writes it back to self.current_mode. A YOLO or Plan user who runs /edit will have their re-sent message processed with Agent-level approval restrictions, and the engine's mode state will be permanently overwritten to Agent for all subsequent turns until the next SendMessage or ChangeMode — directly contradicting the purpose of this PR.

crates/tui/src/core/engine.rs — the Op::EditLastTurn arm at line 1158

Important Files Changed

Filename Overview
crates/tui/src/core/engine.rs Adds current_mode tracking, Op::ChangeMode runtime message injection, and mode in turn metadata — but Op::EditLastTurn hardcodes AppMode::Agent instead of self.current_mode, silently overwriting the active mode on /edit and causing wrong approval policy for YOLO/Plan users.
crates/tui/src/core/engine/tests.rs Adds integration test change_mode_op_injects_runtime_event_into_session_messages (exercises the full Op dispatch path) plus three unit tests covering mode in turn metadata and message format; turn_metadata_mode_updates_with_change_mode_op still sets engine.current_mode directly rather than via Op dispatch, but the new async test compensates for the most critical path.
crates/tui/src/core/engine/turn_loop.rs Upgrades prefix-freeze from Phase 2 (warn-only) to Phase 3 (emit one-shot PrefixCacheChange event on first freeze); straightforward and self-contained.

Sequence Diagram

sequenceDiagram
    participant UI
    participant Engine
    participant Session
    participant Agent

    UI->>Engine: "Op::ChangeMode { mode: Yolo }"
    Engine->>Engine: "previous_mode = current_mode (Agent)"
    Engine->>Engine: "current_mode = Yolo"
    Engine->>Session: refresh_system_prompt(Yolo)
    Engine->>UI: emit_session_updated()
    Engine->>Session: add_message(mode_change_runtime_event)
    Engine->>UI: emit_session_updated()
    Engine->>UI: Event::status("Mode changed to: YOLO mode")

    UI->>Engine: "Op::SendMessage { mode: Yolo, ... }"
    Engine->>Engine: "current_mode = Yolo"
    Engine->>Session: add user_msg with turn_meta (Current mode: YOLO mode)
    Engine->>Agent: MessageRequest (messages including mode_change event + turn_meta)
    Agent-->>Engine: response (re-evaluates blocked operations)

    Note over Engine: Op::EditLastTurn bypasses current_mode
    UI->>Engine: "Op::EditLastTurn { new_message }"
    Engine->>Session: truncate to last user message
    Engine->>Engine: "mode = AppMode::Agent (hardcoded!)"
    Engine->>Engine: "handle_send_message(mode=Agent) overwriting current_mode"
    Engine->>Agent: MessageRequest (turn_meta shows Agent, YOLO ops blocked)
Loading

Comments Outside Diff (1)

  1. crates/tui/src/core/engine.rs, line 1158-1161 (link)

    P1 EditLastTurn silently resets active mode to Agent

    Op::EditLastTurn passes AppMode::Agent to handle_send_message, which immediately overwrites self.current_mode = mode. A user who has switched to YOLO or Plan mode and then uses /edit will have their turn re-dispatched under Agent restrictions — and the engine's tracked mode will be permanently downgraded to Agent for all subsequent mid-turn messages too. The comment on the same line says "reusing the engine's stored mode/model config", but the mode is not reused at all. Using self.current_mode in place of the hardcoded AppMode::Agent would match the intent.

    Fix in Codex Fix in Claude Code Fix in Cursor

Fix All in Codex Fix All in Claude Code Fix All in Cursor

Reviews (2): Last reviewed commit: "fix(engine): address review feedback — i..." | Re-trigger Greptile

encyc added 3 commits June 2, 2026 13:52
…nPrefix layer

Upgrades the three-zone diagnostic layer from silent tracing::debug!
to user-visible Event::PrefixCacheChange events.

- First turn: emits 'frozen: <short_id>' event (stability 100%, no change)
- Drift detected: emits PrefixDrift description event (stability 0%, changed)
- Stable turns: no event (existing PrefixStabilityManager handles this)

Phase 2→3: only turn_loop.rs, +18 lines.
…freeze

The existing PrefixStabilityManager::check_and_update() block already
emits PrefixCacheChange on every check (stable + drift). The three-zone
layer was re-using the same event type, doubling prefix_change_count
and prefix_checks_total.

Fix: emit only the one-shot 'frozen: <id>' event on first turn.
Drift is still verified and logged (tracing::debug!) but not
re-emitted — check_and_update already surfaces the change.
…turn metadata

When the user switches modes mid-session (Agent → YOLO → Plan), the agent
now receives a <codewhale:runtime_event kind="mode_change"> message in the
conversation. This lets the agent re-evaluate any operations that were
blocked by the previous mode's approval policy.

Additionally, the current AppMode is now included in every turn's
<turn_meta> block so mid-turn messages (steer, REPL, LSP diagnostics)
correctly report the active mode instead of always showing "Agent".

Changes:
- Add  field to Engine struct
-  now injects a runtime event into session.messages
-  now includes "Current mode: {mode_label}"
-  uses
-  accepts mode parameter
- All call sites updated (handle_send_message, tests)

Closes Hmbown#2515
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 2, 2026

Thanks @encyc for taking the time to contribute.

This repository is currently observing a maintainer-managed contribution gate in dry-run mode, so this pull request is staying open. When enforcement is enabled, pull requests from contributors who are not listed in .github/APPROVED_CONTRIBUTORS will be closed automatically.

Please read CONTRIBUTING.md for the expected contribution shape. A maintainer can grant PR access by commenting /lgtm on a pull request.

@gemini-code-assist
Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

Comment thread crates/tui/src/core/engine.rs
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

Comment thread crates/tui/src/core/engine/tests.rs
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dfb0b17187

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1059 to +1060
let msg = Self::mode_change_runtime_message(previous_mode, mode);
self.session.add_message(msg);
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 👍 / 👎.

Comment on lines 1293 to 1297
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,
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 👍 / 👎.

Comment on lines +346 to +350
.send(Event::PrefixCacheChange {
description: format!("frozen: {}", frozen.short_id()),
system_prompt_changed: false,
tools_changed: false,
stability_pct: 100,
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 👍 / 👎.

…t and add Op-path test

Review feedback addressed:
- Greptile P1: 'YOLO-mode hesitation' in the Agent arm re-eval hint was
  self-contradictory. Changed to 'any operations you ran automatically
  under YOLO mode now require explicit user approval'.
- Greptile P1: Added async test
  change_mode_op_injects_runtime_event_into_session_messages that
  exercises the full Op::ChangeMode dispatch path (spawn engine.run(),
  send Op, read SessionUpdated events, verify injected message).

The existing turn_metadata_mode_updates_with_change_mode_op test is
retained as a unit test for the current_mode field update logic; the
new async test covers the integration path.

Refs Hmbown#2577
Copy link
Copy Markdown
Contributor Author

@encyc encyc left a comment

Choose a reason for hiding this comment

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

Two Greptile P1 issues fixed in 29c745c:

  1. Agent arm re-eval hint wording — changed from the contradictory "YOLO-mode hesitation" to "any operations you ran automatically under YOLO mode"
  2. Added Op-path integration testchange_mode_op_injects_runtime_event_into_session_messages exercises the full Op::ChangeModesession.messages path via tokio::spawn(engine.run()), verifying the runtime event is injected and emitted through SessionUpdated.

Regarding the Codex P2 items:

Session reload pop-up (runtime_event as user draft): This is a pre-existing issue — subagent_completion_runtime_message in turn_loop.rs:2258 uses the exact same role: "user" + <codewhale:runtime_event> pattern and has the same recover_interrupted_user_tail() behavior on reload. A proper fix (filtering visibility="internal" in recover_interrupted_user_tail) should be tracked separately for both message types.

Steer race condition during active turn: Acknowledged — if Op::ChangeMode is queued while handle_deepseek_turn() is blocking the op loop, steer messages on rx_steer will still use the old self.current_mode. This is a low-probability edge case (mode changes typically happen between turns) and can be addressed in a follow-up.

turn_loop.rs:350 prefix cache comment: This is unrelated to this PR — it touches a different file and concerns prefix-cache stability counting, not mode-change behavior.

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.

Feature: Real-time mode change awareness for the agent

1 participant