Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions codex-rs/core/src/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,37 @@ impl Session {
fn show_raw_agent_reasoning(&self) -> bool {
self.services.show_raw_agent_reasoning
}

pub async fn set_plan_mode_enabled(&self, enabled: bool) {
let mut state = self.state.lock().await;
state.plan_mode = enabled;
}

async fn maybe_prefix_plan_change_input(&self, items: Vec<InputItem>) -> Vec<InputItem> {
let (should_emit, current) = {
let mut state = self.state.lock().await;
let current = state.plan_mode;
let changed = state.last_emitted_plan_mode != Some(current);
if changed {
state.last_emitted_plan_mode = Some(current);
}
(changed, current)
};
if should_emit {
let text = if current {
"User engaged plan mode. *DO NOT* write any code or make changes unless the user has given approval AND plan mode is disabled. In this mode, first create a plan of action, then show it to the user and wait for their approval before executing any patch calls. Even if user explicitly asks you to write code, you should *still wait* until you get the \"Plan mode disengaged\" system message.".to_string()
} else {
"User disengaged plan mode. You may now write code or make changes.".to_string()
};
let item = ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText { text }],
};
self.record_conversation_items(&[item]).await;
}
items
}
}

impl Drop for Session {
Expand Down Expand Up @@ -1143,6 +1174,7 @@ async fn submission_loop(
model,
effort,
summary,
plan_mode,
} => {
// Recalculate the persistent turn context with provided overrides.
let prev = Arc::clone(&turn_context);
Expand Down Expand Up @@ -1229,12 +1261,20 @@ async fn submission_loop(
))])
.await;
}

// Persist the plan mode state only; a user-visible message is emitted
// the next time user input is sent.
if let Some(enabled) = plan_mode {
sess.set_plan_mode_enabled(enabled).await;
}
}
Op::UserInput { items } => {
turn_context
.client
.get_otel_event_manager()
.user_prompt(&items);
// If plan mode changed since last emission, prefix a user message first.
let items = sess.maybe_prefix_plan_change_input(items).await;
// attempt to inject input into current task
if let Err(items) = sess.inject_input(items).await {
// no current task, spawn a new one
Expand Down Expand Up @@ -3203,4 +3243,146 @@ mod tests {
pretty_assertions::assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 });
assert!(exec_output.output.contains("hi"));
}

#[tokio::test]
async fn plan_mode_prefixes_once_on_enable_and_skips_after() {
use crate::protocol::InputItem;
let (session, _tc) = make_session_and_context();
let sess = Arc::new(session);

// Enable plan mode before first send
sess.set_plan_mode_enabled(true).await;

// First send should be prefixed with ENGAGED line
let items = vec![InputItem::Text {
text: "hi".to_string(),
}];
let out = sess.maybe_prefix_plan_change_input(items).await;
// Items are unchanged; notice is a separate developer message in history.
assert_eq!(out.len(), 1, "user input should be unchanged");
match &out[0] {
InputItem::Text { text } => assert_eq!(text, "hi"),
_ => panic!("expected original user text only"),
}
// Find developer notice in history
let history = sess.history_snapshot().await;
let dev = history
.iter()
.rev()
.find_map(|ri| match ri {
ResponseItem::Message { role, content, .. } if role == "developer" => {
let full = content
.iter()
.filter_map(|c| match c {
ContentItem::InputText { text } => Some(text),
_ => None,
})
.cloned()
.collect::<Vec<_>>()
.join("");
Some(full)
}
_ => None,
})
.expect("developer notice in history");
let lower = dev.to_lowercase();
assert!(dev.contains("<user_action>"));
assert!(lower.contains("plan mode"));
assert!(lower.contains("engag"), "expected engaged text: {dev}");

// Second send without change should not get prefixed
let items2 = vec![InputItem::Text {
text: "hello".to_string(),
}];
let out2 = sess.maybe_prefix_plan_change_input(items2).await;
assert_eq!(out2.len(), 1, "no prefix after no change");
match &out2[0] {
InputItem::Text { text } => assert_eq!(text, "hello"),
_ => panic!("expected original user text only"),
}
}

#[tokio::test]
async fn plan_mode_prefixes_once_on_disable() {
use crate::protocol::InputItem;
let (session, _tc) = make_session_and_context();
let sess = Arc::new(session);

// Enable then emit once
sess.set_plan_mode_enabled(true).await;
let _ = sess
.maybe_prefix_plan_change_input(vec![InputItem::Text {
text: "first".to_string(),
}])
.await;

// Disable plan mode; next send should be prefixed with DISENGAGED
sess.set_plan_mode_enabled(false).await;
let out = sess
.maybe_prefix_plan_change_input(vec![InputItem::Text {
text: "second".to_string(),
}])
.await;
assert_eq!(out.len(), 1, "user input should be unchanged");
let history = sess.history_snapshot().await;
let notices: Vec<String> = history
.iter()
.filter_map(|ri| match ri {
ResponseItem::Message { role, content, .. } if role == "developer" => Some(
content
.iter()
.filter_map(|c| match c {
ContentItem::InputText { text } => Some(text),
_ => None,
})
.cloned()
.collect::<Vec<_>>()
.join(""),
),
_ => None,
})
.collect();
assert!(notices.len() >= 2, "expected two developer notices");
let last = notices.last().unwrap();
let lower = last.to_lowercase();
assert!(last.contains("<user_action>"));
assert!(lower.contains("plan mode"));
assert!(
lower.contains("disengag"),
"expected disengaged text: {last}"
);

// Next send with no change should not be prefixed
let out2 = sess
.maybe_prefix_plan_change_input(vec![InputItem::Text {
text: "third".to_string(),
}])
.await;
assert_eq!(out2.len(), 1, "no prefix after disable state unchanged");
}

