feat(engine): inject mode-change runtime message and include mode in turn metadata
Inject a mode-change runtime message into the engine and include mode information in turn metadata for better tracking.
This commit is contained in:
@@ -496,6 +496,8 @@ pub struct Engine {
|
||||
/// This keeps prompt refreshes cheap while still noticing append/update
|
||||
/// writes from slop ledger tools during the same session.
|
||||
slop_ledger_gate_cache: Option<(Option<SystemTime>, Option<String>)>,
|
||||
/// Current operating mode. Updated on `ChangeMode` and `SendMessage`.
|
||||
current_mode: AppMode,
|
||||
}
|
||||
|
||||
// === Internal tool helpers ===
|
||||
@@ -739,6 +741,7 @@ impl Engine {
|
||||
slop_ledger_gate_cache: None,
|
||||
workshop_vars,
|
||||
sandbox_backend,
|
||||
current_mode: AppMode::Agent,
|
||||
};
|
||||
engine.rehydrate_latest_canonical_state();
|
||||
|
||||
@@ -1164,11 +1167,23 @@ impl Engine {
|
||||
let _ = self.tx_event.send(Event::AgentList { agents }).await;
|
||||
}
|
||||
Op::ChangeMode { mode } => {
|
||||
let previous_mode = self.current_mode;
|
||||
self.current_mode = mode;
|
||||
self.refresh_system_prompt(mode);
|
||||
self.emit_session_updated().await;
|
||||
// Notify the agent that the mode has changed so it can re-evaluate
|
||||
// any operations that were blocked by the previous mode's policy.
|
||||
if previous_mode != mode {
|
||||
let msg = Self::mode_change_runtime_message(previous_mode, mode);
|
||||
self.session.add_message(msg);
|
||||
self.emit_session_updated().await;
|
||||
}
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(format!("Mode changed to: {mode:?}")))
|
||||
.send(Event::status(format!(
|
||||
"Mode changed to: {}",
|
||||
mode.description()
|
||||
)))
|
||||
.await;
|
||||
}
|
||||
Op::SetModel { model, mode } => {
|
||||
@@ -1308,6 +1323,47 @@ impl Engine {
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Build a runtime event message notifying the agent that the operating mode has changed.
|
||||
/// This lets the agent re-evaluate any operations that were blocked by the previous mode.
|
||||
fn mode_change_runtime_message(previous_mode: AppMode, new_mode: AppMode) -> Message {
|
||||
let (policy_note, re_eval_hint) = match new_mode {
|
||||
AppMode::Yolo => (
|
||||
"all operations run automatically without requiring user approval",
|
||||
"Re-evaluate any previously blocked write, patch, or shell operations \
|
||||
— they are now auto-approved.",
|
||||
),
|
||||
AppMode::Agent => (
|
||||
"read-only operations run silently; writes, patches, and shell \
|
||||
commands require user approval",
|
||||
"Any operations you ran automatically under YOLO mode now require \
|
||||
explicit user approval before executing.",
|
||||
),
|
||||
AppMode::Plan => (
|
||||
"all writes and patches are blocked; shell and code execution are unavailable",
|
||||
"Any previously planned operations that require writes or shell access \
|
||||
must wait until the mode changes back to Agent or YOLO.",
|
||||
),
|
||||
};
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: format!(
|
||||
"<codewhale:runtime_event kind=\"mode_change\" visibility=\"internal\">\n\
|
||||
This is an internal runtime event, not user input. The operating mode has changed \
|
||||
from {previous} mode to {new} mode.\n\n\
|
||||
In {new} mode: {policy}\n\n\
|
||||
{re_eval}\n\
|
||||
</codewhale:runtime_event>",
|
||||
previous = previous_mode.description(),
|
||||
new = new_mode.description(),
|
||||
policy = policy_note,
|
||||
re_eval = re_eval_hint,
|
||||
),
|
||||
cache_control: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_session_message(&mut self, message: Message) {
|
||||
self.session.add_message(message);
|
||||
self.emit_session_updated().await;
|
||||
@@ -1316,11 +1372,13 @@ impl Engine {
|
||||
fn turn_metadata_block(
|
||||
&self,
|
||||
routed_model: &str,
|
||||
mode: AppMode,
|
||||
auto_model: bool,
|
||||
reasoning_effort: Option<&str>,
|
||||
reasoning_effort_auto: bool,
|
||||
) -> ContentBlock {
|
||||
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
|
||||
let mode_label = mode.description();
|
||||
let working_set_summary = self
|
||||
.session
|
||||
.working_set
|
||||
@@ -1330,6 +1388,7 @@ impl Engine {
|
||||
|
||||
let mut lines = vec![
|
||||
format!("Current local date: {today}"),
|
||||
format!("Current mode: {mode_label}"),
|
||||
format!("Current model: {routed_model}"),
|
||||
];
|
||||
if auto_model {
|
||||
@@ -1352,6 +1411,7 @@ impl Engine {
|
||||
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,
|
||||
self.session.auto_model,
|
||||
self.session.reasoning_effort.as_deref(),
|
||||
@@ -1362,6 +1422,7 @@ impl Engine {
|
||||
fn user_text_message_with_turn_metadata_for_route(
|
||||
&self,
|
||||
text: String,
|
||||
mode: AppMode,
|
||||
routed_model: &str,
|
||||
auto_model: bool,
|
||||
reasoning_effort: Option<&str>,
|
||||
@@ -1372,6 +1433,7 @@ impl Engine {
|
||||
content: vec![
|
||||
self.turn_metadata_block(
|
||||
routed_model,
|
||||
mode,
|
||||
auto_model,
|
||||
reasoning_effort,
|
||||
reasoning_effort_auto,
|
||||
@@ -1407,6 +1469,9 @@ impl Engine {
|
||||
// Reset cancel token for fresh turn (in case previous was cancelled)
|
||||
self.reset_cancel_token();
|
||||
|
||||
// Track current mode so mid-turn messages include the right mode in turn metadata.
|
||||
self.current_mode = mode;
|
||||
|
||||
// Drain stale steer messages from previous turns.
|
||||
while self.rx_steer.try_recv().is_ok() {}
|
||||
|
||||
@@ -1486,6 +1551,7 @@ impl Engine {
|
||||
// Add user message to session
|
||||
let user_msg = self.user_text_message_with_turn_metadata_for_route(
|
||||
content,
|
||||
mode,
|
||||
&model,
|
||||
auto_model,
|
||||
reasoning_effort.as_deref(),
|
||||
|
||||
@@ -1693,6 +1693,61 @@ async fn change_mode_refreshes_session_prompt_and_updates_session() {
|
||||
assert!(prompt.contains("Approval Policy: Auto"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn change_mode_op_injects_runtime_event_into_session_messages() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let config = EngineConfig {
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let (engine, handle) = Engine::new(config, &Config::default());
|
||||
|
||||
let run = tokio::spawn(engine.run());
|
||||
// Switch from default Agent → YOLO
|
||||
handle
|
||||
.send(Op::ChangeMode {
|
||||
mode: AppMode::Yolo,
|
||||
})
|
||||
.await
|
||||
.expect("send change mode");
|
||||
|
||||
// Collect session-updated events until we see the injected message
|
||||
let messages = {
|
||||
let mut rx = handle.rx_event.write().await;
|
||||
loop {
|
||||
let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
|
||||
.await
|
||||
.expect("session update after mode switch")
|
||||
.expect("event");
|
||||
if let Event::SessionUpdated { messages, .. } = event {
|
||||
// The last message should be our runtime event
|
||||
if let Some(last) = messages.last()
|
||||
&& let ContentBlock::Text { text, .. } =
|
||||
last.content.first().expect("text block")
|
||||
&& text.contains("kind=\"mode_change\"")
|
||||
{
|
||||
break messages;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
run.abort();
|
||||
|
||||
let last = messages.last().expect("at least one message");
|
||||
let ContentBlock::Text { text, .. } = last.content.first().expect("text block") else {
|
||||
panic!("expected text block");
|
||||
};
|
||||
assert!(
|
||||
text.contains("Agent mode") && text.contains("YOLO mode"),
|
||||
"should contain both previous and new mode: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("Re-evaluate"),
|
||||
"should tell agent to re-evaluate: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_context_length_errors_from_provider_payloads() {
|
||||
let msg = r#"SSE stream request failed: HTTP 400 Bad Request: {"error":{"message":"This model's maximum context length is 131072 tokens. However, you requested 153056 tokens (148960 in the messages, 4096 in the completion).","type":"invalid_request_error"}}"#;
|
||||
@@ -2118,6 +2173,7 @@ fn turn_metadata_includes_auto_model_route() {
|
||||
|
||||
let user_msg = engine.user_text_message_with_turn_metadata_for_route(
|
||||
"debug this regression".to_string(),
|
||||
AppMode::Agent,
|
||||
"deepseek-v4-pro",
|
||||
true,
|
||||
Some("max"),
|
||||
@@ -2134,6 +2190,94 @@ fn turn_metadata_includes_auto_model_route() {
|
||||
assert!(!text.contains("debug this regression"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_metadata_includes_current_mode() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let config = EngineConfig {
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
let (engine, _handle) = Engine::new(config, &Config::default());
|
||||
|
||||
let user_msg = engine.user_text_message_with_turn_metadata_for_route(
|
||||
"test mode metadata".to_string(),
|
||||
AppMode::Yolo,
|
||||
"deepseek-v4-flash",
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
let first_block = user_msg.content.first().expect("turn metadata block");
|
||||
let ContentBlock::Text { text, .. } = first_block else {
|
||||
panic!("expected text metadata block");
|
||||
};
|
||||
|
||||
assert!(
|
||||
text.contains("Current mode: YOLO mode - full tool access without approvals"),
|
||||
"turn metadata should include the current mode label, got: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_metadata_mode_updates_with_change_mode_op() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let config = EngineConfig {
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
let (mut engine, _handle) = Engine::new(config, &Config::default());
|
||||
|
||||
// In agent mode by default
|
||||
let msg = engine.user_text_message_with_turn_metadata("hello".to_string());
|
||||
let first_block = msg.content.first().expect("turn metadata block");
|
||||
let ContentBlock::Text { text, .. } = first_block else {
|
||||
panic!("expected text metadata block");
|
||||
};
|
||||
assert!(
|
||||
text.contains("Agent mode"),
|
||||
"initial mode should be Agent, got: {text}"
|
||||
);
|
||||
|
||||
// Switch to YOLO — user_text_message_with_turn_metadata should reflect the new mode
|
||||
engine.current_mode = AppMode::Yolo;
|
||||
let msg = engine.user_text_message_with_turn_metadata("hello again".to_string());
|
||||
let first_block = msg.content.first().expect("turn metadata block");
|
||||
let ContentBlock::Text { text, .. } = first_block else {
|
||||
panic!("expected text metadata block");
|
||||
};
|
||||
assert!(
|
||||
text.contains("YOLO mode"),
|
||||
"mode after change should be YOLO, got: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_change_runtime_message_format() {
|
||||
let msg = Engine::mode_change_runtime_message(AppMode::Agent, AppMode::Yolo);
|
||||
|
||||
assert_eq!(msg.role, "user");
|
||||
let ContentBlock::Text { text, .. } = msg.content.first().expect("text block") else {
|
||||
panic!("expected text block");
|
||||
};
|
||||
|
||||
assert!(
|
||||
text.contains("codewhale:runtime_event"),
|
||||
"should be a runtime event message"
|
||||
);
|
||||
assert!(
|
||||
text.contains("kind=\"mode_change\""),
|
||||
"should have mode_change kind"
|
||||
);
|
||||
assert!(
|
||||
text.contains("Agent mode") && text.contains("YOLO mode"),
|
||||
"should mention both previous and new mode: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("Re-evaluate"),
|
||||
"should tell agent to re-evaluate blocked operations: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_text_message_keeps_current_turn_input_after_turn_metadata() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -322,7 +322,9 @@ impl Engine {
|
||||
// Three-zone prefix contract (#2264): freeze baseline on first
|
||||
// turn, verify against it on subsequent turns. Operates alongside
|
||||
// PrefixStabilityManager as an independent diagnostic layer.
|
||||
// Phase 2: warn-only, auto-re-freeze on drift.
|
||||
// Phase 3: emit a one-shot 'frozen' event on first turn.
|
||||
// Drift is logged (tracing::debug!) but not re-emitted —
|
||||
// PrefixStabilityManager already reports the change above.
|
||||
let system_text =
|
||||
crate::prefix_cache::system_prompt_text(self.session.system_prompt.as_ref());
|
||||
let current_tools: &[crate::models::Tool] = active_tools.as_deref().unwrap_or_default();
|
||||
@@ -346,7 +348,19 @@ impl Engine {
|
||||
self.session.system_prompt.as_ref(),
|
||||
current_tools.to_vec(),
|
||||
);
|
||||
self.session.frozen_prefix = Some(pinned.freeze());
|
||||
let frozen = pinned.freeze();
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::PrefixCacheChange {
|
||||
description: format!("frozen: {}", frozen.short_id()),
|
||||
system_prompt_changed: false,
|
||||
tools_changed: false,
|
||||
stability_pct: 100,
|
||||
changed: false,
|
||||
pinned_combined_hash: frozen.hash().to_string(),
|
||||
})
|
||||
.await;
|
||||
self.session.frozen_prefix = Some(frozen);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user