merge local main into code-branch-20250918-102707 before merge

This commit is contained in:
James Peter
2025-09-19 08:19:11 +10:00
8 changed files with 378 additions and 113 deletions
+79 -2
View File
@@ -634,6 +634,7 @@ struct State {
current_task: Option<AgentTask>,
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
pending_input: Vec<ResponseInputItem>,
pending_user_input: Vec<QueuedUserInput>,
history: ConversationHistory,
/// Tracks which completed agents (by id) have already been returned to the
/// model for a given batch when using `agent_wait` without `return_all`.
@@ -650,6 +651,13 @@ struct State {
next_internal_sub_id: u64,
}
#[derive(Clone)]
pub(crate) struct QueuedUserInput {
submission_id: String,
response_item: ResponseInputItem,
core_items: Vec<InputItem>,
}
/// Buffers partial turn progress produced during a single HTTP streaming attempt.
/// This is not recorded to persistent history. It is only used to seed retries
/// when the SSE stream disconnects mid‑turn.
@@ -846,6 +854,24 @@ impl Session {
}
}
pub fn has_running_task(&self) -> bool {
self.state.lock().unwrap().current_task.is_some()
}
pub fn queue_user_input(&self, queued: QueuedUserInput) {
let mut state = self.state.lock().unwrap();
state.pending_user_input.push(queued);
}
pub fn pop_next_queued_user_input(&self) -> Option<QueuedUserInput> {
let mut state = self.state.lock().unwrap();
if state.pending_user_input.is_empty() {
None
} else {
Some(state.pending_user_input.remove(0))
}
}
pub(crate) fn next_internal_sub_id(&self) -> String {
let mut state = self.state.lock().unwrap();
let id = state.next_internal_sub_id;
@@ -1566,11 +1592,25 @@ impl Session {
pub fn get_pending_input(&self) -> Vec<ResponseInputItem> {
let mut state = self.state.lock().unwrap();
if state.pending_input.is_empty() {
if state.pending_input.is_empty() && state.pending_user_input.is_empty() {
Vec::with_capacity(0)
} else {
let mut ret = Vec::new();
std::mem::swap(&mut ret, &mut state.pending_input);
if !state.pending_input.is_empty() {
let mut model_inputs = Vec::new();
std::mem::swap(&mut model_inputs, &mut state.pending_input);
ret.extend(model_inputs);
}
if !state.pending_user_input.is_empty() {
let mut queued_user_inputs = Vec::new();
std::mem::swap(&mut queued_user_inputs, &mut state.pending_user_input);
ret.extend(
queued_user_inputs
.into_iter()
.map(|queued| queued.response_item),
);
}
ret
}
}
@@ -2155,6 +2195,31 @@ async fn submission_loop(
let agent = AgentTask::spawn(Arc::clone(&sess), turn_context, sub.id.clone(), items);
sess.set_task(agent);
}
Op::QueueUserInput { items } => {
let sess = match sess.as_ref() {
Some(sess) => sess,
None => {
send_no_session_event(sub.id).await;
continue;
}
};
if sess.has_running_task() {
let response_item = response_input_from_core_items(items.clone());
let queued = QueuedUserInput {
submission_id: sub.id.clone(),
response_item,
core_items: items,
};
sess.queue_user_input(queued);
} else {
// No task running: treat this as immediate user input without aborting.
sess.cleanup_old_status_items().await;
let turn_context = sess.make_turn_context();
let agent = AgentTask::spawn(Arc::clone(&sess), turn_context, sub.id.clone(), items);
sess.set_task(agent);
}
}
Op::ExecApproval { id, decision } => {
let sess = match sess.as_ref() {
Some(sess) => sess,
@@ -2546,6 +2611,18 @@ async fn run_agent(sess: Arc<Session>, turn_context: Arc<TurnContext>, sub_id: S
_ => {}
}
sess.tx_event.send(event).await.ok();
if let Some(queued) = sess.pop_next_queued_user_input() {
let sess_clone = Arc::clone(&sess);
tokio::spawn(async move {
sess_clone.cleanup_old_status_items().await;
let turn_context = sess_clone.make_turn_context();
let submission_id = queued.submission_id;
let items = queued.core_items;
let agent = AgentTask::spawn(Arc::clone(&sess_clone), turn_context, submission_id, items);
sess_clone.set_task(agent);
});
}
}
async fn run_turn(
+7
View File
@@ -105,6 +105,13 @@ pub enum Op {
items: Vec<InputItem>,
},
/// Queue user input to be appended to the next model request without
/// interrupting the current turn.
QueueUserInput {
/// User input items, see `InputItem`
items: Vec<InputItem>,
},
/// Approve a command execution
ExecApproval {
/// The id of the submission we are approving
+7
View File
@@ -61,6 +61,13 @@ pub enum Op {
items: Vec<InputItem>,
},
/// Queue user input to be appended to the next model request without
/// interrupting the current turn.
QueueUserInput {
/// User input items, see `InputItem`
items: Vec<InputItem>,
},
/// Similar to [`Op::UserInput`], but contains additional context required
/// for a turn of a [`crate::codex_conversation::CodexConversation`].
UserTurn {
+22 -16
View File
@@ -57,7 +57,7 @@ pub enum InputResult {
}
struct TokenUsageInfo {
total_token_usage: TokenUsage,
_total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
model_context_window: Option<u64>,
/// Baseline token count present in the context before the user's first
@@ -476,7 +476,7 @@ impl ChatComposer {
.unwrap_or_else(|| last_token_usage.cached_input_tokens.unwrap_or(0));
self.token_usage_info = Some(TokenUsageInfo {
total_token_usage,
_total_token_usage: total_token_usage,
last_token_usage,
model_context_window,
initial_prompt_tokens,
@@ -656,6 +656,10 @@ impl ChatComposer {
self.sync_file_search_popup();
}
pub(crate) fn text(&self) -> &str {
self.textarea.text()
}
/// Close the file-search popup if it is currently active. Returns true if closed.
pub(crate) fn close_file_popup_if_active(&mut self) -> bool {
match self.active_popup {
@@ -1628,22 +1632,24 @@ impl WidgetRef for ChatComposer {
// Prepare token usage spans (always shown when available)
let mut token_spans: Vec<Span> = Vec::new();
if let Some(token_usage_info) = &self.token_usage_info {
let token_usage = &token_usage_info.total_token_usage;
let used_str = format_with_thousands(token_usage.blended_total());
let turn_usage = &token_usage_info.last_token_usage;
let turn_tokens = turn_usage.blended_total();
let used_str = format_with_thousands(turn_tokens);
token_spans.push(Span::from(used_str).style(label_style.add_modifier(Modifier::BOLD)));
token_spans.push(Span::from(" tokens ").style(label_style));
token_spans.push(Span::from(" tokens").style(label_style));
if let Some(context_window) = token_usage_info.model_context_window {
let last_token_usage = &token_usage_info.last_token_usage;
let percent_remaining: u8 = if context_window > 0 {
let percent = 100.0
- (last_token_usage.tokens_in_context_window() as f32
/ context_window as f32
* 100.0);
percent.clamp(0.0, 100.0) as u8
} else { 100 };
token_spans.push(Span::from("(").style(label_style));
token_spans.push(Span::from(percent_remaining.to_string()).style(label_style.add_modifier(Modifier::BOLD)));
token_spans.push(Span::from("% left)").style(label_style));
if context_window > 0 {
let percent_remaining = {
let percent = 100.0
- (turn_usage.tokens_in_context_window() as f32
/ context_window as f32
* 100.0);
percent.clamp(0.0, 100.0) as u8
};
token_spans.push(Span::from(" (").style(label_style));
token_spans.push(Span::from(percent_remaining.to_string()).style(label_style.add_modifier(Modifier::BOLD)));
token_spans.push(Span::from("% left)").style(label_style));
}
}
}
+4
View File
@@ -407,6 +407,10 @@ impl BottomPane<'_> {
self.composer.is_empty()
}
pub(crate) fn composer_text(&self) -> String {
self.composer.text().to_string()
}
pub(crate) fn is_task_running(&self) -> bool {
self.is_task_running
}
+194 -88
View File
@@ -293,6 +293,7 @@ pub(crate) struct ChatWidget<'a> {
// point we submit one queued message and move its cell into the
// normal history within the new turn window.
queued_user_messages: std::collections::VecDeque<UserMessage>,
pending_dispatched_user_messages: std::collections::VecDeque<String>,
// Number of user prompts we pre-pended to history just before starting
// a new turn; used to anchor the next turn window so assistant output
// appears after them.
@@ -1135,6 +1136,14 @@ impl ChatWidget<'_> {
if text.is_empty() {
return;
}
if role == "user" {
if let Some(expected) = self.pending_dispatched_user_messages.front() {
if expected.trim() == text {
self.pending_dispatched_user_messages.pop_front();
return;
}
}
}
if text.starts_with("== System Status ==") {
return;
}
@@ -1150,6 +1159,13 @@ impl ChatWidget<'_> {
Box::new(crate::history_cell::new_user_prompt(text.to_string())),
key,
);
if let Some(front) = self.queued_user_messages.front() {
if front.display_text.trim() == text.trim() {
self.queued_user_messages.pop_front();
self.refresh_queued_user_messages();
}
}
} else {
use crate::history_cell::HistoryCellType;
use crate::history_cell::PlainHistoryCell;
@@ -1762,20 +1778,33 @@ impl ChatWidget<'_> {
// Restore any queued messages back into the composer so the user can
// immediately press Enter to resume the conversation where they left off.
if !self.queued_user_messages.is_empty() {
let mut prefill = String::new();
let existing_input = self.bottom_pane.composer_text();
let mut segments: Vec<String> = Vec::new();
let mut queued_block = String::new();
for (i, qm) in self.queued_user_messages.iter().enumerate() {
if i > 0 {
prefill.push('\n');
queued_block.push_str("\n\n");
}
prefill.push_str(qm.display_text.trim_end());
queued_block.push_str(qm.display_text.trim_end());
}
if !queued_block.trim().is_empty() {
segments.push(queued_block);
}
if !existing_input.trim().is_empty() {
segments.push(existing_input);
}
let combined = segments.join("\n\n");
self.clear_composer();
if !prefill.is_empty() {
self.insert_str(&prefill);
if !combined.is_empty() {
self.insert_str(&combined);
}
self.queued_user_messages.clear();
// Clear any sticky status like "queued for next turn" now that we returned text
self.bottom_pane.update_status_text(String::new());
self.pending_dispatched_user_messages.clear();
self.refresh_queued_user_messages();
}
self.maybe_hide_spinner();
self.request_redraw();
@@ -1987,6 +2016,7 @@ impl ChatWidget<'_> {
pending_jump_back: None,
active_task_ids: HashSet::new(),
queued_user_messages: std::collections::VecDeque::new(),
pending_dispatched_user_messages: std::collections::VecDeque::new(),
pending_user_prompts_for_next_turn: 0,
browser_is_external: false,
// Stable ordering & routing init
@@ -2174,6 +2204,7 @@ impl ChatWidget<'_> {
pending_jump_back: None,
active_task_ids: HashSet::new(),
queued_user_messages: std::collections::VecDeque::new(),
pending_dispatched_user_messages: std::collections::VecDeque::new(),
pending_user_prompts_for_next_turn: 0,
browser_is_external: false,
// Strict ordering init for forked widget
@@ -2963,10 +2994,7 @@ impl ChatWidget<'_> {
for cell in &self.history_cells {
cell.trigger_fade();
}
let UserMessage {
display_text,
mut ordered_items,
} = user_message;
let mut message = user_message;
// If our configured cwd no longer exists (e.g., a worktree folder was
// deleted outside the app), try to automatically recover to the repo
// root for worktrees and re-submit the same message there.
@@ -2993,7 +3021,7 @@ impl ChatWidget<'_> {
// Re-submit this exact message after switching cwd
self.app_event_tx.send(AppEvent::SwitchCwd(
fallback_root,
Some(display_text.clone()),
Some(message.display_text.clone()),
));
return;
}
@@ -3005,10 +3033,10 @@ impl ChatWidget<'_> {
)));
return;
}
let original_text = display_text.clone();
let original_text = message.display_text.clone();
// Build a combined string view of the text-only parts to process slash commands
let mut text_only = String::new();
for it in &ordered_items {
for it in &message.ordered_items {
if let InputItem::Text { text } = it {
if !text_only.is_empty() {
text_only.push('\n');
@@ -3078,8 +3106,12 @@ impl ChatWidget<'_> {
kind: crate::history_cell::HistoryCellType::Notice,
});
ordered_items.clear();
ordered_items.push(InputItem::Text { text: res.prompt });
message
.ordered_items
.clear();
message
.ordered_items
.push(InputItem::Text { text: res.prompt });
// Continue with normal submission after this match block
}
}
@@ -3130,13 +3162,21 @@ impl ChatWidget<'_> {
});
// Replace the message with the resolved prompt
ordered_items.clear();
ordered_items.push(InputItem::Text { text: res.prompt });
message
.ordered_items
.clear();
message
.ordered_items
.push(InputItem::Text { text: res.prompt });
} else {
// Fallback to default expansion behavior
let expanded = _expanded;
ordered_items.clear();
ordered_items.push(InputItem::Text { text: expanded });
message
.ordered_items
.clear();
message
.ordered_items
.push(InputItem::Text { text: expanded });
}
}
crate::slash_command::ProcessedCommand::RegularCommand(cmd, _args) => {
@@ -3298,18 +3338,131 @@ impl ChatWidget<'_> {
}
// Use the ordered items (text + images interleaved with markers)
items.extend(ordered_items);
items.extend(message.ordered_items.clone());
message.ordered_items = items;
if items.is_empty() {
if message.ordered_items.is_empty() {
return;
}
// Debug logging for what we're sending
let ephemeral_count = items
let turn_active = self.is_task_running()
|| !self.active_task_ids.is_empty()
|| self.stream.is_write_cycle_active()
|| !self.queued_user_messages.is_empty();
if turn_active {
tracing::info!(
"Queuing user input while turn is active (queued: {})",
self.queued_user_messages.len() + 1
);
self.queued_user_messages.push_back(message);
self.refresh_queued_user_messages();
let queue_items = self
.queued_user_messages
.back()
.map(|msg| msg.ordered_items.clone())
.unwrap_or_default();
match self.codex_op_tx.send(Op::QueueUserInput { items: queue_items }) {
Ok(()) => {
if let Some(sent_message) = self.queued_user_messages.pop_back() {
self.refresh_queued_user_messages();
self.finalize_sent_user_message(sent_message);
}
}
Err(e) => {
tracing::error!("failed to send QueueUserInput op: {e}");
}
}
return;
}
let mut batch: Vec<UserMessage> = self.queued_user_messages.drain(..).collect();
batch.push(message);
self.refresh_queued_user_messages();
self.send_user_messages_to_agent(batch);
// (debug watchdog removed)
}
fn flush_pending_agent_notes(&mut self) {
for note in self.pending_agent_notes.drain(..) {
if let Err(e) = self.codex_op_tx.send(Op::AddToHistory { text: note }) {
tracing::error!("failed to send AddToHistory op: {e}");
}
}
}
fn finalize_sent_user_message(&mut self, message: UserMessage) {
let UserMessage {
display_text,
..
} = message;
if !display_text.is_empty() {
self.history_push_prompt_next_req(history_cell::new_user_prompt(display_text.clone()));
self.pending_user_prompts_for_next_turn =
self.pending_user_prompts_for_next_turn.saturating_add(1);
self.pending_dispatched_user_messages
.push_back(display_text.clone());
}
self.flush_pending_agent_notes();
if !display_text.is_empty() {
if let Err(e) = self
.codex_op_tx
.send(Op::AddToHistory { text: display_text })
{
tracing::error!("failed to send AddHistory op: {e}");
}
}
self.request_redraw();
}
fn send_user_messages_to_agent(&mut self, messages: Vec<UserMessage>) {
if messages.is_empty() {
return;
}
let mut combined_items: Vec<InputItem> = Vec::new();
let mut history_texts: Vec<String> = Vec::new();
for (idx, UserMessage {
display_text,
ordered_items,
}) in messages.into_iter().enumerate()
{
if !display_text.is_empty() {
self.history_push_prompt_next_req(history_cell::new_user_prompt(
display_text.clone(),
));
self.pending_user_prompts_for_next_turn =
self.pending_user_prompts_for_next_turn.saturating_add(1);
history_texts.push(display_text.clone());
}
if idx > 0 && !combined_items.is_empty() && !ordered_items.is_empty() {
combined_items.push(InputItem::Text {
text: "\n\n".to_string(),
});
}
combined_items.extend(ordered_items);
}
if combined_items.is_empty() {
return;
}
let total_items = combined_items.len();
let ephemeral_count = combined_items
.iter()
.filter(|item| matches!(item, InputItem::EphemeralImage { .. }))
.count();
let total_items = items.len();
if ephemeral_count > 0 {
tracing::info!(
"Sending {} items to model (including {} ephemeral images)",
@@ -3318,40 +3471,26 @@ impl ChatWidget<'_> {
);
}
// New policy: always send immediately even if a task is running.
// The core will abort any current task on UserInput and start a new turn.
// This prevents messages from being stranded in `queued_user_messages`
// after an interrupt (there is no TaskComplete after Op::Interrupt).
if self.bottom_pane.is_task_running() || !self.active_task_ids.is_empty() {
self.bottom_pane
.update_status_text("interrupting & starting new turn".to_string());
self.flush_pending_agent_notes();
if let Err(e) = self
.codex_op_tx
.send(Op::UserInput {
items: combined_items,
})
{
tracing::error!("failed to send Op::UserInput: {e}");
}
// Idle path: append the user cell first so the upcoming TaskStarted
// window begins after it, ensuring assistant output renders below.
if !original_text.is_empty() {
self.history_push_prompt_next_req(history_cell::new_user_prompt(original_text.clone()));
self.pending_user_prompts_for_next_turn =
self.pending_user_prompts_for_next_turn.saturating_add(1);
for text in history_texts {
if let Err(e) = self.codex_op_tx.send(Op::AddToHistory { text }) {
tracing::error!("failed to send AddHistory op: {e}");
}
}
}
// If an access-mode change was pending, record it in the agent's
// conversation history right before the next user turn.
for note in self.pending_agent_notes.drain(..) {
let _ = self.codex_op_tx.send(Op::AddToHistory { text: note });
}
// Now send to the agent
let _ = self.codex_op_tx.send(Op::UserInput { items });
if !original_text.is_empty() {
self.codex_op_tx
.send(Op::AddToHistory {
text: original_text.clone(),
})
.unwrap_or_else(|e| tracing::error!("failed to send AddHistory op: {e}"));
}
// (debug watchdog removed)
fn refresh_queued_user_messages(&mut self) {
self.request_redraw();
}
#[allow(dead_code)]
@@ -3759,40 +3898,6 @@ impl ChatWidget<'_> {
// Now that streaming is complete, flush any queued interrupts
self.flush_interrupt_queue();
// If any user messages were queued while the previous task was
// running, dispatch exactly one now to start the next turn.
if let Some(next) = self.queued_user_messages.pop_front() {
// Move it from the sticky queue preview into history
let UserMessage {
display_text,
ordered_items,
} = next;
if !display_text.is_empty() {
self.history_push_prompt_next_req(history_cell::new_user_prompt(
display_text.clone(),
));
self.pending_user_prompts_for_next_turn =
self.pending_user_prompts_for_next_turn.saturating_add(1);
}
self.codex_op_tx
.send(Op::UserInput {
items: ordered_items,
})
.unwrap_or_else(|e| tracing::error!("failed to send queued message: {e}"));
if !display_text.is_empty() {
self.codex_op_tx
.send(Op::AddToHistory { text: display_text })
.unwrap_or_else(|e| {
tracing::error!("failed to send AddHistory for queued: {e}")
});
}
// Keep the spinner up; TaskStarted will follow.
self.bottom_pane.set_task_running(true);
self.bottom_pane
.update_status_text("waiting for model".to_string());
self.request_redraw();
}
// Only drop the working status if nothing is actually running.
let any_tools_running = !self.exec.running_commands.is_empty()
|| !self.tools_state.running_custom_tools.is_empty()
@@ -4559,6 +4664,7 @@ impl ChatWidget<'_> {
self.history_push(history_cell::new_status_output(
&self.config,
&self.total_token_usage,
&self.last_token_usage,
));
}
+1 -1
View File
@@ -544,7 +544,7 @@ fn interrupt_restores_queued_messages_into_composer() {
// Composer should now contain the queued messages joined by newlines, in order.
assert_eq!(
chat.bottom_pane.composer_text(),
"first queued\nsecond queued"
"first queued\n\nsecond queued"
);
// Queue should be cleared and no new user input should have been auto-submitted.
+64 -6
View File
@@ -19,6 +19,7 @@ use codex_core::protocol::FileChange;
use codex_core::protocol::McpInvocation;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::protocol::TokenUsage;
use codex_protocol::num_format::format_with_separators;
use image::DynamicImage;
use image::ImageReader;
use mcp_types::EmbeddedResourceResource;
@@ -8843,7 +8844,11 @@ pub(crate) fn new_model_output(model: &str, effort: ReasoningEffort) -> PlainHis
// Continue with more factory functions...
// I'll add the rest in the next part to keep this manageable
pub(crate) fn new_status_output(config: &Config, usage: &TokenUsage) -> PlainHistoryCell {
pub(crate) fn new_status_output(
config: &Config,
total_usage: &TokenUsage,
last_usage: &TokenUsage,
) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("/status").fg(crate::colors::keyword()));
@@ -8960,24 +8965,77 @@ pub(crate) fn new_status_output(config: &Config, usage: &TokenUsage) -> PlainHis
// Input: <input> [+ <cached> cached]
let mut input_line_spans: Vec<Span<'static>> = vec![
" • Input: ".into(),
usage.non_cached_input().to_string().into(),
format_with_separators(last_usage.non_cached_input()).into(),
];
if let Some(cached) = usage.cached_input_tokens {
if let Some(cached) = last_usage.cached_input_tokens {
if cached > 0 {
input_line_spans.push(format!(" (+ {cached} cached)").into());
input_line_spans.push(format!(" (+ {} cached)", format_with_separators(cached)).into());
}
}
lines.push(Line::from(input_line_spans));
// Output: <output>
lines.push(Line::from(vec![
" • Output: ".into(),
usage.output_tokens.to_string().into(),
format_with_separators(last_usage.output_tokens).into(),
]));
// Total: <total>
lines.push(Line::from(vec![
" • Total: ".into(),
usage.blended_total().to_string().into(),
format_with_separators(last_usage.blended_total()).into(),
]));
lines.push(Line::from(vec![
" • Session total: ".into(),
format_with_separators(total_usage.blended_total()).into(),
]));
// 📐 Model Limits
let context_window = config.model_context_window;
let max_output_tokens = config.model_max_output_tokens;
let auto_compact_limit = config.model_auto_compact_token_limit;
if context_window.is_some() || max_output_tokens.is_some() || auto_compact_limit.is_some() {
lines.push(Line::from(""));
lines.push(Line::from(vec!["📐 ".into(), "Model Limits".bold()]));
if let Some(context_window) = context_window {
let used = last_usage.tokens_in_context_window().min(context_window);
let percent_full = if context_window > 0 {
((used as f64 / context_window as f64) * 100.0).min(100.0)
} else {
0.0
};
lines.push(Line::from(format!(
" • Context window: {} used of {} ({:.0}% full)",
format_with_separators(used),
format_with_separators(context_window),
percent_full
)));
}
if let Some(max_output_tokens) = max_output_tokens {
lines.push(Line::from(format!(
" • Max output tokens: {}",
format_with_separators(max_output_tokens)
)));
}
if let Some(limit) = auto_compact_limit {
if limit <= 0 {
lines.push(Line::from(" • Auto-compact threshold: disabled"));
} else {
let limit_u64 = limit as u64;
let remaining = limit_u64.saturating_sub(total_usage.total_tokens);
lines.push(Line::from(format!(
" • Auto-compact threshold: {} ({} remaining)",
format_with_separators(limit_u64),
format_with_separators(remaining)
)));
if total_usage.total_tokens > limit_u64 {
lines.push(Line::from(" • Compacting will trigger on the next turn".dim()));
}
}
}
}
PlainHistoryCell {
lines,