mirror of
https://github.com/openai/codex.git
synced 2026-11-13 22:47:20 +00:00
## What changed - Add experimental `mcpServer/event/stream/start` and `mcpServer/event/stream/stop` requests for hosted apps, plus `mcpServer/event/stream/notification` forwarding. - Scope subscriptions to the owning app-server connection and subscribed thread, enforce unique IDs and a per-connection limit, and clean them up when the thread is unsubscribed or the connection closes. - Wait for the MCP active notification before completing startup, retry streams that close immediately, and terminate them when authentication or hosted runtime ownership changes. ## Testing - Add an app-server integration test covering activation, event forwarding, duplicate subscription rejection, and explicit cancellation. GitOrigin-RevId: 8a6fc1615adfc5af7e67def3b824fa5909ab3e7b
343 lines
15 KiB
Rust
343 lines
15 KiB
Rust
//! Thread targeting helpers for app-server requests and notifications.
|
|
|
|
use codex_app_server_protocol::ServerNotification;
|
|
use codex_app_server_protocol::ServerRequest;
|
|
use codex_protocol::ThreadId;
|
|
|
|
pub(super) fn server_request_thread_id(request: &ServerRequest) -> Option<ThreadId> {
|
|
match request {
|
|
ServerRequest::CommandExecutionRequestApproval { params, .. } => {
|
|
ThreadId::from_string(¶ms.thread_id).ok()
|
|
}
|
|
ServerRequest::FileChangeRequestApproval { params, .. } => {
|
|
ThreadId::from_string(¶ms.thread_id).ok()
|
|
}
|
|
ServerRequest::ToolRequestUserInput { params, .. } => {
|
|
ThreadId::from_string(¶ms.thread_id).ok()
|
|
}
|
|
ServerRequest::McpServerElicitationRequest { params, .. } => {
|
|
ThreadId::from_string(¶ms.thread_id).ok()
|
|
}
|
|
ServerRequest::PermissionsRequestApproval { params, .. } => {
|
|
ThreadId::from_string(¶ms.thread_id).ok()
|
|
}
|
|
ServerRequest::DynamicToolCall { params, .. } => {
|
|
ThreadId::from_string(¶ms.thread_id).ok()
|
|
}
|
|
ServerRequest::CurrentTimeRead { params, .. } => {
|
|
ThreadId::from_string(¶ms.thread_id).ok()
|
|
}
|
|
ServerRequest::ChatgptAuthTokensRefresh { .. }
|
|
| ServerRequest::AttestationGenerate { .. }
|
|
| ServerRequest::ApplyPatchApproval { .. }
|
|
| ServerRequest::ExecCommandApproval { .. } => None,
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub(super) enum ServerNotificationThreadTarget {
|
|
Thread(ThreadId),
|
|
InvalidThreadId(String),
|
|
AppScoped,
|
|
Global,
|
|
}
|
|
|
|
pub(super) fn server_notification_thread_target(
|
|
notification: &ServerNotification,
|
|
) -> ServerNotificationThreadTarget {
|
|
let thread_id = match notification {
|
|
ServerNotification::Error(notification) => Some(notification.thread_id.as_str()),
|
|
ServerNotification::ThreadStarted(notification) => Some(notification.thread.id.as_str()),
|
|
ServerNotification::ThreadStatusChanged(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ThreadReverted(notification) => Some(notification.thread_id.as_str()),
|
|
ServerNotification::ThreadArchived(notification) => Some(notification.thread_id.as_str()),
|
|
ServerNotification::ThreadDeleted(notification) => Some(notification.thread_id.as_str()),
|
|
ServerNotification::ThreadUnarchived(notification) => Some(notification.thread_id.as_str()),
|
|
ServerNotification::ThreadClosed(notification) => Some(notification.thread_id.as_str()),
|
|
ServerNotification::ThreadNameUpdated(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ThreadProjectUpdated(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ThreadTokenUsageUpdated(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ThreadGoalUpdated(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ThreadGoalCleared(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ThreadQueueChanged(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ThreadSettingsUpdated(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::TurnStarted(notification) => Some(notification.thread_id.as_str()),
|
|
ServerNotification::HookStarted(notification) => Some(notification.thread_id.as_str()),
|
|
ServerNotification::TurnCompleted(notification) => Some(notification.thread_id.as_str()),
|
|
ServerNotification::HookCompleted(notification) => Some(notification.thread_id.as_str()),
|
|
ServerNotification::TurnDiffUpdated(notification) => Some(notification.thread_id.as_str()),
|
|
ServerNotification::TurnPlanUpdated(notification) => Some(notification.thread_id.as_str()),
|
|
ServerNotification::ItemStarted(notification) => Some(notification.thread_id.as_str()),
|
|
ServerNotification::ItemGuardianApprovalReviewStarted(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ItemGuardianApprovalReviewCompleted(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::StrictReviewRequired(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ItemCompleted(notification) => Some(notification.thread_id.as_str()),
|
|
ServerNotification::RawResponseItemCompleted(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::RawResponseCompleted(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::AgentMessageDelta(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::PlanDelta(notification) => Some(notification.thread_id.as_str()),
|
|
ServerNotification::CommandExecutionOutputDelta(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::TerminalInteraction(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::FileChangeOutputDelta(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::FileChangePatchUpdated(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ServerRequestResolved(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::McpToolCallProgress(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ReasoningSummaryTextDelta(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ReasoningSummaryPartAdded(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ReasoningTextDelta(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ContextCompacted(notification) => Some(notification.thread_id.as_str()),
|
|
ServerNotification::ModelRerouted(notification) => Some(notification.thread_id.as_str()),
|
|
ServerNotification::ModelVerification(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ModelSafetyBufferingUpdated(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::TurnModerationMetadata(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ThreadRealtimeStarted(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ThreadRealtimeItemAdded(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ThreadRealtimeTranscriptDelta(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ThreadRealtimeTranscriptDone(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ThreadRealtimeSdp(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ThreadRealtimeError(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ThreadRealtimeClosed(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::Warning(notification) => notification.thread_id.as_deref(),
|
|
ServerNotification::GuardianWarning(notification) => Some(notification.thread_id.as_str()),
|
|
ServerNotification::McpServerStatusUpdated(notification) => {
|
|
match notification.thread_id.as_deref() {
|
|
Some(thread_id) => Some(thread_id),
|
|
None => return ServerNotificationThreadTarget::AppScoped,
|
|
}
|
|
}
|
|
ServerNotification::ProjectChanged(_)
|
|
| ServerNotification::SkillsChanged(_)
|
|
| ServerNotification::McpServerOauthLoginCompleted(_)
|
|
| ServerNotification::AccountUpdated(_)
|
|
| ServerNotification::AccountRateLimitsUpdated(_)
|
|
| ServerNotification::AppListUpdated(_)
|
|
| ServerNotification::EnvironmentConnected(_)
|
|
| ServerNotification::EnvironmentDisconnected(_)
|
|
| ServerNotification::RemoteControlStatusChanged(_)
|
|
| ServerNotification::ExternalAgentConfigImportProgress(_)
|
|
| ServerNotification::ExternalAgentConfigImportCompleted(_)
|
|
| ServerNotification::DeprecationNotice(_)
|
|
| ServerNotification::ConfigWarning(_)
|
|
| ServerNotification::FuzzyFileSearchSessionUpdated(_)
|
|
| ServerNotification::FuzzyFileSearchSessionCompleted(_)
|
|
| ServerNotification::CommandExecOutputDelta(_)
|
|
| ServerNotification::ProcessOutputDelta(_)
|
|
| ServerNotification::ProcessExited(_)
|
|
| ServerNotification::McpServerEventStream(_)
|
|
| ServerNotification::FsChanged(_)
|
|
| ServerNotification::WindowsWorldWritableWarning(_)
|
|
| ServerNotification::WindowsSandboxSetupCompleted(_)
|
|
| ServerNotification::AccountLoginCompleted(_) => None,
|
|
};
|
|
|
|
match thread_id {
|
|
Some(thread_id) => match ThreadId::from_string(thread_id) {
|
|
Ok(thread_id) => ServerNotificationThreadTarget::Thread(thread_id),
|
|
Err(_) => ServerNotificationThreadTarget::InvalidThreadId(thread_id.to_string()),
|
|
},
|
|
None => ServerNotificationThreadTarget::Global,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::ServerNotificationThreadTarget;
|
|
use super::server_notification_thread_target;
|
|
use crate::test_support::PathBufExt;
|
|
use crate::test_support::test_path_buf;
|
|
use codex_app_server_protocol::GuardianWarningNotification;
|
|
use codex_app_server_protocol::McpServerStartupState;
|
|
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
|
|
use codex_app_server_protocol::ServerNotification;
|
|
use codex_app_server_protocol::ThreadSettings;
|
|
use codex_app_server_protocol::ThreadSettingsUpdatedNotification;
|
|
use codex_app_server_protocol::WarningNotification;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::config_types::CollaborationMode;
|
|
use codex_protocol::config_types::ModeKind;
|
|
use codex_protocol::config_types::Settings;
|
|
use codex_protocol::openai_models::ReasoningEffort;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
fn test_thread_settings() -> ThreadSettings {
|
|
ThreadSettings {
|
|
cwd: test_path_buf("/tmp/thread-settings").abs(),
|
|
approval_policy: codex_app_server_protocol::AskForApproval::Never,
|
|
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User,
|
|
sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly {
|
|
network_access: false,
|
|
},
|
|
active_permission_profile: None,
|
|
model: "gpt-5.4".to_string(),
|
|
model_provider: "openai".to_string(),
|
|
service_tier: None,
|
|
effort: Some(ReasoningEffort::High),
|
|
summary: None,
|
|
collaboration_mode: CollaborationMode {
|
|
mode: ModeKind::Default,
|
|
settings: Settings {
|
|
model: "gpt-5.4".to_string(),
|
|
reasoning_effort: Some(ReasoningEffort::High),
|
|
developer_instructions: None,
|
|
},
|
|
},
|
|
multi_agent_mode: Default::default(),
|
|
personality: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn warning_notifications_without_threads_are_global() {
|
|
let notification = ServerNotification::Warning(WarningNotification {
|
|
thread_id: None,
|
|
message: "warning".to_string(),
|
|
});
|
|
|
|
let target = server_notification_thread_target(¬ification);
|
|
|
|
assert_eq!(target, ServerNotificationThreadTarget::Global);
|
|
}
|
|
|
|
#[test]
|
|
fn warning_notifications_route_to_threads_when_thread_id_is_present() {
|
|
let thread_id = ThreadId::new();
|
|
let notification = ServerNotification::Warning(WarningNotification {
|
|
thread_id: Some(thread_id.to_string()),
|
|
message: "warning".to_string(),
|
|
});
|
|
|
|
let target = server_notification_thread_target(¬ification);
|
|
|
|
assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id));
|
|
}
|
|
|
|
#[test]
|
|
fn guardian_warning_notifications_route_to_threads() {
|
|
let thread_id = ThreadId::new();
|
|
let notification = ServerNotification::GuardianWarning(GuardianWarningNotification {
|
|
thread_id: thread_id.to_string(),
|
|
message: "warning".to_string(),
|
|
});
|
|
|
|
let target = server_notification_thread_target(¬ification);
|
|
|
|
assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id));
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_startup_notifications_route_to_threads() {
|
|
let thread_id = ThreadId::new();
|
|
let notification =
|
|
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
|
thread_id: Some(thread_id.to_string()),
|
|
name: "sentry".to_string(),
|
|
status: McpServerStartupState::Failed,
|
|
error: Some("sentry is not logged in".to_string()),
|
|
failure_reason: None,
|
|
});
|
|
|
|
let target = server_notification_thread_target(¬ification);
|
|
|
|
assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id));
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_startup_notifications_without_threads_are_app_scoped() {
|
|
let notification =
|
|
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
|
thread_id: None,
|
|
name: "sentry".to_string(),
|
|
status: McpServerStartupState::Failed,
|
|
error: Some("sentry is not logged in".to_string()),
|
|
failure_reason: None,
|
|
});
|
|
|
|
let target = server_notification_thread_target(¬ification);
|
|
|
|
assert_eq!(target, ServerNotificationThreadTarget::AppScoped);
|
|
}
|
|
|
|
#[test]
|
|
fn thread_settings_updated_notifications_route_to_threads() {
|
|
let thread_id = ThreadId::new();
|
|
let notification =
|
|
ServerNotification::ThreadSettingsUpdated(ThreadSettingsUpdatedNotification {
|
|
thread_id: thread_id.to_string(),
|
|
thread_settings: test_thread_settings(),
|
|
});
|
|
|
|
let target = server_notification_thread_target(¬ification);
|
|
|
|
assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id));
|
|
}
|
|
}
|