Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions codex-rs/app-server-protocol/schema/json/ClientRequest.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 27 additions & 1 deletion codex-rs/app-server-protocol/src/protocol/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2707,7 +2707,33 @@ mod tests {
"id": 8,
"params": {
"cursor": null,
"limit": null
"limit": null,
"threadId": null
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}

#[test]
fn serialize_list_experimental_features_with_thread_id() -> Result<()> {
let request = ClientRequest::ExperimentalFeatureList {
request_id: RequestId::Integer(8),
params: v2::ExperimentalFeatureListParams {
cursor: Some("3".to_string()),
limit: Some(2),
thread_id: Some("00000000-0000-4000-8000-000000000001".to_string()),
},
};
assert_eq!(
json!({
"method": "experimentalFeature/list",
"id": 8,
"params": {
"cursor": "3",
"limit": 2,
"threadId": "00000000-0000-4000-8000-000000000001"
}
}),
serde_json::to_value(&request)?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ pub struct ExperimentalFeatureListParams {
/// Optional page size; defaults to a reasonable server-side value.
#[ts(optional = nullable)]
pub limit: Option<u32>,
/// Optional loaded thread id. Pass this when showing feature state for an
/// existing thread so enablement is computed from that thread's refreshed
/// config, including project-local config for the thread's cwd.
#[ts(optional = nullable)]
pub thread_id: Option<String>,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/app-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ 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/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. 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`, `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.
- `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment.
- `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.
Expand Down
24 changes: 22 additions & 2 deletions codex-rs/app-server/src/request_processors/catalog_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,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,
thread_id,
} = params;
let config = match thread_id.as_deref() {
Some(thread_id) => {
let thread_id = ThreadId::from_string(thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
let thread = self
.thread_manager
.get_thread(thread_id)
.await
.map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?;
let thread_config = thread.config().await;
self.config_manager
.load_latest_config_for_thread(thread_config.as_ref())
.await
.map_err(|err| internal_error(format!("failed to reload config: {err}")))?
}
None => self.load_latest_config(/*fallback_cwd*/ None).await?,
};
let auth = self.auth_manager.auth().await;
let workspace_codex_plugins_enabled = self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
Expand Down
101 changes: 101 additions & 0 deletions codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::time::Duration;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::McpProcess;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::ConfigReadParams;
Expand All @@ -16,6 +17,8 @@ use codex_app_server_protocol::ExperimentalFeatureStage;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_config::LoaderOverrides;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config::ConfigBuilder;
Expand Down Expand Up @@ -156,6 +159,104 @@ async fn experimental_feature_list_marks_apps_and_plugins_disabled_by_workspace_
Ok(())
}

#[tokio::test]
async fn experimental_feature_list_resolves_thread_project_config() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
let workspace = TempDir::new()?;
let server_uri = server.uri();
let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\");
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
model_provider = "mock_provider"

[projects."{workspace_key}"]
trust_level = "trusted"

[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#
),
)?;
let project_config_dir = workspace.path().join(".codex");
std::fs::create_dir_all(&project_config_dir)?;
std::fs::write(
project_config_dir.join("config.toml"),
r#"[features]
memories = true
"#,
)?;

let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;

let thread_start_id = mcp
.send_thread_start_request(ThreadStartParams {
cwd: Some(workspace.path().display().to_string()),
..Default::default()
})
.await?;
let ThreadStartResponse { thread, .. } =
read_response::<ThreadStartResponse>(&mut mcp, thread_start_id).await?;

let request_id = mcp
.send_experimental_feature_list_request(ExperimentalFeatureListParams {
cursor: None,
limit: None,
thread_id: Some(thread.id),
})
.await?;

let actual = read_response::<ExperimentalFeatureListResponse>(&mut mcp, request_id).await?;
let memories = actual
.data
.iter()
.find(|feature| feature.name == "memories")
.expect("memories feature should be present");
assert!(memories.enabled);

Ok(())
}

#[tokio::test]
async fn experimental_feature_list_rejects_unknown_thread_id() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;

let request_id = mcp
.send_experimental_feature_list_request(ExperimentalFeatureListParams {
cursor: None,
limit: None,
thread_id: Some("00000000-0000-4000-8000-000000000001".to_string()),
})
.await?;
let JSONRPCError { error, .. } = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;

assert_eq!(error.code, -32600);
assert!(
error
.message
.contains("thread not found: 00000000-0000-4000-8000-000000000001"),
"{}",
error.message
);

Ok(())
}

#[tokio::test]
async fn experimental_feature_enablement_set_applies_to_global_and_thread_config_reads()
-> Result<()> {
Expand Down
Loading