mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
Clean up experimental feature popup backend wiring
This commit is contained in:
+15
-1
@@ -785,6 +785,20 @@
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"cwd": {
|
||||
"description": "Optional working directory used to resolve project config layers.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"profile": {
|
||||
"description": "Optional config profile used to compute effective feature enablement.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -6327,4 +6341,4 @@
|
||||
}
|
||||
],
|
||||
"title": "ClientRequest"
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -8363,6 +8363,20 @@
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"cwd": {
|
||||
"description": "Optional working directory used to resolve project config layers.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"profile": {
|
||||
"description": "Optional config profile used to compute effective feature enablement.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "ExperimentalFeatureListParams",
|
||||
@@ -18261,4 +18275,4 @@
|
||||
},
|
||||
"title": "CodexAppServerProtocol",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -4861,6 +4861,20 @@
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"cwd": {
|
||||
"description": "Optional working directory used to resolve project config layers.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"profile": {
|
||||
"description": "Optional config profile used to compute effective feature enablement.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "ExperimentalFeatureListParams",
|
||||
@@ -16146,4 +16160,4 @@
|
||||
},
|
||||
"title": "CodexAppServerProtocolV2",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -16,8 +16,22 @@
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"cwd": {
|
||||
"description": "Optional working directory used to resolve project config layers.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"profile": {
|
||||
"description": "Optional config profile used to compute effective feature enablement.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "ExperimentalFeatureListParams",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -10,4 +10,12 @@ cursor?: string | null,
|
||||
/**
|
||||
* Optional page size; defaults to a reasonable server-side value.
|
||||
*/
|
||||
limit?: number | null, };
|
||||
limit?: number | null,
|
||||
/**
|
||||
* Optional working directory used to resolve project config layers.
|
||||
*/
|
||||
cwd?: string | null,
|
||||
/**
|
||||
* Optional config profile used to compute effective feature enablement.
|
||||
*/
|
||||
profile?: string | null, };
|
||||
|
||||
@@ -2528,7 +2528,9 @@ mod tests {
|
||||
"id": 8,
|
||||
"params": {
|
||||
"cursor": null,
|
||||
"limit": null
|
||||
"limit": null,
|
||||
"cwd": null,
|
||||
"profile": null
|
||||
}
|
||||
}),
|
||||
serde_json::to_value(&request)?,
|
||||
|
||||
@@ -2611,6 +2611,12 @@ pub struct ExperimentalFeatureListParams {
|
||||
/// Optional page size; defaults to a reasonable server-side value.
|
||||
#[ts(optional = nullable)]
|
||||
pub limit: Option<u32>,
|
||||
/// Optional working directory used to resolve project config layers.
|
||||
#[ts(optional = nullable)]
|
||||
pub cwd: Option<String>,
|
||||
/// Optional config profile used to compute effective feature enablement.
|
||||
#[ts(optional = nullable)]
|
||||
pub profile: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
|
||||
@@ -193,8 +193,8 @@ Example with notification opt-out:
|
||||
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
|
||||
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
|
||||
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
|
||||
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
|
||||
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
|
||||
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, optional cwd/profile-scoped enablement, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
|
||||
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for beta features returned by `experimentalFeature/list`, plus the additional capability toggles `apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`, and `workspace_dependencies`. For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
|
||||
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
|
||||
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
|
||||
- `hooks/list` — list discovered hooks for one or more `cwd` values.
|
||||
|
||||
@@ -401,10 +401,64 @@ fn parse_key_path(path: &str) -> Result<Vec<String>, String> {
|
||||
if path.trim().is_empty() {
|
||||
return Err("keyPath must not be empty".to_string());
|
||||
}
|
||||
Ok(path
|
||||
.split('.')
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect())
|
||||
|
||||
let mut segments = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut chars = path.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'.' => segments.push(std::mem::take(&mut current)),
|
||||
'"' if current.is_empty() => {
|
||||
parse_quoted_key_path_segment(&mut chars, &mut current)?;
|
||||
if let Some(next) = chars.peek()
|
||||
&& *next != '.'
|
||||
{
|
||||
return Err(
|
||||
"quoted keyPath segment must be followed by `.` or end of path".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
ch => current.push(ch),
|
||||
}
|
||||
}
|
||||
segments.push(current);
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
fn parse_quoted_key_path_segment<I>(
|
||||
chars: &mut std::iter::Peekable<I>,
|
||||
output: &mut String,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
I: Iterator<Item = char>,
|
||||
{
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'"' => return Ok(()),
|
||||
'\\' => output.push(parse_key_path_escape(chars)?),
|
||||
ch => output.push(ch),
|
||||
}
|
||||
}
|
||||
Err("unterminated quoted keyPath segment".to_string())
|
||||
}
|
||||
|
||||
fn parse_key_path_escape<I>(chars: &mut std::iter::Peekable<I>) -> Result<char, String>
|
||||
where
|
||||
I: Iterator<Item = char>,
|
||||
{
|
||||
match chars.next() {
|
||||
Some('"') => Ok('"'),
|
||||
Some('\\') => Ok('\\'),
|
||||
Some('n') => Ok('\n'),
|
||||
Some('r') => Ok('\r'),
|
||||
Some('t') => Ok('\t'),
|
||||
Some('b') => Ok('\u{08}'),
|
||||
Some('f') => Ok('\u{0c}'),
|
||||
Some(other) => Err(format!(
|
||||
"unsupported escape in quoted keyPath segment: \\{other}"
|
||||
)),
|
||||
None => Err("unterminated escape in quoted keyPath segment".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -88,6 +88,16 @@ unified_exec = true
|
||||
})
|
||||
.await
|
||||
.expect("write succeeds");
|
||||
service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
||||
key_path: "profiles.\"team.prod\".features.goals".to_string(),
|
||||
value: serde_json::json!(true),
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
expected_version: None,
|
||||
})
|
||||
.await
|
||||
.expect("quoted profile write succeeds");
|
||||
|
||||
let updated = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = r#"# Codex user configuration
|
||||
@@ -101,6 +111,9 @@ hide_full_access_warning = true
|
||||
[features]
|
||||
unified_exec = true
|
||||
personality = true
|
||||
|
||||
[profiles."team.prod".features]
|
||||
goals = true
|
||||
"#;
|
||||
assert_eq!(updated, expected);
|
||||
Ok(())
|
||||
|
||||
@@ -284,8 +284,28 @@ impl CatalogRequestProcessor {
|
||||
&self,
|
||||
params: ExperimentalFeatureListParams,
|
||||
) -> Result<ExperimentalFeatureListResponse, JSONRPCErrorError> {
|
||||
let ExperimentalFeatureListParams { cursor, limit } = params;
|
||||
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
||||
let ExperimentalFeatureListParams {
|
||||
cursor,
|
||||
limit,
|
||||
cwd,
|
||||
profile,
|
||||
} = params;
|
||||
let request_overrides = profile.map(|profile| {
|
||||
HashMap::from([("profile".to_string(), serde_json::Value::String(profile))])
|
||||
});
|
||||
let config = self
|
||||
.config_manager
|
||||
.load_for_cwd(
|
||||
request_overrides,
|
||||
ConfigOverrides::default(),
|
||||
cwd.map(PathBuf::from),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to reload config: {err}"),
|
||||
data: None,
|
||||
})?;
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let workspace_codex_plugins_enabled = self
|
||||
.workspace_codex_plugins_enabled(&config, auth.as_ref())
|
||||
|
||||
@@ -40,7 +40,9 @@ use codex_config::MatcherGroup as CoreMatcherGroup;
|
||||
use codex_config::ResidencyRequirement as CoreResidencyRequirement;
|
||||
use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_features::FEATURES;
|
||||
use codex_features::Feature;
|
||||
use codex_features::Stage;
|
||||
use codex_features::canonical_feature_for_key;
|
||||
use codex_features::feature_for_key;
|
||||
use codex_login::AuthManager;
|
||||
@@ -51,7 +53,7 @@ use codex_protocol::protocol::Op;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
|
||||
const ALWAYS_SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
|
||||
"apps",
|
||||
"memories",
|
||||
"plugins",
|
||||
@@ -59,8 +61,26 @@ const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
|
||||
"tool_search",
|
||||
"tool_suggest",
|
||||
"tool_call_mcp_elicitation",
|
||||
"workspace_dependencies",
|
||||
];
|
||||
|
||||
fn supported_experimental_feature_enablement_keys() -> Vec<&'static str> {
|
||||
let mut keys = ALWAYS_SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.to_vec();
|
||||
for spec in FEATURES {
|
||||
if matches!(spec.stage, Stage::Experimental { .. }) && !keys.contains(&spec.key) {
|
||||
keys.push(spec.key);
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
fn experimental_feature_enablement_supported(key: &str) -> bool {
|
||||
ALWAYS_SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key)
|
||||
|| FEATURES
|
||||
.iter()
|
||||
.any(|spec| spec.key == key && matches!(spec.stage, Stage::Experimental { .. }))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ConfigRequestProcessor {
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
@@ -97,7 +117,7 @@ impl ConfigRequestProcessor {
|
||||
let fallback_cwd = params.cwd.as_ref().map(PathBuf::from);
|
||||
let mut response = self.config_manager.read(params).await.map_err(map_error)?;
|
||||
let config = self.load_latest_config(fallback_cwd).await?;
|
||||
for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT {
|
||||
for feature_key in supported_experimental_feature_enablement_keys() {
|
||||
let Some(feature) = feature_for_key(feature_key) else {
|
||||
continue;
|
||||
};
|
||||
@@ -339,13 +359,13 @@ impl ConfigRequestProcessor {
|
||||
let ExperimentalFeatureEnablementSetParams { enablement } = params;
|
||||
for key in enablement.keys() {
|
||||
if canonical_feature_for_key(key).is_some() {
|
||||
if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) {
|
||||
if experimental_feature_enablement_supported(key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Err(invalid_request(format!(
|
||||
"unsupported feature enablement `{key}`: currently supported features are {}",
|
||||
SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ")
|
||||
supported_experimental_feature_enablement_keys().join(", ")
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use toml::Value as TomlValue;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
@@ -39,8 +40,16 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
#[tokio::test]
|
||||
async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
"[features]\ngoals = false\n\n[profiles.dev.features]\ngoals = true\n",
|
||||
)?;
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cli_overrides(vec![(
|
||||
"profile".to_string(),
|
||||
TomlValue::String("dev".to_string()),
|
||||
)])
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.loader_overrides(LoaderOverrides::with_managed_config_path_for_tests(
|
||||
codex_home.path().join("managed_config.toml"),
|
||||
@@ -52,7 +61,10 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_experimental_feature_list_request(ExperimentalFeatureListParams::default())
|
||||
.send_experimental_feature_list_request(ExperimentalFeatureListParams {
|
||||
profile: Some("dev".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let actual = read_response::<ExperimentalFeatureListResponse>(&mut mcp, request_id).await?;
|
||||
@@ -239,6 +251,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
|
||||
BTreeMap::from([
|
||||
("memories".to_string(), true),
|
||||
("plugins".to_string(), true),
|
||||
("goals".to_string(), true),
|
||||
("tool_search".to_string(), true),
|
||||
("tool_suggest".to_string(), true),
|
||||
("tool_call_mcp_elicitation".to_string(), false),
|
||||
@@ -252,6 +265,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
|
||||
enablement: BTreeMap::from([
|
||||
("memories".to_string(), true),
|
||||
("plugins".to_string(), true),
|
||||
("goals".to_string(), true),
|
||||
("tool_search".to_string(), true),
|
||||
("tool_suggest".to_string(), true),
|
||||
("tool_call_mcp_elicitation".to_string(), false),
|
||||
@@ -282,6 +296,13 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
|
||||
.and_then(|features| features.get("plugins")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("goals")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
@@ -308,12 +329,14 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_feature_enablement_set_allows_remote_control() -> Result<()> {
|
||||
async fn experimental_feature_enablement_set_allows_electron_app_runtime_toggles() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
let remote_control_enabled = false;
|
||||
let enablement = BTreeMap::from([("remote_control".to_string(), remote_control_enabled)]);
|
||||
let enablement = BTreeMap::from([
|
||||
("remote_control".to_string(), false),
|
||||
("workspace_dependencies".to_string(), true),
|
||||
]);
|
||||
|
||||
let actual = set_experimental_feature_enablement(&mut mcp, enablement.clone()).await?;
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::app_server_session::AppServerSession;
|
||||
use crate::app_server_session::AppServerStartedThread;
|
||||
use crate::app_server_session::app_server_rate_limit_snapshots;
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::bottom_pane::ExperimentalFeatureItem;
|
||||
use crate::bottom_pane::FeedbackAudience;
|
||||
use crate::bottom_pane::McpServerElicitationFormRequest;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
|
||||
@@ -87,6 +87,108 @@ impl App {
|
||||
self.chat_widget.set_approvals_reviewer(reviewer);
|
||||
}
|
||||
|
||||
pub(super) async fn open_experimental_popup_with_app_server(
|
||||
&mut self,
|
||||
app_server: &mut AppServerSession,
|
||||
) {
|
||||
let cwd = if app_server.is_remote() {
|
||||
app_server.remote_cwd_override().map(Path::to_path_buf)
|
||||
} else {
|
||||
Some(self.chat_widget.config_ref().cwd.to_path_buf())
|
||||
};
|
||||
match app_server
|
||||
.experimental_feature_list(cwd.as_deref(), self.active_profile.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let features = response
|
||||
.data
|
||||
.into_iter()
|
||||
.filter_map(ExperimentalFeatureItem::from_api)
|
||||
.collect();
|
||||
self.chat_widget.show_experimental_popup(features);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(error = %err, "failed to load experimental features");
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to load experimental features: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn update_feature_flags_with_app_server(
|
||||
&mut self,
|
||||
app_server: &mut AppServerSession,
|
||||
updates: Vec<(Feature, bool)>,
|
||||
) {
|
||||
if updates.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let memory_tool_was_enabled = self.config.features.enabled(Feature::MemoryTool);
|
||||
let requested_keys = updates
|
||||
.iter()
|
||||
.map(|(feature, _)| feature.key().to_string())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
|
||||
if let Err(err) = app_server
|
||||
.write_feature_flags(&updates, self.active_profile.as_deref())
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = %err, "failed to persist remote feature flags");
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to update experimental features: {err}"));
|
||||
return;
|
||||
}
|
||||
|
||||
let cwd = if app_server.is_remote() {
|
||||
app_server.remote_cwd_override().map(Path::to_path_buf)
|
||||
} else {
|
||||
Some(self.chat_widget.config_ref().cwd.to_path_buf())
|
||||
};
|
||||
let response = match app_server
|
||||
.experimental_feature_list(cwd.as_deref(), self.active_profile.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
tracing::error!(error = %err, "failed to refresh remote feature flags");
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Updated experimental features, but failed to refresh local state: {err}"
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut memory_tool_is_enabled = memory_tool_was_enabled;
|
||||
for feature in response.data {
|
||||
if !requested_keys.contains(&feature.name) {
|
||||
continue;
|
||||
}
|
||||
let Some(core_feature) = codex_features::feature_for_key(&feature.name) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let enabled = self
|
||||
.chat_widget
|
||||
.set_feature_enabled(core_feature, feature.enabled);
|
||||
if let Err(err) = self.config.features.set_enabled(core_feature, enabled) {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
feature = feature.name,
|
||||
"failed to sync constrained app feature state from remote app-server"
|
||||
);
|
||||
}
|
||||
if core_feature == Feature::MemoryTool {
|
||||
memory_tool_is_enabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
if memory_tool_is_enabled && !memory_tool_was_enabled {
|
||||
self.chat_widget.add_memories_enable_notice();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn try_set_approval_policy_on_config(
|
||||
&mut self,
|
||||
config: &mut Config,
|
||||
|
||||
@@ -669,6 +669,10 @@ impl App {
|
||||
AppEvent::ClearThreadGoal { thread_id } => {
|
||||
self.clear_thread_goal(app_server, thread_id).await;
|
||||
}
|
||||
AppEvent::OpenExperimentalFeaturesPopup => {
|
||||
self.open_experimental_popup_with_app_server(app_server)
|
||||
.await;
|
||||
}
|
||||
AppEvent::SendAddCreditsNudgeEmail { credit_type } => {
|
||||
if self
|
||||
.chat_widget
|
||||
@@ -1458,7 +1462,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
AppEvent::UpdateFeatureFlags { updates } => {
|
||||
self.update_feature_flags(updates).await;
|
||||
if self.remote_app_server_url.is_some() {
|
||||
self.update_feature_flags_with_app_server(app_server, updates)
|
||||
.await;
|
||||
} else {
|
||||
self.update_feature_flags(updates).await;
|
||||
}
|
||||
}
|
||||
AppEvent::UpdateMemorySettings {
|
||||
use_memories,
|
||||
|
||||
@@ -240,6 +240,9 @@ pub(crate) enum AppEvent {
|
||||
thread_id: ThreadId,
|
||||
},
|
||||
|
||||
/// Load the experimental-features popup from the app-server catalog.
|
||||
OpenExperimentalFeaturesPopup,
|
||||
|
||||
/// Result of refreshing rate limits.
|
||||
RateLimitsLoaded {
|
||||
origin: RateLimitRefreshOrigin,
|
||||
|
||||
@@ -21,7 +21,10 @@ use codex_app_server_protocol::AskForApproval;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||
use codex_app_server_protocol::ConfigEdit as AppServerConfigEdit;
|
||||
use codex_app_server_protocol::ConfigWriteResponse;
|
||||
use codex_app_server_protocol::ExperimentalFeatureListParams;
|
||||
use codex_app_server_protocol::ExperimentalFeatureListResponse;
|
||||
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
|
||||
use codex_app_server_protocol::ExternalAgentConfigDetectResponse;
|
||||
use codex_app_server_protocol::ExternalAgentConfigImportParams;
|
||||
@@ -33,6 +36,7 @@ use codex_app_server_protocol::GetAccountResponse;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::LogoutAccountResponse;
|
||||
use codex_app_server_protocol::MemoryResetResponse;
|
||||
use codex_app_server_protocol::MergeStrategy;
|
||||
use codex_app_server_protocol::Model as ApiModel;
|
||||
use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::ModelListResponse;
|
||||
@@ -102,6 +106,7 @@ use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnSteerParams;
|
||||
use codex_app_server_protocol::TurnSteerResponse;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_features::Feature;
|
||||
use codex_otel::TelemetryAuthMode;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::approvals::GuardianAssessmentEvent;
|
||||
@@ -887,6 +892,68 @@ impl AppServerSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn write_feature_flags(
|
||||
&mut self,
|
||||
updates: &[(Feature, bool)],
|
||||
active_profile: Option<&str>,
|
||||
) -> Result<ConfigWriteResponse> {
|
||||
let request_id = self.next_request_id();
|
||||
self.client
|
||||
.request_typed(ClientRequest::ConfigBatchWrite {
|
||||
request_id,
|
||||
params: ConfigBatchWriteParams {
|
||||
edits: updates
|
||||
.iter()
|
||||
.map(|(feature, enabled)| AppServerConfigEdit {
|
||||
key_path: feature_config_key_path(*feature, active_profile),
|
||||
value: serde_json::json!(enabled),
|
||||
merge_strategy: MergeStrategy::Upsert,
|
||||
})
|
||||
.collect(),
|
||||
file_path: None,
|
||||
expected_version: None,
|
||||
reload_user_config: true,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("config/batchWrite failed while updating feature flags in TUI")
|
||||
}
|
||||
|
||||
pub(crate) async fn experimental_feature_list(
|
||||
&mut self,
|
||||
cwd: Option<&std::path::Path>,
|
||||
active_profile: Option<&str>,
|
||||
) -> Result<ExperimentalFeatureListResponse> {
|
||||
let mut cursor = None;
|
||||
let mut data = Vec::new();
|
||||
loop {
|
||||
let request_id = self.next_request_id();
|
||||
let response: ExperimentalFeatureListResponse = self
|
||||
.client
|
||||
.request_typed(ClientRequest::ExperimentalFeatureList {
|
||||
request_id,
|
||||
params: ExperimentalFeatureListParams {
|
||||
cursor,
|
||||
limit: Some(100),
|
||||
cwd: cwd.map(|cwd| cwd.to_string_lossy().to_string()),
|
||||
profile: active_profile.map(ToOwned::to_owned),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("experimentalFeature/list failed in TUI")?;
|
||||
data.extend(response.data);
|
||||
match response.next_cursor {
|
||||
Some(next_cursor) => cursor = Some(next_cursor),
|
||||
None => {
|
||||
return Ok(ExperimentalFeatureListResponse {
|
||||
data,
|
||||
next_cursor: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_realtime_start(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
@@ -969,6 +1036,44 @@ impl AppServerSession {
|
||||
}
|
||||
}
|
||||
|
||||
fn feature_config_key_path(feature: Feature, active_profile: Option<&str>) -> String {
|
||||
match active_profile {
|
||||
Some(profile) => format!(
|
||||
"profiles.{}.features.{}",
|
||||
key_path_segment(profile),
|
||||
feature.key()
|
||||
),
|
||||
None => format!("features.{}", feature.key()),
|
||||
}
|
||||
}
|
||||
|
||||
fn key_path_segment(segment: &str) -> String {
|
||||
if !segment.is_empty()
|
||||
&& segment
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
|
||||
{
|
||||
return segment.to_string();
|
||||
}
|
||||
|
||||
let mut quoted = String::with_capacity(segment.len() + 2);
|
||||
quoted.push('"');
|
||||
for ch in segment.chars() {
|
||||
match ch {
|
||||
'"' => quoted.push_str("\\\""),
|
||||
'\\' => quoted.push_str("\\\\"),
|
||||
'\n' => quoted.push_str("\\n"),
|
||||
'\r' => quoted.push_str("\\r"),
|
||||
'\t' => quoted.push_str("\\t"),
|
||||
'\u{08}' => quoted.push_str("\\b"),
|
||||
'\u{0c}' => quoted.push_str("\\f"),
|
||||
ch => quoted.push(ch),
|
||||
}
|
||||
}
|
||||
quoted.push('"');
|
||||
quoted
|
||||
}
|
||||
|
||||
fn thread_realtime_start_params(
|
||||
thread_id: ThreadId,
|
||||
transport: Option<ThreadRealtimeStartTransport>,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use codex_app_server_protocol::ExperimentalFeature;
|
||||
use codex_app_server_protocol::ExperimentalFeatureStage;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
@@ -20,6 +22,7 @@ use crate::render::renderable::Renderable;
|
||||
use crate::style::user_message_style;
|
||||
|
||||
use codex_features::Feature;
|
||||
use codex_features::feature_for_key;
|
||||
|
||||
use super::CancellationEvent;
|
||||
use super::bottom_pane_view::BottomPaneView;
|
||||
@@ -31,11 +34,28 @@ use super::selection_popup_common::render_rows;
|
||||
|
||||
pub(crate) struct ExperimentalFeatureItem {
|
||||
pub feature: Feature,
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub description: String,
|
||||
pub original_enabled: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl ExperimentalFeatureItem {
|
||||
pub(crate) fn from_api(feature: ExperimentalFeature) -> Option<Self> {
|
||||
if feature.stage != ExperimentalFeatureStage::Beta {
|
||||
return None;
|
||||
}
|
||||
let core_feature = feature_for_key(&feature.name)?;
|
||||
Some(Self {
|
||||
feature: core_feature,
|
||||
display_name: feature.display_name?,
|
||||
description: feature.description?,
|
||||
original_enabled: feature.enabled,
|
||||
enabled: feature.enabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ExperimentalFeaturesView {
|
||||
features: Vec<ExperimentalFeatureItem>,
|
||||
state: ScrollState,
|
||||
@@ -90,7 +110,7 @@ impl ExperimentalFeaturesView {
|
||||
' '
|
||||
};
|
||||
let marker = if item.enabled { 'x' } else { ' ' };
|
||||
let name = format!("{prefix} [{marker}] {}", item.name);
|
||||
let name = format!("{prefix} [{marker}] {}", item.display_name);
|
||||
rows.push(GenericDisplayRow {
|
||||
name,
|
||||
description: Some(item.description.clone()),
|
||||
@@ -198,13 +218,13 @@ impl BottomPaneView for ExperimentalFeaturesView {
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
// Save the updates
|
||||
if !self.features.is_empty() {
|
||||
let updates = self
|
||||
.features
|
||||
.iter()
|
||||
.map(|item| (item.feature, item.enabled))
|
||||
.collect();
|
||||
let updates: Vec<_> = self
|
||||
.features
|
||||
.iter()
|
||||
.filter(|item| item.enabled != item.original_enabled)
|
||||
.map(|item| (item.feature, item.enabled))
|
||||
.collect();
|
||||
if !updates.is_empty() {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::UpdateFeatureFlags { updates });
|
||||
}
|
||||
|
||||
@@ -129,7 +129,6 @@ use codex_config::types::ApprovalsReviewer;
|
||||
use codex_config::types::Notifications;
|
||||
use codex_config::types::WindowsSandboxModeToml;
|
||||
use codex_core_skills::model::SkillMetadata;
|
||||
use codex_features::FEATURES;
|
||||
use codex_features::Feature;
|
||||
#[cfg(test)]
|
||||
use codex_git_utils::CommitLogEntry;
|
||||
@@ -8460,21 +8459,7 @@ impl ChatWidget {
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn open_experimental_popup(&mut self) {
|
||||
let features: Vec<ExperimentalFeatureItem> = FEATURES
|
||||
.iter()
|
||||
.filter_map(|spec| {
|
||||
let name = spec.stage.experimental_menu_name()?;
|
||||
let description = spec.stage.experimental_menu_description()?;
|
||||
Some(ExperimentalFeatureItem {
|
||||
feature: spec.id,
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
enabled: self.config.features.enabled(spec.id),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
pub(crate) fn show_experimental_popup(&mut self, features: Vec<ExperimentalFeatureItem>) {
|
||||
let view = ExperimentalFeaturesView::new(features, self.app_event_tx.clone());
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
|
||||
@@ -297,7 +297,8 @@ impl ChatWidget {
|
||||
);
|
||||
}
|
||||
SlashCommand::Experimental => {
|
||||
self.open_experimental_popup();
|
||||
self.app_event_tx
|
||||
.send(AppEvent::OpenExperimentalFeaturesPopup);
|
||||
}
|
||||
SlashCommand::AutoReview => {
|
||||
self.open_auto_review_denials_popup();
|
||||
|
||||
@@ -131,7 +131,6 @@ pub(super) use codex_config::types::Notifications;
|
||||
pub(super) use codex_config::types::WindowsSandboxModeToml;
|
||||
pub(super) use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
pub(super) use codex_core_skills::model::SkillMetadata;
|
||||
pub(super) use codex_features::FEATURES;
|
||||
pub(super) use codex_features::Feature;
|
||||
pub(super) use codex_git_utils::CommitLogEntry;
|
||||
pub(super) use codex_otel::RuntimeMetricsSummary;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use super::*;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::ExperimentalFeature;
|
||||
use codex_app_server_protocol::ExperimentalFeatureStage;
|
||||
use codex_app_server_protocol::HookErrorInfo;
|
||||
use codex_app_server_protocol::HooksListEntry;
|
||||
use codex_app_server_protocol::HooksListResponse;
|
||||
use codex_app_server_protocol::MarketplaceRemoveResponse;
|
||||
use codex_features::Stage;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2159,14 +2160,16 @@ async fn experimental_features_popup_snapshot() {
|
||||
let features = vec![
|
||||
ExperimentalFeatureItem {
|
||||
feature: Feature::JsRepl,
|
||||
name: "JavaScript REPL".to_string(),
|
||||
display_name: "JavaScript REPL".to_string(),
|
||||
description: "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities.".to_string(),
|
||||
original_enabled: false,
|
||||
enabled: false,
|
||||
},
|
||||
ExperimentalFeatureItem {
|
||||
feature: Feature::ShellTool,
|
||||
name: "Shell tool".to_string(),
|
||||
display_name: "Shell tool".to_string(),
|
||||
description: "Allow the model to run shell commands.".to_string(),
|
||||
original_enabled: true,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
@@ -2183,12 +2186,22 @@ async fn experimental_features_toggle_saves_on_exit() {
|
||||
|
||||
let expected_feature = Feature::JsRepl;
|
||||
let view = ExperimentalFeaturesView::new(
|
||||
vec![ExperimentalFeatureItem {
|
||||
feature: expected_feature,
|
||||
name: "JavaScript REPL".to_string(),
|
||||
description: "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities.".to_string(),
|
||||
enabled: false,
|
||||
}],
|
||||
vec![
|
||||
ExperimentalFeatureItem {
|
||||
feature: expected_feature,
|
||||
display_name: "JavaScript REPL".to_string(),
|
||||
description: "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities.".to_string(),
|
||||
original_enabled: false,
|
||||
enabled: false,
|
||||
},
|
||||
ExperimentalFeatureItem {
|
||||
feature: Feature::ShellTool,
|
||||
display_name: "Shell tool".to_string(),
|
||||
description: "Allow the model to run shell commands.".to_string(),
|
||||
original_enabled: true,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
chat.app_event_tx.clone(),
|
||||
);
|
||||
chat.bottom_pane.show_view(Box::new(view));
|
||||
@@ -2220,15 +2233,33 @@ async fn experimental_features_toggle_saves_on_exit() {
|
||||
#[tokio::test]
|
||||
async fn experimental_popup_omits_stable_guardian_approval() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let guardian_stage = FEATURES
|
||||
.iter()
|
||||
.find(|spec| spec.id == Feature::GuardianApproval)
|
||||
.map(|spec| spec.stage)
|
||||
.expect("expected guardian approval feature metadata");
|
||||
let features = vec![
|
||||
ExperimentalFeature {
|
||||
name: "guardian_approval".to_string(),
|
||||
stage: ExperimentalFeatureStage::Stable,
|
||||
display_name: None,
|
||||
description: None,
|
||||
announcement: None,
|
||||
enabled: true,
|
||||
default_enabled: true,
|
||||
},
|
||||
ExperimentalFeature {
|
||||
name: "memories".to_string(),
|
||||
stage: ExperimentalFeatureStage::Beta,
|
||||
display_name: Some("Memories".to_string()),
|
||||
description: Some("Allow Codex to create and use memories.".to_string()),
|
||||
announcement: None,
|
||||
enabled: false,
|
||||
default_enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(guardian_stage, Stage::Stable);
|
||||
|
||||
chat.open_experimental_popup();
|
||||
chat.show_experimental_popup(
|
||||
features
|
||||
.into_iter()
|
||||
.filter_map(ExperimentalFeatureItem::from_api)
|
||||
.collect(),
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 120);
|
||||
assert!(
|
||||
|
||||
Reference in New Issue
Block a user