Notify users when asynchronous questions arrive in the TUI (#46574)

## What changed

Send an `async-question` notification when new unanswered questions arrive, honoring existing notification settings and taking priority over turn completion. Show a shortened question title for a single question or a count for a batch. Empty batches, duplicate deliveries, and replayed history do not trigger notifications.

## Testing

Add tests covering live arrival, replay and duplicate suppression, notification settings, priority over turn completion, batch summaries, and long-title truncation.

GitOrigin-RevId: 205dc3ff5461c2166159af5143b0ca7279a87f42
This commit is contained in:
Eric Traut
2026-09-19 00:40:43 +00:00
committed by copyberry
parent 94e3c20906
commit d5afb62bb3
4 changed files with 122 additions and 1 deletions

View File

@@ -30,6 +30,7 @@ pub(super) enum Notification {
EditApprovalRequested { cwd: PathBuf, changes: Vec<PathBuf> },
ElicitationRequested { server_name: String },
PlanModePrompt { title: String },
AsyncQuestion { title: String },
}
impl Notification {
@@ -62,6 +63,9 @@ impl Notification {
Notification::PlanModePrompt { title } => {
format!("Plan mode prompt: {title}")
}
Notification::AsyncQuestion { title } => {
format!("Question: {title}")
}
}
}
@@ -72,6 +76,7 @@ impl Notification {
| Notification::EditApprovalRequested { .. }
| Notification::ElicitationRequested { .. } => "approval-requested",
Notification::PlanModePrompt { .. } => "plan-mode-prompt",
Notification::AsyncQuestion { .. } => "async-question",
}
}
@@ -81,7 +86,8 @@ impl Notification {
Notification::ExecApprovalRequested { .. }
| Notification::EditApprovalRequested { .. }
| Notification::ElicitationRequested { .. }
| Notification::PlanModePrompt { .. } => 1,
| Notification::PlanModePrompt { .. }
| Notification::AsyncQuestion { .. } => 1,
}
}

View File

@@ -11,7 +11,19 @@ impl ChatWidget {
message_id: &str,
questions: &[AsyncUserInputQuestion],
) {
let previous_count = self.bottom_pane.question_editor().unanswered_count();
self.bottom_pane.push_async_questions(message_id, questions);
let added_count = self.bottom_pane.question_editor().unanswered_count() - previous_count;
if added_count > 0 {
let title = match questions {
[question] if !question.title.trim().is_empty() => {
truncate_text(question.title.trim(), /*max_graphemes*/ 30)
}
_ if added_count == 1 => "Question requested".to_string(),
_ => format!("{added_count} questions requested"),
};
self.notify(Notification::AsyncQuestion { title });
}
self.refresh_pending_input_preview();
}

View File

@@ -297,3 +297,6 @@ pub(super) use helpers::*;
#[path = "tests/questions_tests.rs"]
mod questions_tests;
#[path = "tests/question_notifications_tests.rs"]
mod question_notifications_tests;

View File

@@ -0,0 +1,100 @@
//! Async questions notify once on arrival and honor the terminal notification settings.
use super::*;
use codex_protocol::items::AsyncUserInputQuestion;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn live_async_question_notifies_once_and_takes_priority_over_turn_completion() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
let item = AppServerThreadItem::AgentMessage {
id: "question".into(),
text: String::new(),
phase: None,
memory_citation: None,
delivery: None,
questions: Some(vec![AsyncUserInputQuestion {
title: "Which environment?".into(),
options: None,
}]),
};
for kind in [
ReplayKind::ResumeInitialMessages,
ReplayKind::ThreadSnapshot,
] {
chat.replay_thread_item(item.clone(), "turn".into(), kind);
assert!(chat.pending_notification.is_none());
}
let notification = ServerNotification::ItemCompleted(ItemCompletedNotification {
item,
thread_id: "thread".into(),
turn_id: "turn".into(),
completed_at_ms: 0,
});
chat.handle_server_notification(notification.clone(), /*replay_kind*/ None);
chat.notify(Notification::AgentTurnComplete {
response: "Done".into(),
});
insta::assert_snapshot!(
chat.pending_notification.take().unwrap().display(),
@"Question: Which environment?"
);
chat.handle_server_notification(notification.clone(), /*replay_kind*/ None);
assert!(chat.pending_notification.is_none());
chat.bottom_pane.clear_pending_questions();
chat.handle_server_notification(notification, /*replay_kind*/ None);
assert!(chat.pending_notification.is_none());
}
#[tokio::test]
async fn async_question_notifications_respect_settings_and_ignore_empty_batches() {
for (settings, expected) in [
(Notifications::Enabled(true), true),
(Notifications::Enabled(false), false),
(Notifications::Custom(vec!["async-question".into()]), true),
(
Notifications::Custom(vec!["agent-turn-complete".into()]),
false,
),
] {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.local_settings.tui.notification_settings.notifications = settings;
chat.add_async_questions("empty", &[]);
assert!(chat.pending_notification.is_none());
chat.add_async_questions(
"question",
&[AsyncUserInputQuestion {
title: "Which way?".into(),
options: None,
}],
);
assert_eq!(chat.pending_notification.is_some(), expected);
}
}
#[tokio::test]
async fn async_question_notification_summarizes_batches_and_bounds_long_titles() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
let questions = vec![
AsyncUserInputQuestion {
title: "Which environment should we deploy this change to?".into(),
options: None,
},
AsyncUserInputQuestion {
title: "Any details?".into(),
options: None,
},
];
chat.add_async_questions("batch", &questions);
insta::assert_snapshot!(
chat.pending_notification.take().unwrap().display(),
@"Question: 2 questions requested"
);
chat.add_async_questions("single", &questions[..1]);
insta::assert_snapshot!(
chat.pending_notification.take().unwrap().display(),
@"Question: Which environment should we..."
);
}