mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Route extension warnings to app-server threads (#35168)
## Why Extension warnings need a stable thread target even when they are emitted outside an active turn. ## What changed - Add `ExtensionWarning` and a dedicated `ExtensionEventSink::emit_warning` path with an explicit thread ID and optional turn ID. - Deliver extension warnings as thread-scoped app-server `warning` notifications, preserving listener ordering and waiting briefly for a subscriber when no listener is active. - Move skills warnings to the new path, limiting provider catalog warnings to four messages of 256 bytes each. ## Testing - Cover warning routing, ordering, UTF-8-safe truncation, subscriber fallback, and invalid thread IDs. - Verify skills warning bounds and app-server delivery of executor catalog budget warnings. GitOrigin-RevId: 4dcb1ba7f17c0edb1a9d548f9568e0c734bffc15
This commit is contained in:
1 parent
c8957bbf0f
commit
5dd992acd3
15 files changed
+592
-65
No files matched your search
@@ -1,10 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::Weak;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ThreadGoal;
|
||||
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
|
||||
use codex_app_server_protocol::WarningNotification;
|
||||
use codex_core::NewThread;
|
||||
use codex_core::StartThreadOptions;
|
||||
use codex_core::ThreadManager;
|
||||
@@ -15,6 +17,7 @@ use codex_extension_api::AgentSpawner;
|
||||
use codex_extension_api::ExtensionEventSink;
|
||||
use codex_extension_api::ExtensionRegistry;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_extension_api::ExtensionWarning;
|
||||
use codex_goal_extension::GoalService;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_login::AuthManager;
|
||||
@@ -26,6 +29,7 @@ use codex_rollout::state_db::StateDbHandle;
|
||||
use codex_thread_store::ThreadStore;
|
||||
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
|
||||
use crate::thread_state::ThreadListenerCommand;
|
||||
use crate::thread_state::ThreadStateManager;
|
||||
|
||||
@@ -122,11 +126,36 @@ pub(crate) fn app_server_extension_event_sink(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn send_thread_warning(
|
||||
outgoing: &Arc<OutgoingMessageSender>,
|
||||
thread_state_manager: &ThreadStateManager,
|
||||
thread_id: ThreadId,
|
||||
message: String,
|
||||
) {
|
||||
let subscribed_connection_ids = thread_state_manager
|
||||
.subscribed_connection_ids(thread_id)
|
||||
.await;
|
||||
let thread_outgoing = ThreadScopedOutgoingMessageSender::new(
|
||||
Arc::clone(outgoing),
|
||||
subscribed_connection_ids,
|
||||
thread_id,
|
||||
);
|
||||
thread_outgoing
|
||||
.send_server_notification(ServerNotification::Warning(WarningNotification {
|
||||
thread_id: Some(thread_id.to_string()),
|
||||
message,
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
struct AppServerExtensionEventSink {
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
thread_state_manager: ThreadStateManager,
|
||||
}
|
||||
|
||||
const MAX_EXTENSION_WARNING_BYTES: usize = 256;
|
||||
const EXTENSION_WARNING_SUBSCRIBER_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
impl ExtensionEventSink for AppServerExtensionEventSink {
|
||||
fn emit(&self, event: Event) {
|
||||
match event.msg {
|
||||
@@ -167,6 +196,62 @@ impl ExtensionEventSink for AppServerExtensionEventSink {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_warning(&self, warning: ExtensionWarning) {
|
||||
let ExtensionWarning {
|
||||
thread_id,
|
||||
turn_id: _,
|
||||
message,
|
||||
} = warning;
|
||||
let Ok(thread_id) = ThreadId::from_string(&thread_id) else {
|
||||
tracing::warn!(
|
||||
%thread_id,
|
||||
"dropping extension warning with invalid thread id"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let mut message = message;
|
||||
if message.len() > MAX_EXTENSION_WARNING_BYTES {
|
||||
let mut truncate_at = MAX_EXTENSION_WARNING_BYTES;
|
||||
while !message.is_char_boundary(truncate_at) {
|
||||
truncate_at -= 1;
|
||||
}
|
||||
message.truncate(truncate_at);
|
||||
}
|
||||
if let Some(listener_command_tx) = self
|
||||
.thread_state_manager
|
||||
.current_listener_command_tx(thread_id)
|
||||
{
|
||||
let command = ThreadListenerCommand::EmitWarning {
|
||||
message: message.clone(),
|
||||
};
|
||||
if listener_command_tx.send(command).is_ok() {
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
"failed to enqueue extension warning for {thread_id}: listener command channel is closed"
|
||||
);
|
||||
}
|
||||
let outgoing = Arc::clone(&self.outgoing);
|
||||
let thread_state_manager = self.thread_state_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
if tokio::time::timeout(
|
||||
EXTENSION_WARNING_SUBSCRIBER_TIMEOUT,
|
||||
thread_state_manager.wait_for_thread_subscriber(thread_id),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
%thread_id,
|
||||
timeout_secs = EXTENSION_WARNING_SUBSCRIBER_TIMEOUT.as_secs(),
|
||||
"dropping extension warning after waiting for a thread subscriber"
|
||||
);
|
||||
return;
|
||||
}
|
||||
send_thread_warning(&outgoing, &thread_state_manager, thread_id, message).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn guardian_agent_spawner(
|
||||
@@ -189,8 +274,6 @@ pub(crate) fn guardian_agent_spawner(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_protocol::protocol::ThreadGoal as CoreThreadGoal;
|
||||
use codex_protocol::protocol::ThreadGoalStatus;
|
||||
use codex_protocol::protocol::ThreadGoalUpdatedEvent;
|
||||
@@ -198,10 +281,15 @@ mod tests {
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::outgoing_message::ConnectionId;
|
||||
use crate::outgoing_message::OutgoingEnvelope;
|
||||
use crate::outgoing_message::OutgoingMessage;
|
||||
use crate::thread_state::ConnectionCapabilities;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_server_event_sink_uses_listener_fifo_for_goal_updates_and_clears() {
|
||||
async fn app_server_event_sink_uses_listener_fifo_for_goal_updates_warnings_and_clears() {
|
||||
let (outgoing_tx, _outgoing_rx) = mpsc::channel(4);
|
||||
let outgoing = Arc::new(OutgoingMessageSender::new(
|
||||
outgoing_tx,
|
||||
@@ -213,15 +301,19 @@ mod tests {
|
||||
thread_state_manager.register_listener_command_tx(thread_id, listener_command_tx.clone());
|
||||
let sink = app_server_extension_event_sink(outgoing, thread_state_manager);
|
||||
|
||||
for turn_id in ["turn-1", "turn-2"] {
|
||||
sink.emit(thread_goal_updated_event(thread_id, turn_id));
|
||||
}
|
||||
sink.emit(thread_goal_updated_event(thread_id, "turn-1"));
|
||||
sink.emit_warning(ExtensionWarning {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: Some("turn-warning".to_string()),
|
||||
message: "catalog was shortened".to_string(),
|
||||
});
|
||||
sink.emit(thread_goal_updated_event(thread_id, "turn-2"));
|
||||
listener_command_tx
|
||||
.send(ThreadListenerCommand::EmitThreadGoalCleared)
|
||||
.expect("listener command channel should be open");
|
||||
|
||||
let mut observed = Vec::new();
|
||||
for _ in 0..3 {
|
||||
for _ in 0..4 {
|
||||
let command = timeout(Duration::from_secs(1), listener_command_rx.recv())
|
||||
.await
|
||||
.expect("timed out waiting for listener command")
|
||||
@@ -230,6 +322,7 @@ mod tests {
|
||||
ThreadListenerCommand::EmitThreadGoalUpdated { turn_id, .. } => {
|
||||
observed.push(turn_id.expect("extension goal updates should include turn ids"));
|
||||
}
|
||||
ThreadListenerCommand::EmitWarning { message } => observed.push(message),
|
||||
ThreadListenerCommand::EmitThreadGoalCleared => {
|
||||
observed.push("cleared".to_string())
|
||||
}
|
||||
@@ -240,6 +333,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
vec![
|
||||
"turn-1".to_string(),
|
||||
"catalog was shortened".to_string(),
|
||||
"turn-2".to_string(),
|
||||
"cleared".to_string()
|
||||
],
|
||||
@@ -247,6 +341,233 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_server_event_sink_truncates_warning_before_listener_enqueue() {
|
||||
let (outgoing_tx, _outgoing_rx) = mpsc::channel(4);
|
||||
let outgoing = Arc::new(OutgoingMessageSender::new(
|
||||
outgoing_tx,
|
||||
AnalyticsEventsClient::disabled(),
|
||||
));
|
||||
let thread_state_manager = ThreadStateManager::new();
|
||||
let thread_id = ThreadId::default();
|
||||
let (listener_command_tx, mut listener_command_rx) = mpsc::unbounded_channel();
|
||||
thread_state_manager.register_listener_command_tx(thread_id, listener_command_tx);
|
||||
let sink = app_server_extension_event_sink(outgoing, thread_state_manager);
|
||||
|
||||
sink.emit_warning(ExtensionWarning {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: Some("turn-warning".to_string()),
|
||||
message: "🙂".repeat(65),
|
||||
});
|
||||
|
||||
let command = timeout(Duration::from_secs(1), listener_command_rx.recv())
|
||||
.await
|
||||
.expect("timed out waiting for listener command")
|
||||
.expect("listener command channel closed unexpectedly");
|
||||
let ThreadListenerCommand::EmitWarning { message } = command else {
|
||||
panic!("expected warning listener command");
|
||||
};
|
||||
assert_eq!(message, "🙂".repeat(64));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_server_event_sink_targets_subscriber_without_listener() {
|
||||
let (outgoing_tx, mut outgoing_rx) = mpsc::channel(4);
|
||||
let outgoing = Arc::new(OutgoingMessageSender::new(
|
||||
outgoing_tx,
|
||||
AnalyticsEventsClient::disabled(),
|
||||
));
|
||||
let thread_id = ThreadId::new();
|
||||
let subscribed_connection = ConnectionId(1);
|
||||
let unrelated_connection = ConnectionId(2);
|
||||
let thread_state_manager = ThreadStateManager::new();
|
||||
for connection_id in [subscribed_connection, unrelated_connection] {
|
||||
thread_state_manager
|
||||
.connection_initialized(connection_id, ConnectionCapabilities::default())
|
||||
.await;
|
||||
}
|
||||
thread_state_manager
|
||||
.try_ensure_connection_subscribed(
|
||||
thread_id,
|
||||
subscribed_connection,
|
||||
/*experimental_raw_events*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("connection should be subscribed");
|
||||
let sink = app_server_extension_event_sink(outgoing, thread_state_manager);
|
||||
|
||||
sink.emit_warning(ExtensionWarning {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
message: "catalog was shortened".to_string(),
|
||||
});
|
||||
|
||||
let envelope = timeout(Duration::from_secs(1), outgoing_rx.recv())
|
||||
.await
|
||||
.expect("timed out waiting for warning notification")
|
||||
.expect("outgoing channel closed unexpectedly");
|
||||
let OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message,
|
||||
write_complete_tx: _,
|
||||
} = envelope
|
||||
else {
|
||||
panic!("expected connection-targeted warning notification");
|
||||
};
|
||||
assert_eq!(connection_id, subscribed_connection);
|
||||
let OutgoingMessage::AppServerNotification(envelope) = message else {
|
||||
panic!("expected app-server warning notification");
|
||||
};
|
||||
let ServerNotification::Warning(notification) = envelope.notification else {
|
||||
panic!("expected warning notification");
|
||||
};
|
||||
assert_eq!(
|
||||
notification,
|
||||
WarningNotification {
|
||||
thread_id: Some(thread_id.to_string()),
|
||||
message: "catalog was shortened".to_string(),
|
||||
}
|
||||
);
|
||||
assert!(outgoing_rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_server_event_sink_waits_for_subscriber_without_listener() {
|
||||
let (outgoing_tx, mut outgoing_rx) = mpsc::channel(4);
|
||||
let outgoing = Arc::new(OutgoingMessageSender::new(
|
||||
outgoing_tx,
|
||||
AnalyticsEventsClient::disabled(),
|
||||
));
|
||||
let thread_id = ThreadId::new();
|
||||
let subscribed_connection = ConnectionId(1);
|
||||
let thread_state_manager = ThreadStateManager::new();
|
||||
thread_state_manager
|
||||
.connection_initialized(subscribed_connection, ConnectionCapabilities::default())
|
||||
.await;
|
||||
let sink = app_server_extension_event_sink(outgoing, thread_state_manager.clone());
|
||||
|
||||
sink.emit_warning(ExtensionWarning {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
message: "catalog was shortened".to_string(),
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
thread_state_manager
|
||||
.try_ensure_connection_subscribed(
|
||||
thread_id,
|
||||
subscribed_connection,
|
||||
/*experimental_raw_events*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("connection should be subscribed");
|
||||
|
||||
let envelope = timeout(Duration::from_secs(1), outgoing_rx.recv())
|
||||
.await
|
||||
.expect("timed out waiting for warning notification")
|
||||
.expect("outgoing channel closed unexpectedly");
|
||||
let OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message,
|
||||
write_complete_tx: _,
|
||||
} = envelope
|
||||
else {
|
||||
panic!("expected connection-targeted warning notification");
|
||||
};
|
||||
assert_eq!(connection_id, subscribed_connection);
|
||||
let OutgoingMessage::AppServerNotification(envelope) = message else {
|
||||
panic!("expected app-server warning notification");
|
||||
};
|
||||
let ServerNotification::Warning(notification) = envelope.notification else {
|
||||
panic!("expected warning notification");
|
||||
};
|
||||
assert_eq!(
|
||||
notification,
|
||||
WarningNotification {
|
||||
thread_id: Some(thread_id.to_string()),
|
||||
message: "catalog was shortened".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_server_event_sink_targets_subscriber_after_listener_closes() {
|
||||
let (outgoing_tx, mut outgoing_rx) = mpsc::channel(4);
|
||||
let outgoing = Arc::new(OutgoingMessageSender::new(
|
||||
outgoing_tx,
|
||||
AnalyticsEventsClient::disabled(),
|
||||
));
|
||||
let thread_id = ThreadId::new();
|
||||
let subscribed_connection = ConnectionId(1);
|
||||
let thread_state_manager = ThreadStateManager::new();
|
||||
thread_state_manager
|
||||
.connection_initialized(subscribed_connection, ConnectionCapabilities::default())
|
||||
.await;
|
||||
thread_state_manager
|
||||
.try_ensure_connection_subscribed(
|
||||
thread_id,
|
||||
subscribed_connection,
|
||||
/*experimental_raw_events*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("connection should be subscribed");
|
||||
let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel();
|
||||
drop(listener_command_rx);
|
||||
thread_state_manager.register_listener_command_tx(thread_id, listener_command_tx);
|
||||
let sink = app_server_extension_event_sink(outgoing, thread_state_manager);
|
||||
|
||||
sink.emit_warning(ExtensionWarning {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
message: "catalog was shortened".to_string(),
|
||||
});
|
||||
|
||||
let envelope = timeout(Duration::from_secs(1), outgoing_rx.recv())
|
||||
.await
|
||||
.expect("timed out waiting for warning notification")
|
||||
.expect("outgoing channel closed unexpectedly");
|
||||
let OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message,
|
||||
write_complete_tx: _,
|
||||
} = envelope
|
||||
else {
|
||||
panic!("expected connection-targeted warning notification");
|
||||
};
|
||||
assert_eq!(connection_id, subscribed_connection);
|
||||
let OutgoingMessage::AppServerNotification(envelope) = message else {
|
||||
panic!("expected app-server warning notification");
|
||||
};
|
||||
let ServerNotification::Warning(notification) = envelope.notification else {
|
||||
panic!("expected warning notification");
|
||||
};
|
||||
assert_eq!(
|
||||
notification,
|
||||
WarningNotification {
|
||||
thread_id: Some(thread_id.to_string()),
|
||||
message: "catalog was shortened".to_string(),
|
||||
}
|
||||
);
|
||||
assert!(outgoing_rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_server_event_sink_drops_warning_with_invalid_thread_id() {
|
||||
let (outgoing_tx, mut outgoing_rx) = mpsc::channel(4);
|
||||
let outgoing = Arc::new(OutgoingMessageSender::new(
|
||||
outgoing_tx,
|
||||
AnalyticsEventsClient::disabled(),
|
||||
));
|
||||
let sink = app_server_extension_event_sink(outgoing, ThreadStateManager::new());
|
||||
|
||||
sink.emit_warning(ExtensionWarning {
|
||||
thread_id: "not-a-thread-id".to_string(),
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
message: "catalog was shortened".to_string(),
|
||||
});
|
||||
|
||||
assert!(outgoing_rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
fn thread_goal_updated_event(thread_id: ThreadId, turn_id: &str) -> Event {
|
||||
Event {
|
||||
id: turn_id.to_string(),
|
||||
|
||||
Reference in new issue
Block a user