Make TUI async question replies compatible with desktop (#46486)

## Why

Async questions answered on another client should disappear from the TUI without losing drafts for other questions, even when questions have identical titles.

## What changed

- Send answers using the desktop reply envelope with stable per-question IDs, and resolve matching questions from committed messages and replayed history.
- Render replies as readable question-and-answer text in transcripts, queue previews, and input history.
- Preserve separate reply envelopes and message order when retrying rejected or interrupted input.
- Account for JSON escaping in input limits and fall back to plain text for oversized question IDs.

## Testing

Add regression coverage for cross-client dismissal, draft preservation, replay ordering, reply parsing, IDE context, distinct replies with identical text, and retry ordering. Update the async question scenario to use the reply envelope.

GitOrigin-RevId: 9b9e4b1e140508590401622d96cfc16fc192eb7c
This commit is contained in:
Eric Traut
2026-09-18 01:31:32 +00:00
committed by copyberry
parent 3d5b66c655
commit dbf478850f
23 changed files with 639 additions and 60 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -2966,6 +2966,7 @@ dependencies = [
"codex-protocol",
"codex-utils-string",
"pretty_assertions",
"serde_json",
]
[[package]]

View File

@@ -15,6 +15,7 @@ workspace = true
[dependencies]
codex-protocol = { workspace = true }
codex-utils-string = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }

View File

@@ -1,23 +1,28 @@
//! Bounded question framing accompanying an explicitly submitted user answer.
//! Async answers use the desktop's existing reply envelope and stable question identity.
//! Model-authored framing is bounded; oversized identities use the previous plain-text format.
use super::ContextualUserFragment;
use codex_protocol::models::ContentItemKind;
/// Identifies an answered question without repeating an unbounded model-authored prompt.
pub struct AnsweredQuestion {
pub struct AnsweredQuestion<'a> {
question_id: Option<&'a str>,
question: String,
answer: &'a str,
}
impl AnsweredQuestion {
pub fn new(question: &str) -> Self {
impl<'a> AnsweredQuestion<'a> {
pub fn new(question_id: &'a str, question: &str, answer: &'a str) -> Self {
let end = question.floor_char_boundary(question.len().min(512));
Self {
question: question[..end].to_string(),
question_id: (question_id.len() <= 512).then_some(question_id),
question: question[..end].replace(['\n', '\r'], " "),
answer,
}
}
}
impl ContextualUserFragment for AnsweredQuestion {
impl ContextualUserFragment for AnsweredQuestion<'_> {
fn content_kind(&self) -> ContentItemKind {
ContentItemKind("user.answered_question".into())
}
@@ -25,13 +30,28 @@ impl ContextualUserFragment for AnsweredQuestion {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
if self.question_id.is_some() {
Self::type_markers()
} else {
("", "")
}
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
(
"<send_user_message_question_reply>",
"</send_user_message_question_reply>",
)
}
fn body(&self) -> String {
format!("> {}\n\n", self.question.replace(['\n', '\r'], " "))
let Some(question_id) = self.question_id else {
return format!("> {}\n\n{}", self.question, self.answer);
};
let replies = serde_json::json!([{
"answer": self.answer,
"question": self.question,
"questionItemId": question_id,
}]);
format!("\n{replies}\n")
}
}

View File

@@ -1,4 +1,4 @@
//! Bounds and Unicode handling for model-authored question framing.
//! Bounds, Unicode handling, and escaping for async question replies.
use super::*;
use pretty_assertions::assert_eq;
@@ -6,13 +6,15 @@ use pretty_assertions::assert_eq;
#[test]
fn question_context_is_bounded_and_keeps_unicode_boundaries() {
let text = "é\n".repeat(1_000);
let rendered = AnsweredQuestion::new(&text).render();
assert!(rendered.len() <= 516);
let id = r#"["request_user_input_async","message",1]"#;
let answer = "A \"quoted\" answer\nwith a second line";
let fragment = AnsweredQuestion::new(id, &text, answer);
assert_eq!(
rendered,
format!(
"> {}\n\n",
text[..text.floor_char_boundary(512)].replace('\n', " ")
)
serde_json::from_str::<serde_json::Value>(&fragment.body()).unwrap(),
serde_json::json!([{
"questionItemId": id,
"question": text[..text.floor_char_boundary(512)].replace('\n', " "),
"answer": answer,
}]),
);
}

View File

@@ -277,7 +277,8 @@ async fn astra_asks_an_async_question_and_receives_the_answer_while_working() ->
})
.await;
let answer = format!("{}Customers", AnsweredQuestion::new(question).render());
let question_id = json!(["request_user_input_async", "audience-question", 0]).to_string();
let answer = AnsweredQuestion::new(&question_id, question, "Customers").render();
test.codex
.steer_turn(TurnInputRequest::user_input(vec![text(&answer)]), turn_id)
.await?;

