mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why `request_user_input` is moving beyond its original plan-mode-only workflow, and future default/goal-mode usage needs a way for the model to ask helpful but non-blocking questions without forcing the turn to wait forever. This PR adds an explicit `autoResolutionMs` contract so a later client/runtime change can auto-resolve unanswered prompts after a bounded window while leaving truly blocking questions unchanged. This is contract plumbing only; it does not implement the client-side timer or auto-selection behavior, and the model-facing description treats the field as reserved unless the current runtime explicitly supports auto-resolution. ## What Changed - Added optional `autoResolutionMs` to the model-facing `request_user_input` args and core `RequestUserInputEvent`. - Added model-facing schema text for `autoResolutionMs` while marking it reserved for runtimes that explicitly support auto-resolution. - Bounds `autoResolutionMs` to `60_000..=240_000` ms during argument normalization by clamping out-of-range model-provided values. - Propagated the field through app-server v2 `ToolRequestUserInputParams`, app-server request forwarding, generated TypeScript, and JSON schema fixtures. - Updated app-server, core, protocol, and TUI call sites/tests so omitted values preserve existing `None`/`null` behavior and coverage verifies a `Some(60_000)` round trip. ## Verification - `just test -p codex-app-server-protocol` - `just test -p codex-core request_user_input` - `just test -p codex-app-server request_user_input_round_trip` - `just test -p codex-tui request_user_input` - `just test -p codex-protocol`
247 lines
8.8 KiB
Rust
247 lines
8.8 KiB
Rust
//! Queue prompt overlays and deferred tool activity while another interrupt is visible.
|
|
|
|
use std::collections::VecDeque;
|
|
|
|
use crate::app::app_server_requests::ResolvedAppServerRequest;
|
|
use crate::approval_events::ApplyPatchApprovalRequestEvent;
|
|
use crate::approval_events::ExecApprovalRequestEvent;
|
|
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
|
use codex_app_server_protocol::RequestId as AppServerRequestId;
|
|
use codex_app_server_protocol::ThreadItem;
|
|
use codex_app_server_protocol::ToolRequestUserInputParams;
|
|
use codex_protocol::request_permissions::RequestPermissionsEvent;
|
|
|
|
use super::ChatWidget;
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) enum QueuedInterrupt {
|
|
ExecApproval(ExecApprovalRequestEvent),
|
|
ApplyPatchApproval(ApplyPatchApprovalRequestEvent),
|
|
Elicitation {
|
|
request_id: AppServerRequestId,
|
|
params: McpServerElicitationRequestParams,
|
|
},
|
|
RequestPermissions(RequestPermissionsEvent),
|
|
RequestUserInput(ToolRequestUserInputParams),
|
|
ItemStarted(ThreadItem),
|
|
ItemCompleted(ThreadItem),
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub(crate) struct InterruptManager {
|
|
queue: VecDeque<QueuedInterrupt>,
|
|
}
|
|
|
|
impl InterruptManager {
|
|
pub(crate) fn new() -> Self {
|
|
Self {
|
|
queue: VecDeque::new(),
|
|
}
|
|
}
|
|
|
|
#[inline]
|
|
pub(crate) fn is_empty(&self) -> bool {
|
|
self.queue.is_empty()
|
|
}
|
|
|
|
pub(crate) fn push_exec_approval(&mut self, ev: ExecApprovalRequestEvent) {
|
|
self.queue.push_back(QueuedInterrupt::ExecApproval(ev));
|
|
}
|
|
|
|
pub(crate) fn push_apply_patch_approval(&mut self, ev: ApplyPatchApprovalRequestEvent) {
|
|
self.queue
|
|
.push_back(QueuedInterrupt::ApplyPatchApproval(ev));
|
|
}
|
|
|
|
pub(crate) fn push_elicitation(
|
|
&mut self,
|
|
request_id: AppServerRequestId,
|
|
params: McpServerElicitationRequestParams,
|
|
) {
|
|
self.queue
|
|
.push_back(QueuedInterrupt::Elicitation { request_id, params });
|
|
}
|
|
|
|
pub(crate) fn push_request_permissions(&mut self, ev: RequestPermissionsEvent) {
|
|
self.queue
|
|
.push_back(QueuedInterrupt::RequestPermissions(ev));
|
|
}
|
|
|
|
pub(crate) fn push_user_input(&mut self, ev: ToolRequestUserInputParams) {
|
|
self.queue.push_back(QueuedInterrupt::RequestUserInput(ev));
|
|
}
|
|
|
|
pub(crate) fn push_item_started(&mut self, item: ThreadItem) {
|
|
self.queue.push_back(QueuedInterrupt::ItemStarted(item));
|
|
}
|
|
|
|
pub(crate) fn push_item_completed(&mut self, item: ThreadItem) {
|
|
self.queue.push_back(QueuedInterrupt::ItemCompleted(item));
|
|
}
|
|
|
|
pub(crate) fn remove_resolved_prompt(&mut self, request: &ResolvedAppServerRequest) -> bool {
|
|
let original_len = self.queue.len();
|
|
self.queue
|
|
.retain(|queued| !queued.matches_resolved_prompt(request));
|
|
self.queue.len() != original_len
|
|
}
|
|
|
|
pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget) {
|
|
while let Some(q) = self.queue.pop_front() {
|
|
match q {
|
|
QueuedInterrupt::ExecApproval(ev) => chat.handle_exec_approval_now(ev),
|
|
QueuedInterrupt::ApplyPatchApproval(ev) => chat.handle_apply_patch_approval_now(ev),
|
|
QueuedInterrupt::Elicitation { request_id, params } => {
|
|
chat.handle_elicitation_request_now(request_id, params);
|
|
}
|
|
QueuedInterrupt::RequestPermissions(ev) => chat.handle_request_permissions_now(ev),
|
|
QueuedInterrupt::RequestUserInput(ev) => chat.handle_request_user_input_now(ev),
|
|
QueuedInterrupt::ItemStarted(item) => chat.handle_queued_item_started_now(item),
|
|
QueuedInterrupt::ItemCompleted(item) => {
|
|
chat.handle_queued_item_completed_now(item);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl QueuedInterrupt {
|
|
fn matches_resolved_prompt(&self, request: &ResolvedAppServerRequest) -> bool {
|
|
match self {
|
|
QueuedInterrupt::ExecApproval(ev) => {
|
|
matches!(request, ResolvedAppServerRequest::ExecApproval { id }
|
|
if ev.effective_approval_id() == id.as_str())
|
|
}
|
|
QueuedInterrupt::ApplyPatchApproval(ev) => {
|
|
matches!(request, ResolvedAppServerRequest::FileChangeApproval { id }
|
|
if ev.call_id == id.as_str())
|
|
}
|
|
QueuedInterrupt::Elicitation { request_id, params } => {
|
|
matches!(request, ResolvedAppServerRequest::McpElicitation {
|
|
server_name,
|
|
request_id: resolved_request_id,
|
|
} if params.server_name == server_name.as_str() && request_id == resolved_request_id)
|
|
}
|
|
QueuedInterrupt::RequestPermissions(ev) => {
|
|
matches!(request, ResolvedAppServerRequest::PermissionsApproval { id }
|
|
if ev.call_id == id.as_str())
|
|
}
|
|
QueuedInterrupt::RequestUserInput(ev) => {
|
|
matches!(request, ResolvedAppServerRequest::UserInput { call_id }
|
|
if ev.item_id == call_id.as_str())
|
|
}
|
|
QueuedInterrupt::ItemStarted(_) | QueuedInterrupt::ItemCompleted(_) => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::approval_events::ExecApprovalRequestEvent;
|
|
use codex_app_server_protocol::CommandExecutionSource;
|
|
use codex_app_server_protocol::CommandExecutionStatus;
|
|
use codex_app_server_protocol::ThreadItem;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
use super::*;
|
|
|
|
fn user_input(call_id: &str, turn_id: &str) -> ToolRequestUserInputParams {
|
|
ToolRequestUserInputParams {
|
|
thread_id: "thread-1".to_string(),
|
|
item_id: call_id.to_string(),
|
|
turn_id: turn_id.to_string(),
|
|
questions: Vec::new(),
|
|
auto_resolution_ms: None,
|
|
}
|
|
}
|
|
|
|
fn exec_approval(call_id: &str, approval_id: Option<&str>) -> ExecApprovalRequestEvent {
|
|
ExecApprovalRequestEvent {
|
|
call_id: call_id.to_string(),
|
|
approval_id: approval_id.map(str::to_string),
|
|
turn_id: "turn".to_string(),
|
|
command: vec!["true".to_string()],
|
|
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
|
|
reason: None,
|
|
network_approval_context: None,
|
|
proposed_execpolicy_amendment: None,
|
|
proposed_network_policy_amendments: None,
|
|
additional_permissions: None,
|
|
available_decisions: None,
|
|
}
|
|
}
|
|
|
|
fn command_execution(call_id: &str) -> ThreadItem {
|
|
ThreadItem::CommandExecution {
|
|
id: call_id.to_string(),
|
|
command: "true".to_string(),
|
|
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
|
|
process_id: None,
|
|
source: CommandExecutionSource::Agent,
|
|
status: CommandExecutionStatus::InProgress,
|
|
command_actions: Vec::new(),
|
|
aggregated_output: None,
|
|
exit_code: None,
|
|
duration_ms: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn remove_resolved_prompt_removes_matching_user_input_only() {
|
|
let mut manager = InterruptManager::new();
|
|
manager.push_user_input(user_input("call-a", "turn"));
|
|
manager.push_user_input(user_input("call-b", "turn"));
|
|
|
|
assert!(
|
|
manager.remove_resolved_prompt(&ResolvedAppServerRequest::UserInput {
|
|
call_id: "call-b".to_string(),
|
|
})
|
|
);
|
|
|
|
assert_eq!(manager.queue.len(), 1);
|
|
let Some(QueuedInterrupt::RequestUserInput(remaining)) = manager.queue.front() else {
|
|
panic!("expected remaining queued user input");
|
|
};
|
|
assert_eq!(remaining.item_id, "call-a");
|
|
}
|
|
|
|
#[test]
|
|
fn remove_resolved_prompt_matches_exec_approval_id() {
|
|
let mut manager = InterruptManager::new();
|
|
manager.push_exec_approval(exec_approval("call", Some("approval")));
|
|
|
|
assert!(
|
|
!manager.remove_resolved_prompt(&ResolvedAppServerRequest::ExecApproval {
|
|
id: "call".to_string(),
|
|
})
|
|
);
|
|
assert_eq!(manager.queue.len(), 1);
|
|
|
|
assert!(
|
|
manager.remove_resolved_prompt(&ResolvedAppServerRequest::ExecApproval {
|
|
id: "approval".to_string(),
|
|
})
|
|
);
|
|
assert!(manager.queue.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn remove_resolved_prompt_keeps_lifecycle_events() {
|
|
let mut manager = InterruptManager::new();
|
|
manager.push_item_started(command_execution("call"));
|
|
|
|
assert!(
|
|
!manager.remove_resolved_prompt(&ResolvedAppServerRequest::ExecApproval {
|
|
id: "call".to_string(),
|
|
})
|
|
);
|
|
|
|
assert_eq!(manager.queue.len(), 1);
|
|
assert!(matches!(
|
|
manager.queue.front(),
|
|
Some(QueuedInterrupt::ItemStarted(_))
|
|
));
|
|
}
|
|
}
|