Handle TUI message submission receipts and preserve uncertain drafts (#59817)

## Why

The TUI's cached active turn can be stale when a cloud message is submitted. A lost reply also leaves delivery uncertain, so automatically resending the draft risks submitting it twice.

## What changed

- Let the cloud server choose whether an Enter submission starts or steers a turn, and apply its receipt to the matching client message ID.
- Keep Tab-queued input waiting until the admitted turn finishes, including before its start notification arrives.
- Restore rejected submissions to the composer. Hold duplicate or unconfirmed submissions out of automatic delivery until history confirms them.
- Reconcile saved drafts and pending steers by client message ID when resuming sessions, and preserve submissions belonging to other turns during interruption recovery.
- Classify cloud preflight failures as invalid parameters so rejected queued input can be restored safely.

## Testing

Add regression coverage for cloud receipt outcomes, queued-input ordering, rejected drafts, lost replies, interruption recovery, and session resume with identical message text but distinct client IDs.

GitOrigin-RevId: ec4eb581e450488434bc4614ec59273d73ffd0a1
This commit is contained in:
Benjamin Carlsson
2026-11-06 03:51:37 +00:00
committed by copyberry
parent 6eb51a438a
commit a142c90e26
32 changed files with 1522 additions and 258 deletions
@@ -26,6 +26,10 @@ type ReplyStream = BoxStream<'static, Result<cloud::Event, cloud::Error>>;
#[error("{0}")]
struct PreflightRejection(&'static str);
#[derive(Debug, thiserror::Error)]
#[error("Cloud stream is not connected. Reopen the conversation before sending.")]
struct MissingReplyAttachment;
struct Attachment {
thread_id: String,
stream: ReplyStream,
@@ -124,7 +128,7 @@ impl Replies {
&mut self,
id: &str,
output: &mpsc::UnboundedSender<AppServerEvent>,
) -> Result<(), PreflightRejection> {
) -> Result<(), MissingReplyAttachment> {
// Notice a ready disconnect before sending. A continuous stream of events
// must still allow commands to make progress.
let _ = tokio::task::unconstrained(self.forward_next(output)).now_or_never();
@@ -133,9 +137,7 @@ impl Replies {
.as_ref()
.is_none_or(|active| active.thread_id != id)
{
return Err(PreflightRejection(
"Cloud stream is not connected. Reopen the conversation before sending.",
));
return Err(MissingReplyAttachment);
}
Ok(())
}
@@ -204,12 +206,13 @@ async fn run_worker(
.is_some()
{
-32601
} else if user_message_submission
&& (error.is::<PreflightRejection>()
|| matches!(
error.downcast_ref::<cloud::MutationError>(),
Some(cloud::MutationError::InvalidRequest)
))
} else if error.is::<PreflightRejection>()
|| user_message_submission
&& (error.is::<MissingReplyAttachment>()
|| matches!(
error.downcast_ref::<cloud::MutationError>(),
Some(cloud::MutationError::InvalidRequest)
))
{
-32602
} else {
@@ -118,6 +118,25 @@ async fn native_submission_uses_one_admission_and_keeps_preflight_guards() -> Re
};
assert_eq!(source.code, -32602);
}
let queued = handle
.request(protocol::ClientRequest::TurnStart {
request_id: protocol::RequestId::Integer(3),
params: protocol::TurnStartParams {
input: vec![protocol::UserInput::LocalImage {
path: "queued.png".into(),
detail: None,
}],
..submission("queued-image")
},
})
.await?
.expect_err("a queued attachment must fail before admission");
assert_eq!(queued.code, -32602);
assert_eq!(
queued.message,
"Cloud conversations currently accept plain text only."
);
drop(reply_sender);
let Err(crate::TypedRequestError::Server { source, .. }) =
cloud_handle.submit_user_message(submission("closed")).await
-44
View File
@@ -136,7 +136,6 @@ use codex_app_server_protocol::ThreadMemoryMode;
use codex_app_server_protocol::ThreadSettingsUpdateParams;
use codex_app_server_protocol::ThreadStartSource;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnError as AppServerTurnError;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::WriteStatus;
use codex_config::CloudConfigBundleLoader;
@@ -791,49 +790,6 @@ impl RuntimePermissionProfileOverride {
}
}
fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option<AppServerTurnError> {
let TypedRequestError::Server { source, .. } = error else {
return None;
};
let turn_error: AppServerTurnError = serde_json::from_value(source.data.clone()?).ok()?;
matches!(
turn_error.codex_error_info,
Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. })
)
.then_some(turn_error)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ActiveTurnSteerRace {
Missing,
ExpectedTurnMismatch { actual_turn_id: String },
}
fn active_turn_steer_race(error: &TypedRequestError) -> Option<ActiveTurnSteerRace> {
let TypedRequestError::Server { method, source } = error else {
return None;
};
if method != "turn/steer" {
return None;
}
if source.message == "no active turn to steer" {
return Some(ActiveTurnSteerRace::Missing);
}
// App-server steer mismatches mean our cached active turn id is stale, but the response
// includes the server's current active turn so we can resynchronize and retry once.
let mismatch_prefix = "expected active turn id `";
let mismatch_separator = "` but found `";
let actual_turn_id = source
.message
.strip_prefix(mismatch_prefix)?
.split_once(mismatch_separator)?
.1
.strip_suffix('`')?
.to_string();
Some(ActiveTurnSteerRace::ExpectedTurnMismatch { actual_turn_id })
}
fn active_turn_interrupt_race(error: &TypedRequestError) -> Option<String> {
let TypedRequestError::Server { method, source } = error else {
return None;
+3 -1
View File
@@ -886,11 +886,13 @@ impl App {
pub(super) async fn restore_session_draft(
&mut self,
thread_id: ThreadId,
input: ThreadInputState,
mut input: ThreadInputState,
pending_settings: Option<codex_app_server_protocol::ThreadSettingsUpdatedNotification>,
) {
let preserve_in_flight_turn = !self.chat_widget.is_external_writer_view()
&& self.active_turn_id_for_thread(thread_id).await.is_some();
self.reconcile_saved_session_input(thread_id, &mut input)
.await;
self.chat_widget.restore_thread_input_state(
Some(input),
ThreadInputStateRestoreMode {
+2
View File
@@ -59,6 +59,7 @@ impl App {
let AppCommand::UserTurn {
items,
cloud_submission,
cwd,
active_permission_profile,
approval_policy,
@@ -73,6 +74,7 @@ impl App {
);
return;
};
*cloud_submission = crate::app_command::CloudSubmissionIntent::UseKnownTurn;
let permissions_override = Self::turn_permissions_override_from_config(
&retry_config,
active_permission_profile.as_ref(),
+17
View File
@@ -19,6 +19,21 @@ impl App {
}
}
pub(super) async fn reconcile_saved_session_input(
&self,
thread_id: ThreadId,
input: &mut ThreadInputState,
) {
if (!input.pending_steers.is_empty() || input.has_unconfirmed_messages())
&& let Some(channel) = self.thread_event_channels.get(&thread_id)
{
let store = channel.store.lock().await;
let confirmed =
super::thread_events::confirmed_user_message_ids(&store.turns, store.buffer.iter());
input.reconcile_confirmed_messages(&confirmed);
}
}
pub(super) async fn open_resume_picker_for_backends(
&mut self,
tui: &mut tui::Tui,
@@ -93,6 +108,8 @@ impl App {
{
// The resumed widget already reflects the latest server result.
input_state.provider_error = None;
self.reconcile_saved_session_input(thread_id, &mut input_state)
.await;
let preserve_in_flight_turn =
self.active_turn_id_for_thread(thread_id).await.is_some();
self.chat_widget.restore_thread_input_state(
+3
View File
@@ -129,6 +129,9 @@ use crate::app_backtrack::BacktrackState;
use crate::app_backtrack::nth_user_position;
use crate::app_backtrack::user_count;
use crate::app_event::HistoryBatchEntryResponse;
use crate::app_server_session::user_message_submission::ActiveTurnSteerRace;
use crate::app_server_session::user_message_submission::active_turn_not_steerable_turn_error;
use crate::app_server_session::user_message_submission::active_turn_steer_race;
use crate::test_support::boxed_future;
use codex_app_server_protocol::ThreadItem;
+28 -2
View File
@@ -31,6 +31,11 @@ async fn lost_mutation_reply_preserves_work_without_resubmitting() -> Result<()>
app.primary_thread_id = Some(id);
app.chat_widget
.handle_thread_session(test_thread_session(id, app.config.cwd.to_path_buf()));
app.ensure_thread_channel(id)
.store
.lock()
.await
.set_active_turn_id("finished".into());
let listener = TcpListener::bind("127.0.0.1:0").await?;
let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?;
app.app_server_target = AppServerTarget::Remote {
@@ -38,8 +43,21 @@ async fn lost_mutation_reply_preserves_work_without_resubmitting() -> Result<()>
};
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await?;
let mut steered_message = None;
serve_reconnect_requests(tokio_tungstenite::accept_async(stream).await?, |request| {
let params = request.params.unwrap();
let message_id = params["clientUserMessageId"]
.as_str()
.expect("submission ID");
if request.method == "turn/steer" {
assert_eq!(params["expectedTurnId"], "finished");
steered_message = Some(message_id.to_owned());
return std::future::ready(Some(
json!({"error": {"code": -32600, "message": "no active turn to steer"}}),
));
}
assert_eq!(request.method, "turn/start");
assert_eq!(steered_message.as_deref(), Some(message_id));
std::future::ready(None)
})
.await
@@ -157,7 +175,12 @@ async fn lost_mutation_reply_preserves_work_without_resubmitting() -> Result<()>
app.chat_widget.composer_text_with_pending(),
"queued before disconnect"
);
assert!(!app.chat_widget.has_queued_follow_up_messages());
assert_eq!(app.chat_widget.queued_user_message_texts(), ["only once"]);
assert!(
app.chat_widget
.capture_thread_input_state()
.is_some_and(|input| input.has_unconfirmed_messages())
);
let paste = "copied text ".repeat(/*n*/ 92);
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste(paste.clone()))
.await?;
@@ -211,7 +234,10 @@ async fn lost_mutation_reply_preserves_work_without_resubmitting() -> Result<()>
));
}
session.shutdown().await?;
assert_eq!(server.await??, vec!["initialize", "turn/start"]);
assert_eq!(
server.await??,
vec!["initialize", "turn/steer", "turn/start"]
);
Ok(())
}
@@ -510,9 +510,15 @@ stream_max_retries = 0
unreachable!("user turn");
};
let expected_client_id = client_user_message_id.clone();
let pending_input = app.chat_widget.capture_thread_input_state();
// The server reports the current turn. The retried steer must keep this message ID.
app.ensure_thread_channel(thread_id)
.store
.lock()
.await
.set_active_turn_id("stale".into());
app.submit_thread_op(&mut app_server, thread_id, steer)
.await?;
let pending_input = app.chat_widget.capture_thread_input_state();
let other_id = ThreadId::from_string(
&app_test_support::create_fake_rollout(
app.config.codex_home.as_path(),
@@ -43,6 +43,508 @@ use tokio_tungstenite::tungstenite::Error as WebSocketError;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::error::ProtocolError;
#[tokio::test]
async fn cloud_enter_recovers_drafts_from_server_receipts() -> Result<()> {
use codex_app_server_client::CloudOperation;
use codex_app_server_client::CloudUserMessageReceipt::Duplicate;
use codex_app_server_client::CloudUserMessageReceipt::Started;
use codex_app_server_client::CloudUserMessageReceipt::Steered;
let turn = test_turn("actual", TurnStatus::InProgress, Vec::new());
let mut rendered_receipts = Vec::new();
for (label, receipt) in [
("Started", Some(Ok(Started(turn.clone())))),
("Steered", Some(Ok(Steered(turn.clone())))),
("Unconfirmed", None),
(
"Rejected",
Some(Err(JSONRPCErrorError {
code: -32602,
message: "reply stream closed before submission".into(),
data: None,
})),
),
("Duplicate", Some(Ok(Duplicate { turn_id: None }))),
] {
let (mut app, mut events, mut ops) = make_test_app_with_channels().await;
let id = ThreadId::new();
app.enqueue_primary_thread_session(
test_thread_session(id, app.config.cwd.to_path_buf()),
Vec::new(),
)
.await?;
app.ensure_thread_channel(id)
.store
.lock()
.await
.set_active_turn_id("stale".into());
app.chat_widget.handle_server_notification(
turn_started_notification(id, "stale"),
/*replay_kind*/ None,
);
while events.try_recv().is_ok() {}
app.chat_widget.apply_external_edit("follow up".into());
app.chat_widget
.handle_key_event(KeyEvent::from(KeyCode::Enter));
let op = next_user_turn_op(&mut ops);
let Op::UserTurn {
client_user_message_id,
items,
..
} = &op
else {
unreachable!()
};
let expected = (Some(client_user_message_id.clone()), items.clone());
let (client, mut commands, _events) =
codex_app_server_client::CloudClient::channel(/*capacity*/ 1);
let mut server = AppServerSession::new(
crate::backend_id::BackendId::new(),
codex_app_server_client::AppServerClient::Cloud(client),
crate::ThreadParamsMode::Cloud,
);
let rejected = matches!(receipt, Some(Err(_)));
let admitted = matches!(receipt, Some(Ok(Started(_) | Steered(_))));
let steered = matches!(receipt, Some(Ok(Steered(_))));
let (result, ()) = tokio::join!(app.submit_thread_op(&mut server, id, op), async {
let command = commands.recv().await.unwrap();
let CloudOperation::SubmitUserMessage(params) = command.operation else {
panic!("Enter must start or steer")
};
assert_eq!(params.thread_id, id.to_string());
assert_eq!((params.client_user_message_id, params.input), expected);
if let Some(receipt) = receipt {
command
.response
.send(Ok(receipt.map(|r| serde_json::to_value(r).unwrap())))
.unwrap();
}
});
result?;
if !rejected {
app.chat_widget.handle_server_notification(
turn_completed_notification(id, "stale", TurnStatus::Interrupted),
/*replay_kind*/ None,
);
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
}
if admitted {
app.chat_widget.handle_server_notification(
turn_started_notification(id, "actual"),
/*replay_kind*/ None,
);
}
if steered {
app.chat_widget.handle_server_notification(
turn_completed_notification(id, "actual", TurnStatus::Interrupted),
/*replay_kind*/ None,
);
}
let history = std::iter::from_fn(|| events.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => {
Some(lines_to_single_string(&cell.display_lines(/*width*/ 80)))
}
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
let composer =
normalize_snapshot_paths(render_bottom_popup(&app.chat_widget, /*width*/ 80));
rendered_receipts.push(format!("── {label} ──\n{history}\n{composer}"));
assert!(
!std::iter::from_fn(|| ops.try_recv().ok()).any(|op| matches!(op, Op::UserTurn { .. }))
);
}
insta::assert_snapshot!("cloud_composer_receipts", rendered_receipts.join("\n\n"));
Ok(())
}
#[tokio::test]
async fn cloud_resume_picker_reconciles_admitted_steers() -> Result<()> {
use crate::app::agents_overview::tests::overview_thread;
use crate::app_server_session::user_message_submission::UserMessageAdmission;
use codex_app_server_client::CloudOperation;
use codex_app_server_protocol::ItemCompletedNotification;
let (mut app, mut events, mut ops) = make_test_app_with_channels().await;
let [original, other] = [ThreadId::new(), ThreadId::new()];
app.app_server_target = AppServerTarget::Remote {
endpoint: crate::resolve_remote_addr("ws://127.0.0.1:1")?,
};
app.enqueue_primary_thread_session(
test_thread_session(original, app.config.cwd.to_path_buf()),
Vec::new(),
)
.await?;
let turn_id = "finished-turn";
let mut client_ids = Vec::new();
for _ in 0..2 {
app.chat_widget.apply_external_edit("same text".into());
app.chat_widget
.handle_key_event(KeyEvent::from(KeyCode::Enter));
let op = next_user_turn_op(&mut ops);
let Op::UserTurn {
client_user_message_id,
..
} = &op
else {
unreachable!();
};
app.chat_widget.user_message_admitted(
UserMessageAdmission::Steered(turn_id.into()),
&op,
client_user_message_id,
);
client_ids.push(client_user_message_id.clone());
}
let [confirmed_id, unmatched_id]: [String; 2] = client_ids.try_into().unwrap();
let user_item = |client_id: String| ThreadItemPayload::UserMessage {
id: format!("item-{client_id}"),
client_id: Some(client_id),
content: vec![UserInput::Text {
text: "same text".into(),
text_elements: Vec::new(),
}],
};
let mut original_thread = overview_thread(
original,
/*parent_thread_id*/ None,
"Original",
ThreadStatus::Idle,
);
original_thread.cwd = app.config.cwd.clone();
original_thread.turns = vec![test_turn(
turn_id,
TurnStatus::Completed,
vec![user_item(confirmed_id)],
)];
let mut other_thread = overview_thread(
other,
/*parent_thread_id*/ None,
"Other",
ThreadStatus::Idle,
);
other_thread.cwd = app.config.cwd.clone();
let (client, mut commands, _server_events) =
codex_app_server_client::CloudClient::channel(/*capacity*/ 1);
let mut server = AppServerSession::new(
crate::backend_id::BackendId::new(),
codex_app_server_client::AppServerClient::Cloud(client),
crate::ThreadParamsMode::Cloud,
);
let backend_id = server.backend_id();
let serve = tokio::spawn(async move {
let mut resumed_threads = Vec::new();
while let Some(command) = commands.recv().await {
let reply = match command.operation {
CloudOperation::Request(request) => match *request {
ClientRequest::ThreadRead { params, .. } => {
let mut thread = if params.thread_id == original.to_string() {
original_thread.clone()
} else {
other_thread.clone()
};
if !params.include_turns {
thread.turns.clear();
}
Ok(serde_json::json!({"thread": thread}))
}
ClientRequest::ThreadResume { params, .. } => {
let thread = if params.thread_id == original.to_string() {
&original_thread
} else {
&other_thread
};
resumed_threads.push(params.thread_id);
Ok(serde_json::json!({
"thread": thread,
"model": "gpt-5.5",
"modelProvider": thread.model_provider,
"cwd": thread.cwd,
"approvalPolicy": "never",
"approvalsReviewer": "user",
"sandbox": {"type": "readOnly"},
}))
}
ClientRequest::ThreadUnsubscribe { .. } => {
Ok(serde_json::json!({"status": "unsubscribed"}))
}
ClientRequest::ThreadLoadedList { .. } => {
Ok(serde_json::json!({"data": [], "nextCursor": null}))
}
ClientRequest::ThreadGoalGet { .. } => Ok(serde_json::json!({"goal": null})),
ClientRequest::TurnStart { .. } | ClientRequest::TurnSteer { .. } => {
panic!("resuming must not send a turn")
}
_ => Err(JSONRPCErrorError {
code: -32601,
message: "unsupported by the resume test".into(),
data: None,
}),
},
CloudOperation::SubmitUserMessage(_) => panic!("resuming must not send a message"),
CloudOperation::Shutdown => {
command
.response
.send(Ok(Ok(serde_json::Value::Null)))
.unwrap();
break;
}
_ => panic!("unexpected Cloud operation while resuming"),
};
command.response.send(Ok(reply)).unwrap();
}
resumed_threads
});
let mut tui = crate::tui::test_support::make_test_tui()?;
while events.try_recv().is_ok() {}
for thread_id in [other, original] {
app.apply_resume_picker_selection(
&mut tui,
&mut server,
SessionSelection::Resume(SessionTarget {
backend_id,
path: None,
thread_id,
cwd: None,
history_mode: None,
}),
)
.await?;
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
}
assert_eq!(app.chat_widget.queued_user_message_texts(), ["same text"]);
assert!(
app.chat_widget
.capture_thread_input_state()
.is_some_and(|input| input.has_unconfirmed_messages())
);
assert!(
!std::iter::from_fn(|| events.try_recv().ok())
.any(|event| { matches!(event, AppEvent::CodexOp(Op::UserTurn { .. })) })
);
app.chat_widget.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
original.to_string(),
turn_id.into(),
user_item(unmatched_id),
/*started_at_ms*/ None,
/*completed_at_ms*/ 1,
)),
/*replay_kind*/ None,
);
assert!(app.chat_widget.queued_user_message_texts().is_empty());
server.shutdown().await?;
assert_eq!(serve.await?, [other.to_string(), original.to_string()]);
Ok(())
}
#[tokio::test]
async fn conventional_steer_defers_review_and_holds_a_lost_reply() -> Result<()> {
use codex_app_server_client::CloudOperation;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::NonSteerableTurnKind;
for review_active in [true, false] {
let (mut app, _events, mut ops) = make_test_app_with_channels().await;
let id = ThreadId::new();
app.enqueue_primary_thread_session(
test_thread_session(id, app.config.cwd.to_path_buf()),
Vec::new(),
)
.await?;
let turn_id = "active";
app.ensure_thread_channel(id)
.store
.lock()
.await
.set_active_turn_id(turn_id.into());
app.chat_widget.handle_server_notification(
turn_started_notification(id, turn_id),
/*replay_kind*/ None,
);
app.chat_widget.apply_external_edit("first".into());
app.chat_widget.handle_key_event(KeyCode::Enter.into());
let op = next_user_turn_op(&mut ops);
let Op::UserTurn {
client_user_message_id,
items,
..
} = &op
else {
unreachable!();
};
let (client_id, input) = (Some(client_user_message_id.clone()), items.clone());
if review_active {
app.chat_widget.apply_external_edit("second".into());
app.chat_widget.handle_key_event(KeyCode::Tab.into());
}
let (client, mut commands, _server_events) =
codex_app_server_client::CloudClient::channel(/*capacity*/ 1);
let mut server = AppServerSession::new(
crate::backend_id::BackendId::new(),
codex_app_server_client::AppServerClient::Cloud(client),
crate::ThreadParamsMode::Embedded,
);
let (result, ()) = tokio::join!(app.submit_thread_op(&mut server, id, op), async {
let command = commands.recv().await.unwrap();
let CloudOperation::Request(request) = command.operation else {
panic!("expected conventional steer");
};
let ClientRequest::TurnSteer { params, .. } = *request else {
panic!("expected turn/steer");
};
assert_eq!(params.expected_turn_id, turn_id);
assert_eq!(
(params.client_user_message_id, params.input),
(client_id.clone(), input.clone())
);
if review_active {
let message = "cannot steer a review turn";
let error = AppServerTurnError {
message: message.into(),
codex_error_info: Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
}),
additional_details: None,
misalignment: None,
};
command
.response
.send(Ok(Err(JSONRPCErrorError {
code: -32600,
message: message.into(),
data: Some(serde_json::to_value(error).unwrap()),
})))
.unwrap();
}
});
result?;
if review_active {
assert_eq!(
app.chat_widget.queued_user_message_texts(),
["first", "second"]
);
assert_eq!(
app.active_turn_id_for_thread(id).await.as_deref(),
Some(turn_id)
);
} else {
app.chat_widget.handle_server_notification(
turn_completed_notification(id, turn_id, TurnStatus::Interrupted),
/*replay_kind*/ None,
);
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
for client_id in [None, client_id] {
let confirmed = client_id.is_some();
assert_eq!(app.chat_widget.queued_user_message_texts(), ["first"]);
app.chat_widget.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
id.to_string(),
turn_id.into(),
ThreadItemPayload::UserMessage {
id: format!("user-item-{confirmed}"),
client_id,
content: input.clone(),
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 1,
)),
/*replay_kind*/ None,
);
}
assert!(app.chat_widget.queued_user_message_texts().is_empty());
}
assert!(commands.try_recv().is_err());
assert!(
!std::iter::from_fn(|| ops.try_recv().ok()).any(|op| matches!(op, Op::UserTurn { .. }))
);
}
Ok(())
}
#[tokio::test]
async fn queued_start_rejection_restores_the_draft() -> Result<()> {
use crate::ThreadParamsMode;
use codex_app_server_client::CloudOperation;
for (mode, code, message) in [
(ThreadParamsMode::Embedded, -32600, "input was rejected"),
(ThreadParamsMode::Embedded, -32001, "server overloaded"),
(
ThreadParamsMode::Cloud,
-32602,
"Cloud conversations currently accept plain text only.",
),
] {
let (mut app, _events, mut ops) = make_test_app_with_channels().await;
let id = ThreadId::new();
app.enqueue_primary_thread_session(
test_thread_session(id, app.config.cwd.to_path_buf()),
Vec::new(),
)
.await?;
app.chat_widget.handle_server_notification(
turn_started_notification(id, "before-queue"),
/*replay_kind*/ None,
);
app.chat_widget.apply_external_edit("queued draft".into());
app.chat_widget.handle_key_event(KeyCode::Tab.into());
app.chat_widget.handle_server_notification(
turn_completed_notification(id, "before-queue", TurnStatus::Completed),
/*replay_kind*/ None,
);
let op = next_user_turn_op(&mut ops);
let Op::UserTurn {
cloud_submission: crate::app_command::CloudSubmissionIntent::StartIfIdle,
client_user_message_id,
items,
..
} = &op
else {
panic!("expected the deferred draft");
};
let expected = (Some(client_user_message_id.clone()), items.clone());
let (client, mut commands, _server_events) =
codex_app_server_client::CloudClient::channel(/*capacity*/ 1);
let mut server = AppServerSession::new(
crate::backend_id::BackendId::new(),
codex_app_server_client::AppServerClient::Cloud(client),
mode,
);
let (result, ()) = tokio::join!(app.submit_thread_op(&mut server, id, op), async {
let command = commands.recv().await.unwrap();
let CloudOperation::Request(request) = command.operation else {
panic!("queued draft must use typed turn/start");
};
let ClientRequest::TurnStart { params, .. } = *request else {
panic!("expected turn/start");
};
assert_eq!((params.client_user_message_id, params.input), expected);
command
.response
.send(Ok(Err(JSONRPCErrorError {
code,
message: message.into(),
data: None,
})))
.unwrap();
});
result?;
assert_eq!(app.chat_widget.composer_text_with_pending(), "queued draft");
assert!(app.chat_widget.queued_user_message_texts().is_empty());
assert!(commands.try_recv().is_err());
}
Ok(())
}
pub(super) type RecordedRequests = Arc<Mutex<Vec<JSONRPCRequest>>>;
pub(super) type RecordingAppServer = (AppServerSession, RecordedRequests, JoinHandle<Result<()>>);
@@ -8,6 +8,7 @@ expression: "format!(\"{}\\n{}\",\nlines_to_single_string(&app.transcript_cells.
• Reconnecting to server… (<elapsed>)
• Queued follow-up inputs
↳ only once
↳ queued before disconnect
⇧← edit last queued message
@@ -0,0 +1,52 @@
---
source: tui/src/app/tests/session_lifecycle_requests.rs
expression: "rendered_receipts.join(\"\\n\\n\")"
---
── Started ──
› follow up
■ Conversation interrupted - use /feedback if something went wrong
• Working (0s • esc to interrupt)
› Ask Codex to do anything
gpt-test default · /tmp/project
── Steered ──
■ Conversation interrupted - use /feedback if something went wrong
■ Conversation interrupted - use /feedback if something went wrong
› follow up
gpt-test default · /tmp/project
── Unconfirmed ──
■ Cloud submission could not be confirmed: cloud/submitUserMessage transport error: cloud backend dropped its response
■ Conversation interrupted - use /feedback if something went wrong
• Queued follow-up inputs
↳ follow up
⇧← edit last queued message
› Ask Codex to do anything
gpt-test default · /tmp/project
── Rejected ──
■ reply stream closed before submission
• Working (0s • esc to interrupt)
› follow up
tab to queue message 100% context left
── Duplicate ──
■ Conversation interrupted - use /feedback if something went wrong
• Queued follow-up inputs
↳ follow up
⇧← edit last queued message
› Ask Codex to do anything
gpt-test default · /tmp/project
@@ -11,6 +11,70 @@ use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn cloud_admission_keeps_tab_input_queued_until_the_admitted_turn_finishes() {
use crate::app_command::CloudSubmissionIntent;
use crate::app_server_session::user_message_submission::UserMessageAdmission;
for receipt in [
UserMessageAdmission::Started("next".into()),
UserMessageAdmission::Steered("next".into()),
] {
let (mut app, _events, mut ops) = make_test_app_with_channels().await;
let id = ThreadId::new();
let session = test_thread_session(id, app.config.cwd.to_path_buf());
let chat = &mut app.chat_widget;
chat.handle_thread_session_quiet(session);
chat.handle_server_notification(
turn_started_notification(id, "previous"),
/*replay_kind*/ None,
);
chat.apply_external_edit("queued draft".into());
chat.handle_key_event(KeyEvent::from(KeyCode::Tab));
chat.apply_external_edit("enter input".into());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let mut turns = std::iter::from_fn(|| ops.try_recv().ok())
.filter(|op| matches!(op, Op::UserTurn { .. }));
let op = turns.next().expect("expected Enter submission");
let Op::UserTurn {
client_user_message_id,
..
} = &op
else {
unreachable!();
};
chat.user_message_admitted(receipt, &op, client_user_message_id);
chat.handle_server_notification(
turn_completed_notification(id, "previous", TurnStatus::Completed),
/*replay_kind*/ None,
);
assert_eq!(chat.queued_user_message_texts(), ["queued draft"]);
assert!(turns.next().is_none());
chat.apply_external_edit("another draft".into());
chat.handle_key_event(KeyEvent::from(KeyCode::Tab));
assert_eq!(
chat.queued_user_message_texts(),
["queued draft", "another draft"]
);
chat.handle_server_notification(
turn_started_notification(id, "next"),
/*replay_kind*/ None,
);
assert!(turns.next().is_none());
chat.handle_server_notification(
turn_completed_notification(id, "next", TurnStatus::Completed),
/*replay_kind*/ None,
);
assert!(matches!(
turns.next(),
Some(Op::UserTurn { cloud_submission: CloudSubmissionIntent::StartIfIdle, items, .. })
if matches!(items.as_slice(), [UserInput::Text { text, .. }] if text == "queued draft")
));
assert_eq!(chat.queued_user_message_texts(), ["another draft"]);
assert!(turns.next().is_none());
}
}
#[tokio::test]
async fn worktree_creation_rejects_untrusted_source_before_allocation() -> Result<()> {
use crate::app_event::ManagedWorktreeMode;
+29
View File
@@ -26,6 +26,35 @@ pub(super) enum ThreadBufferedEvent {
FeedbackSubmission(FeedbackThreadEvent),
}
pub(super) fn confirmed_user_message_ids<'a>(
turns: &'a [Turn],
events: impl Iterator<Item = &'a ThreadBufferedEvent>,
) -> Vec<String> {
turns
.iter()
.flat_map(Turn::item_payloads)
.chain(events.filter_map(|event| {
let ThreadBufferedEvent::Notification(notification) = event else {
return None;
};
if let ServerNotification::ItemStarted(notification) = notification.as_ref() {
Some(&notification.item.payload)
} else if let ServerNotification::ItemCompleted(notification) = notification.as_ref() {
Some(&notification.item.payload)
} else {
None
}
}))
.filter_map(|item| {
if let ThreadItemPayload::UserMessage { client_id, .. } = item {
client_id.clone()
} else {
None
}
})
.collect::<Vec<_>>()
}
fn is_voice_handoff_item(item: &ThreadItemPayload) -> bool {
matches!(item, ThreadItemPayload::UserMessage { content, .. }
if crate::chatwidget::realtime_delegation_input(content).is_some())
+4 -26
View File
@@ -1845,32 +1845,10 @@ impl App {
self.chat_widget
.remember_realtime_delegated_reasoning_turn(turn_id);
}
let confirmed_message_ids = snapshot
.turns
.iter()
.flat_map(Turn::item_payloads)
.chain(snapshot.events.iter().filter_map(|event| {
let ThreadBufferedEvent::Notification(notification) = event else {
return None;
};
if let ServerNotification::ItemStarted(notification) = notification.as_ref() {
Some(&notification.item.payload)
} else if let ServerNotification::ItemCompleted(notification) =
notification.as_ref()
{
Some(&notification.item.payload)
} else {
None
}
}))
.filter_map(|item| {
if let ThreadItemPayload::UserMessage { client_id, .. } = item {
client_id.clone()
} else {
None
}
})
.collect::<Vec<_>>();
let confirmed_message_ids = super::thread_events::confirmed_user_message_ids(
&snapshot.turns,
snapshot.events.iter(),
);
let recovered_input = snapshot
.input_state
.as_ref()
+107 -111
View File
@@ -3,6 +3,11 @@
//! This is the single app-server submission path for both new user turns and steering.
use super::*;
use crate::app_command::CloudSubmissionIntent;
use crate::app_server_session::UnsupportedLegacyPermissionProfile;
use crate::app_server_session::user_message_submission::TurnStartSettings;
use crate::app_server_session::user_message_submission::UserMessageAdmission;
use crate::app_server_session::user_message_submission::UserMessageSubmissionFailure;
pub(super) async fn try_submit_user_turn(
app: &mut App,
@@ -12,105 +17,116 @@ pub(super) async fn try_submit_user_turn(
) -> Result<bool> {
let AppCommand::UserTurn {
turn_trigger,
cloud_submission,
client_user_message_id,
items,
cwd,
approval_policy,
approvals_reviewer,
active_permission_profile: _,
model,
effort,
summary,
service_tier,
final_output_json_schema,
collaboration_mode,
..
} = op
else {
return Ok(false);
};
if let Some(turn_id) = app.active_turn_id_for_thread(thread_id).await {
if *turn_trigger != "user" {
color_eyre::eyre::bail!("the fork already has an active turn");
}
let mut steer_turn_id = turn_id;
let mut retried_after_turn_mismatch = false;
loop {
match app_server
.turn_steer(
thread_id,
steer_turn_id.clone(),
client_user_message_id.clone(),
items.to_vec(),
)
.await
{
Ok(_) => {
if app.active_thread_id == Some(thread_id)
&& app.chat_widget.thread_id() == Some(thread_id)
{
crate::startup_recovery::acknowledged(client_user_message_id);
}
return Ok(true);
}
Err(error) => {
if let Some(turn_error) = active_turn_not_steerable_turn_error(&error) {
if !app.chat_widget.enqueue_rejected_steer() {
app.chat_widget.add_error_message(turn_error.message);
}
return Ok(true);
}
match active_turn_steer_race(&error) {
Some(ActiveTurnSteerRace::Missing) => {
if let Some(channel) = app.thread_event_channels.get(&thread_id) {
let mut store = channel.store.lock().await;
store.clear_active_turn_id();
}
break;
}
Some(ActiveTurnSteerRace::ExpectedTurnMismatch { actual_turn_id })
if !retried_after_turn_mismatch && actual_turn_id != steer_turn_id =>
{
// Review flows can swap the active turn before the TUI
// processes the corresponding notification. Retry once with
// the server-reported turn id so non-steerable review turns
// still fall through to the existing queueing behavior.
if let Some(channel) = app.thread_event_channels.get(&thread_id) {
let mut store = channel.store.lock().await;
store.set_active_turn_id(actual_turn_id.clone());
}
steer_turn_id = actual_turn_id;
retried_after_turn_mismatch = true;
}
Some(ActiveTurnSteerRace::ExpectedTurnMismatch { actual_turn_id }) => {
if let Some(channel) = app.thread_event_channels.get(&thread_id) {
let mut store = channel.store.lock().await;
store.set_active_turn_id(actual_turn_id);
}
return Err(error.into());
}
None => return Err(error.into()),
}
}
let original_active = app.active_turn_id_for_thread(thread_id).await;
let mut active = original_active.clone();
let preflight = || {
let enabled = app.chat_widget.daybreak_enabled
&& !app.chat_widget.side_conversation_active()
&& !app.side_threads.contains_key(&thread_id);
crate::daybreak::program_for_turn(
&app.chat_widget.model_catalog().models,
model,
app.chat_widget.daybreak_account_eligible(),
enabled,
)
};
let start_settings = || turn_start_settings(app, thread_id, op);
let result = app_server
.submit_user_message(thread_id, op, &mut active, preflight, start_settings)
.await;
if active != original_active
&& let Some(channel) = app.thread_event_channels.get(&thread_id)
{
let mut store = channel.store.lock().await;
if store.active_turn_id() == original_active.as_deref() {
if let Some(active) = active {
store.set_active_turn_id(active);
} else {
store.clear_active_turn_id();
}
}
}
let eligible_account = app.chat_widget.daybreak_account_eligible();
let enabled = app.chat_widget.daybreak_enabled
&& !app.chat_widget.side_conversation_active()
&& !app.side_threads.contains_key(&thread_id);
let cyber_access_program = match crate::daybreak::program_for_turn(
&app.chat_widget.model_catalog().models,
model,
eligible_account,
enabled,
) {
Ok(program) => program,
Err(message) => {
let ordinary =
*turn_trigger == "user" && *cloud_submission != CloudSubmissionIntent::UseKnownTurn;
if let Err(UserMessageSubmissionFailure::Unhandled(error)) = result {
return Err(error);
}
if app.active_thread_id != Some(thread_id) || app.chat_widget.thread_id() != Some(thread_id) {
if let Err(error) = result {
tracing::warn!(%thread_id, ?error, "User submission failed in a background thread");
}
return Ok(true);
}
match result {
Ok(admission) => {
if !matches!(&admission, UserMessageAdmission::Queued(_)) {
crate::startup_recovery::acknowledged(client_user_message_id);
}
if ordinary || !matches!(&admission, UserMessageAdmission::Steered(_)) {
app.chat_widget
.user_message_admitted(admission, op, client_user_message_id);
}
}
Err(UserMessageSubmissionFailure::Preflight(message)) if !ordinary => {
if !app.chat_widget.handle_turn_start_rejection(message.clone()) {
app.chat_widget.add_error_message(message);
}
return Ok(true);
}
Err(UserMessageSubmissionFailure::Preflight(message)) => {
app.chat_widget
.restore_rejected_user_message(client_user_message_id, message);
}
Err(UserMessageSubmissionFailure::Rejected(error)) => {
if error
.downcast_ref::<UnsupportedLegacyPermissionProfile>()
.is_some()
{
app.chat_widget
.set_queue_autosend_suppressed(/*suppressed*/ true);
}
app.chat_widget
.restore_rejected_user_message(client_user_message_id, format!("{error:#}"));
}
Err(UserMessageSubmissionFailure::Unconfirmed { error, reconnect }) => {
app.chat_widget
.hold_unconfirmed_user_message(client_user_message_id);
if !reconnect || !app.recover_transport_error(&error) {
let label = if reconnect {
"Submission"
} else {
"Cloud submission"
};
app.chat_widget
.add_error_message(format!("{label} could not be confirmed: {error}"));
}
}
Err(UserMessageSubmissionFailure::Unhandled(_)) => unreachable!("returned above"),
}
Ok(true)
}
fn turn_start_settings<'a>(
app: &'a App,
thread_id: ThreadId,
op: &AppCommand,
) -> TurnStartSettings<'a> {
let AppCommand::UserTurn {
approval_policy,
approvals_reviewer,
..
} = op
else {
unreachable!("turn/start requires a user turn");
};
let config = app.chat_widget.config_ref();
let selected_profile = app.pending_server_profiles.get(&thread_id).or_else(|| {
@@ -150,30 +166,10 @@ pub(super) async fn try_submit_user_turn(
}),
explicit_profile.and_then(RuntimePermissionProfileOverride::turn_permission_profile),
);
let response = app_server
.turn_start(
thread_id,
turn_trigger,
client_user_message_id.clone(),
items.to_vec(),
cwd.clone(),
turn_approval_policy,
turn_approvals_reviewer,
permissions_override,
config.permissions.user_visible_workspace_roots(),
model.to_string(),
effort.clone(),
*summary,
service_tier.clone(),
collaboration_mode.clone(),
final_output_json_schema.clone(),
cyber_access_program.map(Into::into),
)
.await?;
if app.active_thread_id == Some(thread_id) && app.chat_widget.thread_id() == Some(thread_id) {
crate::startup_recovery::acknowledged(client_user_message_id);
app.chat_widget
.record_safety_buffering_turn(response.turn.id, op);
TurnStartSettings {
approval_policy: turn_approval_policy,
approvals_reviewer: turn_approvals_reviewer,
permissions: permissions_override,
workspace_roots: config.permissions.user_visible_workspace_roots(),
}
Ok(true)
}
+13
View File
@@ -94,6 +94,16 @@ impl Serialize for RealtimeSpeechText {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub(crate) enum CloudSubmissionIntent {
/// Use the TUI's known turn for explicit operations such as safety retries.
UseKnownTurn,
/// Enter lets the server choose whether to start or steer the current turn.
StartOrSteer,
/// A message leaving the local queue starts a fresh turn.
StartIfIdle,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) enum AppCommand {
@@ -118,6 +128,7 @@ pub(crate) enum AppCommand {
},
UserTurn {
turn_trigger: &'static str,
cloud_submission: CloudSubmissionIntent,
client_user_message_id: String,
items: Vec<UserInput>,
cwd: PathBuf,
@@ -226,6 +237,7 @@ impl AppCommand {
#[allow(clippy::too_many_arguments)]
pub(crate) fn user_turn(
turn_trigger: &'static str,
cloud_submission: CloudSubmissionIntent,
client_user_message_id: String,
items: Vec<UserInput>,
cwd: PathBuf,
@@ -240,6 +252,7 @@ impl AppCommand {
) -> Self {
Self::UserTurn {
turn_trigger,
cloud_submission,
client_user_message_id,
items,
cwd,
+1
View File
@@ -21,6 +21,7 @@ mod rollout_history;
pub(crate) use read_state::unread_revision;
mod startup_launch;
mod thread_list;
pub(crate) mod user_message_submission;
mod web_search;
#[cfg(test)]
@@ -0,0 +1,269 @@
//! Route composer input and distinguish admission, rejection, and uncertainty.
//! A duplicate or an unconfirmed response must never trigger an automatic resend.
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::CloudUserMessageReceipt;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::ApprovalsReviewer;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use codex_app_server_protocol::TurnError as AppServerTurnError;
use codex_app_server_protocol::TurnStartParams;
use codex_protocol::ThreadId;
use codex_protocol::turn_input::CyberAccessProgram;
use codex_utils_absolute_path::AbsolutePathBuf;
use color_eyre::eyre::Report;
use crate::app_command::AppCommand;
use crate::app_command::CloudSubmissionIntent;
use super::AppServerSession;
use super::JSONRPC_INVALID_PARAMS;
use super::JSONRPC_INVALID_REQUEST;
use super::JSONRPC_METHOD_NOT_FOUND;
use super::ThreadParamsMode;
use super::TurnPermissionsOverride;
use super::UnsupportedLegacyPermissionProfile;
// Both conventional transports return this before the request reaches its handler.
const JSONRPC_SERVER_OVERLOADED: i64 = -32001;
pub(crate) struct TurnStartSettings<'a> {
pub(crate) approval_policy: Option<AskForApproval>,
pub(crate) approvals_reviewer: Option<ApprovalsReviewer>,
pub(crate) permissions: TurnPermissionsOverride,
pub(crate) workspace_roots: &'a [AbsolutePathBuf],
}
pub(crate) enum UserMessageAdmission {
Started(String),
Steered(String),
/// The turn/start response confirms admission and does not distinguish start from steer.
StartResponse(String),
Duplicate,
Queued(String),
}
#[derive(Debug)]
pub(crate) enum UserMessageSubmissionFailure {
Preflight(String),
Rejected(Report),
Unconfirmed { error: Report, reconnect: bool },
Unhandled(Report),
}
impl UserMessageSubmissionFailure {
fn conventional(error: Report, intent: CloudSubmissionIntent) -> Self {
if intent == CloudSubmissionIntent::UseKnownTurn
|| super::model_provider_error(&error).is_some()
{
return Self::Unhandled(error);
}
if error
.downcast_ref::<UnsupportedLegacyPermissionProfile>()
.is_some()
|| matches!(error.downcast_ref::<TypedRequestError>(),
Some(TypedRequestError::Server { source, .. })
if matches!(source.code, JSONRPC_INVALID_REQUEST | JSONRPC_INVALID_PARAMS | JSONRPC_METHOD_NOT_FOUND | JSONRPC_SERVER_OVERLOADED))
{
Self::Rejected(error)
} else {
Self::Unconfirmed {
error,
reconnect: true,
}
}
}
}
impl AppServerSession {
pub(crate) async fn submit_user_message<'a>(
&mut self,
thread_id: ThreadId,
op: &AppCommand,
active_turn: &mut Option<String>,
preflight: impl FnOnce() -> Result<Option<CyberAccessProgram>, String>,
start_settings: impl FnOnce() -> TurnStartSettings<'a>,
) -> Result<UserMessageAdmission, UserMessageSubmissionFailure> {
let AppCommand::UserTurn {
turn_trigger,
cloud_submission,
client_user_message_id,
items,
final_output_json_schema,
cwd,
model,
effort,
summary,
service_tier,
collaboration_mode,
..
} = op
else {
unreachable!("user-message submission requires a user turn")
};
let intent = if *turn_trigger == "user" {
*cloud_submission
} else {
CloudSubmissionIntent::UseKnownTurn
};
let cloud = self.thread_params_mode() == ThreadParamsMode::Cloud;
if (!cloud || intent == CloudSubmissionIntent::UseKnownTurn) && active_turn.is_some() {
if *turn_trigger != "user" {
return Err(UserMessageSubmissionFailure::Unhandled(
color_eyre::eyre::eyre!("the fork already has an active turn"),
));
}
let mut retried = false;
while let Some(turn_id) = active_turn.clone() {
let error = match self
.turn_steer(
thread_id,
turn_id.clone(),
client_user_message_id.clone(),
items.clone(),
)
.await
{
Ok(response) => return Ok(UserMessageAdmission::Steered(response.turn_id)),
Err(error) => error,
};
if let Some(error) = active_turn_not_steerable_turn_error(&error) {
return Ok(UserMessageAdmission::Queued(error.message));
}
match active_turn_steer_race(&error) {
Some(ActiveTurnSteerRace::Missing) => {
*active_turn = None;
break;
}
Some(ActiveTurnSteerRace::ExpectedTurnMismatch { actual_turn_id }) => {
let retry = !retried && actual_turn_id != turn_id;
*active_turn = Some(actual_turn_id);
if retry {
retried = true;
continue;
}
}
None => {}
}
return Err(UserMessageSubmissionFailure::conventional(
error.into(),
intent,
));
}
}
let program = preflight().map_err(UserMessageSubmissionFailure::Preflight)?;
if cloud
&& intent == CloudSubmissionIntent::StartOrSteer
&& let AppServerRequestHandle::Cloud(handle) = &self.request_handle
{
let result = handle
.submit_user_message(TurnStartParams {
thread_id: thread_id.to_string(),
client_user_message_id: Some(client_user_message_id.clone()),
input: items.clone(),
output_schema: final_output_json_schema.clone(),
..Default::default()
})
.await;
return match result {
Ok(CloudUserMessageReceipt::Started(turn)) => {
Ok(UserMessageAdmission::Started(turn.id))
}
Ok(CloudUserMessageReceipt::Steered(turn)) => {
Ok(UserMessageAdmission::Steered(turn.id))
}
Ok(CloudUserMessageReceipt::Duplicate { .. }) => {
Ok(UserMessageAdmission::Duplicate)
}
// The adapter returns invalid params only for failures before admission.
Err(TypedRequestError::Server { source, .. })
if source.code == JSONRPC_INVALID_PARAMS =>
{
Err(UserMessageSubmissionFailure::Rejected(Report::msg(
source.message,
)))
}
Err(error) => Err(UserMessageSubmissionFailure::Unconfirmed {
error: error.into(),
reconnect: false,
}),
};
}
let settings = start_settings();
self.turn_start(
thread_id,
turn_trigger,
client_user_message_id.clone(),
items.clone(),
cwd.clone(),
settings.approval_policy,
settings.approvals_reviewer,
settings.permissions,
settings.workspace_roots,
model.to_string(),
effort.clone(),
*summary,
service_tier.clone(),
collaboration_mode.clone(),
final_output_json_schema.clone(),
program.map(Into::into),
)
.await
.map(|response| UserMessageAdmission::StartResponse(response.turn.id))
.map_err(
|error| match UserMessageSubmissionFailure::conventional(error, intent) {
UserMessageSubmissionFailure::Rejected(error) => {
UserMessageSubmissionFailure::Rejected(error.wrap_err("Failed to start turn"))
}
failure => failure,
},
)
}
}
pub(crate) fn active_turn_not_steerable_turn_error(
error: &TypedRequestError,
) -> Option<AppServerTurnError> {
let TypedRequestError::Server { source, .. } = error else {
return None;
};
let turn_error: AppServerTurnError = serde_json::from_value(source.data.clone()?).ok()?;
matches!(
turn_error.codex_error_info,
Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. })
)
.then_some(turn_error)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ActiveTurnSteerRace {
Missing,
ExpectedTurnMismatch { actual_turn_id: String },
}
pub(crate) fn active_turn_steer_race(error: &TypedRequestError) -> Option<ActiveTurnSteerRace> {
let TypedRequestError::Server { method, source } = error else {
return None;
};
if method != "turn/steer" {
return None;
}
if source.message == "no active turn to steer" {
return Some(ActiveTurnSteerRace::Missing);
}
// App-server steer mismatches mean our cached active turn id is stale, but the response
// includes the server's current active turn so we can resynchronize and retry once.
let mismatch_prefix = "expected active turn id `";
let mismatch_separator = "` but found `";
let actual_turn_id = source
.message
.strip_prefix(mismatch_prefix)?
.split_once(mismatch_separator)?
.1
.strip_suffix('`')?
.to_string();
Some(ActiveTurnSteerRace::ExpectedTurnMismatch { actual_turn_id })
}
+32 -29
View File
@@ -286,6 +286,7 @@ mod reconnect;
use self::input_queue::InputQueueState;
mod image_submission;
mod input_flow;
mod input_receipts;
mod input_restore;
mod input_submission;
mod interrupts;
@@ -1370,44 +1371,46 @@ impl ChatWidget {
}
// Servers may omit media from receipts, so prefer the submission identity.
if client_id.is_some() && self.last_rendered_user_message_client_id.as_deref() == client_id
{
return;
}
if self
.input_queue
.pending_steers
.front()
.is_some_and(|pending| match client_id {
Some(client_id) => pending.client_id == client_id,
// Older app servers do not echo submission IDs.
None => pending.compare_key == Self::pending_steer_compare_key_from_items(items),
})
{
if let Some(pending) = self.input_queue.pending_steers.pop_front() {
self.refresh_pending_input_preview();
let pending_display =
user_message_display_for_history(pending.user_message, &pending.history_record);
self.on_user_message_display(pending_display);
let already_rendered = client_id.is_some()
&& self.last_rendered_user_message_client_id.as_deref() == client_id;
let steers = &mut self.input_queue.pending_steers;
let matching = match client_id {
Some(id) => steers.iter().enumerate().position(|(index, pending)| {
pending.client_id == id
&& (pending.admitted_turn_id.is_some() || (index == 0 && !already_rendered))
}),
// Older app servers do not echo submission IDs.
None => steers.front().and_then(|pending| {
(pending.compare_key == Self::pending_steer_compare_key_from_items(items))
.then_some(0)
}),
};
if let Some(pending) = matching.and_then(|index| steers.remove(index)) {
self.refresh_pending_input_preview();
if !already_rendered {
if !pending.optimistically_rendered {
let pending_display = user_message_display_for_history(
pending.user_message,
&pending.history_record,
);
self.on_user_message_display(pending_display);
}
// Later receipts carry the wire media, which may differ from the local attachment.
self.last_rendered_user_message_display = Some(display);
self.last_rendered_user_message_client_id = Some(pending.client_id);
} else if self.last_rendered_user_message_display.as_ref() != Some(&display) {
tracing::warn!(
"pending steer matched receipt but queue was empty when rendering committed user message"
);
self.on_user_message_display(display);
}
} else if !self.review.is_review_mode
} else if !already_rendered
&& !self.review.is_review_mode
&& self.last_rendered_user_message_display.as_ref() != Some(&display)
{
self.on_user_message_display(display);
}
if let Some(client_id) = client_id
&& self.input_queue.queued_user_messages.iter().any(|message| {
matches!(&message.delivery, MessageDelivery::Unconfirmed(Some(id)) if id == client_id)
})
&& self
.input_queue
.queued_user_messages
.iter()
.any(|message| message.unconfirmed_user_message_id() == Some(client_id))
{
self.reconcile_recovered_messages(&[client_id.to_string()]);
self.maybe_send_next_queued_input();
+13 -3
View File
@@ -79,7 +79,12 @@ impl ChatWidget {
self.reasoning_header = None;
self.reasoning_summary_parts.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
self.submit_user_message_with_history_and_shell_escape_policy(
user_message,
UserMessageHistoryRecord::UserMessageText,
ShellEscapePolicy::Allow,
UserMessageSource::DirectEnter,
);
} else {
self.queue_user_message(user_message);
}
@@ -261,9 +266,14 @@ impl ChatWidget {
}
let mut submitted_follow_up = false;
while !self.is_user_turn_pending_or_running() {
let Some((queued_message, history_record)) = self.pop_next_queued_user_message() else {
let Some((mut queued_message, history_record)) = self.pop_next_queued_user_message()
else {
break;
};
queued_message.source = match queued_message.source {
UserMessageSource::DirectEnter => UserMessageSource::Prompt,
source => source,
};
match queued_message.action {
QueuedInputAction::Plain => {
let source = queued_message.source;
@@ -348,7 +358,7 @@ impl ChatWidget {
pub(crate) fn is_user_turn_pending_or_running(&self) -> bool {
self.pending_image_submission.is_some()
|| self.input_queue.user_turn_pending_start
|| self.turn_lifecycle.agent_turn_running
|| self.turn_lifecycle.is_running_or_admitted()
|| self.review.is_review_mode
|| (self.bottom_pane.is_task_running() && self.mcp_startup_status.is_none())
}
@@ -34,6 +34,7 @@ pub(super) struct InputQueueState {
/// A user turn has been submitted to core, but `TurnStarted` has not arrived yet.
pub(super) user_turn_pending_start: bool,
pub(super) pending_user_message_client_id: Option<String>,
pub(super) pending_user_message_history_record: Option<UserMessageHistoryRecord>,
/// User messages that tried to steer a non-regular turn and must be retried first.
pub(super) rejected_steers_queue: VecDeque<UserMessage>,
/// The origin of each rejected steer, kept in lockstep with its message.
@@ -57,6 +58,14 @@ pub(super) struct InputQueueState {
}
impl InputQueueState {
pub(super) fn take_pending_steer(&mut self, client_id: &str) -> Option<PendingSteer> {
let index = self
.pending_steers
.iter()
.position(|pending| pending.client_id == client_id)?;
self.pending_steers.remove(index)
}
pub(super) fn submissions_paused(&self) -> bool {
self.suppress_queue_autosend || self.transcript_copy.strong_count() > 0
}
@@ -78,6 +87,7 @@ impl InputQueueState {
self.queued_user_message_history_records.clear();
self.user_turn_pending_start = false;
self.pending_user_message_client_id = None;
self.pending_user_message_history_record = None;
self.rejected_steers_queue.clear();
self.rejected_steer_sources.clear();
self.rejected_steer_history_records.clear();
@@ -139,6 +149,8 @@ mod tests {
.push_back(UserMessage::from("rejected"));
state.pending_steers.push_back(PendingSteer {
client_id: "test-submission".to_string(),
admitted_turn_id: None,
optimistically_rendered: false,
user_message: UserMessage::from("pending"),
history_record: UserMessageHistoryRecord::UserMessageText,
source: UserMessageSource::Prompt,
@@ -0,0 +1,128 @@
//! Apply submission receipts and recover draft input by client message ID.
//! Ambiguous delivery stays out of automatic submission until history confirms it.
use super::*;
use crate::app_server_session::user_message_submission::UserMessageAdmission;
impl ChatWidget {
pub(crate) fn user_message_admitted(
&mut self,
receipt: UserMessageAdmission,
turn: &AppCommand,
client_id: &str,
) {
if let UserMessageAdmission::Started(turn_id) | UserMessageAdmission::Steered(turn_id) =
&receipt
&& (!self.turn_lifecycle.agent_turn_running
|| self.last_turn_id() != Some(turn_id.as_str()))
{
self.turn_lifecycle.admitted_turn_id = Some(turn_id.clone());
self.update_task_running_state();
}
match receipt {
UserMessageAdmission::StartResponse(turn_id) => {
self.record_safety_buffering_turn(turn_id, turn)
}
UserMessageAdmission::Queued(message) => {
if !self.enqueue_rejected_steer_by_id(Some(client_id)) {
self.add_error_message(message);
}
}
UserMessageAdmission::Started(turn_id) => {
if let Some(pending) = self.input_queue.take_pending_steer(client_id) {
let display = user_message_display_for_history(
pending.user_message.clone(),
&pending.history_record,
);
self.on_user_message_display(display);
self.last_rendered_user_message_client_id = Some(pending.client_id);
self.safety_buffering_prompt = Some(pending.user_message);
self.safety_buffering_source = pending.source;
self.refresh_pending_input_preview();
}
self.record_safety_buffering_turn(turn_id, turn);
}
// A duplicate identifies the message, but only its history event confirms
// whether a steer committed. Keep it out of automatic delivery until then.
UserMessageAdmission::Duplicate => {
self.hold_unconfirmed_user_message(client_id);
}
UserMessageAdmission::Steered(turn_id) => {
if let Some(pending) = self
.input_queue
.pending_steers
.iter_mut()
.find(|pending| pending.client_id == client_id)
{
pending.admitted_turn_id = Some(turn_id);
} else if let AppCommand::UserTurn { items, .. } = turn
&& let Some((message, history_record, source)) =
self.take_submitted_message(client_id)
{
self.input_queue.pending_steers.push_back(PendingSteer {
client_id: client_id.to_string(),
admitted_turn_id: Some(turn_id),
optimistically_rendered: true,
user_message: message,
history_record,
source,
compare_key: Self::pending_steer_compare_key_from_items(items),
});
}
if self.input_queue.pending_user_message_client_id.as_deref() == Some(client_id) {
self.input_queue.user_turn_pending_start = false;
self.input_queue.pending_user_message_client_id = None;
}
self.refresh_pending_input_preview();
}
}
}
pub(super) fn take_submitted_message(
&mut self,
client_id: &str,
) -> Option<(UserMessage, UserMessageHistoryRecord, UserMessageSource)> {
let queue = &mut self.input_queue;
if queue.pending_user_message_client_id.as_deref() == Some(client_id) {
queue.pending_user_message_client_id = None;
queue.user_turn_pending_start = false;
let history = queue
.pending_user_message_history_record
.take()
.unwrap_or(UserMessageHistoryRecord::UserMessageText);
self.safety_buffering_prompt
.take()
.map(|message| (message, history, self.safety_buffering_source))
} else {
queue
.take_pending_steer(client_id)
.map(|p| (p.user_message, p.history_record, p.source))
}
}
pub(crate) fn hold_unconfirmed_user_message(&mut self, client_id: &str) {
if let Some((message, record, source)) = self.take_submitted_message(client_id) {
self.input_queue
.queued_user_messages
.push_front(QueuedUserMessage {
source,
delivery: MessageDelivery::Unconfirmed(Some(client_id.to_owned())),
..message.into()
});
self.input_queue
.queued_user_message_history_records
.push_front(record);
self.refresh_pending_input_preview();
self.update_task_running_state();
}
}
pub(crate) fn restore_rejected_user_message(&mut self, client_id: &str, message: String) {
if let Some((draft, history, _)) = self.take_submitted_message(client_id) {
self.restore_user_message_to_composer(user_message_for_restore(draft, &history));
}
self.add_error_message(message);
self.refresh_pending_input_preview();
self.update_task_running_state();
}
}
+65 -10
View File
@@ -1,6 +1,7 @@
//! Input queue restore and thread-input snapshot behavior for `ChatWidget`.
use std::collections::HashSet;
use std::collections::VecDeque;
use codex_protocol::models::local_image_label_text;
@@ -344,23 +345,32 @@ impl ChatWidget {
}
}
pub(crate) fn enqueue_rejected_steer(&mut self) -> bool {
let Some(pending_steer) = self.input_queue.pending_steers.pop_front() else {
pub(super) fn enqueue_rejected_steer(&mut self) -> bool {
self.enqueue_rejected_steer_by_id(/*client_id*/ None)
}
pub(super) fn enqueue_rejected_steer_by_id(&mut self, client_id: Option<&str>) -> bool {
let pending = match client_id {
Some(id) => self.take_submitted_message(id),
None => self
.input_queue
.pending_steers
.pop_front()
.map(|p| (p.user_message, p.history_record, p.source)),
};
let Some((message, history, source)) = pending else {
tracing::warn!(
"received active-turn-not-steerable error without a matching pending steer"
);
return false;
};
self.input_queue
.rejected_steers_queue
.push_back(pending_steer.user_message);
self.input_queue
.rejected_steer_sources
.push_back(pending_steer.source);
self.input_queue.rejected_steers_queue.push_back(message);
self.input_queue.rejected_steer_sources.push_back(source);
self.input_queue
.rejected_steer_history_records
.push_back(pending_steer.history_record);
.push_back(history);
self.refresh_pending_input_preview();
self.update_task_running_state();
true
}
@@ -368,6 +378,49 @@ impl ChatWidget {
/// or review completion.
/// When there are queued user messages, restore them into the composer
/// separated by newlines rather than auto-submitting the next one.
pub(super) fn on_interrupted_server_turn(&mut self, reason: TurnAbortReason, turn_id: &str) {
let queue = &mut self.input_queue;
// Recovery for the interrupted turn must leave other admitted steers and
// unresolved submissions alone until their own events arrive.
let (later, interrupted): (VecDeque<_>, _) = std::mem::take(&mut queue.pending_steers)
.into_iter()
.partition(|steer| {
steer
.admitted_turn_id
.as_deref()
.is_some_and(|id| id != turn_id)
});
queue.pending_steers = interrupted;
let mut unconfirmed = Vec::new();
if queue.has_unconfirmed_messages() {
let history = std::mem::take(&mut queue.queued_user_message_history_records);
for (message, record) in std::mem::take(&mut queue.queued_user_messages)
.into_iter()
.zip(
history
.into_iter()
.chain(std::iter::repeat(UserMessageHistoryRecord::UserMessageText)),
)
{
if matches!(message.delivery, MessageDelivery::Unconfirmed(_)) {
unconfirmed.push((message, record));
} else {
queue.queued_user_messages.push_back(message);
queue.queued_user_message_history_records.push_back(record);
}
}
}
self.on_interrupted_turn(reason);
self.input_queue.pending_steers.extend(later);
for (message, record) in unconfirmed {
self.input_queue.queued_user_messages.push_back(message);
self.input_queue
.queued_user_message_history_records
.push_back(record);
}
self.refresh_pending_input_preview();
}
pub(super) fn on_interrupted_turn(&mut self, reason: TurnAbortReason) {
self.requeue_image_submission();
// Finalize, log a gentle prompt, and clear running state.
@@ -731,7 +784,9 @@ impl ChatWidget {
for pending in pending_steers.into_iter().rev() {
queued_user_messages.push_front(QueuedUserMessage {
source: pending.source,
delivery: if input_state.reconnect_pending {
delivery: if input_state.reconnect_pending
|| pending.admitted_turn_id.is_some()
{
MessageDelivery::Unconfirmed(Some(pending.client_id))
} else {
MessageDelivery::Unsent
@@ -165,9 +165,11 @@ impl ChatWidget {
|| self.pending_image_submission.is_some()
|| self.safety_recourse.is_some()
{
let model_prompt = source == UserMessageSource::Prompt
&& (shell_escape_policy == ShellEscapePolicy::Disallow
|| !user_message.text.starts_with('!'));
let model_prompt = matches!(
source,
UserMessageSource::Prompt | UserMessageSource::DirectEnter
) && (shell_escape_policy == ShellEscapePolicy::Disallow
|| !user_message.text.starts_with('!'));
// A prepared submission was accepted before any messages queued behind it.
let queue_index = if prepared_images.is_some() {
0
@@ -198,9 +200,11 @@ impl ChatWidget {
return (true, None);
}
if !self.is_session_configured() {
let model_prompt = source == UserMessageSource::Prompt
&& (shell_escape_policy == ShellEscapePolicy::Disallow
|| !user_message.text.starts_with('!'));
let model_prompt = matches!(
source,
UserMessageSource::Prompt | UserMessageSource::DirectEnter
) && (shell_escape_policy == ShellEscapePolicy::Disallow
|| !user_message.text.starts_with('!'));
tracing::warn!("cannot submit user message before session is configured; queueing");
self.input_queue
.queued_user_messages
@@ -297,6 +301,17 @@ impl ChatWidget {
}));
}
use crate::app_command::CloudSubmissionIntent;
let (cloud_submission, source) = match source {
UserMessageSource::DirectEnter => (
CloudSubmissionIntent::StartOrSteer,
UserMessageSource::Prompt,
),
UserMessageSource::Prompt => (CloudSubmissionIntent::StartIfIdle, source),
UserMessageSource::QuestionAnswer | UserMessageSource::SafetyRecourse => {
(CloudSubmissionIntent::UseKnownTurn, source)
}
};
if !text.is_empty() {
items.push(UserInput::Text {
text: text.clone(),
@@ -462,6 +477,8 @@ impl ChatWidget {
crate::startup_recovery::bind_submission(&text, &client_user_message_id);
let pending_steer = (!render_in_history).then(|| PendingSteer {
client_id: client_user_message_id.clone(),
admitted_turn_id: None,
optimistically_rendered: false,
user_message: UserMessage {
text: text.clone(),
local_images: local_images.clone(),
@@ -481,6 +498,7 @@ impl ChatWidget {
} else {
"user"
},
cloud_submission,
client_user_message_id.clone(),
items,
self.config.cwd.to_path_buf(),
@@ -530,6 +548,7 @@ impl ChatWidget {
if render_in_history {
self.input_queue.user_turn_pending_start = true;
self.input_queue.pending_user_message_client_id = Some(client_user_message_id.clone());
self.input_queue.pending_user_message_history_record = Some(history_record.clone());
}
// Persist the submitted text to cross-session message history. Mentions are encoded into
+12 -1
View File
@@ -114,6 +114,10 @@ impl ChatWidget {
self.on_replayed_task_started(notification.turn.started_at);
} else {
self.on_task_started();
if self.turn_lifecycle.admitted_turn_id == self.turn_lifecycle.last_turn_id
{
self.turn_lifecycle.admitted_turn_id = None;
}
}
}
}
@@ -447,6 +451,13 @@ impl ChatWidget {
notification: TurnCompletedNotification,
replay_kind: Option<ReplayKind>,
) {
if replay_kind.is_none()
&& notification.turn.status != TurnStatus::InProgress
&& self.turn_lifecycle.admitted_turn_id.as_deref()
== Some(notification.turn.id.as_str())
{
self.turn_lifecycle.admitted_turn_id = None;
}
// User-message dedupe only suppresses the app-server echo of a prompt
// this TUI already rendered locally. Once that turn ends, another
// client can submit the same text and it still needs its own user cell.
@@ -506,7 +517,7 @@ impl ChatWidget {
} else {
TurnAbortReason::Interrupted
};
self.on_interrupted_turn(reason);
self.on_interrupted_server_turn(reason, &notification.turn.id);
}
TurnStatus::Failed => {
if replay_kind.is_none() {
+5 -13
View File
@@ -126,19 +126,11 @@ impl ChatWidget {
}
pub(super) fn reconcile_recovered_messages(&mut self, confirmed_message_ids: &[String]) {
let mut index = 0;
self.input_queue.queued_user_messages.retain(|message| {
let confirmed = matches!(&message.delivery,
MessageDelivery::Unconfirmed(Some(id)) if confirmed_message_ids.contains(id));
if confirmed {
self.input_queue
.queued_user_message_history_records
.remove(index);
} else {
index += 1;
}
!confirmed
});
super::user_messages::remove_confirmed_queued_messages(
&mut self.input_queue.queued_user_messages,
&mut self.input_queue.queued_user_message_history_records,
confirmed_message_ids,
);
self.refresh_pending_input_preview();
}
@@ -1894,6 +1894,51 @@ async fn pending_steer_interrupt_uses_remapped_binding() {
assert!(chat.input_queue.submit_pending_steers_after_interrupt);
}
#[tokio::test]
async fn resumed_steers_reconcile_by_client_id_and_hold_unmatched_admissions() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
// Both messages have the same text; only one has a receipt in the resumed history.
chat.input_queue.pending_steers = VecDeque::from([
PendingSteer {
client_id: "confirmed".into(),
admitted_turn_id: Some("finished".into()),
..pending_steer("same text")
},
PendingSteer {
client_id: "unmatched".into(),
admitted_turn_id: Some("finished".into()),
..pending_steer("same text")
},
]);
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("next draft").into());
chat.input_queue
.queued_user_message_history_records
.push_back(UserMessageHistoryRecord::UserMessageText);
let mut input = chat
.capture_thread_input_state()
.expect("saved session input");
input.reconcile_confirmed_messages(&["confirmed".into()]);
chat.restore_thread_input_state(
Some(input),
ThreadInputStateRestoreMode {
preserve_in_flight_turn: false,
},
);
assert_eq!(
chat.queued_user_message_texts(),
vec!["same text", "next draft"]
);
assert_eq!(
chat.input_queue.queued_user_messages[0].delivery,
MessageDelivery::Unconfirmed(Some("unmatched".into()))
);
assert!(!chat.maybe_send_next_queued_input());
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
async fn restore_thread_input_state_applies_running_state_policy() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -1099,6 +1099,8 @@ pub(super) fn complete_assistant_message(
pub(super) fn pending_steer(text: &str) -> PendingSteer {
PendingSteer {
client_id: "test-submission".to_string(),
admitted_turn_id: None,
optimistically_rendered: false,
user_message: UserMessage::from(text),
history_record: UserMessageHistoryRecord::UserMessageText,
source: UserMessageSource::Prompt,
@@ -11,6 +11,8 @@ pub(super) struct TurnLifecycleState {
/// Tracks whether codex-core currently considers an agent turn to be in progress.
pub(super) agent_turn_running: bool,
pub(super) last_turn_id: Option<String>,
/// Cloud confirmed admission to a turn whose live start we have yet to process.
pub(super) admitted_turn_id: Option<String>,
pub(super) budget_limited_turn_ids: HashSet<String>,
/// Completion labels already inserted into this thread's visible history.
pub(super) rendered_completion_turn_ids: HashSet<String>,
@@ -23,6 +25,7 @@ impl TurnLifecycleState {
sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep),
agent_turn_running: false,
last_turn_id: None,
admitted_turn_id: None,
budget_limited_turn_ids: HashSet::new(),
rendered_completion_turn_ids: HashSet::new(),
goal_status_active_turn_started_at: None,
@@ -42,6 +45,10 @@ impl TurnLifecycleState {
.set_turn_running(/*turn_running*/ false);
}
pub(super) fn is_running_or_admitted(&self) -> bool {
self.agent_turn_running || self.admitted_turn_id.is_some()
}
pub(super) fn restore_running(&mut self, running: bool, now: Instant) {
self.agent_turn_running = running;
self.goal_status_active_turn_started_at = running.then_some(now);
@@ -51,6 +58,7 @@ impl TurnLifecycleState {
pub(super) fn reset_thread(&mut self) {
self.finish();
self.last_turn_id = None;
self.admitted_turn_id = None;
self.budget_limited_turn_ids.clear();
self.rendered_completion_turn_ids.clear();
}
+1 -1
View File
@@ -44,7 +44,7 @@ impl ChatWidget {
/// both the agent turn lifecycle and MCP startup lifecycle.
pub(super) fn update_task_running_state(&mut self) {
self.bottom_pane.set_task_running(
self.turn_lifecycle.agent_turn_running
self.turn_lifecycle.is_running_or_admitted()
|| self.review.is_review_mode
|| self.mcp_startup_status.is_some(),
);
@@ -64,6 +64,7 @@ pub(super) enum ShellEscapePolicy {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum UserMessageSource {
Prompt,
DirectEnter,
QuestionAnswer,
SafetyRecourse,
}
@@ -86,6 +87,13 @@ pub(super) struct QueuedUserMessage {
}
impl QueuedUserMessage {
pub(super) fn unconfirmed_user_message_id(&self) -> Option<&str> {
match &self.delivery {
MessageDelivery::Unconfirmed(id) => id.as_deref(),
MessageDelivery::Unsent => None,
}
}
pub(super) fn new(user_message: UserMessage, action: QueuedInputAction) -> Self {
Self {
user_message,
@@ -209,6 +217,9 @@ impl From<&str> for UserMessage {
pub(crate) struct PendingSteer {
/// Preserved across request retries and thread switches until this submission commits.
pub(crate) client_id: String,
/// The admission identifies the turn whose interruption can discard this input.
pub(super) admitted_turn_id: Option<String>,
pub(super) optimistically_rendered: bool,
pub(super) user_message: UserMessage,
pub(super) history_record: UserMessageHistoryRecord,
pub(super) source: UserMessageSource,
@@ -852,7 +863,36 @@ impl ChatWidget {
}
}
pub(super) fn remove_confirmed_queued_messages(
messages: &mut VecDeque<QueuedUserMessage>,
history: &mut VecDeque<UserMessageHistoryRecord>,
confirmed_ids: &[String],
) {
let mut index = 0;
messages.retain(|message| {
let confirmed = message
.unconfirmed_user_message_id()
.is_some_and(|id| confirmed_ids.iter().any(|confirmed_id| confirmed_id == id));
if confirmed {
history.remove(index);
} else {
index += 1;
}
!confirmed
});
}
impl ThreadInputState {
pub(crate) fn reconcile_confirmed_messages(&mut self, confirmed_ids: &[String]) {
self.pending_steers
.retain(|steer| !confirmed_ids.contains(&steer.client_id));
remove_confirmed_queued_messages(
&mut self.queued_user_messages,
&mut self.queued_user_message_history_records,
confirmed_ids,
);
}
pub(crate) fn has_unconfirmed_messages(&self) -> bool {
self.queued_user_messages
.iter()