mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
## What changed - Save writable `/experimental` selections through `config/batchWrite`, including features that are not known to the local client. - Refresh configured values after writes and surface timeouts, failures, and higher-priority overrides without changing the active task's settings. - Serialize saves and discovery, let accepted writes finish after the popup closes, and retain uncertain selections for explicit retry. ## Testing - Cover new server feature keys, server defaults, overrides, write and readback failures, timeouts, selected config profiles, and popup retry behavior. GitOrigin-RevId: acee4e10cbb8c2fce9211e8fadd3cd67fa0eb1a5
253 lines
11 KiB
Rust
253 lines
11 KiB
Rust
use super::*;
|
|
use codex_app_server_client::RemoteAppServerClient;
|
|
use codex_app_server_client::RemoteAppServerConnectArgs;
|
|
use codex_app_server_client::RemoteAppServerEndpoint;
|
|
use codex_app_server_protocol::JSONRPCMessage;
|
|
use futures::SinkExt;
|
|
use futures::StreamExt;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use tokio_tungstenite::tungstenite::Message;
|
|
|
|
#[tokio::test]
|
|
async fn experimental_features_rpc_paginates_thread_config_and_bounds_bad_servers() {
|
|
for scenario in ["pages", "cycle", "limit", "error", "timeout"] {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let url = format!("ws://{}", listener.local_addr().unwrap());
|
|
let thread_id = ThreadId::new();
|
|
let server = tokio::spawn(async move {
|
|
let (stream, _) = listener.accept().await.unwrap();
|
|
let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap();
|
|
let mut page = 0;
|
|
while let Some(Ok(Message::Text(text))) = socket.next().await {
|
|
let JSONRPCMessage::Request(request) = serde_json::from_str(&text).unwrap() else {
|
|
continue;
|
|
};
|
|
let result = if request.method == "initialize" {
|
|
json!({"userAgent": "experimental-test"})
|
|
} else {
|
|
assert_eq!(request.method, "experimentalFeature/list");
|
|
assert_eq!(
|
|
request.params,
|
|
Some(json!({
|
|
"cursor": if page == 0 { None } else { Some(page.to_string()) },
|
|
"limit": 100, "threadId": thread_id.to_string()
|
|
}))
|
|
);
|
|
page += 1;
|
|
if scenario == "timeout" {
|
|
// Leave the connection open without answering discovery.
|
|
continue;
|
|
}
|
|
if scenario == "error" {
|
|
socket.send(Message::Text(json!({"id": request.id, "error": {"code": -32601, "message": "method not found", "data": "private wire content"}}).to_string().into())).await.unwrap();
|
|
continue;
|
|
}
|
|
json!({"data": [{
|
|
"name": format!("feature-{page}"), "stage": "beta", "displayName": "Server experiment",
|
|
"description": "Server description", "announcement": null,
|
|
"enabled": true, "defaultEnabled": false
|
|
}], "nextCursor": match scenario {
|
|
"pages" if page == 2 => None,
|
|
"cycle" => Some("1".to_string()),
|
|
_ => Some(page.to_string()),
|
|
}})
|
|
};
|
|
socket
|
|
.send(Message::Text(
|
|
json!({"id": request.id, "result": result})
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
}
|
|
page
|
|
});
|
|
let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
|
|
endpoint: RemoteAppServerEndpoint::WebSocket {
|
|
websocket_url: url,
|
|
auth_token: None,
|
|
},
|
|
client_name: "experimental-test".to_string(),
|
|
client_version: "0.0.0".to_string(),
|
|
experimental_api: true,
|
|
mcp_server_openai_form_elicitation: false,
|
|
opt_out_notification_methods: Vec::new(),
|
|
channel_capacity: 8,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let (tx, rx) = oneshot::channel();
|
|
fetch(
|
|
AppServerRequestHandle::Remote(client.request_handle()),
|
|
thread_id,
|
|
"tui-experimental-features",
|
|
tx,
|
|
);
|
|
let result = tokio::time::timeout(Duration::from_secs(/*secs*/ 10), rx)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
if scenario == "pages" {
|
|
let features = result.unwrap();
|
|
assert_eq!(
|
|
features,
|
|
["feature-1", "feature-2"]
|
|
.into_iter()
|
|
.map(|name| ExperimentalFeature {
|
|
name: name.to_string(),
|
|
stage: codex_app_server_protocol::ExperimentalFeatureStage::Beta,
|
|
display_name: Some("Server experiment".to_string()),
|
|
description: Some("Server description".to_string()),
|
|
announcement: None,
|
|
enabled: true,
|
|
default_enabled: false,
|
|
})
|
|
.collect::<Vec<_>>()
|
|
);
|
|
} else {
|
|
let error = result.unwrap_err();
|
|
assert_eq!(
|
|
error,
|
|
match scenario {
|
|
"cycle" => "Experimental feature pagination repeated a cursor",
|
|
"limit" => "Experimental feature discovery exceeded 10 pages",
|
|
"error" => "Experimental feature request failed",
|
|
"timeout" => "Experimental feature discovery timed out",
|
|
_ => unreachable!(),
|
|
}
|
|
);
|
|
}
|
|
if scenario == "timeout" {
|
|
// Reopening while the abandoned request is still unanswered cannot
|
|
// accumulate more pending requests or forward more calls to the server.
|
|
for _ in 0..3 {
|
|
let (tx, rx) = oneshot::channel();
|
|
fetch(
|
|
AppServerRequestHandle::Remote(client.request_handle()),
|
|
thread_id,
|
|
"tui-experimental-features",
|
|
tx,
|
|
);
|
|
assert_eq!(
|
|
rx.await.unwrap(),
|
|
Err("Experimental feature request failed".to_string())
|
|
);
|
|
}
|
|
}
|
|
client.shutdown().await.unwrap();
|
|
assert_eq!(
|
|
server.await.unwrap(),
|
|
match scenario {
|
|
"pages" | "cycle" => 2,
|
|
"limit" => 10,
|
|
_ => 1,
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn experimental_feature_writes_use_server_defaults_and_refresh_configured_values() {
|
|
for scenario in [
|
|
"overridden",
|
|
"write_error",
|
|
"write_timeout",
|
|
"readback_error",
|
|
] {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let url = format!("ws://{}", listener.local_addr().unwrap());
|
|
let thread_id = ThreadId::new();
|
|
let selected_file = std::env::temp_dir().join("selected.config.toml");
|
|
let server = tokio::spawn(async move {
|
|
let (stream, _) = listener.accept().await.unwrap();
|
|
let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap();
|
|
let mut wrote = false;
|
|
while let Some(Ok(Message::Text(text))) = socket.next().await {
|
|
let JSONRPCMessage::Request(request) = serde_json::from_str(&text).unwrap() else {
|
|
continue;
|
|
};
|
|
let fail = (request.method == "config/batchWrite" && scenario == "write_error")
|
|
|| (request.method == "experimentalFeature/list"
|
|
&& wrote
|
|
&& scenario == "readback_error");
|
|
if fail {
|
|
socket.send(Message::Text(json!({"id": request.id, "error": {"code": -32600, "message": "rejected", "data": "private diagnostic"}}).to_string().into())).await.unwrap();
|
|
continue;
|
|
}
|
|
let result = match request.method.as_str() {
|
|
"initialize" => json!({"userAgent": "experimental-test"}),
|
|
"experimentalFeature/list" => {
|
|
json!({"data": [
|
|
{"name": "default_off", "stage": "beta", "displayName": null, "description": null, "announcement": null, "enabled": !wrote || scenario == "overridden", "defaultEnabled": false},
|
|
{"name": "default_on", "stage": "beta", "displayName": null, "description": null, "announcement": null, "enabled": !wrote, "defaultEnabled": true},
|
|
{"name": "new_server_feature", "stage": "beta", "displayName": null, "description": null, "announcement": null, "enabled": wrote, "defaultEnabled": false}
|
|
], "nextCursor": null})
|
|
}
|
|
"config/batchWrite" => {
|
|
assert_eq!(
|
|
request.params,
|
|
Some(json!({
|
|
"edits": [
|
|
{"keyPath": "features.\"default_off\"", "value": null, "mergeStrategy": "replace"},
|
|
{"keyPath": "features.\"default_on\"", "value": false, "mergeStrategy": "replace"},
|
|
{"keyPath": "features.\"new_server_feature\"", "value": true, "mergeStrategy": "replace"}
|
|
], "filePath": null, "expectedVersion": null, "reloadUserConfig": true
|
|
}))
|
|
);
|
|
wrote = true;
|
|
if scenario == "write_timeout" {
|
|
tokio::time::pause();
|
|
continue;
|
|
}
|
|
json!({"status": "ok", "version": "v2", "filePath": selected_file, "overriddenMetadata": null})
|
|
}
|
|
other => panic!("unexpected RPC: {other}"),
|
|
};
|
|
socket
|
|
.send(Message::Text(
|
|
json!({"id": request.id, "result": result})
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
}
|
|
});
|
|
let client = crate::connect_remote_app_server(RemoteAppServerEndpoint::WebSocket {
|
|
websocket_url: url,
|
|
auth_token: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let handle = client.request_handle();
|
|
let result = write(
|
|
handle,
|
|
thread_id,
|
|
vec![
|
|
("default_off".to_string(), false),
|
|
("default_on".to_string(), false),
|
|
("new_server_feature".to_string(), true),
|
|
],
|
|
)
|
|
.await;
|
|
if scenario == "write_timeout" {
|
|
tokio::time::resume();
|
|
}
|
|
match scenario {
|
|
"overridden" => assert!(result.unwrap().warning.is_some()),
|
|
scenario => {
|
|
let error = result.unwrap_err();
|
|
assert!(error.contains(match scenario {
|
|
"readback_error" => "saved, but configured values could not be refreshed",
|
|
"write_timeout" => "write may still finish",
|
|
_ => "Failed to save experimental features.",
|
|
}));
|
|
}
|
|
}
|
|
let _ = client.shutdown().await;
|
|
server.await.unwrap();
|
|
}
|
|
}
|