mirror of
https://github.com/openai/codex.git
synced 2026-11-09 16:41:37 +00:00
## Why The app directory is a discovery catalog and can include apps that are not callable in the active thread. Using it for `$` suggestions can therefore offer mentions that cannot be used. ## What changed - Load mention candidates from `app/installed`, enrich callable apps through bounded `app/read` batches, and retain compatibility with app servers that do not support `app/installed`. - Keep the mention catalog separate from the `/apps` directory cache and scope results to the current thread, workspace, and account. - Refresh mentions after app notifications, enablement changes, configuration updates, and apps MCP readiness, while immediately removing disabled apps. ## Testing Add coverage for batching and fallback behavior, stale scoped results, notification refreshes, enablement changes, MCP startup races, and filtering non-callable apps from mentions. GitOrigin-RevId: 4d9e20794aed30cc97120170792fb555b86fe1e7
171 lines
6.9 KiB
Rust
171 lines
6.9 KiB
Rust
use super::*;
|
|
use codex_app_server_protocol::AppsListParams;
|
|
use codex_app_server_protocol::JSONRPCMessage;
|
|
use futures::SinkExt;
|
|
use futures::StreamExt;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use tokio::net::TcpListener;
|
|
use tokio_tungstenite::accept_async;
|
|
use tokio_tungstenite::tungstenite::Message;
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum MockResponses {
|
|
FirstBatchFails,
|
|
Unsupported,
|
|
}
|
|
|
|
async fn load_mock_mentions(responses: MockResponses) -> Result<(ConnectorsSnapshot, Vec<usize>)> {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
let websocket_url = format!("ws://{}", listener.local_addr()?);
|
|
let server = tokio::spawn(async move {
|
|
let (stream, _) = listener.accept().await?;
|
|
let mut websocket = accept_async(stream).await?;
|
|
let mut read_batches = Vec::new();
|
|
|
|
while let Some(frame) = websocket.next().await {
|
|
let Message::Text(text) = frame? else {
|
|
continue;
|
|
};
|
|
let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else {
|
|
continue;
|
|
};
|
|
let request_id = request.id.clone();
|
|
let response = match request.method.as_str() {
|
|
"initialize" => {
|
|
json!({ "id": request_id, "result": { "userAgent": "codex-tui-test" } })
|
|
}
|
|
"app/installed" => {
|
|
assert_eq!(
|
|
request.params,
|
|
Some(json!({ "threadId": "thread-1", "forceRefresh": true }))
|
|
);
|
|
if responses == MockResponses::Unsupported {
|
|
let message = "Invalid request: unknown variant `app/installed`";
|
|
json!({ "id": request_id, "error": { "code": -32600, "message": message } })
|
|
} else {
|
|
let mut apps = (0..103)
|
|
.map(|index| json!({
|
|
"id": format!("app-{index:03}"),
|
|
"runtimeName": (index != 102).then(|| format!("Runtime {index:03}")),
|
|
"enabled": true,
|
|
"callable": true,
|
|
}))
|
|
.collect::<Vec<_>>();
|
|
apps.extend([
|
|
json!({ "id": "disabled", "enabled": false, "callable": false }),
|
|
json!({ "id": "no-visible-tools", "enabled": true, "callable": false }),
|
|
]);
|
|
json!({ "id": request_id, "result": { "apps": apps } })
|
|
}
|
|
}
|
|
"app/list" => {
|
|
let params = serde_json::from_value::<AppsListParams>(
|
|
request.params.expect("app/list params"),
|
|
)?;
|
|
assert_eq!(
|
|
(params.thread_id.as_deref(), params.force_refetch),
|
|
(Some("thread-1"), true)
|
|
);
|
|
json!({
|
|
"id": request_id,
|
|
"result": {
|
|
"data": [
|
|
{ "id": "installed", "name": "Installed", "isAccessible": true },
|
|
{ "id": "disabled", "name": "Disabled", "isAccessible": true, "isEnabled": false },
|
|
{ "id": "directory", "name": "Directory", "isAccessible": false }
|
|
],
|
|
"nextCursor": null
|
|
}
|
|
})
|
|
}
|
|
"app/read" => {
|
|
let params = serde_json::from_value::<AppsReadParams>(
|
|
request.params.expect("app/read params"),
|
|
)?;
|
|
assert_eq!(params.thread_id.as_deref(), Some("thread-1"));
|
|
assert!(!params.include_tools);
|
|
assert!(params.app_ids.iter().all(|id| id.starts_with("app-")));
|
|
read_batches.push(params.app_ids.len());
|
|
|
|
if responses == MockResponses::FirstBatchFails && read_batches.len() == 1 {
|
|
json!({
|
|
"id": request_id,
|
|
"error": { "code": -32603, "message": "metadata service unavailable" }
|
|
})
|
|
} else {
|
|
let apps = params
|
|
.app_ids
|
|
.into_iter()
|
|
.filter(|id| id != "app-102")
|
|
.map(|id| json!({ "id": id, "name": format!("Connector {id}") }))
|
|
.collect::<Vec<_>>();
|
|
json!({
|
|
"id": request_id,
|
|
"result": { "apps": apps, "missingAppIds": ["app-102"] }
|
|
})
|
|
}
|
|
}
|
|
method => panic!("unexpected app-server request: {method}"),
|
|
};
|
|
websocket
|
|
.send(Message::Text(response.to_string().into()))
|
|
.await?;
|
|
}
|
|
|
|
Ok::<_, anyhow::Error>(read_batches)
|
|
});
|
|
let client = crate::connect_remote_app_server(
|
|
codex_app_server_client::RemoteAppServerEndpoint::WebSocket {
|
|
websocket_url,
|
|
auth_token: None,
|
|
},
|
|
)
|
|
.await
|
|
.expect("connect to mock app server");
|
|
|
|
let snapshot = fetch_installed_connector_mentions(
|
|
client.request_handle(),
|
|
Some("thread-1".to_string()),
|
|
/*force_refresh*/ true,
|
|
)
|
|
.await?;
|
|
client.shutdown().await?;
|
|
Ok((snapshot, server.await??))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn installed_connector_mentions_read_callable_apps_in_bounded_thread_scoped_batches()
|
|
-> Result<()> {
|
|
let (snapshot, read_batches) = load_mock_mentions(MockResponses::FirstBatchFails).await?;
|
|
assert_eq!(read_batches, vec![100, 3]);
|
|
assert_eq!(snapshot.connectors.len(), 105);
|
|
assert!(snapshot.connectors[103..].iter().all(|app| !app.is_enabled));
|
|
let selected_apps = snapshot
|
|
.connectors
|
|
.iter()
|
|
.filter(|app| ["app-000", "app-100", "app-102"].contains(&app.id.as_str()))
|
|
.map(|app| app.name.as_str())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
selected_apps,
|
|
["Connector app-100", "Runtime 000", "app-102"]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn installed_connector_mentions_fall_back_for_legacy_app_servers() -> Result<()> {
|
|
let (snapshot, read_batches) = load_mock_mentions(MockResponses::Unsupported).await?;
|
|
|
|
assert!(read_batches.is_empty());
|
|
assert_eq!(
|
|
snapshot.connectors,
|
|
serde_json::from_value::<Vec<AppInfo>>(json!([
|
|
{ "id": "installed", "name": "Installed", "isAccessible": true },
|
|
{ "id": "disabled", "name": "Disabled", "isAccessible": true, "isEnabled": false }
|
|
]))?
|
|
);
|
|
Ok(())
|
|
}
|