feat(engine): inject mode-change runtime message and include mode in turn metadata#2577
feat(engine): inject mode-change runtime message and include mode in turn metadata#2577encyc wants to merge 4 commits into
Conversation
…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
|
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 Please read |
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
| slop_ledger_gate_cache: None, | ||
| workshop_vars, | ||
| sandbox_backend, | ||
| current_mode: AppMode::Agent, |
There was a problem hiding this comment.
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.
| current_mode: AppMode::Agent, | |
| current_mode: AppMode::Agent, // TODO: initialise from EngineConfig so ChangeMode before first SendMessage doesn't inject a spurious transition event |
There was a problem hiding this comment.
💡 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".
| let msg = Self::mode_change_runtime_message(previous_mode, mode); | ||
| self.session.add_message(msg); |
There was a problem hiding this comment.
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 👍 / 👎.
| 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, |
There was a problem hiding this comment.
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 👍 / 👎.
| .send(Event::PrefixCacheChange { | ||
| description: format!("frozen: {}", frozen.short_id()), | ||
| system_prompt_changed: false, | ||
| tools_changed: false, | ||
| stability_pct: 100, |
There was a problem hiding this comment.
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
encyc
left a comment
There was a problem hiding this comment.
Two Greptile P1 issues fixed in 29c745c:
- Agent arm re-eval hint wording — changed from the contradictory "YOLO-mode hesitation" to "any operations you ran automatically under YOLO mode"
- Added Op-path integration test —
change_mode_op_injects_runtime_event_into_session_messagesexercises the fullOp::ChangeMode→session.messagespath viatokio::spawn(engine.run()), verifying the runtime event is injected and emitted throughSessionUpdated.
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.
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
AppModeis 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.rscurrent_mode: AppModefield onEnginestruct — tracks the active modeOp::ChangeModehandling — injects amode_change_runtime_messageintosession.messages, which uses<codewhale:runtime_event kind="mode_change">XML (same pattern as sub-agent completion events). The message includes:turn_metadata_block— now includesCurrent mode: {mode_label}lineuser_text_message_with_turn_metadata— usesself.current_modeinstead of hardcodedAppMode::Agentuser_text_message_with_turn_metadata_for_route— acceptsmode: AppModeparameterhandle_send_message— storesself.current_mode = modeearlyengine/tests.rsturn_metadata_includes_current_mode— verifies mode appears in turn_metaturn_metadata_mode_updates_with_change_mode_op— verifies mid-turn messages pick up mode changesmode_change_runtime_message_format— verifies runtime event formatturn_metadata_includes_auto_model_routeto passAppMode::AgentVerification
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 activeAppModeto every turn's<turn_meta>block so the agent always has accurate context about its operating policy.Enginegains acurrent_mode: AppModefield updated on bothOp::ChangeModeandOp::SendMessage;mode_change_runtime_messageconstructs the runtime event using the same XML pattern as sub-agent completion events.turn_metadata_blocknow accepts amode: AppModeparameter and emits aCurrent mode:line;user_text_message_with_turn_metadatathreadsself.current_modethrough automatically.turn_loop.rsupgrades the prefix-freeze diagnostic from Phase 2 (warn-only) to Phase 3 (emitting a one-shotPrefixCacheChangeevent on the first turn freeze).Confidence Score: 4/5
Safe to merge with one fix:
Op::EditLastTurnhardcodesAppMode::Agentinstead ofself.current_mode, silently overwriting the engine's tracked mode and re-dispatching the edited turn under the wrong approval policy.The
Op::EditLastTurnhandler passes a hardcodedAppMode::Agenttohandle_send_message, which immediately writes it back toself.current_mode. A YOLO or Plan user who runs/editwill 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 nextSendMessageorChangeMode— directly contradicting the purpose of this PR.crates/tui/src/core/engine.rs — the
Op::EditLastTurnarm at line 1158Important Files Changed
current_modetracking,Op::ChangeModeruntime message injection, and mode in turn metadata — butOp::EditLastTurnhardcodesAppMode::Agentinstead ofself.current_mode, silently overwriting the active mode on/editand causing wrong approval policy for YOLO/Plan users.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_opstill setsengine.current_modedirectly rather than via Op dispatch, but the new async test compensates for the most critical path.PrefixCacheChangeevent 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)Comments Outside Diff (1)
crates/tui/src/core/engine.rs, line 1158-1161 (link)EditLastTurnsilently resets active mode to AgentOp::EditLastTurnpassesAppMode::Agenttohandle_send_message, which immediately overwritesself.current_mode = mode. A user who has switched to YOLO or Plan mode and then uses/editwill 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. Usingself.current_modein place of the hardcodedAppMode::Agentwould match the intent.Reviews (2): Last reviewed commit: "fix(engine): address review feedback — i..." | Re-trigger Greptile