Move TUI on top of app server (parallel code) (#14717)

This PR replicates the `tui` code directory and creates a temporary
parallel `tui_app_server` directory. It also implements a new feature
flag `tui_app_server` to select between the two tui implementations.

Once the new app-server-based TUI is stabilized, we'll delete the old
`tui` directory and feature flag.
This commit is contained in:
Eric Traut
2026-03-16 10:49:19 -06:00
committed by GitHub
parent c04a0a7454
commit db89b73a9c
1109 changed files with 134253 additions and 17 deletions
@@ -0,0 +1,324 @@
//! 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, None, None, 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(None, 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())
);
}
}
@@ -0,0 +1,613 @@
/*
This module holds the temporary adapter layer between the TUI and the app
server during the hybrid migration period.
For now, the TUI still owns its existing direct-core behavior, but startup
allocates a local in-process app server and drains its event stream. Keeping
the app-server-specific wiring here keeps that transitional logic out of the
main `app.rs` orchestration path.
As more TUI flows move onto the app-server surface directly, this adapter
should shrink and eventually disappear.
*/
use super::App;
use crate::app_event::AppEvent;
use crate::app_server_session::AppServerSession;
use crate::app_server_session::app_server_rate_limit_snapshot_to_core;
use crate::app_server_session::status_account_display_from_auth_mode;
use codex_app_server_client::AppServerEvent;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItem;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ModeKind;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::ContextCompactionItem;
use codex_protocol::items::ImageGenerationItem;
use codex_protocol::items::PlanItem;
use codex_protocol::items::ReasoningItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::items::WebSearchItem;
use codex_protocol::protocol::AgentMessageDeltaEvent;
use codex_protocol::protocol::AgentReasoningDeltaEvent;
use codex_protocol::protocol::AgentReasoningRawContentDeltaEvent;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::PlanDeltaEvent;
use codex_protocol::protocol::RealtimeConversationClosedEvent;
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
use codex_protocol::protocol::RealtimeConversationStartedEvent;
use codex_protocol::protocol::RealtimeEvent;
use codex_protocol::protocol::ThreadNameUpdatedEvent;
use codex_protocol::protocol::TokenCountEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnStartedEvent;
use serde_json::Value;
impl App {
pub(super) async fn handle_app_server_event(
&mut self,
app_server_client: &AppServerSession,
event: AppServerEvent,
) {
match event {
AppServerEvent::Lagged { skipped } => {
tracing::warn!(
skipped,
"app-server event consumer lagged; dropping ignored events"
);
}
AppServerEvent::ServerNotification(notification) => match notification {
ServerNotification::ServerRequestResolved(notification) => {
self.pending_app_server_requests
.resolve_notification(&notification.request_id);
}
ServerNotification::AccountRateLimitsUpdated(notification) => {
self.chat_widget.on_rate_limit_snapshot(Some(
app_server_rate_limit_snapshot_to_core(notification.rate_limits),
));
}
ServerNotification::AccountUpdated(notification) => {
self.chat_widget.update_account_state(
status_account_display_from_auth_mode(
notification.auth_mode,
notification.plan_type,
),
notification.plan_type,
matches!(
notification.auth_mode,
Some(codex_app_server_protocol::AuthMode::Chatgpt)
),
);
}
notification => {
if let Some((thread_id, events)) =
server_notification_thread_events(notification)
{
for event in events {
if self.primary_thread_id.is_none()
|| matches!(event.msg, EventMsg::SessionConfigured(_))
&& self.primary_thread_id == Some(thread_id)
{
if let Err(err) = self.enqueue_primary_event(event).await {
tracing::warn!(
"failed to enqueue primary app-server server notification: {err}"
);
}
} else if let Err(err) =
self.enqueue_thread_event(thread_id, event).await
{
tracing::warn!(
"failed to enqueue app-server server notification for {thread_id}: {err}"
);
}
}
}
}
},
AppServerEvent::LegacyNotification(notification) => {
if let Some((thread_id, event)) = legacy_thread_event(notification.params) {
self.pending_app_server_requests.note_legacy_event(&event);
if self.primary_thread_id.is_none()
|| matches!(event.msg, EventMsg::SessionConfigured(_))
&& self.primary_thread_id == Some(thread_id)
{
if let Err(err) = self.enqueue_primary_event(event).await {
tracing::warn!("failed to enqueue primary app-server event: {err}");
}
} else if let Err(err) = self.enqueue_thread_event(thread_id, event).await {
tracing::warn!(
"failed to enqueue app-server thread event for {thread_id}: {err}"
);
}
}
}
AppServerEvent::ServerRequest(request) => {
if let Some(unsupported) = self
.pending_app_server_requests
.note_server_request(&request)
{
tracing::warn!(
request_id = ?unsupported.request_id,
message = unsupported.message,
"rejecting unsupported app-server request"
);
self.chat_widget
.add_error_message(unsupported.message.clone());
if let Err(err) = self
.reject_app_server_request(
app_server_client,
unsupported.request_id,
unsupported.message,
)
.await
{
tracing::warn!("{err}");
}
}
}
AppServerEvent::Disconnected { message } => {
tracing::warn!("app-server event stream disconnected: {message}");
self.chat_widget.add_error_message(message.clone());
self.app_event_tx.send(AppEvent::FatalExitRequest(message));
}
}
}
async fn reject_app_server_request(
&self,
app_server_client: &AppServerSession,
request_id: codex_app_server_protocol::RequestId,
reason: String,
) -> std::result::Result<(), String> {
app_server_client
.reject_server_request(
request_id,
JSONRPCErrorError {
code: -32000,
message: reason,
data: None,
},
)
.await
.map_err(|err| format!("failed to reject app-server request: {err}"))
}
}
fn legacy_thread_event(params: Option<Value>) -> Option<(ThreadId, Event)> {
let Value::Object(mut params) = params? else {
return None;
};
let thread_id = params
.remove("conversationId")
.and_then(|value| serde_json::from_value::<String>(value).ok())
.and_then(|value| ThreadId::from_string(&value).ok());
let event = serde_json::from_value::<Event>(Value::Object(params)).ok()?;
let thread_id = thread_id.or(match &event.msg {
EventMsg::SessionConfigured(session) => Some(session.session_id),
_ => None,
})?;
Some((thread_id, event))
}
fn server_notification_thread_events(
notification: ServerNotification,
) -> Option<(ThreadId, Vec<Event>)> {
match notification {
ServerNotification::ThreadTokenUsageUpdated(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::TokenCount(TokenCountEvent {
info: Some(TokenUsageInfo {
total_token_usage: token_usage_from_app_server(
notification.token_usage.total,
),
last_token_usage: token_usage_from_app_server(
notification.token_usage.last,
),
model_context_window: notification.token_usage.model_context_window,
}),
rate_limits: None,
}),
}],
)),
ServerNotification::Error(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::Error(ErrorEvent {
message: notification.error.message,
codex_error_info: notification
.error
.codex_error_info
.and_then(app_server_codex_error_info_to_core),
}),
}],
)),
ServerNotification::ThreadNameUpdated(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent {
thread_id: ThreadId::from_string(&notification.thread_id).ok()?,
thread_name: notification.thread_name,
}),
}],
)),
ServerNotification::TurnStarted(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: notification.turn.id,
model_context_window: None,
collaboration_mode_kind: ModeKind::default(),
}),
}],
)),
ServerNotification::TurnCompleted(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: notification.turn.id,
last_agent_message: None,
}),
}],
)),
ServerNotification::ItemStarted(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::ItemStarted(ItemStartedEvent {
thread_id: ThreadId::from_string(&notification.thread_id).ok()?,
turn_id: notification.turn_id,
item: thread_item_to_core(notification.item)?,
}),
}],
)),
ServerNotification::ItemCompleted(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: ThreadId::from_string(&notification.thread_id).ok()?,
turn_id: notification.turn_id,
item: thread_item_to_core(notification.item)?,
}),
}],
)),
ServerNotification::AgentMessageDelta(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: notification.delta,
}),
}],
)),
ServerNotification::PlanDelta(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::PlanDelta(PlanDeltaEvent {
thread_id: notification.thread_id,
turn_id: notification.turn_id,
item_id: notification.item_id,
delta: notification.delta,
}),
}],
)),
ServerNotification::ReasoningSummaryTextDelta(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
delta: notification.delta,
}),
}],
)),
ServerNotification::ReasoningTextDelta(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
delta: notification.delta,
}),
}],
)),
ServerNotification::ThreadRealtimeStarted(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::RealtimeConversationStarted(RealtimeConversationStartedEvent {
session_id: notification.session_id,
}),
}],
)),
ServerNotification::ThreadRealtimeItemAdded(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::ConversationItemAdded(notification.item),
}),
}],
)),
ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::AudioOut(notification.audio.into()),
}),
}],
)),
ServerNotification::ThreadRealtimeError(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::Error(notification.message),
}),
}],
)),
ServerNotification::ThreadRealtimeClosed(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::RealtimeConversationClosed(RealtimeConversationClosedEvent {
reason: notification.reason,
}),
}],
)),
_ => None,
}
}
fn token_usage_from_app_server(
value: codex_app_server_protocol::TokenUsageBreakdown,
) -> TokenUsage {
TokenUsage {
input_tokens: value.input_tokens,
cached_input_tokens: value.cached_input_tokens,
output_tokens: value.output_tokens,
reasoning_output_tokens: value.reasoning_output_tokens,
total_tokens: value.total_tokens,
}
}
fn thread_item_to_core(item: ThreadItem) -> Option<TurnItem> {
match item {
ThreadItem::UserMessage { id, content } => Some(TurnItem::UserMessage(UserMessageItem {
id,
content: content
.into_iter()
.map(codex_app_server_protocol::UserInput::into_core)
.collect(),
})),
ThreadItem::AgentMessage { id, text, phase } => {
Some(TurnItem::AgentMessage(AgentMessageItem {
id,
content: vec![AgentMessageContent::Text { text }],
phase,
}))
}
ThreadItem::Plan { id, text } => Some(TurnItem::Plan(PlanItem { id, text })),
ThreadItem::Reasoning {
id,
summary,
content,
} => Some(TurnItem::Reasoning(ReasoningItem {
id,
summary_text: summary,
raw_content: content,
})),
ThreadItem::WebSearch { id, query, action } => Some(TurnItem::WebSearch(WebSearchItem {
id,
query,
action: app_server_web_search_action_to_core(action?)?,
})),
ThreadItem::ImageGeneration {
id,
status,
revised_prompt,
result,
} => Some(TurnItem::ImageGeneration(ImageGenerationItem {
id,
status,
revised_prompt,
result,
saved_path: None,
})),
ThreadItem::ContextCompaction { id } => {
Some(TurnItem::ContextCompaction(ContextCompactionItem { id }))
}
ThreadItem::CommandExecution { .. }
| ThreadItem::FileChange { .. }
| ThreadItem::McpToolCall { .. }
| ThreadItem::DynamicToolCall { .. }
| ThreadItem::CollabAgentToolCall { .. }
| ThreadItem::ImageView { .. }
| ThreadItem::EnteredReviewMode { .. }
| ThreadItem::ExitedReviewMode { .. } => {
tracing::debug!("ignoring unsupported app-server thread item in TUI adapter");
None
}
}
}
fn app_server_web_search_action_to_core(
action: codex_app_server_protocol::WebSearchAction,
) -> Option<codex_protocol::models::WebSearchAction> {
match action {
codex_app_server_protocol::WebSearchAction::Search { query, queries } => {
Some(codex_protocol::models::WebSearchAction::Search { query, queries })
}
codex_app_server_protocol::WebSearchAction::OpenPage { url } => {
Some(codex_protocol::models::WebSearchAction::OpenPage { url })
}
codex_app_server_protocol::WebSearchAction::FindInPage { url, pattern } => {
Some(codex_protocol::models::WebSearchAction::FindInPage { url, pattern })
}
codex_app_server_protocol::WebSearchAction::Other => None,
}
}
fn app_server_codex_error_info_to_core(
value: codex_app_server_protocol::CodexErrorInfo,
) -> Option<codex_protocol::protocol::CodexErrorInfo> {
serde_json::from_value(serde_json::to_value(value).ok()?).ok()
}
#[cfg(test)]
mod tests {
use super::server_notification_thread_events;
use codex_app_server_protocol::AgentMessageDeltaNotification;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_protocol::ThreadId;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::TurnItem;
use codex_protocol::models::MessagePhase;
use codex_protocol::protocol::EventMsg;
use pretty_assertions::assert_eq;
#[test]
fn bridges_completed_agent_messages_from_server_notifications() {
let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string();
let turn_id = "019cee8c-b9b4-7f10-a1b0-38caa876a012".to_string();
let item_id = "msg_123".to_string();
let (actual_thread_id, events) = server_notification_thread_events(
ServerNotification::ItemCompleted(ItemCompletedNotification {
item: ThreadItem::AgentMessage {
id: item_id,
text: "Hello from your coding assistant.".to_string(),
phase: Some(MessagePhase::FinalAnswer),
},
thread_id: thread_id.clone(),
turn_id: turn_id.clone(),
}),
)
.expect("notification should bridge");
assert_eq!(
actual_thread_id,
ThreadId::from_string(&thread_id).expect("valid thread id")
);
let [event] = events.as_slice() else {
panic!("expected one bridged event");
};
assert_eq!(event.id, String::new());
let EventMsg::ItemCompleted(completed) = &event.msg else {
panic!("expected item completed event");
};
assert_eq!(
completed.thread_id,
ThreadId::from_string(&thread_id).expect("valid thread id")
);
assert_eq!(completed.turn_id, turn_id);
match &completed.item {
TurnItem::AgentMessage(AgentMessageItem { id, content, phase }) => {
assert_eq!(id, "msg_123");
let [AgentMessageContent::Text { text }] = content.as_slice() else {
panic!("expected a single text content item");
};
assert_eq!(text, "Hello from your coding assistant.");
assert_eq!(*phase, Some(MessagePhase::FinalAnswer));
}
_ => panic!("expected bridged agent message item"),
}
}
#[test]
fn bridges_turn_completion_from_server_notifications() {
let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string();
let turn_id = "019cee8c-b9b4-7f10-a1b0-38caa876a012".to_string();
let (actual_thread_id, events) = server_notification_thread_events(
ServerNotification::TurnCompleted(TurnCompletedNotification {
thread_id: thread_id.clone(),
turn: Turn {
id: turn_id.clone(),
items: Vec::new(),
status: TurnStatus::Completed,
error: None,
},
}),
)
.expect("notification should bridge");
assert_eq!(
actual_thread_id,
ThreadId::from_string(&thread_id).expect("valid thread id")
);
let [event] = events.as_slice() else {
panic!("expected one bridged event");
};
assert_eq!(event.id, String::new());
let EventMsg::TurnComplete(completed) = &event.msg else {
panic!("expected turn complete event");
};
assert_eq!(completed.turn_id, turn_id);
assert_eq!(completed.last_agent_message, None);
}
#[test]
fn bridges_text_deltas_from_server_notifications() {
let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string();
let (_, agent_events) = server_notification_thread_events(
ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification {
thread_id: thread_id.clone(),
turn_id: "turn".to_string(),
item_id: "item".to_string(),
delta: "Hello".to_string(),
}),
)
.expect("notification should bridge");
let [agent_event] = agent_events.as_slice() else {
panic!("expected one bridged agent delta event");
};
assert_eq!(agent_event.id, String::new());
let EventMsg::AgentMessageDelta(delta) = &agent_event.msg else {
panic!("expected bridged agent message delta");
};
assert_eq!(delta.delta, "Hello");
let (_, reasoning_events) = server_notification_thread_events(
ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
thread_id,
turn_id: "turn".to_string(),
item_id: "item".to_string(),
delta: "Thinking".to_string(),
summary_index: 0,
}),
)
.expect("notification should bridge");
let [reasoning_event] = reasoning_events.as_slice() else {
panic!("expected one bridged reasoning delta event");
};
assert_eq!(reasoning_event.id, String::new());
let EventMsg::AgentReasoningDelta(delta) = &reasoning_event.msg else {
panic!("expected bridged reasoning delta");
};
assert_eq!(delta.delta, "Thinking");
}
}
@@ -0,0 +1,645 @@
use std::collections::HashMap;
use crate::app_command::AppCommand;
use crate::app_command::AppCommandView;
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
use codex_app_server_protocol::GrantedPermissionProfile;
use codex_app_server_protocol::McpServerElicitationAction;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_app_server_protocol::McpServerElicitationRequestResponse;
use codex_app_server_protocol::PermissionsRequestApprovalResponse;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ToolRequestUserInputResponse;
use codex_protocol::approvals::ElicitationRequest;
use codex_protocol::mcp::RequestId as McpRequestId;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ReviewDecision;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct AppServerRequestResolution {
pub(super) request_id: AppServerRequestId,
pub(super) result: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct UnsupportedAppServerRequest {
pub(super) request_id: AppServerRequestId,
pub(super) message: String,
}
#[derive(Debug, Default)]
pub(super) struct PendingAppServerRequests {
exec_approvals: HashMap<String, AppServerRequestId>,
file_change_approvals: HashMap<String, AppServerRequestId>,
permissions_approvals: HashMap<String, AppServerRequestId>,
user_inputs: HashMap<String, AppServerRequestId>,
mcp_pending_by_matcher: HashMap<McpServerMatcher, AppServerRequestId>,
mcp_legacy_by_matcher: HashMap<McpServerMatcher, McpLegacyRequestKey>,
mcp_legacy_requests: HashMap<McpLegacyRequestKey, AppServerRequestId>,
}
impl PendingAppServerRequests {
pub(super) fn clear(&mut self) {
self.exec_approvals.clear();
self.file_change_approvals.clear();
self.permissions_approvals.clear();
self.user_inputs.clear();
self.mcp_pending_by_matcher.clear();
self.mcp_legacy_by_matcher.clear();
self.mcp_legacy_requests.clear();
}
pub(super) fn note_server_request(
&mut self,
request: &ServerRequest,
) -> Option<UnsupportedAppServerRequest> {
match request {
ServerRequest::CommandExecutionRequestApproval { request_id, params } => {
let approval_id = params
.approval_id
.clone()
.unwrap_or_else(|| params.item_id.clone());
self.exec_approvals.insert(approval_id, request_id.clone());
None
}
ServerRequest::FileChangeRequestApproval { request_id, params } => {
self.file_change_approvals
.insert(params.item_id.clone(), request_id.clone());
None
}
ServerRequest::PermissionsRequestApproval { request_id, params } => {
self.permissions_approvals
.insert(params.item_id.clone(), request_id.clone());
None
}
ServerRequest::ToolRequestUserInput { request_id, params } => {
self.user_inputs
.insert(params.turn_id.clone(), request_id.clone());
None
}
ServerRequest::McpServerElicitationRequest { request_id, params } => {
let matcher = McpServerMatcher::from_v2(params);
if let Some(legacy_key) = self.mcp_legacy_by_matcher.remove(&matcher) {
self.mcp_legacy_requests
.insert(legacy_key, request_id.clone());
} else {
self.mcp_pending_by_matcher
.insert(matcher, request_id.clone());
}
None
}
ServerRequest::DynamicToolCall { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
message: "Dynamic tool calls are not available in app-server TUI yet."
.to_string(),
})
}
ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
message: "ChatGPT auth token refresh is not available in app-server TUI yet."
.to_string(),
})
}
ServerRequest::ApplyPatchApproval { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
message:
"Legacy patch approval requests are not available in app-server TUI yet."
.to_string(),
})
}
ServerRequest::ExecCommandApproval { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
message:
"Legacy command approval requests are not available in app-server TUI yet."
.to_string(),
})
}
}
}
pub(super) fn note_legacy_event(&mut self, event: &Event) {
let EventMsg::ElicitationRequest(request) = &event.msg else {
return;
};
let matcher = McpServerMatcher::from_core(
&request.server_name,
request.turn_id.as_deref(),
&request.request,
);
let legacy_key = McpLegacyRequestKey {
server_name: request.server_name.clone(),
request_id: request.id.clone(),
};
if let Some(request_id) = self.mcp_pending_by_matcher.remove(&matcher) {
self.mcp_legacy_requests.insert(legacy_key, request_id);
} else {
self.mcp_legacy_by_matcher.insert(matcher, legacy_key);
}
}
pub(super) fn take_resolution<T>(
&mut self,
op: T,
) -> Result<Option<AppServerRequestResolution>, String>
where
T: Into<AppCommand>,
{
let op: AppCommand = op.into();
let resolution = match op.view() {
AppCommandView::ExecApproval { id, decision, .. } => self
.exec_approvals
.remove(id)
.map(|request_id| {
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
request_id,
result: serde_json::to_value(CommandExecutionRequestApprovalResponse {
decision: decision.clone().into(),
})
.map_err(|err| {
format!("failed to serialize command execution approval response: {err}")
})?,
})
})
.transpose()?,
AppCommandView::PatchApproval { id, decision } => self
.file_change_approvals
.remove(id)
.map(|request_id| {
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
request_id,
result: serde_json::to_value(FileChangeRequestApprovalResponse {
decision: file_change_decision(decision)?,
})
.map_err(|err| {
format!("failed to serialize file change approval response: {err}")
})?,
})
})
.transpose()?,
AppCommandView::RequestPermissionsResponse { id, response } => self
.permissions_approvals
.remove(id)
.map(|request_id| {
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
request_id,
result: serde_json::to_value(PermissionsRequestApprovalResponse {
permissions: serde_json::from_value::<GrantedPermissionProfile>(
serde_json::to_value(&response.permissions).map_err(|err| {
format!("failed to encode granted permissions: {err}")
})?,
)
.map_err(|err| {
format!("failed to decode granted permissions for app-server: {err}")
})?,
scope: response.scope.into(),
})
.map_err(|err| {
format!("failed to serialize permissions approval response: {err}")
})?,
})
})
.transpose()?,
AppCommandView::UserInputAnswer { id, response } => self
.user_inputs
.remove(id)
.map(|request_id| {
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
request_id,
result: serde_json::to_value(
serde_json::from_value::<ToolRequestUserInputResponse>(
serde_json::to_value(response).map_err(|err| {
format!("failed to encode request_user_input response: {err}")
})?,
)
.map_err(|err| {
format!(
"failed to decode request_user_input response for app-server: {err}"
)
})?,
)
.map_err(|err| {
format!("failed to serialize request_user_input response: {err}")
})?,
})
})
.transpose()?,
AppCommandView::ResolveElicitation {
server_name,
request_id,
decision,
content,
meta,
} => self
.mcp_legacy_requests
.remove(&McpLegacyRequestKey {
server_name: server_name.to_string(),
request_id: request_id.clone(),
})
.map(|request_id| {
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
request_id,
result: serde_json::to_value(McpServerElicitationRequestResponse {
action: match decision {
codex_protocol::approvals::ElicitationAction::Accept => {
McpServerElicitationAction::Accept
}
codex_protocol::approvals::ElicitationAction::Decline => {
McpServerElicitationAction::Decline
}
codex_protocol::approvals::ElicitationAction::Cancel => {
McpServerElicitationAction::Cancel
}
},
content: content.clone(),
meta: meta.clone(),
})
.map_err(|err| {
format!("failed to serialize MCP elicitation response: {err}")
})?,
})
})
.transpose()?,
_ => None,
};
Ok(resolution)
}
pub(super) fn resolve_notification(&mut self, request_id: &AppServerRequestId) {
self.exec_approvals.retain(|_, value| value != request_id);
self.file_change_approvals
.retain(|_, value| value != request_id);
self.permissions_approvals
.retain(|_, value| value != request_id);
self.user_inputs.retain(|_, value| value != request_id);
self.mcp_pending_by_matcher
.retain(|_, value| value != request_id);
self.mcp_legacy_requests
.retain(|_, value| value != request_id);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct McpServerMatcher {
server_name: String,
turn_id: Option<String>,
request: String,
}
impl McpServerMatcher {
fn from_v2(params: &McpServerElicitationRequestParams) -> Self {
Self {
server_name: params.server_name.clone(),
turn_id: params.turn_id.clone(),
request: serde_json::to_string(
&serde_json::to_value(&params.request).unwrap_or(serde_json::Value::Null),
)
.unwrap_or_else(|_| "null".to_string()),
}
}
fn from_core(server_name: &str, turn_id: Option<&str>, request: &ElicitationRequest) -> Self {
let request = match request {
ElicitationRequest::Form {
meta,
message,
requested_schema,
} => serde_json::to_string(&serde_json::json!({
"mode": "form",
"_meta": meta,
"message": message,
"requestedSchema": requested_schema,
}))
.unwrap_or_else(|_| "null".to_string()),
ElicitationRequest::Url {
meta,
message,
url,
elicitation_id,
} => serde_json::to_string(&serde_json::json!({
"mode": "url",
"_meta": meta,
"message": message,
"url": url,
"elicitationId": elicitation_id,
}))
.unwrap_or_else(|_| "null".to_string()),
};
Self {
server_name: server_name.to_string(),
turn_id: turn_id.map(str::to_string),
request,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct McpLegacyRequestKey {
server_name: String,
request_id: McpRequestId,
}
fn file_change_decision(decision: &ReviewDecision) -> Result<FileChangeApprovalDecision, String> {
match decision {
ReviewDecision::Approved => Ok(FileChangeApprovalDecision::Accept),
ReviewDecision::ApprovedForSession => Ok(FileChangeApprovalDecision::AcceptForSession),
ReviewDecision::Denied => Ok(FileChangeApprovalDecision::Decline),
ReviewDecision::Abort => Ok(FileChangeApprovalDecision::Cancel),
ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
Err("execpolicy amendment is not a valid file change approval decision".to_string())
}
ReviewDecision::NetworkPolicyAmendment { .. } => {
Err("network policy amendment is not a valid file change approval decision".to_string())
}
}
}
#[cfg(test)]
mod tests {
use super::PendingAppServerRequests;
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::FileChangeRequestApprovalParams;
use codex_app_server_protocol::McpElicitationObjectType;
use codex_app_server_protocol::McpElicitationSchema;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_app_server_protocol::PermissionGrantScope;
use codex_app_server_protocol::PermissionsRequestApprovalParams;
use codex_app_server_protocol::PermissionsRequestApprovalResponse;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ToolRequestUserInputAnswer;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::ToolRequestUserInputResponse;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::approvals::ElicitationRequest;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::mcp::RequestId as McpRequestId;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
#[test]
fn resolves_exec_approval_through_app_server_request_id() {
let mut pending = PendingAppServerRequests::default();
let request = ServerRequest::CommandExecutionRequestApproval {
request_id: AppServerRequestId::Integer(41),
params: CommandExecutionRequestApprovalParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(),
approval_id: Some("approval-1".to_string()),
reason: None,
network_approval_context: None,
command: Some("ls".to_string()),
cwd: None,
command_actions: None,
additional_permissions: None,
skill_metadata: None,
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
available_decisions: None,
},
};
assert_eq!(pending.note_server_request(&request), None);
let resolution = pending
.take_resolution(&Op::ExecApproval {
id: "approval-1".to_string(),
turn_id: None,
decision: ReviewDecision::Approved,
})
.expect("resolution should serialize")
.expect("request should be pending");
assert_eq!(resolution.request_id, AppServerRequestId::Integer(41));
assert_eq!(resolution.result, json!({ "decision": "accept" }));
}
#[test]
fn resolves_permissions_and_user_input_through_app_server_request_id() {
let mut pending = PendingAppServerRequests::default();
assert_eq!(
pending.note_server_request(&ServerRequest::PermissionsRequestApproval {
request_id: AppServerRequestId::Integer(7),
params: PermissionsRequestApprovalParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "perm-1".to_string(),
reason: None,
permissions: serde_json::from_value(json!({
"network": { "enabled": null }
}))
.expect("valid permissions"),
},
}),
None
);
assert_eq!(
pending.note_server_request(&ServerRequest::ToolRequestUserInput {
request_id: AppServerRequestId::Integer(8),
params: ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-2".to_string(),
item_id: "tool-1".to_string(),
questions: Vec::new(),
},
}),
None
);
let permissions = pending
.take_resolution(&Op::RequestPermissionsResponse {
id: "perm-1".to_string(),
response: codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: serde_json::from_value(json!({
"network": { "enabled": null }
}))
.expect("valid permissions"),
scope: codex_protocol::request_permissions::PermissionGrantScope::Session,
},
})
.expect("permissions response should serialize")
.expect("permissions request should be pending");
assert_eq!(permissions.request_id, AppServerRequestId::Integer(7));
assert_eq!(
serde_json::from_value::<PermissionsRequestApprovalResponse>(permissions.result)
.expect("permissions response should decode"),
PermissionsRequestApprovalResponse {
permissions: serde_json::from_value(json!({
"network": { "enabled": null }
}))
.expect("valid permissions"),
scope: PermissionGrantScope::Session,
}
);
let user_input = pending
.take_resolution(&Op::UserInputAnswer {
id: "turn-2".to_string(),
response: codex_protocol::request_user_input::RequestUserInputResponse {
answers: std::iter::once((
"question".to_string(),
codex_protocol::request_user_input::RequestUserInputAnswer {
answers: vec!["yes".to_string()],
},
))
.collect(),
},
})
.expect("user input response should serialize")
.expect("user input request should be pending");
assert_eq!(user_input.request_id, AppServerRequestId::Integer(8));
assert_eq!(
serde_json::from_value::<ToolRequestUserInputResponse>(user_input.result)
.expect("user input response should decode"),
ToolRequestUserInputResponse {
answers: std::iter::once((
"question".to_string(),
ToolRequestUserInputAnswer {
answers: vec!["yes".to_string()],
},
))
.collect(),
}
);
}
#[test]
fn correlates_mcp_elicitation_between_legacy_event_and_server_request() {
let mut pending = PendingAppServerRequests::default();
pending.note_legacy_event(&Event {
id: "event-1".to_string(),
msg: EventMsg::ElicitationRequest(ElicitationRequestEvent {
turn_id: Some("turn-1".to_string()),
server_name: "example".to_string(),
id: McpRequestId::String("mcp-1".to_string()),
request: ElicitationRequest::Form {
meta: None,
message: "Need input".to_string(),
requested_schema: json!({
"type": "object",
"properties": {},
}),
},
}),
});
assert_eq!(
pending.note_server_request(&ServerRequest::McpServerElicitationRequest {
request_id: AppServerRequestId::Integer(12),
params: McpServerElicitationRequestParams {
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
server_name: "example".to_string(),
request: McpServerElicitationRequest::Form {
meta: None,
message: "Need input".to_string(),
requested_schema: McpElicitationSchema {
schema_uri: None,
type_: McpElicitationObjectType::Object,
properties: BTreeMap::new(),
required: None,
},
},
},
}),
None
);
let resolution = pending
.take_resolution(&Op::ResolveElicitation {
server_name: "example".to_string(),
request_id: McpRequestId::String("mcp-1".to_string()),
decision: ElicitationAction::Accept,
content: Some(json!({ "answer": "yes" })),
meta: Some(json!({ "source": "tui" })),
})
.expect("elicitation response should serialize")
.expect("elicitation request should be pending");
assert_eq!(resolution.request_id, AppServerRequestId::Integer(12));
assert_eq!(
resolution.result,
json!({
"action": "accept",
"content": { "answer": "yes" },
"_meta": { "source": "tui" }
})
);
}
#[test]
fn rejects_dynamic_tool_calls_as_unsupported() {
let mut pending = PendingAppServerRequests::default();
let unsupported = pending
.note_server_request(&ServerRequest::DynamicToolCall {
request_id: AppServerRequestId::Integer(99),
params: codex_app_server_protocol::DynamicToolCallParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
call_id: "tool-1".to_string(),
tool: "tool".to_string(),
arguments: json!({}),
},
})
.expect("dynamic tool calls should be rejected");
assert_eq!(unsupported.request_id, AppServerRequestId::Integer(99));
assert_eq!(
unsupported.message,
"Dynamic tool calls are not available in app-server TUI yet."
);
}
#[test]
fn rejects_invalid_patch_decisions_for_file_change_requests() {
let mut pending = PendingAppServerRequests::default();
assert_eq!(
pending.note_server_request(&ServerRequest::FileChangeRequestApproval {
request_id: AppServerRequestId::Integer(13),
params: FileChangeRequestApprovalParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "patch-1".to_string(),
reason: None,
grant_root: None,
},
}),
None
);
let error = pending
.take_resolution(&Op::PatchApproval {
id: "patch-1".to_string(),
decision: ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec![
"echo".to_string(),
"hi".to_string(),
]),
},
})
.expect_err("invalid patch decision should fail");
assert_eq!(
error,
"execpolicy amendment is not a valid file change approval decision"
);
}
}
@@ -0,0 +1,733 @@
use crate::app_command::AppCommand;
use crate::app_command::AppCommandView;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
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<T>(op: T) -> bool
where
T: Into<AppCommand>,
{
let op: AppCommand = op.into();
matches!(
op.view(),
AppCommandView::ExecApproval { .. }
| AppCommandView::PatchApproval { .. }
| AppCommandView::ResolveElicitation { .. }
| AppCommandView::RequestPermissionsResponse { .. }
| AppCommandView::UserInputAnswer { .. }
| AppCommandView::Shutdown
)
}
pub(super) fn note_outbound_op<T>(&mut self, op: T)
where
T: Into<AppCommand>,
{
let op: AppCommand = op.into();
match op.view() {
AppCommandView::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,
);
}
}
AppCommandView::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,
);
}
AppCommandView::ResolveElicitation {
server_name,
request_id,
..
} => {
self.elicitation_requests
.remove(&ElicitationRequestKey::new(
server_name.to_string(),
request_id.clone(),
));
}
AppCommandView::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.
AppCommandView::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);
}
}
AppCommandView::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,
skill_metadata: 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,
skill_metadata: 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,
skill_metadata: 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);
}
}