mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
Resolve MCP statusline providers and read thread-scoped resources (#61249)
## What changed - Parse statusline entrypoints from MCP tool metadata, requiring exactly one valid `ui://` resource URI per tool. - Resolve saved or discovered providers against the displayed thread's tool catalog, checking global advertisement, plugin ownership, and connection status. Reject providers whose effective server, plugin, or marketplace registration includes project configuration. - Read the resolved resource on the displayed thread, forwarding the statusline snapshot in `com.openai/codex-statusline-state` metadata. ## Testing Add tests for malformed and ambiguous entrypoints, thread-specific resource reads, configuration precedence, project overrides, ownership mismatches, and unavailable providers. GitOrigin-RevId: 5a43212177704f546e5d53be53f8979fe3bb8d8a
This commit is contained in:
@@ -67,8 +67,8 @@ pub(super) async fn prepare_fresh_startup_config(
|
||||
}
|
||||
};
|
||||
let defaults = crate::config_update::read_effective_config_with_layers(
|
||||
app_server.request_handle(),
|
||||
defaults_cwd,
|
||||
&app_server.request_handle(),
|
||||
defaults_cwd.display().to_string(),
|
||||
)
|
||||
.await?;
|
||||
super::new_session::overlay_new_session_defaults(
|
||||
|
||||
@@ -439,15 +439,15 @@ pub(crate) struct EffectiveConfig {
|
||||
}
|
||||
|
||||
pub(crate) async fn read_effective_config_with_layers(
|
||||
request_handle: AppServerRequestHandle,
|
||||
cwd: &Path,
|
||||
request_handle: &AppServerRequestHandle,
|
||||
cwd: String,
|
||||
) -> Result<EffectiveConfig> {
|
||||
request_handle
|
||||
.request_typed(ClientRequest::ConfigRead {
|
||||
request_id: RequestId::String(format!("tui-config-read-{}", Uuid::new_v4())),
|
||||
params: ConfigReadParams {
|
||||
include_layers: true,
|
||||
cwd: Some(cwd.display().to_string()),
|
||||
cwd: Some(cwd),
|
||||
},
|
||||
})
|
||||
.await
|
||||
@@ -459,7 +459,7 @@ pub(crate) async fn read_effective_config_if_supported(
|
||||
request_handle: AppServerRequestHandle,
|
||||
cwd: &Path,
|
||||
) -> Result<Option<EffectiveConfig>> {
|
||||
match read_effective_config_with_layers(request_handle, cwd).await {
|
||||
match read_effective_config_with_layers(&request_handle, cwd.display().to_string()).await {
|
||||
Ok(response) => Ok(Some(response)),
|
||||
Err(err)
|
||||
if matches!(
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
//! MCP server identity and inventory for statusline providers.
|
||||
//! Resolve globally advertised statusline tools and read them on the displayed thread.
|
||||
|
||||
mod descriptor;
|
||||
#[cfg_attr(not(test), expect(dead_code, reason = "staged provider discovery"))]
|
||||
mod inventory;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
|
||||
use crate::config_update::read_effective_config_with_layers;
|
||||
use codex_app_server_client::AppServerRequestHandle;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::McpResourceReadParams;
|
||||
use codex_app_server_protocol::McpResourceReadResponse;
|
||||
use codex_app_server_protocol::McpServerConnectionStatus;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use color_eyre::Result;
|
||||
use color_eyre::eyre::OptionExt as _;
|
||||
use color_eyre::eyre::bail;
|
||||
pub(crate) use descriptor::StatuslineProvider;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) struct McpServerName(String);
|
||||
|
||||
@@ -19,3 +37,159 @@ impl From<&str> for McpServerName {
|
||||
Self(name.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) struct McpToolName(String);
|
||||
|
||||
impl From<&str> for McpToolName {
|
||||
fn from(name: &str) -> Self {
|
||||
Self(name.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// The saved identity is independent of the latest display title and resource URI.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) struct ProviderId {
|
||||
pub(crate) server: McpServerName,
|
||||
pub(crate) tool: McpToolName,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), expect(dead_code, reason = "staged provider choice"))]
|
||||
pub(crate) enum ProviderSelection<'a> {
|
||||
Saved(&'a ProviderId),
|
||||
/// A provider advertised during the current picker refresh.
|
||||
Discovered(&'a StatuslineProvider),
|
||||
}
|
||||
|
||||
pub(crate) struct ResolvedStatuslineProvider {
|
||||
client: AppServerRequestHandle,
|
||||
thread: ThreadId,
|
||||
provider: StatuslineProvider,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), expect(dead_code, reason = "staged resource reader"))]
|
||||
impl ResolvedStatuslineProvider {
|
||||
pub(crate) async fn lookup(
|
||||
client: &AppServerRequestHandle,
|
||||
thread: ThreadId,
|
||||
cwd: &PathUri,
|
||||
selection: ProviderSelection<'_>,
|
||||
) -> Result<Option<Self>> {
|
||||
let config =
|
||||
read_effective_config_with_layers(client, cwd.inferred_native_path_string()).await?;
|
||||
let layers = config
|
||||
.layers
|
||||
.ok_or_eyre("config/read omitted the requested layers")?;
|
||||
let project_sets = |section: &str, key: &str| {
|
||||
let definitions: Vec<_> = layers
|
||||
.iter()
|
||||
.filter(|layer| layer["disabledReason"].is_null())
|
||||
.filter_map(|layer| {
|
||||
Some((
|
||||
layer["name"]["type"] == "project",
|
||||
layer["config"][section].get(key)?,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
has_project_field(&definitions)
|
||||
};
|
||||
let (id, advertisement) = match selection {
|
||||
ProviderSelection::Saved(id) => (id, None),
|
||||
ProviderSelection::Discovered(provider) => (&provider.id, Some(provider)),
|
||||
};
|
||||
if project_sets("mcp_servers", id.server.as_str()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let owner = if let Some(provider) = advertisement {
|
||||
provider.plugin_id.clone()
|
||||
} else {
|
||||
let Some(global) = inventory::server(client, &id.server, /*thread*/ None).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if global.provider(&id.tool)?.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
global.plugin_id
|
||||
};
|
||||
if let Some(plugin) = &owner
|
||||
&& (project_sets("plugins", &plugin.as_key())
|
||||
|| project_sets("marketplaces", &plugin.marketplace_name))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(live) = inventory::server(client, &id.server, Some(thread)).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if live.plugin_id != owner {
|
||||
return Ok(None);
|
||||
}
|
||||
let provider = live.provider(&id.tool)?;
|
||||
if live.runtime_status != Some(McpServerConnectionStatus::Connected) {
|
||||
bail!("MCP server {} is not connected", id.server.as_str());
|
||||
}
|
||||
Ok(provider.map(|provider| Self {
|
||||
client: client.clone(),
|
||||
thread,
|
||||
provider,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn provider(&self) -> &StatuslineProvider {
|
||||
&self.provider
|
||||
}
|
||||
|
||||
/// The host routes by name. A reload can replace that name between lookup and read.
|
||||
/// A thread/start override can also replace a configured command before lookup. Both
|
||||
/// registrations report the same name and no plugin ID. Same-owner plugin replacements
|
||||
/// are indistinguishable for the same reason.
|
||||
pub(crate) async fn read(&self, snapshot: Value) -> Result<McpResourceReadResponse> {
|
||||
let provider = &self.provider;
|
||||
Ok(self
|
||||
.client
|
||||
.request_typed(ClientRequest::McpResourceRead {
|
||||
request_id: RequestId::String(Uuid::new_v4().to_string()),
|
||||
params: McpResourceReadParams {
|
||||
thread_id: Some(self.thread.to_string()),
|
||||
server: provider.id.server.0.clone(),
|
||||
uri: provider.uri.clone(),
|
||||
connector_id: None,
|
||||
origin_call_id: None,
|
||||
target: None,
|
||||
meta: Some(serde_json::Map::from_iter([(
|
||||
"com.openai/codex-statusline-state".into(),
|
||||
snapshot,
|
||||
)])),
|
||||
},
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Config layers arrive highest first. A project field only matters if a higher layer
|
||||
/// leaves it in the effective registration. Arrays and scalars are replaced as a whole.
|
||||
fn has_project_field(definitions: &[(bool, &Value)]) -> bool {
|
||||
let Some((&(project, first), rest)) = definitions.split_first() else {
|
||||
return false;
|
||||
};
|
||||
if !first.is_object() || rest.iter().all(|(origin, _)| *origin == project) {
|
||||
return project;
|
||||
}
|
||||
let mut fields: HashMap<&str, Vec<_>> = HashMap::new();
|
||||
for &(project, value) in definitions {
|
||||
let Some(table) = value.as_object() else {
|
||||
break;
|
||||
};
|
||||
for (key, value) in table {
|
||||
fields.entry(key).or_default().push((project, value));
|
||||
}
|
||||
}
|
||||
if fields.is_empty() {
|
||||
project
|
||||
} else {
|
||||
fields.values().any(|values| has_project_field(values))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "statusline_provider_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Interpret the statusline entrypoint advertised by one MCP tool.
|
||||
|
||||
use super::McpServerName;
|
||||
use super::McpToolName;
|
||||
use super::ProviderId;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_protocol::mcp::Tool;
|
||||
use std::cell::Cell;
|
||||
use url::SyntaxViolation;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct StatuslineProvider {
|
||||
pub(crate) id: ProviderId,
|
||||
pub(crate) title: String,
|
||||
pub(crate) plugin_label: Option<String>,
|
||||
pub(crate) uri: String,
|
||||
pub(super) plugin_id: Option<PluginId>,
|
||||
}
|
||||
|
||||
impl StatuslineProvider {
|
||||
pub(super) fn from_tool(
|
||||
server: &McpServerName,
|
||||
plugin_id: Option<&PluginId>,
|
||||
tool: &Tool,
|
||||
) -> Option<Self> {
|
||||
let meta = tool.meta.as_ref()?;
|
||||
let entries = meta.get("openai/ui")?.get("entrypoints")?.as_array()?;
|
||||
let mut valid_uris = entries
|
||||
.iter()
|
||||
.filter(|entry| entry["type"] == "statusline")
|
||||
.filter_map(|entry| entry["resourceUri"].as_str())
|
||||
.filter(|uri| {
|
||||
// The resource read forwards the original spelling. Reject errors the URL parser recovers from.
|
||||
let valid = Cell::new(true);
|
||||
let violation = |issue| {
|
||||
if issue != SyntaxViolation::EmbeddedCredentials {
|
||||
valid.set(false);
|
||||
}
|
||||
};
|
||||
uri.starts_with("ui://")
|
||||
&& Url::options()
|
||||
.syntax_violation_callback(Some(&violation))
|
||||
.parse(uri)
|
||||
.is_ok()
|
||||
&& valid.get()
|
||||
});
|
||||
let uri = valid_uris.next()?;
|
||||
if valid_uris.next().is_some() {
|
||||
tracing::warn!(
|
||||
server = server.as_str(),
|
||||
tool = tool.name.as_str(),
|
||||
"ignoring MCP tool with multiple valid statusline entrypoints"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
id: ProviderId {
|
||||
server: server.clone(),
|
||||
tool: McpToolName(tool.name.clone()),
|
||||
},
|
||||
title: tool.title.clone().unwrap_or_else(|| tool.name.clone()),
|
||||
plugin_label: plugin_id.map(PluginId::as_key),
|
||||
uri: uri.to_owned(),
|
||||
plugin_id: plugin_id.cloned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Read MCP tool catalogs from the connected app server’s global inventory.
|
||||
|
||||
use super::McpServerName;
|
||||
use super::McpToolName;
|
||||
use super::StatuslineProvider;
|
||||
use codex_app_server_client::AppServerRequestHandle;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::ListMcpServerStatusParams;
|
||||
@@ -10,9 +12,11 @@ use codex_app_server_protocol::McpServerStatusDetail;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginIdError;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::mcp::Tool;
|
||||
use color_eyre::Result;
|
||||
use color_eyre::eyre::bail;
|
||||
use color_eyre::eyre::eyre;
|
||||
use futures::Stream;
|
||||
use futures::stream;
|
||||
use serde::Deserialize;
|
||||
@@ -49,6 +53,26 @@ impl TryFrom<McpServerStatus> for StatuslineServer {
|
||||
}
|
||||
}
|
||||
|
||||
impl StatuslineServer {
|
||||
fn catalog(&self) -> Result<&HashMap<String, Tool>> {
|
||||
self.tools.as_ref().map_err(|error| eyre!("{error}"))
|
||||
}
|
||||
pub(super) fn providers(&self) -> Result<Vec<StatuslineProvider>> {
|
||||
Ok(self
|
||||
.catalog()?
|
||||
.values()
|
||||
.filter_map(|tool| {
|
||||
StatuslineProvider::from_tool(&self.name, self.plugin_id.as_ref(), tool)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
pub(super) fn provider(&self, name: &McpToolName) -> Result<Option<StatuslineProvider>> {
|
||||
Ok(self.catalog()?.get(&name.0).and_then(|tool| {
|
||||
StatuslineProvider::from_tool(&self.name, self.plugin_id.as_ref(), tool)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct InventoryPage {
|
||||
@@ -119,11 +143,12 @@ fn local_server(value: Value) -> Result<Option<StatuslineServer>> {
|
||||
pub(super) async fn server(
|
||||
client: &AppServerRequestHandle,
|
||||
name: &McpServerName,
|
||||
thread: Option<ThreadId>,
|
||||
) -> Result<Option<StatuslineServer>> {
|
||||
let mut page = read_page(
|
||||
client,
|
||||
ListMcpServerStatusParams {
|
||||
thread_id: None,
|
||||
thread_id: thread.map(|id| id.to_string()),
|
||||
server_name: Some(name.0.clone()),
|
||||
detail: Some(McpServerStatusDetail::ToolsAndAuthOnly),
|
||||
cursor: None,
|
||||
|
||||
@@ -173,7 +173,7 @@ async fn targeted_inventory_rejects_another_server() {
|
||||
let name = "calendar".into();
|
||||
let other = row("other", vec![json!({"name":"footer","inputSchema":{}})]);
|
||||
let (result, ()) = tokio::join!(
|
||||
server(&client, &name),
|
||||
server(&client, &name, /*thread*/ None),
|
||||
reply(
|
||||
&mut commands,
|
||||
"mcpServerStatus/list",
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
#![cfg(test)]
|
||||
//! Tool metadata identifies global picker choices. Selected resources use the displayed thread.
|
||||
|
||||
use super::inventory::StatuslineServer;
|
||||
use super::test_support::Requests;
|
||||
use super::test_support::reply;
|
||||
use super::test_support::row;
|
||||
use super::test_support::test_client;
|
||||
use super::*;
|
||||
use codex_app_server_protocol::McpServerStatus;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
fn server(raw: Value) -> StatuslineServer {
|
||||
StatuslineServer::try_from(serde_json::from_value::<McpServerStatus>(raw).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn statusline(uri: &str) -> Value {
|
||||
json!({"type":"statusline","resourceUri":uri})
|
||||
}
|
||||
|
||||
fn tool(name: &str, entries: Value) -> Value {
|
||||
json!({"name":name,"inputSchema":{"type":"object"},"_meta":{"openai/ui":{"entrypoints":entries}}})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tool_contributes_exactly_one_valid_statusline_entry() {
|
||||
let good = statusline("ui://demo/a%20b");
|
||||
let malformed = [
|
||||
Value::Null,
|
||||
json!({"type":"statusline"}),
|
||||
json!({"type":"statusline","resourceUri":12}),
|
||||
statusline("https://example.test/footer"),
|
||||
statusline("ui://["),
|
||||
json!({"type":"desktop","resourceUri":"ui://demo/window"}),
|
||||
statusline("ui://demo/ba\nd"),
|
||||
statusline("ui://demo/%"),
|
||||
];
|
||||
for extra in &malformed {
|
||||
let mut raw = row("demo", vec![tool("status", json!([extra, good, extra]))]);
|
||||
raw["pluginId"] = json!("calendar@local");
|
||||
let row = server(raw);
|
||||
let providers = row.providers().unwrap();
|
||||
let [provider] = providers.as_slice() else {
|
||||
panic!("expected one statusline provider")
|
||||
};
|
||||
assert_eq!(
|
||||
(provider.uri.as_str(), provider.plugin_label.as_deref()),
|
||||
("ui://demo/a%20b", Some("calendar@local"))
|
||||
);
|
||||
}
|
||||
for entries in [
|
||||
json!(malformed),
|
||||
json!([good, good]),
|
||||
json!([good, statusline("ui://demo/other")]),
|
||||
json!({}),
|
||||
] {
|
||||
let row = server(row("demo", vec![tool("status", entries)]));
|
||||
assert!(row.providers().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
async fn scoped_config(commands: &mut Requests, cwd: &str, layers: &[Value]) {
|
||||
reply(
|
||||
commands,
|
||||
"config/read",
|
||||
json!({"includeLayers":true,"cwd":cwd}),
|
||||
json!({"config":{},"layers":layers}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn named(commands: &mut Requests, thread: Option<ThreadId>, row: Value) {
|
||||
reply(
|
||||
commands,
|
||||
"mcpServerStatus/list",
|
||||
json!({"threadId":thread.map(|id| id.to_string()),"serverName":row["name"],
|
||||
"detail":"toolsAndAuthOnly","cursor":null,"limit":null}),
|
||||
json!({"data":[row],"nextCursor":null}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn picker_choice_reads_the_displayed_threads_uri_with_the_snapshot() -> Result<()> {
|
||||
let thread = ThreadId::new();
|
||||
let cwd = PathUri::parse("file:///C:/workspace")?;
|
||||
let listing = |uri: &str| row("calendar", vec![tool("next", json!([statusline(uri)]))]);
|
||||
let known = server(listing("ui://calendar/global"))
|
||||
.providers()?
|
||||
.pop()
|
||||
.unwrap();
|
||||
let snapshot = json!({"version":1,"thread":{"id":thread.to_string()}});
|
||||
let layers = [
|
||||
json!({"name":{"type":"sessionFlags"},"config":{"mcp_servers":{"calendar":{"command":"calendar"}}}}),
|
||||
json!({"name":{"type":"project"},"config":{"mcp_servers":{"calendar":{"command":"project"}}}}),
|
||||
json!({"name":{"type":"project"},"disabledReason":"untrusted",
|
||||
"config":{"mcp_servers":{"calendar":{"args":["ignored"]}}}}),
|
||||
];
|
||||
let (client, mut requests) = test_client();
|
||||
let host = async {
|
||||
scoped_config(&mut requests, r"C:\workspace", &layers).await;
|
||||
let mut live = listing("ui://calendar/today");
|
||||
live["tools"]["next"]["title"] = json!("Next event");
|
||||
named(&mut requests, Some(thread), live).await;
|
||||
reply(
|
||||
&mut requests,
|
||||
"mcpServer/resource/read",
|
||||
json!({
|
||||
"threadId": thread.to_string(),
|
||||
"server": "calendar",
|
||||
"uri": "ui://calendar/today",
|
||||
"connectorId": null,
|
||||
"target": null,
|
||||
"originCallId": null,
|
||||
"_meta": {"com.openai/codex-statusline-state": snapshot},
|
||||
}),
|
||||
json!({"contents": [], "originCallId": null}),
|
||||
)
|
||||
.await;
|
||||
};
|
||||
let read = async {
|
||||
let provider = ResolvedStatuslineProvider::lookup(
|
||||
&client,
|
||||
thread,
|
||||
&cwd,
|
||||
ProviderSelection::Discovered(&known),
|
||||
)
|
||||
.await?
|
||||
.unwrap();
|
||||
assert_eq!(provider.provider().title, "Next event");
|
||||
provider.read(snapshot.clone()).await
|
||||
};
|
||||
let (result, ()) = tokio::join!(read, host);
|
||||
result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn visible_project_registration_blocks_the_selected_provider() -> Result<()> {
|
||||
let cwd = PathUri::parse("file:///workspace/b")?;
|
||||
for (owner, session, project) in [
|
||||
(
|
||||
None,
|
||||
json!({"mcp_servers":{"calendar":{"command":"calendar"}}}),
|
||||
json!({"mcp_servers":{"calendar":{"command":"replacement","args":["from-project"]}}}),
|
||||
),
|
||||
(
|
||||
Some("daily@local"),
|
||||
json!({}),
|
||||
json!({"plugins":{"daily@local":{"release_id":"replacement"}}}),
|
||||
),
|
||||
(
|
||||
Some("daily@local"),
|
||||
json!({}),
|
||||
json!({"marketplaces":{"local":{"source_type":"local","source":"/replacement"}}}),
|
||||
),
|
||||
] {
|
||||
let mut listing = row(
|
||||
"calendar",
|
||||
vec![tool("today", json!([statusline("ui://calendar/today")]))],
|
||||
);
|
||||
listing["pluginId"] = json!(owner);
|
||||
let known = server(listing).providers()?.pop().unwrap();
|
||||
let (client, mut requests) = test_client();
|
||||
let host = async move {
|
||||
scoped_config(
|
||||
&mut requests,
|
||||
"/workspace/b",
|
||||
&[
|
||||
json!({"name":{"type":"sessionFlags"},"config":session}),
|
||||
json!({"name":{"type":"project"},"config":project}),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
};
|
||||
let lookup = ResolvedStatuslineProvider::lookup(
|
||||
&client,
|
||||
ThreadId::new(),
|
||||
&cwd,
|
||||
ProviderSelection::Discovered(&known),
|
||||
);
|
||||
let (provider, ()) = tokio::join!(lookup, host);
|
||||
assert!(provider?.is_none());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selecting_a_plugin_requires_the_globally_advertised_tool_and_the_connected_owner()
|
||||
-> Result<()> {
|
||||
let (client, mut commands) = test_client();
|
||||
let thread = ThreadId::new();
|
||||
let named_plugin = |name| {
|
||||
let mut value = row(
|
||||
"calendar",
|
||||
vec![tool(name, json!([statusline("ui://calendar/status")]))],
|
||||
);
|
||||
value["pluginId"] = json!("daily@local");
|
||||
value
|
||||
};
|
||||
let global = named_plugin("status");
|
||||
let mut replacement = global.clone();
|
||||
replacement["pluginId"] = json!("project@local");
|
||||
replacement["runtimeStatus"] = json!("failed");
|
||||
let mut disconnected = global.clone();
|
||||
disconnected["runtimeStatus"] = json!("failed");
|
||||
let mut list_failure = disconnected.clone();
|
||||
list_failure["toolsError"] = json!("calendar catalog failed");
|
||||
let mut unverified = global.clone();
|
||||
unverified.as_object_mut().unwrap().remove("httpOrigin");
|
||||
let unavailable = [
|
||||
(named_plugin("other"), None),
|
||||
(global.clone(), Some(replacement)),
|
||||
(global.clone(), Some(named_plugin("renamed"))),
|
||||
(global.clone(), Some(unverified)),
|
||||
];
|
||||
let rejected = unavailable.len();
|
||||
let mut live = global.clone();
|
||||
live["tools"]["status"]["_meta"]["openai/ui"]["entrypoints"] =
|
||||
json!([statusline("ui://calendar/current")]);
|
||||
let worker = tokio::spawn(async move {
|
||||
for (global, live) in unavailable.into_iter().chain([
|
||||
(global.clone(), Some(disconnected)),
|
||||
(global.clone(), Some(list_failure)),
|
||||
(global, Some(live)),
|
||||
]) {
|
||||
scoped_config(&mut commands, "/workspace/a", &[]).await;
|
||||
named(&mut commands, /*thread*/ None, global).await;
|
||||
if let Some(live) = live {
|
||||
named(&mut commands, Some(thread), live).await;
|
||||
}
|
||||
}
|
||||
reply(
|
||||
&mut commands,
|
||||
"mcpServer/resource/read",
|
||||
json!({
|
||||
"threadId":thread.to_string(),"server":"calendar","uri":"ui://calendar/current",
|
||||
"connectorId":null,"target":null,"originCallId":null,
|
||||
"_meta":{"com.openai/codex-statusline-state":{"version":1}}
|
||||
}),
|
||||
json!({"contents":[],"originCallId":null}),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
let cwd = PathUri::parse("file:///workspace/a")?;
|
||||
let id = ProviderId {
|
||||
server: "calendar".into(),
|
||||
tool: "status".into(),
|
||||
};
|
||||
let lookup =
|
||||
|| ResolvedStatuslineProvider::lookup(&client, thread, &cwd, ProviderSelection::Saved(&id));
|
||||
for _ in 0..rejected {
|
||||
assert!(lookup().await?.is_none());
|
||||
}
|
||||
for message in ["is not connected", "calendar catalog failed"] {
|
||||
assert!(
|
||||
lookup()
|
||||
.await
|
||||
.is_err_and(|e| e.to_string().contains(message))
|
||||
);
|
||||
}
|
||||
lookup().await?.unwrap().read(json!({"version":1})).await?;
|
||||
worker.await?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user