mirror of
https://github.com/openai/codex.git
synced 2026-11-11 14:32:25 +00:00
## What changed - Add `thread/attachment/add`, `thread/attachment/list`, and `thread/attachment/remove` to manage durable resource references without loading the owning thread or changing conversation history. - Identify attachments by thread, `attachmentType`, and `identityKey`. Repeated adds return the existing attachment; repeated removals succeed without emitting another update. Listing supports cursor pagination. - Broadcast `thread/attachment/updated` after creation or deletion, after responding to the requester. Serialize attachment mutations with thread lifecycle operations and reject stores that do not support attachments. - Update protocol schemas, TypeScript and Python bindings, documentation, and TUI notification handling so attachment updates do not add untracked threads to the agent picker. ## Testing Add coverage for unloaded threads, pagination, idempotent addition and removal, reattachment, invalid inputs, unsupported stores, response-before-notification ordering across clients, and TUI notification routing. GitOrigin-RevId: 0d29cf903afd4e931a70a9cf96300475213b34aa
378 lines
16 KiB
Rust
378 lines
16 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::ThreadAttachmentUpdated(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::ThreadRealtimeItemStarted(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ThreadRealtimeItemTranscriptDelta(notification) => {
|
|
Some(notification.thread_id.as_str())
|
|
}
|
|
ServerNotification::ThreadRealtimeItemCompleted(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::AuthRecoveryStarted(notification)
|
|
| ServerNotification::AuthRecoveryCompleted(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::ThreadAttachmentOperation;
|
|
use codex_app_server_protocol::ThreadAttachmentUpdatedNotification;
|
|
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));
|
|
}
|
|
|
|
#[test]
|
|
fn thread_attachment_updated_notifications_route_to_threads() {
|
|
let thread_id = ThreadId::new();
|
|
let notification =
|
|
ServerNotification::ThreadAttachmentUpdated(ThreadAttachmentUpdatedNotification {
|
|
thread_id: thread_id.to_string(),
|
|
attachment_type: "pull_request".to_string(),
|
|
identity_key: r#"["github.com","openai","codex",123]"#.to_string(),
|
|
attachment_id: "attachment-1".to_string(),
|
|
operation: ThreadAttachmentOperation::Deleted,
|
|
});
|
|
|
|
let target = server_notification_thread_target(¬ification);
|
|
|
|
assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id));
|
|
}
|
|
}
|