View File

@@ -1,5 +1,6 @@
---
source: core/tests/suite/scenarios.rs
assertion_line: 304
expression: "context_snapshot::format_context_snapshot(\"Astra asks who a launch update is for, keeps working, and receives the user's answer in the active turn.\",\n&entries, &ContextSnapshotOptions::default().rewrite_known_segments(),)"
---
Scenario: Astra asks who a launch update is for, keeps working, and receives the user's answer in the active turn.
@@ -41,9 +42,9 @@ Scenario: Astra asks who a launch update is for, keeps working, and receives the
09:message/assistant:
I drafted a short launch update.
10:message/user:
> Who should receive the launch update?
Customers
<send_user_message_question_reply>
[{"answer":"Customers","question":"Who should receive the launch update?","questionItemId":"[\\"request_user_input_async\\",\\"audience-question\\",0]"}]
</send_user_message_question_reply>
-- request 4 (request) --
11:message/assistant:
Here is the launch update for customers.

View File

@@ -0,0 +1,64 @@
//! Recognize the desktop's existing async-question reply envelope for display and dismissal.
//! Only complete envelopes, optionally following the standard IDE context prefix, are interpreted.
use codex_app_server_protocol::UserInput;
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AsyncQuestionReply {
pub(crate) question_item_id: String,
question: String,
answer: String,
}
pub(crate) fn parse(text: &str) -> Option<Vec<AsyncQuestionReply>> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Replies {
Many(Vec<AsyncQuestionReply>),
One(AsyncQuestionReply),
}
let text = text.trim();
// JSON strings escape newlines, so this cannot match a delimiter inside an answer.
let text = if text.starts_with("# Context from my IDE setup:\n") {
text.rsplit_once("\n## My request for Codex:\n")?.1.trim()
} else {
text
};
let json = text
.strip_prefix("<send_user_message_question_reply>")?
.strip_suffix("</send_user_message_question_reply>")?;
let replies = match serde_json::from_str::<Replies>(json).ok()? {
Replies::Many(replies) => replies,
Replies::One(reply) => vec![reply],
};
(!replies.is_empty()).then_some(replies)
}
pub(crate) fn display_text(text: &str) -> Option<String> {
Some(
parse(text)?
.iter()
.map(|reply| format!("> {}\n\n{}", reply.question, reply.answer))
.collect::<Vec<_>>()
.join("\n\n"),
)
}
pub(crate) fn parse_input(input: &[UserInput]) -> Option<Vec<AsyncQuestionReply>> {
let mut content = input
.iter()
.filter(|item| !matches!(item, UserInput::Skill { .. } | UserInput::Mention { .. }));
let UserInput::Text { text, .. } = content.next()? else {
return None;
};
if content.next().is_some() {
return None;
}
parse(text)
}
#[cfg(test)]
#[path = "async_question_reply_tests.rs"]
mod tests;

View File

@@ -0,0 +1,35 @@
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn desktop_question_reply_accepts_single_and_batched_envelopes() {
let reply = json!({"questionItemId": "[\"request_user_input_async\",\"item\",0]", "question": "Which environment?", "answer": "Staging", "extra": true});
for payload in [reply.clone(), json!([reply])] {
let text = format!(
" \n<send_user_message_question_reply>\n{payload}\n</send_user_message_question_reply>\n "
);
assert_eq!(
display_text(&text).as_deref(),
Some("> Which environment?\n\nStaging")
);
}
let text = "<send_user_message_question_reply>[{\"questionItemId\":\"one\",\"question\":\"First?\",\"answer\":\"Yes\"},{\"questionItemId\":\"two\",\"question\":\"Second?\",\"answer\":\"No\"}]</send_user_message_question_reply>";
assert_eq!(
display_text(text).as_deref(),
Some("> First?\n\nYes\n\n> Second?\n\nNo")
);
}
#[test]
fn malformed_or_embedded_question_envelopes_remain_ordinary_text() {
for text in [
"> Which environment?\n\nStaging",
"<send_user_message_question_reply>[]</send_user_message_question_reply>",
"<send_user_message_question_reply>[{\"questionItemId\":\"one\",\"question\":\"First?\",\"answer\":\"Yes\"},null]</send_user_message_question_reply>",
"Quoted: <send_user_message_question_reply>{\"questionItemId\":\"one\",\"question\":\"First?\",\"answer\":\"Yes\"}</send_user_message_question_reply>",
"<send_user_message_question_reply>{\"questionItemId\":\"one\",\"question\":\"First?\",\"answer\":\"Yes\"}</send_user_message_question_reply> trailing text",
] {
assert_eq!(parse(text), None, "{text}");
}
}

