mirror of
https://github.com/openai/codex.git
synced 2026-11-03 08:22:30 +00:00
Remove the legacy TUI split (#15922)
This is the part 1 of 2 PRs that will delete the `tui` / `tui_app_server` split. This part simply deletes the existing `tui` directory and marks the `tui_app_server` feature flag as removed. I left the `tui_app_server` feature flag in place for now so its presence doesn't result in an error. It is simply ignored. Part 2 will rename the `tui_app_server` directory `tui`. I did this as two parts to reduce visible code churn.
This commit is contained in:
1 parent
307e427a9b
commit
d65deec617
856 files changed
+257
-130872
No files matched your search
@@ -1,331 +0,0 @@
|
||||
//! Multi-agent picker navigation and labeling state for the TUI app.
|
||||
//!
|
||||
//! This module exists to keep the pure parts of multi-agent navigation out of [`crate::app::App`].
|
||||
//! It owns the stable spawn-order cache used by the `/agent` picker, keyboard next/previous
|
||||
//! navigation, and the contextual footer label for the thread currently being watched.
|
||||
//!
|
||||
//! Responsibilities here are intentionally narrow:
|
||||
//! - remember picker entries and their first-seen order
|
||||
//! - answer traversal questions like "what is the next thread?"
|
||||
//! - derive user-facing picker/footer text from cached thread metadata
|
||||
//!
|
||||
//! Responsibilities that stay in `App`:
|
||||
//! - discovering threads from the backend
|
||||
//! - deciding which thread is currently displayed
|
||||
//! - mutating UI state such as switching threads or updating the footer widget
|
||||
//!
|
||||
//! The key invariant is that traversal follows first-seen spawn order rather than thread-id sort
|
||||
//! order. Once a thread id is observed it keeps its place in the cycle even if the entry is later
|
||||
//! updated or marked closed.
|
||||
|
||||
use crate::multi_agents::AgentPickerThreadEntry;
|
||||
use crate::multi_agents::format_agent_picker_item_name;
|
||||
use crate::multi_agents::next_agent_shortcut;
|
||||
use crate::multi_agents::previous_agent_shortcut;
|
||||
use codex_protocol::ThreadId;
|
||||
use ratatui::text::Span;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Small state container for multi-agent picker ordering and labeling.
|
||||
///
|
||||
/// `App` owns thread lifecycle and UI side effects. This type keeps the pure rules for stable
|
||||
/// spawn-order traversal, picker copy, and active-agent labels together and separately testable.
|
||||
///
|
||||
/// The core invariant is that `order` records first-seen thread ids exactly once, while `threads`
|
||||
/// stores the latest metadata for those ids. Mutation is intentionally funneled through `upsert`,
|
||||
/// `mark_closed`, and `clear` so those two collections do not drift semantically even if they are
|
||||
/// temporarily out of sync during teardown races.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct AgentNavigationState {
|
||||
/// Latest picker metadata for each tracked thread id.
|
||||
threads: HashMap<ThreadId, AgentPickerThreadEntry>,
|
||||
/// Stable first-seen traversal order for picker rows and keyboard cycling.
|
||||
order: Vec<ThreadId>,
|
||||
}
|
||||
|
||||
/// Direction of keyboard traversal through the stable picker order.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum AgentNavigationDirection {
|
||||
/// Move toward the entry that was seen earlier in spawn order, wrapping at the front.
|
||||
Previous,
|
||||
/// Move toward the entry that was seen later in spawn order, wrapping at the end.
|
||||
Next,
|
||||
}
|
||||
|
||||
impl AgentNavigationState {
|
||||
/// Returns the cached picker entry for a specific thread id.
|
||||
///
|
||||
/// Callers use this when they already know which thread they care about and need the last
|
||||
/// metadata captured for picker or footer rendering. If a caller assumes every tracked thread
|
||||
/// must be present here, shutdown races can turn that assumption into a panic elsewhere, so
|
||||
/// this stays optional.
|
||||
pub(crate) fn get(&self, thread_id: &ThreadId) -> Option<&AgentPickerThreadEntry> {
|
||||
self.threads.get(thread_id)
|
||||
}
|
||||
|
||||
/// Returns whether the picker cache currently knows about any threads.
|
||||
///
|
||||
/// This is the cheapest way for `App` to decide whether opening the picker should show "No
|
||||
/// agents available yet." rather than constructing picker rows from an empty state.
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.threads.is_empty()
|
||||
}
|
||||
|
||||
/// Inserts or updates a picker entry while preserving first-seen traversal order.
|
||||
///
|
||||
/// The key invariant of this module is enforced here: a thread id is appended to `order` only
|
||||
/// the first time it is seen. Later updates may change nickname, role, or closed state, but
|
||||
/// they must not move the thread in the cycle or keyboard navigation would feel unstable.
|
||||
pub(crate) fn upsert(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
agent_nickname: Option<String>,
|
||||
agent_role: Option<String>,
|
||||
is_closed: bool,
|
||||
) {
|
||||
if !self.threads.contains_key(&thread_id) {
|
||||
self.order.push(thread_id);
|
||||
}
|
||||
self.threads.insert(
|
||||
thread_id,
|
||||
AgentPickerThreadEntry {
|
||||
agent_nickname,
|
||||
agent_role,
|
||||
is_closed,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Marks a thread as closed without removing it from the traversal cache.
|
||||
///
|
||||
/// Closed threads stay in the picker and in spawn order so users can still review them and so
|
||||
/// next/previous navigation does not reshuffle around disappearing entries. If a caller "cleans
|
||||
/// this up" by deleting the entry instead, wraparound navigation will silently change shape
|
||||
/// mid-session.
|
||||
pub(crate) fn mark_closed(&mut self, thread_id: ThreadId) {
|
||||
if let Some(entry) = self.threads.get_mut(&thread_id) {
|
||||
entry.is_closed = true;
|
||||
} else {
|
||||
self.upsert(
|
||||
thread_id, /*agent_nickname*/ None, /*agent_role*/ None,
|
||||
/*is_closed*/ true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops all cached picker state.
|
||||
///
|
||||
/// This is used when `App` tears down thread event state and needs the picker cache to return
|
||||
/// to a pristine single-session state.
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.threads.clear();
|
||||
self.order.clear();
|
||||
}
|
||||
|
||||
/// Returns whether there is at least one tracked thread other than the primary one.
|
||||
///
|
||||
/// `App` uses this to decide whether the picker should be available even when the collaboration
|
||||
/// feature flag is currently disabled, because already-existing sub-agent threads should remain
|
||||
/// inspectable.
|
||||
pub(crate) fn has_non_primary_thread(&self, primary_thread_id: Option<ThreadId>) -> bool {
|
||||
self.threads
|
||||
.keys()
|
||||
.any(|thread_id| Some(*thread_id) != primary_thread_id)
|
||||
}
|
||||
|
||||
/// Returns live picker rows in the same order users cycle through them.
|
||||
///
|
||||
/// The `order` vector is intentionally historical and may briefly contain thread ids that no
|
||||
/// longer have cached metadata, so this filters through the map instead of assuming both
|
||||
/// collections are perfectly synchronized.
|
||||
pub(crate) fn ordered_threads(&self) -> Vec<(ThreadId, &AgentPickerThreadEntry)> {
|
||||
self.order
|
||||
.iter()
|
||||
.filter_map(|thread_id| self.threads.get(thread_id).map(|entry| (*thread_id, entry)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the adjacent thread id for keyboard navigation in stable spawn order.
|
||||
///
|
||||
/// The caller must pass the thread whose transcript is actually being shown to the user, not
|
||||
/// just whichever thread bookkeeping most recently marked active. If the wrong current thread
|
||||
/// is supplied, next/previous navigation will jump in a way that feels nondeterministic even
|
||||
/// though the cache itself is correct.
|
||||
pub(crate) fn adjacent_thread_id(
|
||||
&self,
|
||||
current_displayed_thread_id: Option<ThreadId>,
|
||||
direction: AgentNavigationDirection,
|
||||
) -> Option<ThreadId> {
|
||||
let ordered_threads = self.ordered_threads();
|
||||
if ordered_threads.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let current_thread_id = current_displayed_thread_id?;
|
||||
let current_idx = ordered_threads
|
||||
.iter()
|
||||
.position(|(thread_id, _)| *thread_id == current_thread_id)?;
|
||||
let next_idx = match direction {
|
||||
AgentNavigationDirection::Next => (current_idx + 1) % ordered_threads.len(),
|
||||
AgentNavigationDirection::Previous => {
|
||||
if current_idx == 0 {
|
||||
ordered_threads.len() - 1
|
||||
} else {
|
||||
current_idx - 1
|
||||
}
|
||||
}
|
||||
};
|
||||
Some(ordered_threads[next_idx].0)
|
||||
}
|
||||
|
||||
/// Derives the contextual footer label for the currently displayed thread.
|
||||
///
|
||||
/// This intentionally returns `None` until there is more than one tracked thread so
|
||||
/// single-thread sessions do not waste footer space restating the obvious. When metadata for
|
||||
/// the displayed thread is missing, the label falls back to the same generic naming rules used
|
||||
/// by the picker.
|
||||
pub(crate) fn active_agent_label(
|
||||
&self,
|
||||
current_displayed_thread_id: Option<ThreadId>,
|
||||
primary_thread_id: Option<ThreadId>,
|
||||
) -> Option<String> {
|
||||
if self.threads.len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let thread_id = current_displayed_thread_id?;
|
||||
let is_primary = primary_thread_id == Some(thread_id);
|
||||
Some(
|
||||
self.threads
|
||||
.get(&thread_id)
|
||||
.map(|entry| {
|
||||
format_agent_picker_item_name(
|
||||
entry.agent_nickname.as_deref(),
|
||||
entry.agent_role.as_deref(),
|
||||
is_primary,
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
format_agent_picker_item_name(
|
||||
/*agent_nickname*/ None, /*agent_role*/ None, is_primary,
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the `/agent` picker subtitle from the same canonical bindings used by key handling.
|
||||
///
|
||||
/// Keeping this text derived from the actual shortcut helpers prevents the picker copy from
|
||||
/// drifting if the bindings ever change on one platform.
|
||||
pub(crate) fn picker_subtitle() -> String {
|
||||
let previous: Span<'static> = previous_agent_shortcut().into();
|
||||
let next: Span<'static> = next_agent_shortcut().into();
|
||||
format!(
|
||||
"Select an agent to watch. {} previous, {} next.",
|
||||
previous.content, next.content
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Returns only the ordered thread ids for focused tests of traversal invariants.
|
||||
///
|
||||
/// This helper exists so tests can assert on ordering without embedding the full picker entry
|
||||
/// payload in every expectation.
|
||||
pub(crate) fn ordered_thread_ids(&self) -> Vec<ThreadId> {
|
||||
self.ordered_threads()
|
||||
.into_iter()
|
||||
.map(|(thread_id, _)| thread_id)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn populated_state() -> (AgentNavigationState, ThreadId, ThreadId, ThreadId) {
|
||||
let mut state = AgentNavigationState::default();
|
||||
let main_thread_id =
|
||||
ThreadId::from_string("00000000-0000-0000-0000-000000000101").expect("valid thread");
|
||||
let first_agent_id =
|
||||
ThreadId::from_string("00000000-0000-0000-0000-000000000102").expect("valid thread");
|
||||
let second_agent_id =
|
||||
ThreadId::from_string("00000000-0000-0000-0000-000000000103").expect("valid thread");
|
||||
|
||||
state.upsert(main_thread_id, None, None, false);
|
||||
state.upsert(
|
||||
first_agent_id,
|
||||
Some("Robie".to_string()),
|
||||
Some("explorer".to_string()),
|
||||
false,
|
||||
);
|
||||
state.upsert(
|
||||
second_agent_id,
|
||||
Some("Bob".to_string()),
|
||||
Some("worker".to_string()),
|
||||
false,
|
||||
);
|
||||
|
||||
(state, main_thread_id, first_agent_id, second_agent_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_preserves_first_seen_order() {
|
||||
let (mut state, main_thread_id, first_agent_id, second_agent_id) = populated_state();
|
||||
|
||||
state.upsert(
|
||||
first_agent_id,
|
||||
Some("Robie".to_string()),
|
||||
Some("worker".to_string()),
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state.ordered_thread_ids(),
|
||||
vec![main_thread_id, first_agent_id, second_agent_id]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_thread_id_wraps_in_spawn_order() {
|
||||
let (state, main_thread_id, first_agent_id, second_agent_id) = populated_state();
|
||||
|
||||
assert_eq!(
|
||||
state.adjacent_thread_id(Some(second_agent_id), AgentNavigationDirection::Next),
|
||||
Some(main_thread_id)
|
||||
);
|
||||
assert_eq!(
|
||||
state.adjacent_thread_id(Some(second_agent_id), AgentNavigationDirection::Previous),
|
||||
Some(first_agent_id)
|
||||
);
|
||||
assert_eq!(
|
||||
state.adjacent_thread_id(Some(main_thread_id), AgentNavigationDirection::Previous),
|
||||
Some(second_agent_id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_subtitle_mentions_shortcuts() {
|
||||
let previous: Span<'static> = previous_agent_shortcut().into();
|
||||
let next: Span<'static> = next_agent_shortcut().into();
|
||||
let subtitle = AgentNavigationState::picker_subtitle();
|
||||
|
||||
assert!(subtitle.contains(previous.content.as_ref()));
|
||||
assert!(subtitle.contains(next.content.as_ref()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_agent_label_tracks_current_thread() {
|
||||
let (state, main_thread_id, first_agent_id, _) = populated_state();
|
||||
|
||||
assert_eq!(
|
||||
state.active_agent_label(Some(first_agent_id), Some(main_thread_id)),
|
||||
Some("Robie [explorer]".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
state.active_agent_label(Some(main_thread_id), Some(main_thread_id)),
|
||||
Some("Main [default]".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,721 +0,0 @@
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct ElicitationRequestKey {
|
||||
server_name: String,
|
||||
request_id: codex_protocol::mcp::RequestId,
|
||||
}
|
||||
|
||||
impl ElicitationRequestKey {
|
||||
fn new(server_name: String, request_id: codex_protocol::mcp::RequestId) -> Self {
|
||||
Self {
|
||||
server_name,
|
||||
request_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
// Tracks which interactive prompts are still unresolved in the thread-event buffer.
|
||||
//
|
||||
// Thread snapshots are replayed when switching threads/agents. Most events should replay
|
||||
// verbatim, but interactive prompts (approvals, request_user_input, MCP elicitations) must
|
||||
// only replay if they are still pending. This state is updated from:
|
||||
// - inbound events (`note_event`)
|
||||
// - outbound ops that resolve a prompt (`note_outbound_op`)
|
||||
// - buffer eviction (`note_evicted_event`)
|
||||
//
|
||||
// We keep both fast lookup sets (for snapshot filtering by call_id/request key) and
|
||||
// turn-indexed queues/vectors so `TurnComplete`/`TurnAborted` can clear stale prompts tied
|
||||
// to a turn. `request_user_input` removal is FIFO because the overlay answers queued prompts
|
||||
// in FIFO order for a shared `turn_id`.
|
||||
pub(super) struct PendingInteractiveReplayState {
|
||||
exec_approval_call_ids: HashSet<String>,
|
||||
exec_approval_call_ids_by_turn_id: HashMap<String, Vec<String>>,
|
||||
patch_approval_call_ids: HashSet<String>,
|
||||
patch_approval_call_ids_by_turn_id: HashMap<String, Vec<String>>,
|
||||
elicitation_requests: HashSet<ElicitationRequestKey>,
|
||||
request_permissions_call_ids: HashSet<String>,
|
||||
request_permissions_call_ids_by_turn_id: HashMap<String, Vec<String>>,
|
||||
request_user_input_call_ids: HashSet<String>,
|
||||
request_user_input_call_ids_by_turn_id: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
impl PendingInteractiveReplayState {
|
||||
pub(super) fn event_can_change_pending_thread_approvals(event: &Event) -> bool {
|
||||
matches!(
|
||||
&event.msg,
|
||||
EventMsg::ExecApprovalRequest(_)
|
||||
| EventMsg::ApplyPatchApprovalRequest(_)
|
||||
| EventMsg::ElicitationRequest(_)
|
||||
| EventMsg::RequestPermissions(_)
|
||||
| EventMsg::ExecCommandBegin(_)
|
||||
| EventMsg::PatchApplyBegin(_)
|
||||
| EventMsg::TurnComplete(_)
|
||||
| EventMsg::TurnAborted(_)
|
||||
| EventMsg::ShutdownComplete
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn op_can_change_state(op: &Op) -> bool {
|
||||
matches!(
|
||||
op,
|
||||
Op::ExecApproval { .. }
|
||||
| Op::PatchApproval { .. }
|
||||
| Op::ResolveElicitation { .. }
|
||||
| Op::RequestPermissionsResponse { .. }
|
||||
| Op::UserInputAnswer { .. }
|
||||
| Op::Shutdown
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn note_outbound_op(&mut self, op: &Op) {
|
||||
match op {
|
||||
Op::ExecApproval { id, turn_id, .. } => {
|
||||
self.exec_approval_call_ids.remove(id);
|
||||
if let Some(turn_id) = turn_id {
|
||||
Self::remove_call_id_from_turn_map_entry(
|
||||
&mut self.exec_approval_call_ids_by_turn_id,
|
||||
turn_id,
|
||||
id,
|
||||
);
|
||||
}
|
||||
}
|
||||
Op::PatchApproval { id, .. } => {
|
||||
self.patch_approval_call_ids.remove(id);
|
||||
Self::remove_call_id_from_turn_map(
|
||||
&mut self.patch_approval_call_ids_by_turn_id,
|
||||
id,
|
||||
);
|
||||
}
|
||||
Op::ResolveElicitation {
|
||||
server_name,
|
||||
request_id,
|
||||
..
|
||||
} => {
|
||||
self.elicitation_requests
|
||||
.remove(&ElicitationRequestKey::new(
|
||||
server_name.clone(),
|
||||
request_id.clone(),
|
||||
));
|
||||
}
|
||||
Op::RequestPermissionsResponse { id, .. } => {
|
||||
self.request_permissions_call_ids.remove(id);
|
||||
Self::remove_call_id_from_turn_map(
|
||||
&mut self.request_permissions_call_ids_by_turn_id,
|
||||
id,
|
||||
);
|
||||
}
|
||||
// `Op::UserInputAnswer` identifies the turn, not the prompt call_id. The UI
|
||||
// answers queued prompts for the same turn in FIFO order, so remove the oldest
|
||||
// queued call_id for that turn.
|
||||
Op::UserInputAnswer { id, .. } => {
|
||||
let mut remove_turn_entry = false;
|
||||
if let Some(call_ids) = self.request_user_input_call_ids_by_turn_id.get_mut(id) {
|
||||
if !call_ids.is_empty() {
|
||||
let call_id = call_ids.remove(0);
|
||||
self.request_user_input_call_ids.remove(&call_id);
|
||||
}
|
||||
if call_ids.is_empty() {
|
||||
remove_turn_entry = true;
|
||||
}
|
||||
}
|
||||
if remove_turn_entry {
|
||||
self.request_user_input_call_ids_by_turn_id.remove(id);
|
||||
}
|
||||
}
|
||||
Op::Shutdown => self.clear(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn note_event(&mut self, event: &Event) {
|
||||
match &event.msg {
|
||||
EventMsg::ExecApprovalRequest(ev) => {
|
||||
let approval_id = ev.effective_approval_id();
|
||||
self.exec_approval_call_ids.insert(approval_id.clone());
|
||||
self.exec_approval_call_ids_by_turn_id
|
||||
.entry(ev.turn_id.clone())
|
||||
.or_default()
|
||||
.push(approval_id);
|
||||
}
|
||||
EventMsg::ExecCommandBegin(ev) => {
|
||||
self.exec_approval_call_ids.remove(&ev.call_id);
|
||||
Self::remove_call_id_from_turn_map(
|
||||
&mut self.exec_approval_call_ids_by_turn_id,
|
||||
&ev.call_id,
|
||||
);
|
||||
}
|
||||
EventMsg::ApplyPatchApprovalRequest(ev) => {
|
||||
self.patch_approval_call_ids.insert(ev.call_id.clone());
|
||||
self.patch_approval_call_ids_by_turn_id
|
||||
.entry(ev.turn_id.clone())
|
||||
.or_default()
|
||||
.push(ev.call_id.clone());
|
||||
}
|
||||
EventMsg::PatchApplyBegin(ev) => {
|
||||
self.patch_approval_call_ids.remove(&ev.call_id);
|
||||
Self::remove_call_id_from_turn_map(
|
||||
&mut self.patch_approval_call_ids_by_turn_id,
|
||||
&ev.call_id,
|
||||
);
|
||||
}
|
||||
EventMsg::ElicitationRequest(ev) => {
|
||||
self.elicitation_requests.insert(ElicitationRequestKey::new(
|
||||
ev.server_name.clone(),
|
||||
ev.id.clone(),
|
||||
));
|
||||
}
|
||||
EventMsg::RequestUserInput(ev) => {
|
||||
self.request_user_input_call_ids.insert(ev.call_id.clone());
|
||||
self.request_user_input_call_ids_by_turn_id
|
||||
.entry(ev.turn_id.clone())
|
||||
.or_default()
|
||||
.push(ev.call_id.clone());
|
||||
}
|
||||
EventMsg::RequestPermissions(ev) => {
|
||||
self.request_permissions_call_ids.insert(ev.call_id.clone());
|
||||
self.request_permissions_call_ids_by_turn_id
|
||||
.entry(ev.turn_id.clone())
|
||||
.or_default()
|
||||
.push(ev.call_id.clone());
|
||||
}
|
||||
// A turn ending (normally or aborted/replaced) invalidates any unresolved
|
||||
// turn-scoped approvals, permission prompts, and request_user_input prompts.
|
||||
EventMsg::TurnComplete(ev) => {
|
||||
self.clear_exec_approval_turn(&ev.turn_id);
|
||||
self.clear_patch_approval_turn(&ev.turn_id);
|
||||
self.clear_request_permissions_turn(&ev.turn_id);
|
||||
self.clear_request_user_input_turn(&ev.turn_id);
|
||||
}
|
||||
EventMsg::TurnAborted(ev) => {
|
||||
if let Some(turn_id) = &ev.turn_id {
|
||||
self.clear_exec_approval_turn(turn_id);
|
||||
self.clear_patch_approval_turn(turn_id);
|
||||
self.clear_request_permissions_turn(turn_id);
|
||||
self.clear_request_user_input_turn(turn_id);
|
||||
}
|
||||
}
|
||||
EventMsg::ShutdownComplete => self.clear(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn note_evicted_event(&mut self, event: &Event) {
|
||||
match &event.msg {
|
||||
EventMsg::ExecApprovalRequest(ev) => {
|
||||
let approval_id = ev.effective_approval_id();
|
||||
self.exec_approval_call_ids.remove(&approval_id);
|
||||
Self::remove_call_id_from_turn_map_entry(
|
||||
&mut self.exec_approval_call_ids_by_turn_id,
|
||||
&ev.turn_id,
|
||||
&approval_id,
|
||||
);
|
||||
}
|
||||
EventMsg::ApplyPatchApprovalRequest(ev) => {
|
||||
self.patch_approval_call_ids.remove(&ev.call_id);
|
||||
Self::remove_call_id_from_turn_map_entry(
|
||||
&mut self.patch_approval_call_ids_by_turn_id,
|
||||
&ev.turn_id,
|
||||
&ev.call_id,
|
||||
);
|
||||
}
|
||||
EventMsg::ElicitationRequest(ev) => {
|
||||
self.elicitation_requests
|
||||
.remove(&ElicitationRequestKey::new(
|
||||
ev.server_name.clone(),
|
||||
ev.id.clone(),
|
||||
));
|
||||
}
|
||||
EventMsg::RequestUserInput(ev) => {
|
||||
self.request_user_input_call_ids.remove(&ev.call_id);
|
||||
let mut remove_turn_entry = false;
|
||||
if let Some(call_ids) = self
|
||||
.request_user_input_call_ids_by_turn_id
|
||||
.get_mut(&ev.turn_id)
|
||||
{
|
||||
call_ids.retain(|call_id| call_id != &ev.call_id);
|
||||
if call_ids.is_empty() {
|
||||
remove_turn_entry = true;
|
||||
}
|
||||
}
|
||||
if remove_turn_entry {
|
||||
self.request_user_input_call_ids_by_turn_id
|
||||
.remove(&ev.turn_id);
|
||||
}
|
||||
}
|
||||
EventMsg::RequestPermissions(ev) => {
|
||||
self.request_permissions_call_ids.remove(&ev.call_id);
|
||||
let mut remove_turn_entry = false;
|
||||
if let Some(call_ids) = self
|
||||
.request_permissions_call_ids_by_turn_id
|
||||
.get_mut(&ev.turn_id)
|
||||
{
|
||||
call_ids.retain(|call_id| call_id != &ev.call_id);
|
||||
if call_ids.is_empty() {
|
||||
remove_turn_entry = true;
|
||||
}
|
||||
}
|
||||
if remove_turn_entry {
|
||||
self.request_permissions_call_ids_by_turn_id
|
||||
.remove(&ev.turn_id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn should_replay_snapshot_event(&self, event: &Event) -> bool {
|
||||
match &event.msg {
|
||||
EventMsg::ExecApprovalRequest(ev) => self
|
||||
.exec_approval_call_ids
|
||||
.contains(&ev.effective_approval_id()),
|
||||
EventMsg::ApplyPatchApprovalRequest(ev) => {
|
||||
self.patch_approval_call_ids.contains(&ev.call_id)
|
||||
}
|
||||
EventMsg::ElicitationRequest(ev) => {
|
||||
self.elicitation_requests
|
||||
.contains(&ElicitationRequestKey::new(
|
||||
ev.server_name.clone(),
|
||||
ev.id.clone(),
|
||||
))
|
||||
}
|
||||
EventMsg::RequestUserInput(ev) => {
|
||||
self.request_user_input_call_ids.contains(&ev.call_id)
|
||||
}
|
||||
EventMsg::RequestPermissions(ev) => {
|
||||
self.request_permissions_call_ids.contains(&ev.call_id)
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn has_pending_thread_approvals(&self) -> bool {
|
||||
!self.exec_approval_call_ids.is_empty()
|
||||
|| !self.patch_approval_call_ids.is_empty()
|
||||
|| !self.elicitation_requests.is_empty()
|
||||
|| !self.request_permissions_call_ids.is_empty()
|
||||
}
|
||||
|
||||
fn clear_request_user_input_turn(&mut self, turn_id: &str) {
|
||||
if let Some(call_ids) = self.request_user_input_call_ids_by_turn_id.remove(turn_id) {
|
||||
for call_id in call_ids {
|
||||
self.request_user_input_call_ids.remove(&call_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_request_permissions_turn(&mut self, turn_id: &str) {
|
||||
if let Some(call_ids) = self.request_permissions_call_ids_by_turn_id.remove(turn_id) {
|
||||
for call_id in call_ids {
|
||||
self.request_permissions_call_ids.remove(&call_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_exec_approval_turn(&mut self, turn_id: &str) {
|
||||
if let Some(call_ids) = self.exec_approval_call_ids_by_turn_id.remove(turn_id) {
|
||||
for call_id in call_ids {
|
||||
self.exec_approval_call_ids.remove(&call_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_patch_approval_turn(&mut self, turn_id: &str) {
|
||||
if let Some(call_ids) = self.patch_approval_call_ids_by_turn_id.remove(turn_id) {
|
||||
for call_id in call_ids {
|
||||
self.patch_approval_call_ids.remove(&call_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_call_id_from_turn_map(
|
||||
call_ids_by_turn_id: &mut HashMap<String, Vec<String>>,
|
||||
call_id: &str,
|
||||
) {
|
||||
call_ids_by_turn_id.retain(|_, call_ids| {
|
||||
call_ids.retain(|queued_call_id| queued_call_id != call_id);
|
||||
!call_ids.is_empty()
|
||||
});
|
||||
}
|
||||
|
||||
fn remove_call_id_from_turn_map_entry(
|
||||
call_ids_by_turn_id: &mut HashMap<String, Vec<String>>,
|
||||
turn_id: &str,
|
||||
call_id: &str,
|
||||
) {
|
||||
let mut remove_turn_entry = false;
|
||||
if let Some(call_ids) = call_ids_by_turn_id.get_mut(turn_id) {
|
||||
call_ids.retain(|queued_call_id| queued_call_id != call_id);
|
||||
if call_ids.is_empty() {
|
||||
remove_turn_entry = true;
|
||||
}
|
||||
}
|
||||
if remove_turn_entry {
|
||||
call_ids_by_turn_id.remove(turn_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.exec_approval_call_ids.clear();
|
||||
self.exec_approval_call_ids_by_turn_id.clear();
|
||||
self.patch_approval_call_ids.clear();
|
||||
self.patch_approval_call_ids_by_turn_id.clear();
|
||||
self.elicitation_requests.clear();
|
||||
self.request_permissions_call_ids.clear();
|
||||
self.request_permissions_call_ids_by_turn_id.clear();
|
||||
self.request_user_input_call_ids.clear();
|
||||
self.request_user_input_call_ids_by_turn_id.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::ThreadEventStore;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn thread_event_snapshot_keeps_pending_request_user_input() {
|
||||
let mut store = ThreadEventStore::new(8);
|
||||
let request = Event {
|
||||
id: "ev-1".to_string(),
|
||||
msg: EventMsg::RequestUserInput(
|
||||
codex_protocol::request_user_input::RequestUserInputEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
questions: Vec::new(),
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
store.push_event(request);
|
||||
|
||||
let snapshot = store.snapshot();
|
||||
assert_eq!(snapshot.events.len(), 1);
|
||||
assert!(matches!(
|
||||
snapshot.events.first().map(|event| &event.msg),
|
||||
Some(EventMsg::RequestUserInput(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_snapshot_drops_resolved_request_user_input_after_user_answer() {
|
||||
let mut store = ThreadEventStore::new(8);
|
||||
store.push_event(Event {
|
||||
id: "ev-1".to_string(),
|
||||
msg: EventMsg::RequestUserInput(
|
||||
codex_protocol::request_user_input::RequestUserInputEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
questions: Vec::new(),
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
store.note_outbound_op(&Op::UserInputAnswer {
|
||||
id: "turn-1".to_string(),
|
||||
response: codex_protocol::request_user_input::RequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
},
|
||||
});
|
||||
|
||||
let snapshot = store.snapshot();
|
||||
assert!(
|
||||
snapshot.events.is_empty(),
|
||||
"resolved request_user_input prompt should not replay on thread switch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_snapshot_drops_resolved_exec_approval_after_outbound_approval_id() {
|
||||
let mut store = ThreadEventStore::new(8);
|
||||
store.push_event(Event {
|
||||
id: "ev-1".to_string(),
|
||||
msg: EventMsg::ExecApprovalRequest(
|
||||
codex_protocol::protocol::ExecApprovalRequestEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
approval_id: Some("approval-1".to_string()),
|
||||
turn_id: "turn-1".to_string(),
|
||||
command: vec!["echo".to_string(), "hi".to_string()],
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
reason: None,
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: Vec::new(),
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
store.note_outbound_op(&Op::ExecApproval {
|
||||
id: "approval-1".to_string(),
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
decision: codex_protocol::protocol::ReviewDecision::Approved,
|
||||
});
|
||||
|
||||
let snapshot = store.snapshot();
|
||||
assert!(
|
||||
snapshot.events.is_empty(),
|
||||
"resolved exec approval prompt should not replay on thread switch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_snapshot_drops_answered_request_user_input_for_multi_prompt_turn() {
|
||||
let mut store = ThreadEventStore::new(8);
|
||||
store.push_event(Event {
|
||||
id: "ev-1".to_string(),
|
||||
msg: EventMsg::RequestUserInput(
|
||||
codex_protocol::request_user_input::RequestUserInputEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
questions: Vec::new(),
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
store.note_outbound_op(&Op::UserInputAnswer {
|
||||
id: "turn-1".to_string(),
|
||||
response: codex_protocol::request_user_input::RequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
},
|
||||
});
|
||||
|
||||
store.push_event(Event {
|
||||
id: "ev-2".to_string(),
|
||||
msg: EventMsg::RequestUserInput(
|
||||
codex_protocol::request_user_input::RequestUserInputEvent {
|
||||
call_id: "call-2".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
questions: Vec::new(),
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
let snapshot = store.snapshot();
|
||||
assert_eq!(snapshot.events.len(), 1);
|
||||
assert!(matches!(
|
||||
snapshot.events.first().map(|event| &event.msg),
|
||||
Some(EventMsg::RequestUserInput(ev)) if ev.call_id == "call-2"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_snapshot_keeps_newer_request_user_input_pending_when_same_turn_has_queue() {
|
||||
let mut store = ThreadEventStore::new(8);
|
||||
store.push_event(Event {
|
||||
id: "ev-1".to_string(),
|
||||
msg: EventMsg::RequestUserInput(
|
||||
codex_protocol::request_user_input::RequestUserInputEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
questions: Vec::new(),
|
||||
},
|
||||
),
|
||||
});
|
||||
store.push_event(Event {
|
||||
id: "ev-2".to_string(),
|
||||
msg: EventMsg::RequestUserInput(
|
||||
codex_protocol::request_user_input::RequestUserInputEvent {
|
||||
call_id: "call-2".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
questions: Vec::new(),
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
store.note_outbound_op(&Op::UserInputAnswer {
|
||||
id: "turn-1".to_string(),
|
||||
response: codex_protocol::request_user_input::RequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
},
|
||||
});
|
||||
|
||||
let snapshot = store.snapshot();
|
||||
assert_eq!(snapshot.events.len(), 1);
|
||||
assert!(matches!(
|
||||
snapshot.events.first().map(|event| &event.msg),
|
||||
Some(EventMsg::RequestUserInput(ev)) if ev.call_id == "call-2"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_snapshot_drops_resolved_patch_approval_after_outbound_approval() {
|
||||
let mut store = ThreadEventStore::new(8);
|
||||
store.push_event(Event {
|
||||
id: "ev-1".to_string(),
|
||||
msg: EventMsg::ApplyPatchApprovalRequest(
|
||||
codex_protocol::protocol::ApplyPatchApprovalRequestEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
changes: HashMap::new(),
|
||||
reason: None,
|
||||
grant_root: None,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
store.note_outbound_op(&Op::PatchApproval {
|
||||
id: "call-1".to_string(),
|
||||
decision: codex_protocol::protocol::ReviewDecision::Approved,
|
||||
});
|
||||
|
||||
let snapshot = store.snapshot();
|
||||
assert!(
|
||||
snapshot.events.is_empty(),
|
||||
"resolved patch approval prompt should not replay on thread switch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_snapshot_drops_pending_approvals_when_turn_aborts() {
|
||||
let mut store = ThreadEventStore::new(8);
|
||||
store.push_event(Event {
|
||||
id: "ev-1".to_string(),
|
||||
msg: EventMsg::ExecApprovalRequest(
|
||||
codex_protocol::protocol::ExecApprovalRequestEvent {
|
||||
call_id: "exec-call-1".to_string(),
|
||||
approval_id: Some("approval-1".to_string()),
|
||||
turn_id: "turn-1".to_string(),
|
||||
command: vec!["echo".to_string(), "hi".to_string()],
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
reason: None,
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: Vec::new(),
|
||||
},
|
||||
),
|
||||
});
|
||||
store.push_event(Event {
|
||||
id: "ev-2".to_string(),
|
||||
msg: EventMsg::ApplyPatchApprovalRequest(
|
||||
codex_protocol::protocol::ApplyPatchApprovalRequestEvent {
|
||||
call_id: "patch-call-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
changes: HashMap::new(),
|
||||
reason: None,
|
||||
grant_root: None,
|
||||
},
|
||||
),
|
||||
});
|
||||
store.push_event(Event {
|
||||
id: "ev-3".to_string(),
|
||||
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::Replaced,
|
||||
}),
|
||||
});
|
||||
|
||||
let snapshot = store.snapshot();
|
||||
assert!(snapshot.events.iter().all(|event| {
|
||||
!matches!(
|
||||
&event.msg,
|
||||
EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_)
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_snapshot_drops_resolved_elicitation_after_outbound_resolution() {
|
||||
let mut store = ThreadEventStore::new(8);
|
||||
let request_id = codex_protocol::mcp::RequestId::String("request-1".to_string());
|
||||
store.push_event(Event {
|
||||
id: "ev-1".to_string(),
|
||||
msg: EventMsg::ElicitationRequest(codex_protocol::approvals::ElicitationRequestEvent {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
server_name: "server-1".to_string(),
|
||||
id: request_id.clone(),
|
||||
request: codex_protocol::approvals::ElicitationRequest::Form {
|
||||
meta: None,
|
||||
message: "Please confirm".to_string(),
|
||||
requested_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
store.note_outbound_op(&Op::ResolveElicitation {
|
||||
server_name: "server-1".to_string(),
|
||||
request_id,
|
||||
decision: codex_protocol::approvals::ElicitationAction::Accept,
|
||||
content: None,
|
||||
meta: None,
|
||||
});
|
||||
|
||||
let snapshot = store.snapshot();
|
||||
assert!(
|
||||
snapshot.events.is_empty(),
|
||||
"resolved elicitation prompt should not replay on thread switch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_event_store_reports_pending_thread_approvals() {
|
||||
let mut store = ThreadEventStore::new(8);
|
||||
assert_eq!(store.has_pending_thread_approvals(), false);
|
||||
|
||||
store.push_event(Event {
|
||||
id: "ev-1".to_string(),
|
||||
msg: EventMsg::ExecApprovalRequest(
|
||||
codex_protocol::protocol::ExecApprovalRequestEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
approval_id: None,
|
||||
turn_id: "turn-1".to_string(),
|
||||
command: vec!["echo".to_string(), "hi".to_string()],
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
reason: None,
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: Vec::new(),
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
assert_eq!(store.has_pending_thread_approvals(), true);
|
||||
|
||||
store.note_outbound_op(&Op::ExecApproval {
|
||||
id: "call-1".to_string(),
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
decision: codex_protocol::protocol::ReviewDecision::Approved,
|
||||
});
|
||||
|
||||
assert_eq!(store.has_pending_thread_approvals(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_user_input_does_not_count_as_pending_thread_approval() {
|
||||
let mut store = ThreadEventStore::new(8);
|
||||
store.push_event(Event {
|
||||
id: "ev-1".to_string(),
|
||||
msg: EventMsg::RequestUserInput(
|
||||
codex_protocol::request_user_input::RequestUserInputEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
questions: Vec::new(),
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
assert_eq!(store.has_pending_thread_approvals(), false);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user