mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
53a1f4c29e
## Why Fixes #21894. When the TUI handles an MCP elicitation, the request payload already includes the thread that generated the elicitation. `ChatWidget::handle_elicitation_request_now` was ignoring that value and using the currently visible chat thread instead. In a multi-session TUI, that can send `resolve_elicitation` to an older visible thread rather than the session that owns the pending elicitation, producing `elicitation request not found` and leaving the prompt unresolved. ## What changed - Parse `McpServerElicitationRequestParams.thread_id` in the ChatWidget elicitation handler and use it for app-link, form, fallback approval, and auto-decline resolution paths. - Keep the existing visible-thread fallback only for malformed request payloads with an invalid thread id. - Update the invalid URL elicitation regression test so the visible thread and request thread intentionally differ.
451 lines
18 KiB
Rust
451 lines
18 KiB
Rust
//! Interactive tool request surfaces for `ChatWidget`.
|
|
//!
|
|
//! This module owns approval, permission, elicitation, and user-input prompts
|
|
//! that block on user decisions.
|
|
|
|
use super::*;
|
|
|
|
impl ChatWidget {
|
|
pub(super) fn on_exec_approval_request(&mut self, _id: String, ev: ExecApprovalRequestEvent) {
|
|
let ev2 = ev.clone();
|
|
self.defer_or_handle(
|
|
|q| q.push_exec_approval(ev),
|
|
|s| s.handle_exec_approval_now(ev2),
|
|
);
|
|
}
|
|
|
|
pub(super) fn on_apply_patch_approval_request(
|
|
&mut self,
|
|
_id: String,
|
|
ev: ApplyPatchApprovalRequestEvent,
|
|
) {
|
|
let ev2 = ev.clone();
|
|
self.defer_or_handle(
|
|
|q| q.push_apply_patch_approval(ev),
|
|
|s| s.handle_apply_patch_approval_now(ev2),
|
|
);
|
|
}
|
|
|
|
/// Handle guardian review lifecycle events for the current thread.
|
|
///
|
|
/// In-progress assessments temporarily own the live status footer so the
|
|
/// user can see what is being reviewed, including parallel review
|
|
/// aggregation. Terminal assessments clear or update that footer state and
|
|
/// render the final approved/denied history cell when guardian returns a
|
|
/// decision.
|
|
pub(super) fn on_guardian_assessment(&mut self, ev: GuardianAssessmentEvent) {
|
|
let permission_request_summary = |subject: &str, reason: &Option<String>| {
|
|
reason
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|reason| !reason.is_empty())
|
|
.map(|reason| format!("{subject}: {reason}"))
|
|
.unwrap_or_else(|| subject.to_string())
|
|
};
|
|
let guardian_action_summary = |action: &GuardianAssessmentAction| match action {
|
|
GuardianAssessmentAction::Command { command, .. } => Some(command.clone()),
|
|
GuardianAssessmentAction::Execve { program, argv, .. } => {
|
|
let command = if argv.is_empty() {
|
|
vec![program.clone()]
|
|
} else {
|
|
argv.clone()
|
|
};
|
|
shlex::try_join(command.iter().map(String::as_str))
|
|
.ok()
|
|
.or_else(|| Some(command.join(" ")))
|
|
}
|
|
GuardianAssessmentAction::ApplyPatch { files, .. } => Some(if files.len() == 1 {
|
|
format!("apply_patch touching {}", files[0].display())
|
|
} else {
|
|
format!("apply_patch touching {} files", files.len())
|
|
}),
|
|
GuardianAssessmentAction::NetworkAccess { target, .. } => {
|
|
Some(format!("network access to {target}"))
|
|
}
|
|
GuardianAssessmentAction::McpToolCall {
|
|
server,
|
|
tool_name,
|
|
connector_name,
|
|
..
|
|
} => {
|
|
let label = connector_name.as_deref().unwrap_or(server.as_str());
|
|
Some(format!("MCP {tool_name} on {label}"))
|
|
}
|
|
GuardianAssessmentAction::RequestPermissions { reason, .. } => {
|
|
Some(permission_request_summary("permission request", reason))
|
|
}
|
|
};
|
|
let guardian_command = |action: &GuardianAssessmentAction| match action {
|
|
GuardianAssessmentAction::Command { command, .. } => shlex::split(command)
|
|
.filter(|command| !command.is_empty())
|
|
.or_else(|| Some(vec![command.clone()])),
|
|
GuardianAssessmentAction::Execve { program, argv, .. } => Some(if argv.is_empty() {
|
|
vec![program.clone()]
|
|
} else {
|
|
argv.clone()
|
|
})
|
|
.filter(|command| !command.is_empty()),
|
|
GuardianAssessmentAction::ApplyPatch { .. }
|
|
| GuardianAssessmentAction::NetworkAccess { .. }
|
|
| GuardianAssessmentAction::McpToolCall { .. }
|
|
| GuardianAssessmentAction::RequestPermissions { .. } => None,
|
|
};
|
|
|
|
if ev.status == GuardianAssessmentStatus::InProgress
|
|
&& let Some(detail) = guardian_action_summary(&ev.action)
|
|
{
|
|
// In-progress assessments own the live footer state while the
|
|
// review is pending. Parallel reviews are aggregated into one
|
|
// footer summary by `PendingGuardianReviewStatus`.
|
|
self.bottom_pane.ensure_status_indicator();
|
|
self.bottom_pane
|
|
.set_interrupt_hint_visible(/*visible*/ true);
|
|
self.status_state
|
|
.pending_guardian_review_status
|
|
.start_or_update(ev.id.clone(), detail);
|
|
if let Some(status) = self
|
|
.status_state
|
|
.pending_guardian_review_status
|
|
.status_indicator_state()
|
|
{
|
|
self.set_status(
|
|
status.header,
|
|
status.details,
|
|
StatusDetailsCapitalization::Preserve,
|
|
status.details_max_lines,
|
|
);
|
|
}
|
|
self.request_redraw();
|
|
return;
|
|
}
|
|
|
|
// Terminal assessments remove the matching pending footer entry first,
|
|
// then render the final approved/denied history cell below.
|
|
if self
|
|
.status_state
|
|
.pending_guardian_review_status
|
|
.finish(&ev.id)
|
|
{
|
|
if let Some(status) = self
|
|
.status_state
|
|
.pending_guardian_review_status
|
|
.status_indicator_state()
|
|
{
|
|
self.set_status(
|
|
status.header,
|
|
status.details,
|
|
StatusDetailsCapitalization::Preserve,
|
|
status.details_max_lines,
|
|
);
|
|
} else if self.status_state.current_status.is_guardian_review() {
|
|
self.set_status_header(String::from("Working"));
|
|
}
|
|
} else if self.status_state.pending_guardian_review_status.is_empty()
|
|
&& self.status_state.current_status.is_guardian_review()
|
|
{
|
|
self.set_status_header(String::from("Working"));
|
|
}
|
|
|
|
if ev.status == GuardianAssessmentStatus::Approved {
|
|
let cell = if let Some(command) = guardian_command(&ev.action) {
|
|
history_cell::new_approval_decision_cell(
|
|
history_cell::ApprovalDecisionSubject::Command(command),
|
|
crate::history_cell::ReviewDecision::Approved,
|
|
history_cell::ApprovalDecisionActor::Guardian,
|
|
)
|
|
} else if let Some(summary) = guardian_action_summary(&ev.action) {
|
|
history_cell::new_guardian_approved_action_request(summary)
|
|
} else {
|
|
let summary = serde_json::to_string(&ev.action)
|
|
.unwrap_or_else(|_| "<unrenderable guardian action>".to_string());
|
|
history_cell::new_guardian_approved_action_request(summary)
|
|
};
|
|
|
|
self.add_boxed_history(cell);
|
|
self.request_redraw();
|
|
return;
|
|
}
|
|
|
|
if ev.status == GuardianAssessmentStatus::TimedOut {
|
|
let cell = if let Some(command) = guardian_command(&ev.action) {
|
|
history_cell::new_approval_decision_cell(
|
|
history_cell::ApprovalDecisionSubject::Command(command),
|
|
crate::history_cell::ReviewDecision::TimedOut,
|
|
history_cell::ApprovalDecisionActor::Guardian,
|
|
)
|
|
} else {
|
|
match &ev.action {
|
|
GuardianAssessmentAction::ApplyPatch { files, .. } => {
|
|
let files = files
|
|
.iter()
|
|
.map(|path| path.display().to_string())
|
|
.collect::<Vec<_>>();
|
|
history_cell::new_guardian_timed_out_patch_request(files)
|
|
}
|
|
GuardianAssessmentAction::McpToolCall {
|
|
server, tool_name, ..
|
|
} => history_cell::new_guardian_timed_out_action_request(format!(
|
|
"codex could call MCP tool {server}.{tool_name}"
|
|
)),
|
|
GuardianAssessmentAction::NetworkAccess { target, .. } => {
|
|
history_cell::new_guardian_timed_out_action_request(format!(
|
|
"codex could access {target}"
|
|
))
|
|
}
|
|
GuardianAssessmentAction::RequestPermissions { reason, .. } => {
|
|
history_cell::new_guardian_timed_out_action_request(
|
|
permission_request_summary("codex could request permissions", reason),
|
|
)
|
|
}
|
|
GuardianAssessmentAction::Command { .. } => unreachable!(),
|
|
GuardianAssessmentAction::Execve { .. } => unreachable!(),
|
|
}
|
|
};
|
|
|
|
self.add_boxed_history(cell);
|
|
self.request_redraw();
|
|
return;
|
|
}
|
|
|
|
if ev.status != GuardianAssessmentStatus::Denied {
|
|
return;
|
|
}
|
|
self.review.recent_auto_review_denials.push(ev.clone());
|
|
let cell = if let Some(command) = guardian_command(&ev.action) {
|
|
history_cell::new_approval_decision_cell(
|
|
history_cell::ApprovalDecisionSubject::Command(command),
|
|
crate::history_cell::ReviewDecision::Denied,
|
|
history_cell::ApprovalDecisionActor::Guardian,
|
|
)
|
|
} else {
|
|
match &ev.action {
|
|
GuardianAssessmentAction::ApplyPatch { files, .. } => {
|
|
let files = files
|
|
.iter()
|
|
.map(|path| path.display().to_string())
|
|
.collect::<Vec<_>>();
|
|
history_cell::new_guardian_denied_patch_request(files)
|
|
}
|
|
GuardianAssessmentAction::McpToolCall {
|
|
server, tool_name, ..
|
|
} => history_cell::new_guardian_denied_action_request(format!(
|
|
"codex to call MCP tool {server}.{tool_name}"
|
|
)),
|
|
GuardianAssessmentAction::NetworkAccess { target, .. } => {
|
|
history_cell::new_guardian_denied_action_request(format!(
|
|
"codex to access {target}"
|
|
))
|
|
}
|
|
GuardianAssessmentAction::RequestPermissions { reason, .. } => {
|
|
history_cell::new_guardian_denied_action_request(permission_request_summary(
|
|
"codex to request permissions",
|
|
reason,
|
|
))
|
|
}
|
|
GuardianAssessmentAction::Command { .. } => unreachable!(),
|
|
GuardianAssessmentAction::Execve { .. } => unreachable!(),
|
|
}
|
|
};
|
|
|
|
self.add_boxed_history(cell);
|
|
self.request_redraw();
|
|
}
|
|
|
|
pub(super) fn on_elicitation_request(
|
|
&mut self,
|
|
request_id: AppServerRequestId,
|
|
params: McpServerElicitationRequestParams,
|
|
) {
|
|
let request_id2 = request_id.clone();
|
|
let params2 = params.clone();
|
|
self.defer_or_handle(
|
|
|q| q.push_elicitation(request_id, params),
|
|
|s| s.handle_elicitation_request_now(request_id2, params2),
|
|
);
|
|
}
|
|
|
|
pub(super) fn on_request_user_input(&mut self, ev: ToolRequestUserInputParams) {
|
|
let ev2 = ev.clone();
|
|
self.defer_or_handle(
|
|
|q| q.push_user_input(ev),
|
|
|s| s.handle_request_user_input_now(ev2),
|
|
);
|
|
}
|
|
|
|
pub(super) fn on_request_permissions(&mut self, ev: RequestPermissionsEvent) {
|
|
let ev2 = ev.clone();
|
|
self.defer_or_handle(
|
|
|q| q.push_request_permissions(ev),
|
|
|s| s.handle_request_permissions_now(ev2),
|
|
);
|
|
}
|
|
|
|
pub(crate) fn handle_exec_approval_now(&mut self, ev: ExecApprovalRequestEvent) {
|
|
self.flush_answer_stream_with_separator();
|
|
let command = shlex::try_join(ev.command.iter().map(String::as_str))
|
|
.unwrap_or_else(|_| ev.command.join(" "));
|
|
self.notify(Notification::ExecApprovalRequested { command });
|
|
|
|
let available_decisions = ev.effective_available_decisions();
|
|
let request = ApprovalRequest::Exec {
|
|
thread_id: self.thread_id.unwrap_or_default(),
|
|
thread_label: None,
|
|
id: ev.effective_approval_id(),
|
|
command: ev.command,
|
|
reason: ev.reason,
|
|
available_decisions,
|
|
network_approval_context: ev.network_approval_context,
|
|
additional_permissions: ev.additional_permissions,
|
|
};
|
|
self.bottom_pane
|
|
.push_approval_request(request, &self.config.features);
|
|
self.set_ambient_pet_notification(
|
|
crate::pets::PetNotificationKind::Waiting,
|
|
/*body*/ None,
|
|
);
|
|
self.request_redraw();
|
|
}
|
|
|
|
pub(crate) fn handle_apply_patch_approval_now(&mut self, ev: ApplyPatchApprovalRequestEvent) {
|
|
self.flush_answer_stream_with_separator();
|
|
|
|
let request = ApprovalRequest::ApplyPatch {
|
|
thread_id: self.thread_id.unwrap_or_default(),
|
|
thread_label: None,
|
|
id: ev.call_id,
|
|
reason: ev.reason,
|
|
changes: ev.changes.clone(),
|
|
cwd: self.config.cwd.clone(),
|
|
};
|
|
self.bottom_pane
|
|
.push_approval_request(request, &self.config.features);
|
|
self.set_ambient_pet_notification(
|
|
crate::pets::PetNotificationKind::Waiting,
|
|
/*body*/ None,
|
|
);
|
|
self.request_redraw();
|
|
self.notify(Notification::EditApprovalRequested {
|
|
cwd: self.config.cwd.to_path_buf(),
|
|
changes: ev.changes.keys().cloned().collect(),
|
|
});
|
|
}
|
|
|
|
pub(crate) fn handle_elicitation_request_now(
|
|
&mut self,
|
|
request_id: AppServerRequestId,
|
|
params: McpServerElicitationRequestParams,
|
|
) {
|
|
self.flush_answer_stream_with_separator();
|
|
|
|
self.notify(Notification::ElicitationRequested {
|
|
server_name: params.server_name.clone(),
|
|
});
|
|
|
|
let thread_id = ThreadId::from_string(¶ms.thread_id)
|
|
.unwrap_or_else(|_| self.thread_id.unwrap_or_default());
|
|
if let Some(params) = crate::bottom_pane::AppLinkViewParams::from_url_app_server_request(
|
|
thread_id,
|
|
¶ms.server_name,
|
|
request_id.clone(),
|
|
¶ms.request,
|
|
) {
|
|
self.open_app_link_view(params);
|
|
} else if let Some(request) = McpServerElicitationFormRequest::from_app_server_request(
|
|
thread_id,
|
|
request_id.clone(),
|
|
params.clone(),
|
|
) {
|
|
self.bottom_pane
|
|
.push_mcp_server_elicitation_request(request);
|
|
} else {
|
|
match params.request {
|
|
McpServerElicitationRequest::Form { message, .. } => {
|
|
let request = ApprovalRequest::McpElicitation {
|
|
thread_id,
|
|
thread_label: None,
|
|
server_name: params.server_name,
|
|
request_id,
|
|
message,
|
|
};
|
|
self.bottom_pane
|
|
.push_approval_request(request, &self.config.features);
|
|
}
|
|
McpServerElicitationRequest::Url { .. } => {
|
|
self.app_event_tx.resolve_elicitation(
|
|
thread_id,
|
|
params.server_name,
|
|
request_id,
|
|
codex_app_server_protocol::McpServerElicitationAction::Decline,
|
|
/*content*/ None,
|
|
/*meta*/ None,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
self.set_ambient_pet_notification(
|
|
crate::pets::PetNotificationKind::Waiting,
|
|
/*body*/ None,
|
|
);
|
|
self.request_redraw();
|
|
}
|
|
|
|
pub(crate) fn push_approval_request(&mut self, request: ApprovalRequest) {
|
|
self.bottom_pane
|
|
.push_approval_request(request, &self.config.features);
|
|
self.set_ambient_pet_notification(
|
|
crate::pets::PetNotificationKind::Waiting,
|
|
/*body*/ None,
|
|
);
|
|
self.request_redraw();
|
|
}
|
|
|
|
pub(crate) fn push_mcp_server_elicitation_request(
|
|
&mut self,
|
|
request: McpServerElicitationFormRequest,
|
|
) {
|
|
self.bottom_pane
|
|
.push_mcp_server_elicitation_request(request);
|
|
self.set_ambient_pet_notification(
|
|
crate::pets::PetNotificationKind::Waiting,
|
|
/*body*/ None,
|
|
);
|
|
self.request_redraw();
|
|
}
|
|
|
|
pub(crate) fn handle_request_user_input_now(&mut self, ev: ToolRequestUserInputParams) {
|
|
self.flush_answer_stream_with_separator();
|
|
let question_count = ev.questions.len();
|
|
let summary = Notification::user_input_request_summary(&ev.questions);
|
|
let title = match (question_count, summary.as_deref()) {
|
|
(1, Some(summary)) => summary.to_string(),
|
|
(1, None) => "Question requested".to_string(),
|
|
(count, _) => format!("{count} questions requested"),
|
|
};
|
|
self.notify(Notification::PlanModePrompt { title });
|
|
self.bottom_pane.push_user_input_request(ev);
|
|
self.set_ambient_pet_notification(
|
|
crate::pets::PetNotificationKind::Waiting,
|
|
/*body*/ None,
|
|
);
|
|
self.request_redraw();
|
|
}
|
|
|
|
pub(crate) fn handle_request_permissions_now(&mut self, ev: RequestPermissionsEvent) {
|
|
self.flush_answer_stream_with_separator();
|
|
let request = ApprovalRequest::Permissions {
|
|
thread_id: self.thread_id.unwrap_or_default(),
|
|
thread_label: None,
|
|
call_id: ev.call_id,
|
|
reason: ev.reason,
|
|
permissions: ev.permissions,
|
|
};
|
|
self.bottom_pane
|
|
.push_approval_request(request, &self.config.features);
|
|
self.set_ambient_pet_notification(
|
|
crate::pets::PetNotificationKind::Waiting,
|
|
/*body*/ None,
|
|
);
|
|
self.request_redraw();
|
|
}
|
|
}
|