View File

@@ -1,5 +1,5 @@
//! Inline editing for asynchronous questions. Legacy request_user_input keeps its own overlay.
//! Only locally accepted submissions remove questions; arrival and expiry never steal focus.
//! Local submissions and committed desktop replies remove questions; arrival never steals focus.
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::CancellationEvent;
@@ -36,6 +36,7 @@ pub(super) const DESIRED_SPACERS_BETWEEN_SECTIONS: u16 = 2;
#[derive(Debug, Clone, PartialEq)]
struct PendingQuestion {
message_id: String,
question_id: String,
question: AsyncUserInputQuestion,
options_state: ScrollState,
draft: ComposerDraft,
@@ -49,6 +50,7 @@ pub(crate) struct QuestionState {
current_idx: usize,
expanded: bool,
seen_ids: HashSet<String>,
answered_ids: HashSet<String>,
}
pub(crate) enum QuestionSubmission {

View File

@@ -1,4 +1,4 @@
//! Pending async questions are local drafts; handling one removes it immediately.
//! Pending async questions retain drafts until handled locally or answered by another client.
//! Message IDs survive removal so replay cannot reopen an answered or skipped question.
use super::*;
@@ -12,7 +12,15 @@ impl AsyncQuestions {
}
let was_empty = self.state.pending.is_empty();
let expires_at = (!self.expanded).then(|| Instant::now() + Duration::from_secs(30));
self.state.pending.extend(questions.iter().map(|question| {
for (index, question) in questions.iter().enumerate() {
// Match the desktop's JSON.stringify([tool name, item id, question index]).
let question_id =
serde_json::json!(["request_user_input_async", message_id, index]).to_string();
if self.state.answered_ids.contains(&question_id)
|| self.state.answered_ids.contains(message_id)
{
continue;
}
// Bound work before cloning or wrapping model-authored suggestions.
let question = AsyncUserInputQuestion {
title: question.title.clone(),
@@ -31,14 +39,15 @@ impl AsyncQuestions {
.is_some_and(|options| !options.is_empty());
let mut options_state = ScrollState::new();
options_state.selected_idx = has_options.then_some(0);
PendingQuestion {
self.state.pending.push(PendingQuestion {
message_id: message_id.into(),
question_id,
question,
options_state,
draft: ComposerDraft::default(),
expires_at,
}
}));
});
}
if was_empty {
self.state.current_idx = 0;
self.restore_current_draft();
@@ -120,15 +129,54 @@ impl AsyncQuestions {
selected.to_string()
};
let text = text.trim();
let framing = AnsweredQuestion::new(&answer.question.title).render();
let limit = codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS - framing.chars().count();
if text.chars().count() > limit {
if text.is_empty() {
return;
}
let reply =
AnsweredQuestion::new(&answer.question_id, &answer.question.title, text).render();
if reply.chars().count() > codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS {
self.composer.show_footer_flash(
format!("Answer too long; limit {limit} characters").into(),
"Answer too long; shorten it before sending".into(),
Duration::from_secs(5),
);
} else if !text.is_empty() {
self.submission = Some(QuestionSubmission::Submit(format!("{framing}{text}")));
} else {
self.submission = Some(QuestionSubmission::Submit(reply));
}
}
pub(crate) fn resolve_answers(&mut self, question_ids: &[String]) {
// History can arrive before live questions or before restoring local drafts.
self.state.answered_ids.extend(question_ids.iter().cloned());
// Older desktop replies identify the whole source message instead of one question.
let answered = |question: &PendingQuestion| {
question_ids.contains(&question.question_id)
|| question_ids.contains(&question.message_id)
};
if !self.state.pending.iter().any(answered) {
return;
}
let current_answered = self.current_answer().is_some_and(answered);
if current_answered {
self.composer.flush_pending_input();
}
let current_idx = self
.state
.pending
.iter()
.take(self.state.current_idx)
.filter(|question| !answered(question))
.count();
self.state.pending.retain(|question| !answered(question));
self.state.current_idx = if current_idx < self.state.pending.len() {
current_idx
} else {
0
};
self.expanded &= !self.state.pending.is_empty();
self.visible_options.set((0, 0));
if current_answered {
self.restore_current_draft();
self.composer.reset_vim_mode();
}
}
@@ -168,6 +216,8 @@ impl AsyncQuestions {
pub(crate) fn restore(&mut self, saved: QuestionState) {
self.visible_options.set((0, 0));
let incoming = std::mem::replace(&mut self.state, saved);
let mut answered_ids = incoming.answered_ids;
answered_ids.extend(self.state.answered_ids.iter().cloned());
self.state.pending.extend(
incoming
.pending
@@ -180,5 +230,6 @@ impl AsyncQuestions {
self.snooze_auto_resolution();
}
self.restore_current_draft();
self.resolve_answers(&answered_ids.into_iter().collect::<Vec<_>>());
}
}

View File

@@ -392,3 +392,44 @@ fn question_navigation_resets_history_recall() {
editor.handle_key_event(KeyCode::Up.into());
assert_eq!(editor.composer.current_text(), "newer");
}
#[test]
fn answered_questions_do_not_reopen_when_history_precedes_local_drafts() {
let mut original = editor();
let saved = original.capture();
original.resolve_answers(&["unknown".into()]);
assert_eq!(original.state.pending, saved.pending);
let mut restored = editor();
restored.state = QuestionState::default();
restored.resolve_answers(&[r#"["request_user_input_async","message",0]"#.into()]);
restored.append(
"message",
&[
question("First", /*options*/ None),
question("Second", /*options*/ None),
],
);
assert_eq!(restored.unanswered_count(), 1);
restored.restore(saved);
assert_eq!(restored.state.pending, original.state.pending[1..]);
// Legacy desktop replies refer to the entire source message.
restored.resolve_answers(&["message".into()]);
assert_eq!(restored.unanswered_count(), 0);
assert!(restored.composer.is_empty());
}
#[test]
fn oversized_question_ids_keep_questions_answerable_without_echoing_the_id() {
let mut editor = editor();
editor.clear_pending();
editor.append(&"x".repeat(1024), &[question("Question", /*options*/ None)]);
assert_eq!(editor.unanswered_count(), 1);
editor.set_expanded(/*expanded*/ true);
editor.handle_paste("Answer".into());
render_editor(&editor, /*width*/ 80, /*height*/ 20);
editor.go_next_or_submit();
let Some(QuestionSubmission::Submit(reply)) = editor.submission else {
panic!("submitted answer");
};
assert_eq!(reply, "> Question\n\nAnswer");
}

View File

@@ -20,7 +20,7 @@ impl BottomPane {
}
}
fn question_editor(&mut self) -> &mut AsyncQuestions {
pub(crate) fn question_editor(&mut self) -> &mut AsyncQuestions {
self.questions.get_or_insert_with(|| {
let mut questions = AsyncQuestions::new(
self.app_event_tx.clone(),

View File

@@ -196,15 +196,23 @@ impl ChatWidget {
(user_message, history_record)
})
} else {
// Reply envelopes must remain separate messages so other clients can read them.
let count = self
.input_queue
.rejected_steers_queue
.iter()
.position(|message| crate::async_question_reply::parse(&message.text).is_some())
.map(|index| index.max(/*other*/ 1))
.unwrap_or(self.input_queue.rejected_steers_queue.len());
let rejected_messages = self
.input_queue
.rejected_steers_queue
.drain(..)
.drain(..count)
.collect::<Vec<_>>();
let sources = self
.input_queue
.rejected_steer_sources
.drain(..)
.drain(..count.min(self.input_queue.rejected_steer_sources.len()))
.collect::<Vec<_>>();
let source = if !rejected_messages.is_empty()
&& sources.len() == rejected_messages.len()
@@ -219,7 +227,7 @@ impl ChatWidget {
let mut history_records = self
.input_queue
.rejected_steer_history_records
.drain(..)
.drain(..count.min(self.input_queue.rejected_steer_history_records.len()))
.collect::<Vec<_>>();
history_records.resize(
rejected_messages.len(),
@@ -329,15 +337,32 @@ impl ChatWidget {
.pending_steers
.drain(..)
.collect::<Vec<_>>();
if !pending_steers.is_empty() {
let source = if pending_steers
.iter()
.all(|pending| pending.source == UserMessageSource::QuestionAnswer)
{
UserMessageSource::QuestionAnswer
} else {
UserMessageSource::Prompt
};
if pending_steers
.iter()
.any(|pending| pending.source == UserMessageSource::QuestionAnswer)
{
// Keep answers intact when an interrupt retries uncommitted input.
for pending in pending_steers {
self.input_queue
.rejected_steers_queue
.push_back(pending.user_message);
self.input_queue
.rejected_steer_sources
.push_back(pending.source);
self.input_queue
.rejected_steer_history_records
.push_back(pending.history_record);
}
if let Some((message, history_record)) = self.pop_next_queued_user_message() {
let source = message.source;
self.submit_user_message_with_history_and_shell_escape_policy(
message.into_user_message(),
history_record,
ShellEscapePolicy::Allow,
source,
);
}
} else if !pending_steers.is_empty() {
let (user_message, history_record) = merge_user_messages_with_history_record(
pending_steers
.into_iter()
@@ -348,7 +373,7 @@ impl ChatWidget {
user_message,
history_record,
ShellEscapePolicy::Allow,
source,
UserMessageSource::Prompt,
);
} else if let Some(combined) = self.drain_pending_messages_for_restore() {
self.restore_composer_state(combined);

View File

@@ -282,7 +282,9 @@ impl ChatWidget {
.retain(|binding| crate::task_mentions::valid_thread_path(&binding.path).is_none());
}
let mentions = collect_tool_mentions(&text, &HashMap::new());
let reply_text = crate::async_question_reply::display_text(&text);
let mentions =
collect_tool_mentions(reply_text.as_deref().unwrap_or(&text), &HashMap::new());
let bound_names: HashSet<String> = mention_bindings
.iter()
.map(|binding| binding.mention.clone())
@@ -513,6 +515,11 @@ impl ChatWidget {
}
};
if let Some((text, elements)) = history {
let reply_text = crate::async_question_reply::display_text(text);
let (text, elements) = match &reply_text {
Some(text) => (text.as_str(), &[][..]),
None => (text.as_str(), elements),
};
self.append_message_history_entry(encode_history_mentions_at_elements(
text,
&encoded_mentions,

View File

@@ -260,6 +260,15 @@ impl ChatWidget {
ThreadItem::UserMessage {
content, client_id, ..
} => {
if let Some(replies) = crate::async_question_reply::parse_input(&content) {
let ids = replies
.into_iter()
.map(|reply| reply.question_item_id)
.collect::<Vec<_>>();
self.bottom_pane.question_editor().resolve_answers(&ids);
self.refresh_pending_input_preview();
self.request_redraw();
}
self.on_committed_user_message(
&content,
client_id.as_deref(),

View File

@@ -47,10 +47,12 @@ async fn accepted_question_answer_uses_existing_delivery_and_keeps_main_draft()
assert_eq!(chat.bottom_pane.composer_text(), "main draft");
let expected = "> Which way?\n\n!literal answer";
if queued {
assert_eq!(
chat.input_queue.queued_user_messages.front().unwrap().text,
expected
insta::assert_snapshot!(
"queued_async_question_reply",
render_bottom_popup(&chat, /*width*/ 80)
);
let restored = chat.pop_latest_queued_composer_state().unwrap();
assert_eq!(restored.text, expected);
assert!(op_rx.try_recv().is_err());
} else {
assert_answer(op_rx.try_recv().unwrap(), expected);
@@ -58,6 +60,54 @@ async fn accepted_question_answer_uses_existing_delivery_and_keeps_main_draft()
}
}
#[tokio::test]
async fn async_question_answers_preserve_ambiguous_skill_selection_and_dismiss_remotely() {
use codex_context_fragments::AnsweredQuestion;
use codex_context_fragments::ContextualUserFragment;
let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
let skill = SkillMetadata {
name: "route".into(),
description: "Choose a route".into(),
short_description: None,
interface: None,
dependencies: None,
path: test_path_buf("/tmp/route/SKILL.md").abs(),
scope: crate::test_support::skill_scope_repo(),
enabled: true,
plugin_id: None,
};
let mut duplicate = skill.clone();
duplicate.path = test_path_buf("/tmp/other-route/SKILL.md").abs();
chat.set_skills(Some(vec![skill.clone(), duplicate]));
chat.add_async_questions("message", &questions());
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
chat.bottom_pane.handle_paste("Use $route".into());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let Op::UserTurn { items, .. } = ops.try_recv().unwrap() else {
panic!("user turn")
};
let id = serde_json::json!(["request_user_input_async", "message", 0]).to_string();
assert_eq!(
items,
vec![
UserInput::Text {
text: AnsweredQuestion::new(&id, "Which way?", "Use $route").render(),
text_elements: Vec::new(),
},
UserInput::Skill {
name: skill.name,
path: skill.path.to_path_buf()
},
]
);
let (mut other, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
other.add_async_questions("message", &questions());
complete_user_message_for_inputs(&mut other, "answer", items);
assert_eq!(question_count(&other), 1);
}
#[tokio::test]
async fn ordinary_follow_up_clears_unanswered_questions_after_accepted_input() {
for queued in [false, true] {
@@ -388,12 +438,14 @@ async fn selected_answers_preserve_long_labels_and_reject_oversized_submissions(
if length > 512 {
assert_eq!(question_count(&chat), saved);
assert!(ops.try_recv().is_err());
chat.bottom_pane
.handle_paste("x".repeat(codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS));
// JSON escaping must count toward the limit even when the answer itself fits.
chat.bottom_pane.handle_paste(
"\"".repeat(codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS / 2),
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_eq!(question_count(&chat), saved);
let rendered = render_bottom_popup(&chat, /*width*/ 80);
insta::assert_snapshot!(rendered.lines().find(|line| line.contains("Answer too long")).unwrap(), @" Answer too long; limit 1048562 characters");
insta::assert_snapshot!(rendered.lines().find(|line| line.contains("Answer too long")).unwrap(), @" Answer too long; shorten it before sending");
} else {
assert_answer(ops.try_recv().unwrap(), &format!("> What next?\n\n{label}"));
}
@@ -614,7 +666,10 @@ async fn question_queue_key_does_not_steer_the_running_turn() {
chat.bottom_pane.handle_paste(" later ".into());
chat.handle_key_event(KeyEvent::from(KeyCode::Tab));
assert_eq!(
chat.input_queue.queued_user_messages.front().unwrap().text,
crate::async_question_reply::display_text(
&chat.input_queue.queued_user_messages.front().unwrap().text
)
.unwrap(),
"> First?\n\nlater"
);
let mut repeat = KeyEvent::from(KeyCode::Tab);
@@ -773,6 +828,10 @@ fn assert_answer(op: Op, expected: &str) {
let Op::UserTurn { items, .. } = op else {
panic!("user turn")
};
let mut items = items;
if let [UserInput::Text { text, .. }] = items.as_mut_slice() {
*text = crate::async_question_reply::display_text(text).unwrap_or_else(|| text.clone());
}
assert_eq!(
items,
vec![UserInput::Text {
@@ -812,3 +871,145 @@ async fn questions_and_queued_messages_share_the_resolved_shortcut() {
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
}
}
#[tokio::test]
async fn desktop_async_answer_dismisses_only_its_question_and_preserves_the_other_draft() {
for replay_kind in [None, Some(ReplayKind::ThreadSnapshot)] {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let questions = vec![
question("Same title?", /*options*/ None),
question("Same title?", /*options*/ None),
];
chat.add_async_questions("questions", &questions);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
chat.bottom_pane
.questions
.as_mut()
.unwrap()
.navigate(/*forward*/ true);
chat.bottom_pane
.handle_paste("My draft for the second question".into());
let reply = r#"<send_user_message_question_reply>
[{"questionItemId":"[\"request_user_input_async\",\"questions\",0]","question":"Same title?","answer":"Answer from desktop"}]
</send_user_message_question_reply>"#;
let reply = format!(
"# Context from my IDE setup:\n\n## Open tabs:\n- lib.rs: src/lib.rs\n\n## My request for Codex:\n{reply}"
);
let item = AppServerThreadItem::UserMessage {
id: "answer".into(),
client_id: None,
content: vec![UserInput::Text {
text: reply,
text_elements: Vec::new(),
}],
};
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: "thread".into(),
turn_id: "turn".into(),
completed_at_ms: 0,
item: item.clone(),
}),
replay_kind,
);
assert_eq!(question_count(&chat), 1);
let cells = crate::thread_transcript::thread_items_to_transcript_cells(
/*thread_id*/ None,
&chat.config.cwd,
[item],
crate::thread_transcript::RawReasoningVisibility::Hidden,
/*config*/ None,
);
insta::assert_snapshot!(
"desktop_async_question_reply",
lines_to_single_string(&cells[0].transcript_lines(/*width*/ 80))
);
insta::assert_snapshot!(
"async_question_answered_on_another_client",
render_bottom_popup(&chat, /*width*/ 80)
);
chat.thread_id = Some(ThreadId::new());
chat.input_queue.suppress_queue_autosend = true;
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_eq!(question_count(&chat), 0);
// Removing the first question must not renumber the surviving question's identity.
let queued = &chat.input_queue.queued_user_messages.front().unwrap().text;
let replies = crate::async_question_reply::parse(queued).unwrap();
assert_eq!(
replies[0].question_item_id,
r#"["request_user_input_async","questions",1]"#
);
assert_eq!(
crate::async_question_reply::display_text(queued).unwrap(),
"> Same title?\n\nMy draft for the second question"
);
}
}
#[tokio::test]
async fn distinct_async_question_replies_with_identical_text_both_render() {
use codex_context_fragments::AnsweredQuestion;
use codex_context_fragments::ContextualUserFragment;
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
drain_insert_history(&mut rx);
for id in ["first", "first", "second"] {
let items = [UserInput::Text {
text: AnsweredQuestion::new(id, "Continue?", "Yes").render(),
text_elements: Vec::new(),
}];
chat.on_committed_user_message(
&items, /*client_id*/ None, /*from_replay*/ false, "turn",
);
}
let history = drain_insert_history(&mut rx);
assert_eq!(history.len(), 2);
}
#[tokio::test]
async fn retried_question_answers_keep_separate_envelopes_and_order() {
for interrupt in [false, true] {
let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.submit_user_message(UserMessage::from("before"));
chat.add_async_questions("message", &questions());
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
for answer in ["North", "Bring a map"] {
chat.bottom_pane.handle_paste(answer.into());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
}
chat.submit_user_message(UserMessage::from("after"));
let mut original = Vec::new();
while let Ok(op) = ops.try_recv() {
let Op::UserTurn { items, .. } = op else {
panic!("user turn")
};
let [UserInput::Text { text, .. }] = items.as_slice() else {
panic!("one text input")
};
original.push(text.clone());
}
assert_eq!(original.len(), 4);
let mut retried = Vec::new();
if interrupt {
chat.input_queue.submit_pending_steers_after_interrupt = true;
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
let Op::UserTurn { items, .. } = ops.try_recv().unwrap() else {
panic!("user turn")
};
let [UserInput::Text { text, .. }] = items.as_slice() else {
panic!("one text input")
};
retried.push(text.clone());
} else {
while !chat.input_queue.pending_steers.is_empty() {
assert!(chat.enqueue_rejected_steer());
}
}
while let Some((message, _)) = chat.pop_next_queued_user_message() {
retried.push(message.text.clone());
}
assert_eq!(retried, original);
}
}

View File

@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests/questions_tests.rs
assertion_line: 859
expression: "render_bottom_popup(&chat, 80)"
---
• Queued follow-up inputs
Same title?
My draft for the second question
enter submit ctrl + ] skip ⌥ + ↓ main prompt

View File

@@ -0,0 +1,9 @@
---
source: tui/src/chatwidget/tests/questions_tests.rs
assertion_line: 856
expression: "lines_to_single_string(&cells[0].transcript_lines(80))"
---
> Same title?
Answer from desktop

View File

@@ -0,0 +1,15 @@
---
source: tui/src/chatwidget/tests/questions_tests.rs
assertion_line: 55
expression: "render_bottom_popup(&chat, 80)"
---
• Queued follow-up inputs
↳ > Which way?
!literal answer
Any details?
Type your answer
enter submit ctrl + ] skip ⌥ + ↓ main prompt ⌥ + ↑ queued messages

View File

@@ -453,7 +453,7 @@ fn merge_remapped_user_messages(messages: impl IntoIterator<Item = UserMessage>)
}
pub(super) fn user_message_for_restore(
message: UserMessage,
mut message: UserMessage,
history_record: &UserMessageHistoryRecord,
) -> UserMessage {
match history_record {
@@ -463,6 +463,10 @@ pub(super) fn user_message_for_restore(
..message
},
UserMessageHistoryRecord::Override(_) | UserMessageHistoryRecord::UserMessageText => {
if let Some(text) = crate::async_question_reply::display_text(&message.text) {
message.text = text;
message.text_elements.clear();
}
message
}
}
@@ -478,7 +482,8 @@ pub(super) fn user_message_preview_text(
}
Some(UserMessageHistoryRecord::Override(_))
| Some(UserMessageHistoryRecord::UserMessageText)
| None => message.text.clone(),
| None => crate::async_question_reply::display_text(&message.text)
.unwrap_or_else(|| message.text.clone()),
}
}
@@ -486,7 +491,10 @@ pub(super) fn user_message_display_for_history(
message: UserMessage,
history_record: &UserMessageHistoryRecord,
) -> UserMessageDisplay {
let message = user_message_for_restore(message, history_record);
let message = match history_record {
UserMessageHistoryRecord::UserMessageText => message,
UserMessageHistoryRecord::Override(_) => user_message_for_restore(message, history_record),
};
ChatWidget::user_message_display_from_parts(
message.text,
message.text_elements,
@@ -551,6 +559,8 @@ pub(super) fn merge_user_messages_with_history_record(
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct UserMessageDisplay {
pub(crate) message: String,
// Keep distinct replies distinct when their visible question and answer text match.
question_ids: Vec<String>,
pub(crate) remote_image_urls: Vec<String>,
pub(crate) local_images: Vec<PathBuf>,
pub(crate) text_elements: Vec<TextElement>,
@@ -681,8 +691,25 @@ impl ChatWidget {
local_images: Vec<PathBuf>,
remote_image_urls: Vec<String>,
) -> UserMessageDisplay {
let question_ids = crate::async_question_reply::parse(&message)
.unwrap_or_default()
.into_iter()
.map(|reply| reply.question_item_id)
.collect();
let reply_text = crate::async_question_reply::display_text(&message);
let (message, prompt_request_offset) =
crate::ide_context::extract_prompt_request_with_offset(&message);
if let Some(message) =
reply_text.or_else(|| crate::async_question_reply::display_text(message))
{
return UserMessageDisplay {
message,
question_ids,
text_elements: Vec::new(),
local_images,
remote_image_urls,
};
}
let prompt_request_end = prompt_request_offset + message.len();
// Prompt context uses the same delimiter and stripping behavior as the desktop app and IDE
// extension. The raw user message goes to the agent, but every surface renders only the
@@ -705,6 +732,7 @@ impl ChatWidget {
UserMessageDisplay {
message: message.to_string(),
question_ids: Vec::new(),
remote_image_urls,
local_images,
text_elements,

View File

@@ -202,6 +202,52 @@ mod tests {
}
}
#[test]
fn async_question_reply_stays_recognizable_with_ide_context() {
use codex_context_fragments::AnsweredQuestion;
use codex_context_fragments::ContextualUserFragment;
let context = IdeContext {
active_file: None,
open_tabs: vec![descriptor("lib.rs", "src/lib.rs")],
};
let mut expected = vec![
UserInput::Text {
text: AnsweredQuestion::new(
"question-id",
"Where?",
"Staging\n## My request for Codex:\nKeep this literal",
)
.render(),
text_elements: Vec::new(),
},
UserInput::Skill {
name: "route".into(),
path: std::path::PathBuf::from("route/SKILL.md"),
},
];
let mut items = expected.clone();
let replies = crate::async_question_reply::parse_input(&items);
let display = crate::chatwidget::ChatWidget::user_message_display_from_inputs(&items);
assert_eq!(
display.message,
"> Where?\n\nStaging\n## My request for Codex:\nKeep this literal"
);
let UserInput::Text { text, .. } = &mut expected[0] else {
panic!("reply text");
};
*text = format!(
"# Context from my IDE setup:\n\n## Open tabs:\n- lib.rs: src/lib.rs\n\n## My request for Codex:\n{text}"
);
assert!(apply_ide_context_to_user_input(&context, &mut items));
assert_eq!(items, expected);
assert_eq!(crate::async_question_reply::parse_input(&items), replies);
assert_eq!(
crate::chatwidget::ChatWidget::user_message_display_from_inputs(&items),
display
);
}
#[test]
fn render_prompt_context_matches_app_format() {
let context = IdeContext {

View File

@@ -112,6 +112,7 @@ mod app_server_connection;
mod app_server_session;
mod approval_events;
mod ascii_animation;
mod async_question_reply;
mod backend_banners;
mod bottom_pane;
mod branch_summary;

View File

@@ -119,10 +119,17 @@ pub(crate) fn thread_items_to_transcript_cells(
.map(codex_app_server_protocol::UserInput::into_core)
.collect(),
};
let message = item.message();
let reply_text = crate::async_question_reply::display_text(&message);
let text_elements = if reply_text.is_some() {
Vec::new()
} else {
item.text_elements()
};
cells.push(Arc::new(UserHistoryCell {
spoken: false,
message: item.message(),
text_elements: item.text_elements(),
message: reply_text.unwrap_or(message),
text_elements,
local_image_paths: item.local_image_paths(),
remote_image_urls: item.image_urls(),
}));