mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
Notify clients when thread skills change
This commit is contained in:
@@ -3453,7 +3453,8 @@
|
||||
"type": "object"
|
||||
},
|
||||
"SkillsChangedNotification": {
|
||||
"description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.",
|
||||
"description": "Notification emitted when the available skills catalog changes.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.",
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
},
|
||||
"SpendControlLimitSnapshot": {
|
||||
|
||||
+2
-1
@@ -16758,7 +16758,8 @@
|
||||
},
|
||||
"SkillsChangedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.",
|
||||
"description": "Notification emitted when the available skills catalog changes.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.",
|
||||
"properties": {},
|
||||
"title": "SkillsChangedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
+2
-1
@@ -14537,7 +14537,8 @@
|
||||
},
|
||||
"SkillsChangedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.",
|
||||
"description": "Notification emitted when the available skills catalog changes.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.",
|
||||
"properties": {},
|
||||
"title": "SkillsChangedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.",
|
||||
"description": "Notification emitted when the available skills catalog changes.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.",
|
||||
"properties": {},
|
||||
"title": "SkillsChangedNotification",
|
||||
"type": "object"
|
||||
}
|
||||
+2
-2
@@ -3,9 +3,9 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Notification emitted when watched local skill files change.
|
||||
* Notification emitted when the available skills catalog changes.
|
||||
*
|
||||
* Treat this as an invalidation signal and re-run `skills/list` with the
|
||||
* client's current parameters when refreshed skill metadata is needed.
|
||||
*/
|
||||
export type SkillsChangedNotification = Record<string, never>;
|
||||
export type SkillsChangedNotification = {};
|
||||
|
||||
@@ -1620,6 +1620,7 @@ server_notification_definitions! {
|
||||
ThreadDeleted => "thread/deleted" (v2::ThreadDeletedNotification),
|
||||
ThreadUnarchived => "thread/unarchived" (v2::ThreadUnarchivedNotification),
|
||||
ThreadClosed => "thread/closed" (v2::ThreadClosedNotification),
|
||||
#[experimental(nested)]
|
||||
SkillsChanged => "skills/changed" (v2::SkillsChangedNotification),
|
||||
ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification),
|
||||
ThreadGoalUpdated => "thread/goal/updated" (v2::ThreadGoalUpdatedNotification),
|
||||
|
||||
@@ -915,11 +915,15 @@ impl From<CoreSkillScope> for SkillScope {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
/// Notification emitted when watched local skill files change.
|
||||
/// Notification emitted when the available skills catalog changes.
|
||||
///
|
||||
/// Treat this as an invalidation signal and re-run `skills/list` with the
|
||||
/// client's current parameters when refreshed skill metadata is needed.
|
||||
pub struct SkillsChangedNotification {}
|
||||
pub struct SkillsChangedNotification {
|
||||
/// The affected thread, or `None` for a process-wide local skill change.
|
||||
#[experimental("skills/changed.threadId")]
|
||||
pub thread_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ Example with notification opt-out:
|
||||
- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Remote rows include nullable `installPolicySource`; local rows return `null`. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**).
|
||||
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Remote plugin details expose the canonical `shareUrl` supplied by the remote catalog when available; it is `null` for local plugins or when the catalog omits it. This field is separate from `summary.shareContext`, which continues to describe user and workspace sharing state. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**).
|
||||
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
|
||||
- `skills/changed` — notification emitted when watched local skill files change.
|
||||
- `skills/changed` — notification emitted when local or thread-selected skills change.
|
||||
- `app/list` — list available apps.
|
||||
- `remoteControl/enable` — experimental; enable remote control for the current app-server process and return the current remote-control status snapshot. By default, any missing enrollment is completed before the response and the preference is persisted for the current app-server client scope. Pass `ephemeral: true` to enable remote control only for the current process without changing the persisted preference.
|
||||
- `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. By default, the disabled preference is persisted for the current app-server client scope. Pass `ephemeral: true` to disable only for the current process without changing the persisted preference. This does not revoke already enrolled controller devices.
|
||||
@@ -1640,7 +1640,7 @@ $skill-creator Add a new skill for triaging flaky CI and include step-by-step us
|
||||
Use `skills/list` to fetch the available skills (optionally scoped by `cwds`, with `forceReload`). Pass the experimental `threadId` field to also receive the selected executor skills currently available to that thread in `threadSkills`; executor discovery problems are returned in `threadSkillWarnings`.
|
||||
`threadId` does not change `data`; that field continues to use `cwds`, or the app-server default working directory when `cwds` is empty.
|
||||
`skills/list` might reuse a cached skills result per `cwd`; setting `forceReload` to `true` refreshes the result from disk.
|
||||
The server also emits `skills/changed` notifications when watched local skill files change. Treat this as an invalidation signal and re-run `skills/list` with your current params when needed.
|
||||
The server also emits `skills/changed` notifications when watched local skill files or a selected executor catalog changes. A thread-scoped notification includes `threadId`; re-run `skills/list` with that same ID to refresh the invocation UI. A notification with `threadId: null` invalidates process-wide local skill state.
|
||||
Use `skills/extraRoots/set` to replace additional standalone skill roots for the current app-server process. These roots use the same layout as other standalone skill roots: each root contains skill directories, and each skill directory contains `SKILL.md`. Missing roots are accepted and load no skills until they exist. This setting is lost when app-server exits.
|
||||
|
||||
```json
|
||||
@@ -1683,7 +1683,7 @@ Use `skills/extraRoots/set` to replace additional standalone skill roots for the
|
||||
```json
|
||||
{
|
||||
"method": "skills/changed",
|
||||
"params": {}
|
||||
"params": { "threadId": "67e8c0d2-5f31-4f0d-9f70-4b529c6fdd01" }
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::sync::Weak;
|
||||
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::SkillsChangedNotification;
|
||||
use codex_app_server_protocol::ThreadGoal;
|
||||
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
|
||||
use codex_core::NewThread;
|
||||
@@ -28,6 +29,27 @@ use crate::outgoing_message::OutgoingMessageSender;
|
||||
use crate::thread_state::ThreadListenerCommand;
|
||||
use crate::thread_state::ThreadStateManager;
|
||||
|
||||
pub(crate) async fn send_thread_skills_changed(
|
||||
outgoing: &OutgoingMessageSender,
|
||||
thread_state_manager: &ThreadStateManager,
|
||||
thread_id: ThreadId,
|
||||
) {
|
||||
let connection_ids = thread_state_manager
|
||||
.subscribed_connection_ids(thread_id)
|
||||
.await;
|
||||
if connection_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
outgoing
|
||||
.send_server_notification_to_connections(
|
||||
&connection_ids,
|
||||
ServerNotification::SkillsChanged(SkillsChangedNotification {
|
||||
thread_id: Some(thread_id.to_string()),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) struct ThreadExtensionDependencies {
|
||||
pub(crate) event_sink: Arc<dyn ExtensionEventSink>,
|
||||
pub(crate) auth_manager: Arc<AuthManager>,
|
||||
@@ -37,6 +59,7 @@ pub(crate) struct ThreadExtensionDependencies {
|
||||
pub(crate) goal_service: Arc<GoalService>,
|
||||
pub(crate) environment_manager: Arc<EnvironmentManager>,
|
||||
pub(crate) executor_skill_provider: Arc<dyn codex_skills_extension::SkillProvider>,
|
||||
pub(crate) skills_catalog_changed: Arc<dyn Fn(ThreadId) + Send + Sync>,
|
||||
/// Process-scoped persistence backend for extensions that need stored thread history.
|
||||
pub(crate) thread_store: Arc<dyn ThreadStore>,
|
||||
}
|
||||
@@ -57,6 +80,7 @@ where
|
||||
goal_service,
|
||||
environment_manager,
|
||||
executor_skill_provider,
|
||||
skills_catalog_changed,
|
||||
thread_store: _thread_store,
|
||||
} = dependencies;
|
||||
let mut builder = ExtensionRegistryBuilder::<Config>::with_event_sink(event_sink);
|
||||
@@ -84,7 +108,7 @@ where
|
||||
.with_orchestrator_provider(Arc::new(
|
||||
codex_skills_extension::OrchestratorSkillProvider::new(),
|
||||
));
|
||||
codex_skills_extension::install_with_providers(
|
||||
codex_skills_extension::install_with_providers_and_catalog_updates(
|
||||
&mut builder,
|
||||
skill_providers,
|
||||
|config: &Config| codex_skills_extension::SkillsExtensionConfig {
|
||||
@@ -92,6 +116,7 @@ where
|
||||
bundled_skills_enabled: config.bundled_skills_enabled(),
|
||||
orchestrator_skills_enabled: config.orchestrator_skills_enabled,
|
||||
},
|
||||
move |thread_id| skills_catalog_changed(thread_id),
|
||||
);
|
||||
Arc::new(builder.build())
|
||||
}
|
||||
|
||||
@@ -239,6 +239,7 @@ mod tests {
|
||||
goal_service: Arc::new(codex_goal_extension::GoalService::new()),
|
||||
environment_manager: Arc::clone(&environment_manager),
|
||||
executor_skill_provider: Arc::clone(&executor_skill_provider),
|
||||
skills_catalog_changed: Arc::new(|_| {}),
|
||||
thread_store: Arc::clone(&thread_store),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::error_code::invalid_request;
|
||||
use crate::extensions::ThreadExtensionDependencies;
|
||||
use crate::extensions::app_server_extension_event_sink;
|
||||
use crate::extensions::guardian_agent_spawner;
|
||||
use crate::extensions::send_thread_skills_changed;
|
||||
use crate::extensions::thread_extensions;
|
||||
use crate::fs_watch::FsWatchManager;
|
||||
use crate::outgoing_message::ConnectionId;
|
||||
@@ -264,6 +265,17 @@ impl MessageProcessor {
|
||||
restriction_product,
|
||||
),
|
||||
);
|
||||
let skills_catalog_changed: Arc<dyn Fn(ThreadId) + Send + Sync> = {
|
||||
let outgoing = outgoing.clone();
|
||||
let thread_state_manager = thread_state_manager.clone();
|
||||
Arc::new(move |thread_id| {
|
||||
let outgoing = outgoing.clone();
|
||||
let thread_state_manager = thread_state_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
send_thread_skills_changed(&outgoing, &thread_state_manager, thread_id).await;
|
||||
});
|
||||
})
|
||||
};
|
||||
let goal_service = Arc::new(GoalService::new());
|
||||
let thread_manager = Arc::new_cyclic(|thread_manager| {
|
||||
ThreadManager::new(
|
||||
@@ -285,6 +297,7 @@ impl MessageProcessor {
|
||||
goal_service: Arc::clone(&goal_service),
|
||||
environment_manager: Arc::clone(&environment_manager_for_extensions),
|
||||
executor_skill_provider: Arc::clone(&executor_skill_provider),
|
||||
skills_catalog_changed: Arc::clone(&skills_catalog_changed),
|
||||
thread_store: Arc::clone(&thread_store),
|
||||
},
|
||||
),
|
||||
@@ -428,7 +441,7 @@ impl MessageProcessor {
|
||||
Arc::clone(&config),
|
||||
config_manager.clone(),
|
||||
pending_thread_unloads,
|
||||
thread_state_manager,
|
||||
thread_state_manager.clone(),
|
||||
thread_watch_manager,
|
||||
thread_list_state_permit,
|
||||
Arc::clone(&skills_watcher),
|
||||
@@ -463,8 +476,12 @@ impl MessageProcessor {
|
||||
arg0_paths,
|
||||
codex_home: config.codex_home.to_path_buf(),
|
||||
});
|
||||
let environment_processor =
|
||||
EnvironmentRequestProcessor::new(thread_manager.environment_manager());
|
||||
let environment_processor = EnvironmentRequestProcessor::new(
|
||||
thread_manager.environment_manager(),
|
||||
Arc::clone(&thread_manager),
|
||||
outgoing.clone(),
|
||||
thread_state_manager,
|
||||
);
|
||||
let fs_processor = FsRequestProcessor::new(
|
||||
Arc::clone(&environment_manager_for_requests),
|
||||
FsWatchManager::new(outgoing.clone()),
|
||||
|
||||
@@ -671,7 +671,7 @@ impl CatalogRequestProcessor {
|
||||
.set_extra_roots(extra_roots);
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::SkillsChanged(
|
||||
codex_app_server_protocol::SkillsChangedNotification {},
|
||||
codex_app_server_protocol::SkillsChangedNotification { thread_id: None },
|
||||
))
|
||||
.await;
|
||||
Ok(SkillsExtraRootsSetResponse {})
|
||||
|
||||
@@ -1,32 +1,109 @@
|
||||
use super::*;
|
||||
use codex_exec_server::Environment;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct EnvironmentRequestProcessor {
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
thread_state_manager: ThreadStateManager,
|
||||
}
|
||||
|
||||
impl EnvironmentRequestProcessor {
|
||||
pub(crate) fn new(environment_manager: Arc<EnvironmentManager>) -> Self {
|
||||
Self {
|
||||
pub(crate) fn new(
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
thread_state_manager: ThreadStateManager,
|
||||
) -> Self {
|
||||
let processor = Self {
|
||||
environment_manager,
|
||||
thread_manager,
|
||||
outgoing,
|
||||
thread_state_manager,
|
||||
};
|
||||
for (environment_id, environment) in processor.environment_manager.registered_environments()
|
||||
{
|
||||
processor.notify_selected_threads_on_readiness_changes(environment_id, environment);
|
||||
}
|
||||
processor
|
||||
}
|
||||
|
||||
pub(crate) async fn environment_add(
|
||||
&self,
|
||||
params: EnvironmentAddParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
let environment_id = params.environment_id;
|
||||
self.environment_manager
|
||||
.upsert_environment(
|
||||
params.environment_id,
|
||||
environment_id.clone(),
|
||||
params.exec_server_url,
|
||||
params.connect_timeout_ms.map(Duration::from_millis),
|
||||
)
|
||||
.map_err(|err| invalid_request(err.to_string()))?;
|
||||
let environment = self
|
||||
.environment_manager
|
||||
.get_environment(&environment_id)
|
||||
.ok_or_else(|| internal_error("upserted environment is unavailable"))?;
|
||||
self.notify_selected_threads_on_readiness_changes(environment_id, environment);
|
||||
Ok(Some(EnvironmentAddResponse {}.into()))
|
||||
}
|
||||
|
||||
fn notify_selected_threads_on_readiness_changes(
|
||||
&self,
|
||||
environment_id: String,
|
||||
environment: Arc<Environment>,
|
||||
) {
|
||||
let Some(mut readiness_changed) = environment.observe_readiness() else {
|
||||
return;
|
||||
};
|
||||
let startup_finished = environment.startup_finished();
|
||||
let thread_manager = Arc::downgrade(&self.thread_manager);
|
||||
let outgoing = Arc::downgrade(&self.outgoing);
|
||||
let thread_state_manager = self.thread_state_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
if !startup_finished && readiness_changed.changed().await.is_err() {
|
||||
return;
|
||||
}
|
||||
loop {
|
||||
let (Some(thread_manager), Some(outgoing)) =
|
||||
(thread_manager.upgrade(), outgoing.upgrade())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for thread_id in thread_manager.list_thread_ids().await {
|
||||
let Ok(thread) = thread_manager.get_thread(thread_id).await else {
|
||||
continue;
|
||||
};
|
||||
let selected_environment =
|
||||
thread.selected_capability_roots().iter().any(|root| {
|
||||
matches!(
|
||||
&root.location,
|
||||
codex_protocol::capabilities::CapabilityRootLocation::Environment {
|
||||
environment_id: selected_environment_id,
|
||||
..
|
||||
} if selected_environment_id == &environment_id
|
||||
)
|
||||
});
|
||||
if selected_environment {
|
||||
crate::extensions::send_thread_skills_changed(
|
||||
&outgoing,
|
||||
&thread_state_manager,
|
||||
thread_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
drop(thread_manager);
|
||||
drop(outgoing);
|
||||
if readiness_changed.changed().await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) async fn environment_info(
|
||||
&self,
|
||||
params: EnvironmentInfoParams,
|
||||
|
||||
@@ -147,7 +147,7 @@ impl SkillsWatcher {
|
||||
skills_service.clear_cache();
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::SkillsChanged(
|
||||
SkillsChangedNotification {},
|
||||
SkillsChangedNotification { thread_id: None },
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::SkillsChangedNotification;
|
||||
use codex_app_server_protocol::ThreadRealtimeStartedNotification;
|
||||
use codex_protocol::protocol::RealtimeConversationVersion;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -182,6 +183,43 @@ async fn experimental_notifications_are_dropped_without_capability() {
|
||||
writer_rx.try_recv().is_err(),
|
||||
"experimental notifications should not reach clients without capability"
|
||||
);
|
||||
|
||||
route_outgoing_envelope(
|
||||
&mut connections,
|
||||
OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message: OutgoingMessage::AppServerNotification(ServerNotification::SkillsChanged(
|
||||
SkillsChangedNotification {
|
||||
thread_id: Some("thread-1".to_string()),
|
||||
},
|
||||
)),
|
||||
write_complete_tx: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(writer_rx.try_recv().is_err());
|
||||
|
||||
route_outgoing_envelope(
|
||||
&mut connections,
|
||||
OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message: OutgoingMessage::AppServerNotification(ServerNotification::SkillsChanged(
|
||||
SkillsChangedNotification { thread_id: None },
|
||||
)),
|
||||
write_complete_tx: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let message = writer_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("global skill changes should remain stable");
|
||||
assert!(matches!(
|
||||
message.message,
|
||||
OutgoingMessage::AppServerNotification(ServerNotification::SkillsChanged(
|
||||
SkillsChangedNotification { thread_id: None }
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -16,6 +16,7 @@ use codex_app_server_protocol::ListMcpServerStatusResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::SelectedCapabilityRoot;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::SkillsChangedNotification;
|
||||
use codex_app_server_protocol::SkillsListParams;
|
||||
use codex_app_server_protocol::SkillsListResponse;
|
||||
use codex_app_server_protocol::ThreadResumeParams;
|
||||
@@ -98,11 +99,6 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume()
|
||||
let response_mock = responses::mount_sse_sequence(
|
||||
&responses_server,
|
||||
vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("environment-unavailable"),
|
||||
responses::ev_assistant_message("unavailable-message", "Waiting"),
|
||||
responses::ev_completed("environment-unavailable"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("environment-available-call"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
@@ -157,22 +153,17 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume()
|
||||
fixture.environment_cwd.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
run_turn(
|
||||
&mut app_server,
|
||||
&thread_id,
|
||||
"Inspect the current capabilities",
|
||||
fixture.environment_cwd.clone(),
|
||||
)
|
||||
.await?;
|
||||
let initial_requests = response_mock.requests();
|
||||
assert_selected_capabilities_absent(&initial_requests[0]);
|
||||
let (thread_skills, warnings) = list_thread_skills(&mut app_server, &thread_id).await?;
|
||||
assert_eq!(thread_skills, Vec::new());
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert!(warnings[0].contains(&format!("environment `{EXECUTOR_ID}` is unavailable")));
|
||||
|
||||
let mut exec_server =
|
||||
spawn_exec_server(fixture.codex_home.path(), &fixture.exec_server_url).await?;
|
||||
let mut exec_server_proxy =
|
||||
DisconnectableWebSocketProxy::start(&fixture.exec_server_url).await?;
|
||||
add_environment(&mut app_server, exec_server_proxy.websocket_url()).await?;
|
||||
wait_for_thread_skills_changed(&mut app_server, &thread_id).await?;
|
||||
wait_for_selected_mcp_server(&mut app_server, &thread_id).await?;
|
||||
|
||||
run_turn(
|
||||
@@ -182,6 +173,7 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume()
|
||||
fixture.environment_cwd.clone(),
|
||||
)
|
||||
.await?;
|
||||
wait_for_thread_skills_changed(&mut app_server, &thread_id).await?;
|
||||
let first_mcp_pid = wait_for_pid_file(&fixture.pid_file).await?;
|
||||
|
||||
run_turn(
|
||||
@@ -200,6 +192,7 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume()
|
||||
)
|
||||
.await?;
|
||||
exec_server_proxy.pause_and_disconnect().await?;
|
||||
wait_for_thread_skills_changed(&mut app_server, &inspection_thread_id).await?;
|
||||
assert_eq!(
|
||||
timeout(
|
||||
Duration::from_secs(2),
|
||||
@@ -210,6 +203,7 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume()
|
||||
(Vec::new(), Vec::new())
|
||||
);
|
||||
exec_server_proxy.resume()?;
|
||||
wait_for_thread_skills_changed(&mut app_server, &inspection_thread_id).await?;
|
||||
wait_for_selected_mcp_server(&mut app_server, &inspection_thread_id).await?;
|
||||
|
||||
let request_id = app_server
|
||||
@@ -229,6 +223,10 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume()
|
||||
data.iter()
|
||||
.any(|entry| { entry.skills.iter().any(|skill| skill.name == SKILL_NAME) })
|
||||
);
|
||||
assert_eq!(
|
||||
list_thread_skills(&mut app_server, &inspection_thread_id).await?,
|
||||
(vec![fixture.thread_skill.clone()], Vec::new())
|
||||
);
|
||||
|
||||
exec_server.kill().await?;
|
||||
drop(app_server);
|
||||
@@ -263,10 +261,10 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume()
|
||||
)
|
||||
.await?;
|
||||
let requests = response_mock.requests();
|
||||
assert_eq!(5, requests.len());
|
||||
assert_selected_plugin_tools_absent(&requests[4]);
|
||||
assert_eq!(4, requests.len());
|
||||
assert_selected_plugin_tools_absent(&requests[3]);
|
||||
assert!(
|
||||
latest_selected_skill_update(&requests[4])
|
||||
latest_selected_skill_update(&requests[3])
|
||||
.is_some_and(|text| text.contains(NO_SELECTED_SKILLS_MESSAGE))
|
||||
);
|
||||
let (thread_skills, warnings) = list_thread_skills(&mut app_server, &thread_id).await?;
|
||||
@@ -276,6 +274,7 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume()
|
||||
|
||||
exec_server = spawn_exec_server(fixture.codex_home.path(), &fixture.exec_server_url).await?;
|
||||
add_environment(&mut app_server, &fixture.exec_server_url).await?;
|
||||
wait_for_thread_skills_changed(&mut app_server, &thread_id).await?;
|
||||
wait_for_selected_mcp_server(&mut app_server, &thread_id).await?;
|
||||
assert_eq!(
|
||||
list_thread_skills(&mut app_server, &thread_id).await?,
|
||||
@@ -293,16 +292,16 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume()
|
||||
assert_ne!(first_mcp_pid, resumed_mcp_pid);
|
||||
|
||||
let requests = response_mock.requests();
|
||||
assert_eq!(6, requests.len());
|
||||
for request in &requests[1..4] {
|
||||
assert_eq!(5, requests.len());
|
||||
for request in &requests[..3] {
|
||||
assert_selected_skill_is_injected(request, /*expected_count*/ 1);
|
||||
assert_selected_plugin_tools(request);
|
||||
assert_plugin_guidance_count(request, /*expected_count*/ 1);
|
||||
}
|
||||
assert_plugin_guidance_count(&requests[4], /*expected_count*/ 1);
|
||||
assert_selected_skill_is_injected(&requests[5], /*expected_count*/ 2);
|
||||
assert_selected_plugin_tools(&requests[5]);
|
||||
let output = requests[2].function_call_output(MCP_CALL_ID);
|
||||
assert_plugin_guidance_count(&requests[3], /*expected_count*/ 1);
|
||||
assert_selected_skill_is_injected(&requests[4], /*expected_count*/ 2);
|
||||
assert_selected_plugin_tools(&requests[4]);
|
||||
let output = requests[1].function_call_output(MCP_CALL_ID);
|
||||
let output = output["output"]
|
||||
.as_str()
|
||||
.expect("MCP function output should be text");
|
||||
@@ -800,6 +799,40 @@ async fn list_thread_skills(
|
||||
Ok((thread_skills, thread_skill_warnings))
|
||||
}
|
||||
|
||||
async fn wait_for_thread_skills_changed(
|
||||
app_server: &mut TestAppServer,
|
||||
thread_id: &str,
|
||||
) -> Result<()> {
|
||||
let notification = timeout(
|
||||
READ_TIMEOUT,
|
||||
app_server.read_stream_until_matching_notification(
|
||||
"thread-scoped skills/changed",
|
||||
|notification| {
|
||||
notification.method == "skills/changed"
|
||||
&& notification
|
||||
.params
|
||||
.as_ref()
|
||||
.and_then(|params| params.get("threadId"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some(thread_id)
|
||||
},
|
||||
),
|
||||
)
|
||||
.await??;
|
||||
let notification: SkillsChangedNotification = serde_json::from_value(
|
||||
notification
|
||||
.params
|
||||
.context("skills/changed params missing")?,
|
||||
)?;
|
||||
assert_eq!(
|
||||
notification,
|
||||
SkillsChangedNotification {
|
||||
thread_id: Some(thread_id.to_string())
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_selected_mcp_server(
|
||||
app_server: &mut TestAppServer,
|
||||
thread_id: &str,
|
||||
|
||||
@@ -60,7 +60,7 @@ async fn expect_skills_changed_notification(
|
||||
.params
|
||||
.context("skills/changed params must be present")?;
|
||||
let notification: SkillsChangedNotification = serde_json::from_value(params)?;
|
||||
assert_eq!(notification, SkillsChangedNotification {});
|
||||
assert_eq!(notification, SkillsChangedNotification { thread_id: None });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -992,7 +992,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
|
||||
.context("skills/changed params must be present")?;
|
||||
let notification: SkillsChangedNotification = serde_json::from_value(params)?;
|
||||
|
||||
assert_eq!(notification, SkillsChangedNotification {});
|
||||
assert_eq!(notification, SkillsChangedNotification { thread_id: None });
|
||||
let updated_skills_request_id = mcp
|
||||
.send_skills_list_request(SkillsListParams {
|
||||
cwds: vec![codex_home.path().to_path_buf()],
|
||||
|
||||
@@ -163,17 +163,40 @@ fn derive_for_enum(input: &DeriveInput, data: &DataEnum) -> TokenStream {
|
||||
|
||||
for variant in &data.variants {
|
||||
let variant_name = &variant.ident;
|
||||
let pattern = match &variant.fields {
|
||||
Fields::Named(_) => quote!(Self::#variant_name { .. }),
|
||||
Fields::Unnamed(_) => quote!(Self::#variant_name ( .. )),
|
||||
Fields::Unit => quote!(Self::#variant_name),
|
||||
};
|
||||
let reason = experimental_reason(&variant.attrs);
|
||||
if let Some(reason) = reason {
|
||||
let pattern = match &variant.fields {
|
||||
Fields::Named(_) => quote!(Self::#variant_name { .. }),
|
||||
Fields::Unnamed(_) => quote!(Self::#variant_name ( .. )),
|
||||
Fields::Unit => quote!(Self::#variant_name),
|
||||
};
|
||||
match_arms.push(quote! {
|
||||
#pattern => Some(#reason),
|
||||
});
|
||||
} else if variant.attrs.iter().any(experimental_nested_attr) {
|
||||
match &variant.fields {
|
||||
Fields::Unnamed(unnamed) if unnamed.unnamed.len() == 1 => {
|
||||
match_arms.push(quote! {
|
||||
Self::#variant_name(payload) => {
|
||||
crate::experimental_api::ExperimentalApi::experimental_reason(payload)
|
||||
}
|
||||
});
|
||||
}
|
||||
Fields::Named(_) | Fields::Unnamed(_) | Fields::Unit => {
|
||||
return syn::Error::new_spanned(
|
||||
variant,
|
||||
"#[experimental(nested)] requires a single-field tuple variant",
|
||||
)
|
||||
.to_compile_error()
|
||||
.into();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let pattern = match &variant.fields {
|
||||
Fields::Named(_) => quote!(Self::#variant_name { .. }),
|
||||
Fields::Unnamed(_) => quote!(Self::#variant_name ( .. )),
|
||||
Fields::Unit => quote!(Self::#variant_name),
|
||||
};
|
||||
match_arms.push(quote! {
|
||||
#pattern => None,
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ use codex_exec_server::SelectedCapabilityRootsStatus;
|
||||
use codex_features::Feature;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::Personality;
|
||||
@@ -638,6 +639,11 @@ impl CodexThread {
|
||||
self.codex.session.services.thread_extension_data.get::<T>()
|
||||
}
|
||||
|
||||
/// Returns the selected capability roots persisted for this thread.
|
||||
pub fn selected_capability_roots(&self) -> &[SelectedCapabilityRoot] {
|
||||
&self.codex.session.services.selected_capability_roots
|
||||
}
|
||||
|
||||
/// Passively inspects the selected capability roots whose environments are ready now.
|
||||
pub fn inspect_selected_capability_roots(&self) -> SelectedCapabilityRootsStatus {
|
||||
self.codex
|
||||
|
||||
@@ -189,6 +189,7 @@ pub(crate) struct Session {
|
||||
struct Inner {
|
||||
connection: StdMutex<ConnectionState>,
|
||||
connection_changed: watch::Sender<()>,
|
||||
readiness_changed: StdMutex<Option<watch::Sender<()>>>,
|
||||
// The remote transport delivers one shared notification stream for every
|
||||
// process on the connection. Keep a local process_id -> session registry so
|
||||
// we can turn those connection-global notifications into process wakeups
|
||||
@@ -252,6 +253,8 @@ pub(crate) struct LazyRemoteExecServerClient {
|
||||
recovery_policy: RecoveryPolicy,
|
||||
// Saves the first startup result so callers share it and failures remain final.
|
||||
startup: Arc<ConnectionAttempt>,
|
||||
// Broadcasts readiness transitions without allowing observers to initiate them.
|
||||
readiness_changed: watch::Sender<()>,
|
||||
// The latest successful client, replaced whenever reconnecting succeeds.
|
||||
current_client: Arc<StdMutex<Option<ExecServerClient>>>,
|
||||
reconnect: Arc<StdMutex<Option<Arc<ConnectionAttempt>>>>,
|
||||
@@ -259,10 +262,12 @@ pub(crate) struct LazyRemoteExecServerClient {
|
||||
|
||||
impl LazyRemoteExecServerClient {
|
||||
pub(crate) fn new(transport_params: ExecServerTransportParams) -> Self {
|
||||
let (readiness_changed, _readiness_changed_rx) = watch::channel(());
|
||||
Self {
|
||||
transport_params,
|
||||
recovery_policy: RecoveryPolicy::Wait,
|
||||
startup: Arc::new(ConnectionAttempt::new()),
|
||||
readiness_changed,
|
||||
current_client: Arc::new(StdMutex::new(None)),
|
||||
reconnect: Arc::new(StdMutex::new(None)),
|
||||
}
|
||||
@@ -309,6 +314,10 @@ impl LazyRemoteExecServerClient {
|
||||
self.initial_client().await.map(drop)
|
||||
}
|
||||
|
||||
pub(crate) fn observe_readiness(&self) -> watch::Receiver<()> {
|
||||
self.readiness_changed.subscribe()
|
||||
}
|
||||
|
||||
pub(crate) async fn get(&self) -> Result<ExecServerClient, ExecServerError> {
|
||||
if matches!(self.recovery_policy, RecoveryPolicy::FailFast) {
|
||||
let client = match self.cached_client() {
|
||||
@@ -348,21 +357,32 @@ impl LazyRemoteExecServerClient {
|
||||
|
||||
async fn initial_client(&self) -> Result<ExecServerClient, ExecServerError> {
|
||||
// The first caller starts the work; every other caller waits for that same result.
|
||||
let transport_params = self.transport_params.clone();
|
||||
let current_client = Arc::clone(&self.current_client);
|
||||
let readiness_changed = self.readiness_changed.clone();
|
||||
let mut initialized = false;
|
||||
let result = self
|
||||
.startup
|
||||
.get_or_init(|| connect_once(self.transport_params.clone()))
|
||||
.await;
|
||||
match result {
|
||||
Ok(client) => {
|
||||
let mut current_client = self
|
||||
.current_client
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if current_client.is_none() {
|
||||
*current_client = Some(client.clone());
|
||||
.get_or_init(|| {
|
||||
initialized = true;
|
||||
async move {
|
||||
let result = connect_once(transport_params).await;
|
||||
if let Ok(client) = &result {
|
||||
client.set_readiness_observer(Some(readiness_changed));
|
||||
*current_client
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) =
|
||||
Some(client.clone());
|
||||
}
|
||||
result
|
||||
}
|
||||
Ok(client.clone())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if initialized {
|
||||
self.readiness_changed.send_replace(());
|
||||
}
|
||||
match result {
|
||||
Ok(client) => Ok(client.clone()),
|
||||
Err(error) => Err(ExecServerError::ConnectionAttempt(Arc::clone(error))),
|
||||
}
|
||||
}
|
||||
@@ -385,11 +405,16 @@ impl LazyRemoteExecServerClient {
|
||||
.get_or_init(|| async {
|
||||
let result = connect_once(self.transport_params.clone()).await;
|
||||
if let Ok(client) = &result {
|
||||
*self
|
||||
let mut current_client = self
|
||||
.current_client
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(client.clone());
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(previous) = current_client.replace(client.clone()) {
|
||||
previous.set_readiness_observer(/*observer*/ None);
|
||||
}
|
||||
client.set_readiness_observer(Some(self.readiness_changed.clone()));
|
||||
}
|
||||
self.readiness_changed.send_replace(());
|
||||
result
|
||||
})
|
||||
.await;
|
||||
@@ -502,6 +527,14 @@ pub enum ExecServerError {
|
||||
}
|
||||
|
||||
impl ExecServerClient {
|
||||
fn set_readiness_observer(&self, observer: Option<watch::Sender<()>>) {
|
||||
*self
|
||||
.inner
|
||||
.readiness_changed
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = observer;
|
||||
}
|
||||
|
||||
fn fail_fast(&self) -> Result<Self, ExecServerError> {
|
||||
self.rpc_client_without_recovery()?;
|
||||
Ok(Self {
|
||||
@@ -846,6 +879,7 @@ impl ExecServerClient {
|
||||
active_process_starts: 0,
|
||||
}),
|
||||
connection_changed,
|
||||
readiness_changed: StdMutex::new(None),
|
||||
sessions: ArcSwap::from_pointee(HashMap::new()),
|
||||
sessions_write_lock: StdMutex::new(()),
|
||||
http_body_streams: ArcSwap::from_pointee(HashMap::new()),
|
||||
|
||||
@@ -310,7 +310,7 @@ impl Inner {
|
||||
return;
|
||||
}
|
||||
|
||||
self.notify_connection_changed();
|
||||
self.notify_readiness_changed();
|
||||
let inner = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
inner.recover(disconnect_message).await;
|
||||
@@ -416,11 +416,23 @@ impl Inner {
|
||||
}
|
||||
};
|
||||
if installed {
|
||||
self.notify_connection_changed();
|
||||
self.notify_readiness_changed();
|
||||
}
|
||||
installed
|
||||
}
|
||||
|
||||
fn notify_readiness_changed(&self) {
|
||||
self.notify_connection_changed();
|
||||
if let Some(readiness_changed) = self
|
||||
.readiness_changed
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.as_ref()
|
||||
{
|
||||
readiness_changed.send_replace(());
|
||||
}
|
||||
}
|
||||
|
||||
fn notify_connection_changed(&self) {
|
||||
self.connection_changed.send_replace(());
|
||||
}
|
||||
@@ -518,7 +530,7 @@ impl Inner {
|
||||
}
|
||||
};
|
||||
if newly_failed {
|
||||
self.notify_connection_changed();
|
||||
self.notify_readiness_changed();
|
||||
fail_all_in_flight_work(self, message.clone()).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +266,16 @@ impl EnvironmentManager {
|
||||
environment_ids
|
||||
}
|
||||
|
||||
/// Returns a snapshot of every currently registered environment.
|
||||
pub fn registered_environments(&self) -> Vec<(String, Arc<Environment>)> {
|
||||
self.environments
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.iter()
|
||||
.map(|(environment_id, environment)| (environment_id.clone(), Arc::clone(environment)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the local environment instance when one is configured.
|
||||
pub fn try_local_environment(&self) -> Option<Arc<Environment>> {
|
||||
self.local_environment.as_ref().map(Arc::clone)
|
||||
@@ -616,6 +626,13 @@ impl Environment {
|
||||
}
|
||||
}
|
||||
|
||||
/// Observes readiness transitions without initiating startup or recovery.
|
||||
pub fn observe_readiness(&self) -> Option<tokio::sync::watch::Receiver<()>> {
|
||||
self.remote_client
|
||||
.as_ref()
|
||||
.map(LazyRemoteExecServerClient::observe_readiness)
|
||||
}
|
||||
|
||||
pub fn get_exec_backend(&self) -> Arc<dyn ExecBackend> {
|
||||
Arc::clone(&self.exec_backend)
|
||||
}
|
||||
@@ -1070,7 +1087,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn environment_manager_leaves_stdio_environment_lazy() {
|
||||
async fn environment_readiness_observer_keeps_stdio_environment_lazy() {
|
||||
let environment = Environment::remote_with_transport(
|
||||
ExecServerTransportParams::StdioCommand {
|
||||
command: StdioExecServerCommand {
|
||||
@@ -1094,8 +1111,19 @@ mod tests {
|
||||
.expect("environment manager");
|
||||
let environment = manager.get_environment("stdio").expect("stdio environment");
|
||||
|
||||
let mut readiness = environment
|
||||
.observe_readiness()
|
||||
.expect("remote environment should expose readiness changes");
|
||||
tokio::task::yield_now().await;
|
||||
assert!(!environment.startup_finished());
|
||||
assert!(environment.wait_until_ready().await.is_err());
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(10), readiness.changed())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
Environment::start_connecting_for_use(&environment);
|
||||
assert!(readiness.changed().await.is_ok());
|
||||
assert!(environment.startup_finished());
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ use codex_extension_api::TurnInputContributor;
|
||||
use codex_extension_api::WorldStateContributionInput;
|
||||
use codex_extension_api::WorldStateSectionContribution;
|
||||
use codex_mcp::McpResourceClient;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -52,6 +53,7 @@ struct SkillsExtension<C> {
|
||||
providers: SkillProviders,
|
||||
event_sink: Arc<dyn ExtensionEventSink>,
|
||||
config_from_host: Arc<dyn Fn(&C) -> SkillsExtensionConfig + Send + Sync>,
|
||||
catalog_changed: Arc<dyn Fn(ThreadId) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl<C> ThreadLifecycleContributor<C> for SkillsExtension<C>
|
||||
@@ -149,8 +151,8 @@ where
|
||||
return Vec::new();
|
||||
};
|
||||
let config = thread_state.config();
|
||||
let catalog = thread_state
|
||||
.executor_catalog_snapshot(
|
||||
let (catalog, changed) = thread_state
|
||||
.project_executor_catalog(
|
||||
&self.providers,
|
||||
SkillListQuery {
|
||||
turn_id: input.turn_id.to_string(),
|
||||
@@ -163,6 +165,9 @@ where
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if changed {
|
||||
(self.catalog_changed)(input.thread_id);
|
||||
}
|
||||
input
|
||||
.turn_store
|
||||
.insert(ExecutorSkillsStepState(catalog.clone()));
|
||||
@@ -406,11 +411,23 @@ pub fn install_with_providers<C>(
|
||||
config_from_host: impl Fn(&C) -> SkillsExtensionConfig + Send + Sync + 'static,
|
||||
) where
|
||||
C: Send + Sync + 'static,
|
||||
{
|
||||
install_with_providers_and_catalog_updates(registry, providers, config_from_host, |_| {});
|
||||
}
|
||||
|
||||
pub fn install_with_providers_and_catalog_updates<C>(
|
||||
registry: &mut ExtensionRegistryBuilder<C>,
|
||||
providers: SkillProviders,
|
||||
config_from_host: impl Fn(&C) -> SkillsExtensionConfig + Send + Sync + 'static,
|
||||
catalog_changed: impl Fn(ThreadId) + Send + Sync + 'static,
|
||||
) where
|
||||
C: Send + Sync + 'static,
|
||||
{
|
||||
let extension = Arc::new(SkillsExtension {
|
||||
providers,
|
||||
event_sink: registry.event_sink(),
|
||||
config_from_host: Arc::new(config_from_host),
|
||||
catalog_changed: Arc::new(catalog_changed),
|
||||
});
|
||||
registry.thread_lifecycle_contributor(extension.clone());
|
||||
registry.config_contributor(extension.clone());
|
||||
|
||||
@@ -13,6 +13,7 @@ mod world_state;
|
||||
pub use config::SkillsExtensionConfig;
|
||||
pub use extension::install;
|
||||
pub use extension::install_with_providers;
|
||||
pub use extension::install_with_providers_and_catalog_updates;
|
||||
pub use provider::ExecutorSkillProvider;
|
||||
pub use provider::HostSkillProvider;
|
||||
pub use provider::OrchestratorSkillProvider;
|
||||
|
||||
@@ -29,6 +29,7 @@ pub struct SkillsThreadState {
|
||||
config: Mutex<SkillsExtensionConfig>,
|
||||
orchestrator_skills_available: bool,
|
||||
executor_cache: Mutex<Vec<CachedExecutorCatalog>>,
|
||||
projected_executor_catalog: Mutex<Option<SkillCatalog>>,
|
||||
orchestrator_cache: Mutex<Option<Arc<OrchestratorGenerationCache>>>,
|
||||
}
|
||||
|
||||
@@ -38,6 +39,7 @@ impl SkillsThreadState {
|
||||
config: Mutex::new(config),
|
||||
orchestrator_skills_available,
|
||||
executor_cache: Mutex::new(Vec::new()),
|
||||
projected_executor_catalog: Mutex::new(None),
|
||||
orchestrator_cache: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
@@ -116,6 +118,21 @@ impl SkillsThreadState {
|
||||
catalog
|
||||
}
|
||||
|
||||
pub(crate) async fn project_executor_catalog(
|
||||
&self,
|
||||
providers: &SkillProviders,
|
||||
query: SkillListQuery,
|
||||
) -> (SkillCatalog, bool) {
|
||||
let catalog = self.executor_catalog_snapshot(providers, query).await;
|
||||
let mut projected = self
|
||||
.projected_executor_catalog
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let changed = projected.as_ref() != Some(&catalog);
|
||||
*projected = Some(catalog.clone());
|
||||
(catalog, changed)
|
||||
}
|
||||
|
||||
pub(crate) async fn orchestrator_catalog_snapshot(
|
||||
&self,
|
||||
mcp_resources: Option<&McpResourceClient>,
|
||||
|
||||
@@ -161,8 +161,8 @@ pub(super) fn server_notification_thread_target(
|
||||
None => return ServerNotificationThreadTarget::AppScoped,
|
||||
}
|
||||
}
|
||||
ServerNotification::SkillsChanged(_)
|
||||
| ServerNotification::McpServerOauthLoginCompleted(_)
|
||||
ServerNotification::SkillsChanged(notification) => notification.thread_id.as_deref(),
|
||||
ServerNotification::McpServerOauthLoginCompleted(_)
|
||||
| ServerNotification::AccountUpdated(_)
|
||||
| ServerNotification::AccountRateLimitsUpdated(_)
|
||||
| ServerNotification::AppListUpdated(_)
|
||||
|
||||
@@ -3804,7 +3804,7 @@ async fn active_non_primary_shutdown_target_returns_none_for_non_shutdown_event(
|
||||
|
||||
assert_eq!(
|
||||
app.active_non_primary_shutdown_target(&ServerNotification::SkillsChanged(
|
||||
codex_app_server_protocol::SkillsChangedNotification {},
|
||||
codex_app_server_protocol::SkillsChangedNotification { thread_id: None },
|
||||
)),
|
||||
None
|
||||
);
|
||||
|
||||
@@ -140,8 +140,12 @@ impl ChatWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
ServerNotification::SkillsChanged(_) => {
|
||||
self.refresh_skills_for_current_cwd(/*force_reload*/ true);
|
||||
ServerNotification::SkillsChanged(notification) => {
|
||||
// Thread-selected skills use executor resource locators that the TUI does not
|
||||
// consume yet. Keep the existing local-skill refresh behavior unchanged.
|
||||
if notification.thread_id.is_none() {
|
||||
self.refresh_skills_for_current_cwd(/*force_reload*/ true);
|
||||
}
|
||||
}
|
||||
ServerNotification::ModelRerouted(_) => {}
|
||||
ServerNotification::ModelVerification(notification) => {
|
||||
|
||||
Reference in New Issue
Block a user