mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Compare commits
1 Commits
rust-v0.92
...
daniel/pla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c7c6a4dfc |
@@ -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 {
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ pub(crate) struct FooterProps {
|
||||
pub(crate) use_shift_enter_hint: bool,
|
||||
pub(crate) is_task_running: bool,
|
||||
pub(crate) context_window_percent: Option<u8>,
|
||||
pub(crate) plan_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -77,7 +78,9 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
|
||||
is_task_running: props.is_task_running,
|
||||
})],
|
||||
FooterMode::ShortcutPrompt => {
|
||||
if props.is_task_running {
|
||||
if props.plan_mode {
|
||||
vec![plan_mode_line()]
|
||||
} else if props.is_task_running {
|
||||
vec![context_window_line(props.context_window_percent)]
|
||||
} else {
|
||||
vec![Line::from(vec![
|
||||
@@ -233,6 +236,10 @@ fn context_window_line(percent: Option<u8>) -> Line<'static> {
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn plan_mode_line() -> Line<'static> {
|
||||
Line::from(vec![">> Plan Mode".cyan()])
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum ShortcutId {
|
||||
Commands,
|
||||
@@ -407,6 +414,7 @@ mod tests {
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
plan_mode: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -418,6 +426,7 @@ mod tests {
|
||||
use_shift_enter_hint: true,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
plan_mode: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -429,6 +438,7 @@ mod tests {
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
plan_mode: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -440,6 +450,7 @@ mod tests {
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
context_window_percent: None,
|
||||
plan_mode: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -451,6 +462,7 @@ mod tests {
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
plan_mode: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -462,6 +474,7 @@ mod tests {
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
plan_mode: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -473,6 +486,7 @@ mod tests {
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
context_window_percent: Some(72),
|
||||
plan_mode: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -264,6 +264,11 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn set_plan_mode(&mut self, enabled: bool) {
|
||||
self.composer.set_plan_mode(enabled);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Replace the composer text with `text`.
|
||||
pub(crate) fn set_composer_text(&mut self, text: String) {
|
||||
self.composer.set_text_content(text);
|
||||
|
||||
@@ -260,6 +260,10 @@ pub(crate) struct ChatWidget {
|
||||
needs_final_message_separator: bool,
|
||||
|
||||
last_rendered_width: std::cell::Cell<Option<usize>>,
|
||||
|
||||
// When true, Plan Mode is active and the composer shows a hint; core gets a
|
||||
// context override to add plan-mode instructions.
|
||||
plan_mode: bool,
|
||||
}
|
||||
|
||||
struct UserMessage {
|
||||
@@ -938,6 +942,7 @@ impl ChatWidget {
|
||||
ghost_snapshots_disabled: true,
|
||||
needs_final_message_separator: false,
|
||||
last_rendered_width: std::cell::Cell::new(None),
|
||||
plan_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1001,6 +1006,7 @@ impl ChatWidget {
|
||||
ghost_snapshots_disabled: true,
|
||||
needs_final_message_separator: false,
|
||||
last_rendered_width: std::cell::Cell::new(None),
|
||||
plan_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1023,6 +1029,15 @@ impl ChatWidget {
|
||||
self.on_ctrl_c();
|
||||
return;
|
||||
}
|
||||
// Shift+Tab toggles Plan Mode.
|
||||
KeyEvent {
|
||||
code: KeyCode::BackTab,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
self.set_plan_mode(!self.plan_mode);
|
||||
return;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('v'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
@@ -1078,6 +1093,28 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_plan_mode(&mut self, enabled: bool) {
|
||||
if self.plan_mode == enabled {
|
||||
return;
|
||||
}
|
||||
self.plan_mode = enabled;
|
||||
// Update composer UI hint
|
||||
self.bottom_pane.set_plan_mode(enabled);
|
||||
// Inform core to update user instructions for subsequent turns
|
||||
self.codex_op_tx
|
||||
.send(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
plan_mode: Some(enabled),
|
||||
})
|
||||
.unwrap_or_else(|e| tracing::error!("failed to send plan mode override: {e}"));
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn attach_image(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
@@ -1732,6 +1769,7 @@ impl ChatWidget {
|
||||
model: Some(model_for_action.clone()),
|
||||
effort: Some(effort_for_action),
|
||||
summary: None,
|
||||
plan_mode: None,
|
||||
}));
|
||||
tx.send(AppEvent::UpdateModel(model_for_action.clone()));
|
||||
tx.send(AppEvent::UpdateReasoningEffort(effort_for_action));
|
||||
@@ -1788,6 +1826,7 @@ impl ChatWidget {
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
plan_mode: None,
|
||||
}));
|
||||
tx.send(AppEvent::UpdateAskForApprovalPolicy(approval));
|
||||
tx.send(AppEvent::UpdateSandboxPolicy(sandbox.clone()));
|
||||
|
||||
@@ -287,6 +287,7 @@ fn make_chatwidget_manual() -> (
|
||||
ghost_snapshots_disabled: false,
|
||||
needs_final_message_separator: false,
|
||||
last_rendered_width: std::cell::Cell::new(None),
|
||||
plan_mode: false,
|
||||
};
|
||||
(widget, rx, op_rx)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user