#[tokio::test]
async fn plan_mode_multiple_toggles_before_send_emits_once() {
use crate::protocol::InputItem;
let (session, _tc) = make_session_and_context();
let sess = Arc::new(session);

// Toggle several times before sending; final state true
sess.set_plan_mode_enabled(true).await;
sess.set_plan_mode_enabled(false).await;
sess.set_plan_mode_enabled(true).await;

let out = sess
.maybe_prefix_plan_change_input(vec![InputItem::Text {
text: "go".to_string(),
}])
.await;
assert_eq!(out.len(), 1, "user input should be unchanged");
let history = sess.history_snapshot().await;
let dev_count = history
.iter()
.filter(|ri| matches!(ri, ResponseItem::Message { role, .. } if role == "developer"))
.count();
assert!(dev_count >= 1, "expected a developer notice recorded");
}
}
3 changes: 3 additions & 0 deletions codex-rs/core/src/state/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ pub(crate) struct SessionState {
pub(crate) history: ConversationHistory,
pub(crate) token_info: Option<TokenUsageInfo>,
pub(crate) latest_rate_limits: Option<RateLimitSnapshot>,
// Tracks current Plan Mode and the last state for which a notice was emitted.
pub(crate) plan_mode: bool,
pub(crate) last_emitted_plan_mode: Option<bool>,
}

impl SessionState {
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/core/tests/suite/model_overrides.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ async fn override_turn_context_does_not_persist_when_config_exists() {
model: Some("o3".to_string()),
effort: Some(Some(ReasoningEffort::High)),
summary: None,
plan_mode: None,
})
.await
.expect("submit override");
Expand Down Expand Up @@ -78,6 +79,7 @@ async fn override_turn_context_does_not_create_config_file() {
model: Some("o3".to_string()),
effort: Some(Some(ReasoningEffort::Medium)),
summary: None,
plan_mode: None,
})
.await
.expect("submit override");
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/tests/suite/prompt_caching.rs
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() {
model: Some("o3".to_string()),
effort: Some(Some(ReasoningEffort::High)),
summary: Some(ReasoningSummary::Detailed),
plan_mode: None,
})
.await
.unwrap();
Expand Down
6 changes: 6 additions & 0 deletions codex-rs/protocol/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ pub enum Op {
/// Updated reasoning summary preference (honored only for reasoning-capable models).
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<ReasoningSummaryConfig>,

/// Toggle Plan Mode for subsequent turns. When enabled, the server will
/// append a Plan Mode instruction to the session's user instructions;
/// when disabled, the server will remove the appended instruction if present.
#[serde(skip_serializing_if = "Option::is_none")]
plan_mode: Option<bool>,
},

/// Approve a command execution
Expand Down
21 changes: 19 additions & 2 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ pub(crate) struct ChatComposer {
footer_mode: FooterMode,
footer_hint_override: Option<Vec<(String, String)>>,
context_window_percent: Option<u8>,
plan_mode: bool,
}

/// Popup state – at most one can be visible at any time.
Expand Down Expand Up @@ -152,12 +153,17 @@ impl ChatComposer {
footer_mode: FooterMode::ShortcutPrompt,
footer_hint_override: None,
context_window_percent: None,
plan_mode: false,
};
// Apply configuration via the setter to keep side-effects centralized.
this.set_disable_paste_burst(disable_paste_burst);
this
}

pub(crate) fn set_plan_mode(&mut self, enabled: bool) {
self.plan_mode = enabled;
}

pub fn desired_height(&self, width: u16) -> u16 {
let footer_props = self.footer_props();
let footer_hint_height = self
Expand Down Expand Up @@ -1337,6 +1343,7 @@ impl ChatComposer {
use_shift_enter_hint: self.use_shift_enter_hint,
is_task_running: self.is_task_running,
context_window_percent: self.context_window_percent,
plan_mode: self.plan_mode,
}
}

Expand All @@ -1345,8 +1352,18 @@ impl ChatComposer {
FooterMode::EscHint => FooterMode::EscHint,
FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay,
FooterMode::CtrlCReminder => FooterMode::CtrlCReminder,
FooterMode::ShortcutPrompt if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder,
FooterMode::ShortcutPrompt if !self.is_empty() => FooterMode::Empty,
FooterMode::ShortcutPrompt => {
if self.ctrl_c_quit_hint {
FooterMode::CtrlCReminder
} else if self.plan_mode {
// Keep footer visible for Plan Mode regardless of textbox content.
FooterMode::ShortcutPrompt
} else if !self.is_empty() {
FooterMode::Empty
} else {
FooterMode::ShortcutPrompt
}
}
other => other,
}
}
Expand Down
Loading
Loading