feat(tui/order): insert plan updates and background events at near-time; keep reasoning ellipsis during streaming. fix(core/websearch): stamp OrderMeta on WebSearchBegin/Complete for correct in-turn placement

This commit is contained in:
James Peter
2025-09-08 09:37:32 +10:00
parent 0930b6b086
commit 81a31dd51c
3 changed files with 114 additions and 52 deletions
+20 -8
View File
@@ -2504,16 +2504,28 @@ async fn try_run_turn(
output.push(ProcessedResponseItem { item, response });
}
ResponseEvent::WebSearchCallBegin { call_id } => {
let _ = sess
.tx_event
.send(sess.make_event(&sub_id, EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id, query: None })))
.await;
// Stamp OrderMeta so the TUI can place the search block within
// the correct request window instead of using an internal epilogue.
let ctx = ToolCallCtx::new(sub_id.to_string(), call_id.clone(), None, None);
let order = ctx.order_meta(attempt_req);
let ev = sess.make_event_with_order(
&sub_id,
EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id, query: None }),
order,
None,
);
sess.send_event(ev).await;
}
ResponseEvent::WebSearchCallCompleted { call_id, query } => {
let _ = sess
.tx_event
.send(sess.make_event(&sub_id, EventMsg::WebSearchComplete(WebSearchCompleteEvent { call_id, query })))
.await;
let ctx = ToolCallCtx::new(sub_id.to_string(), call_id.clone(), None, None);
let order = ctx.order_meta(attempt_req);
let ev = sess.make_event_with_order(
&sub_id,
EventMsg::WebSearchComplete(WebSearchCompleteEvent { call_id, query }),
order,
None,
);
sess.send_event(ev).await;
}
ResponseEvent::Completed {
response_id: _,
+91 -33
View File
@@ -370,6 +370,78 @@ impl ChatWidget<'_> {
format!("O:req={} out={} seq={}", ok.req, ok.out, ok.seq)
}
// Allocate a key that places an internal (non‑model) event at the point it
// occurs during the current request, instead of sinking it to the end.
//
// Strategy:
// - If an OrderMeta is provided, honor it (strict model ordering).
// - Otherwise, if a new turn is queued (a user prompt was just inserted),
// anchor immediately after that prompt within the upcoming request so
// the notice appears in the right window.
// - Otherwise, derive a key within the current request:
// * If there is any existing cell in this request, append after the
// latest key in this request (req = last_seen, out/seq bumped).
// * If no cells exist for this request yet, place near the top of this
// request (after headers/prompts) so provider output can follow.
fn near_time_key(
&mut self,
order: Option<&codex_core::protocol::OrderMeta>,
) -> OrderKey {
if let Some(om) = order {
return Self::order_key_from_order_meta(om);
}
// If we just staged a user prompt for the next request, keep using the
// next‑turn anchor so the background item lands with that turn.
if self.pending_user_prompts_for_next_turn > 0 {
return self.next_req_key_after_prompt();
}
let req = if self.last_seen_request_index > 0 {
self.last_seen_request_index
} else {
// No provider traffic yet: allocate a synthetic request bucket.
// Use the same path as next_internal_key() to keep monotonicity.
if self.current_request_index < self.last_seen_request_index {
self.current_request_index = self.last_seen_request_index;
}
self.current_request_index = self.current_request_index.saturating_add(1);
self.current_request_index
};
// Scan for the latest key within this request to append after.
let mut last_in_req: Option<OrderKey> = None;
for k in &self.cell_order_seq {
if k.req == req {
last_in_req = Some(match last_in_req { Some(prev) => if *k > prev { *k } else { prev }, None => *k });
}
}
self.internal_seq = self.internal_seq.saturating_add(1);
match last_in_req {
Some(last) => OrderKey { req, out: last.out, seq: last.seq.saturating_add(1) },
None => OrderKey { req, out: i32::MIN + 2, seq: self.internal_seq },
}
}
// After inserting a non‑reasoning cell during streaming, restore the
// in‑progress indicator on the latest reasoning cell so the ellipsis
// remains visible while the model continues.
fn restore_reasoning_in_progress_if_streaming(&mut self) {
if !self.stream.is_write_cycle_active() { return; }
if let Some(idx) = self
.history_cells
.iter()
.rposition(|c| c.as_any().downcast_ref::<crate::history_cell::CollapsibleReasoningCell>().is_some())
{
if let Some(rc) = self.history_cells[idx]
.as_any()
.downcast_ref::<crate::history_cell::CollapsibleReasoningCell>()
{
rc.set_in_progress(true);
}
}
}
// Allocate a new synthetic key for internal (non-LLM) messages at the bottom of the
// current (active) request: (req = last_seen, out = +∞, seq = monotonic).
fn next_internal_key(&mut self) -> OrderKey {
@@ -674,11 +746,7 @@ impl ChatWidget<'_> {
if self.is_write_cycle_active() { defer_fn(&mut self.interrupts); } else { handle_fn(self); }
}
fn next_sequence(&mut self) -> u64 {
let s = self.stream_state.next_seq;
self.stream_state.next_seq = self.stream_state.next_seq.saturating_add(1);
s
}
// removed: next_sequence; plan updates are inserted immediately
// Removed order-adjustment helpers; ordering now uses stable order keys on insert.
@@ -1139,7 +1207,7 @@ impl ChatWidget<'_> {
sparkline_data: std::cell::RefCell::new(Vec::new()),
last_sparkline_update: std::cell::RefCell::new(std::time::Instant::now()),
stream: crate::streaming::controller::StreamController::new(config.clone()),
stream_state: StreamState { current_kind: None, closed_answer_ids: HashSet::new(), closed_reasoning_ids: HashSet::new(), next_seq: 1, seq_answer_final: None, drop_streaming: false },
stream_state: StreamState { current_kind: None, closed_answer_ids: HashSet::new(), closed_reasoning_ids: HashSet::new(), seq_answer_final: None, drop_streaming: false },
interrupts: interrupts::InterruptManager::new(),
ended_call_ids: HashSet::new(),
diffs: DiffsState { session_patch_sets: Vec::new(), baseline_file_contents: HashMap::new(), overlay: None, confirm: None, body_visible_rows: std::cell::Cell::new(0) },
@@ -1262,7 +1330,7 @@ impl ChatWidget<'_> {
sparkline_data: std::cell::RefCell::new(Vec::new()),
last_sparkline_update: std::cell::RefCell::new(std::time::Instant::now()),
stream: crate::streaming::controller::StreamController::new(config.clone()),
stream_state: StreamState { current_kind: None, closed_answer_ids: HashSet::new(), closed_reasoning_ids: HashSet::new(), next_seq: 1, seq_answer_final: None, drop_streaming: false },
stream_state: StreamState { current_kind: None, closed_answer_ids: HashSet::new(), closed_reasoning_ids: HashSet::new(), seq_answer_final: None, drop_streaming: false },
interrupts: interrupts::InterruptManager::new(),
ended_call_ids: HashSet::new(),
diffs: DiffsState { session_patch_sets: Vec::new(), baseline_file_contents: HashMap::new(), overlay: None, confirm: None, body_visible_rows: std::cell::Cell::new(0) },
@@ -2468,16 +2536,12 @@ impl ChatWidget<'_> {
self.on_error(message);
}
EventMsg::PlanUpdate(update) => {
// Avoid interleaving plan updates inside streaming sections.
// If a stream is active, defer until the stream finalizes so
// the plan block doesn't split a heading and its content.
if self.is_write_cycle_active() {
let seq = self.next_sequence();
self.interrupts.push_plan_update(seq, update, event.order.clone());
} else {
let key = self.next_internal_key();
let _ = self.history_insert_with_key_global(Box::new(history_cell::new_plan_update(update)), key);
}
// Insert plan updates at the time they occur. If the provider
// supplied OrderMeta, honor it. Otherwise, derive a near‑time key.
let key = self.near_time_key(event.order.as_ref());
let _ = self.history_insert_with_key_global(Box::new(history_cell::new_plan_update(update)), key);
// If we inserted during streaming, keep the reasoning ellipsis visible.
self.restore_reasoning_in_progress_if_streaming();
}
EventMsg::ExecApprovalRequest(ev) => {
let id2 = id.clone();
@@ -2758,21 +2822,16 @@ impl ChatWidget<'_> {
}
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
info!("BackgroundEvent: {message}");
// Surface lightweight background events in the history feed
// so users see confirmations (e.g., CDP connect success).
// If a user prompt was just inserted for the upcoming turn,
// anchor these background events to that next turn so they
// render below the prompt and above model output.
if self.pending_user_prompts_for_next_turn > 0 {
let key = self.next_req_key_after_prompt();
let _ = self.history_insert_with_key_global_tagged(
Box::new(history_cell::new_background_event(message.clone())),
key,
"bg-next-turn",
);
} else {
self.history_push(history_cell::new_background_event(message.clone()));
}
// Insert background events at near‑time so they appear where the
// user saw them, instead of sinking to the end of the turn.
let key = self.near_time_key(event.order.as_ref());
let _ = self.history_insert_with_key_global_tagged(
Box::new(history_cell::new_background_event(message.clone())),
key,
"bg-now",
);
// If we inserted during streaming, keep the reasoning ellipsis visible.
self.restore_reasoning_in_progress_if_streaming();
// Also reflect CDP connect success in the status line.
if message.starts_with("✅ Connected to Chrome via CDP") {
@@ -7794,7 +7853,6 @@ struct StreamState {
current_kind: Option<StreamKind>,
closed_answer_ids: HashSet<StreamId>,
closed_reasoning_ids: HashSet<StreamId>,
next_seq: u64,
seq_answer_final: Option<u64>,
drop_streaming: bool,
}
+3 -11
View File
@@ -7,7 +7,6 @@ use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::McpToolCallBeginEvent;
use codex_core::protocol::McpToolCallEndEvent;
use codex_core::protocol::PatchApplyEndEvent;
use codex_protocol::plan_tool::UpdatePlanArgs;
use super::ChatWidget;
use super::tools;
@@ -21,7 +20,6 @@ pub(crate) enum QueuedInterrupt {
McpBegin { seq: u64, ev: McpToolCallBeginEvent, order: Option<codex_core::protocol::OrderMeta> },
McpEnd { seq: u64, ev: McpToolCallEndEvent, order: Option<codex_core::protocol::OrderMeta> },
PatchEnd { seq: u64, ev: PatchApplyEndEvent },
PlanUpdate { seq: u64, ev: UpdatePlanArgs, order: Option<codex_core::protocol::OrderMeta> },
}
#[derive(Default)]
@@ -70,9 +68,7 @@ impl InterruptManager {
self.queue.push(QueuedInterrupt::PatchEnd { seq, ev });
}
pub(crate) fn push_plan_update(&mut self, seq: u64, ev: UpdatePlanArgs, order: Option<codex_core::protocol::OrderMeta>) {
self.queue.push(QueuedInterrupt::PlanUpdate { seq, ev, order });
}
// Plan updates are inserted near-time immediately; no interrupt queue entry needed.
pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget<'_>) {
// Ensure stable order
@@ -114,10 +110,7 @@ impl InterruptManager {
QueuedInterrupt::PatchEnd { seq: _, ev } => {
chat.handle_patch_apply_end_now(ev);
}
QueuedInterrupt::PlanUpdate { ev, order, .. } => {
let ok = match order.as_ref() { Some(om) => super::ChatWidget::order_key_from_order_meta(om), None => { tracing::warn!("missing OrderMeta in queued PlanUpdate; using synthetic key"); chat.next_internal_key() } };
let _ = chat.history_insert_with_key_global(Box::new(crate::history_cell::new_plan_update(ev)), ok);
}
}
}
}
@@ -131,7 +124,6 @@ fn seq_of(q: &QueuedInterrupt) -> u64 {
| QueuedInterrupt::ExecEnd { seq, .. }
| QueuedInterrupt::McpBegin { seq, .. }
| QueuedInterrupt::McpEnd { seq, .. }
| QueuedInterrupt::PatchEnd { seq, .. }
| QueuedInterrupt::PlanUpdate { seq, .. } => *seq,
| QueuedInterrupt::PatchEnd { seq, .. } => *seq,
}
}