mirror of
https://github.com/openai/codex.git
synced 2026-06-02 11:22:01 +00:00
Compare commits
15 Commits
fcoury/fix
...
nm-codex/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11d1e9cd19 | ||
|
|
c243abfc81 | ||
|
|
8e5f561697 | ||
|
|
a076b21730 | ||
|
|
4cd3ab4a3d | ||
|
|
25d081d10b | ||
|
|
f2e7b462a9 | ||
|
|
bcd8d541f5 | ||
|
|
a717e4ef31 | ||
|
|
20da4c37c5 | ||
|
|
1f93706e99 | ||
|
|
a1ecf0cf1c | ||
|
|
c9dc0f6338 | ||
|
|
10a8a4e84f | ||
|
|
24d5ccc19b |
2
.github/workflows/issue-deduplicator.yml
vendored
2
.github/workflows/issue-deduplicator.yml
vendored
@@ -12,6 +12,7 @@ jobs:
|
||||
# Prevent runs on forks (requires OpenAI API key, wastes Actions minutes)
|
||||
if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate'))
|
||||
runs-on: ubuntu-latest
|
||||
environment: issue-triage
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
@@ -157,6 +158,7 @@ jobs:
|
||||
needs: normalize-duplicates-all
|
||||
if: ${{ needs.normalize-duplicates-all.result == 'success' && needs.normalize-duplicates-all.outputs.has_matches != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: issue-triage
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
|
||||
1
.github/workflows/issue-labeler.yml
vendored
1
.github/workflows/issue-labeler.yml
vendored
@@ -12,6 +12,7 @@ jobs:
|
||||
# Prevent runs on forks (requires OpenAI API key, wastes Actions minutes)
|
||||
if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label'))
|
||||
runs-on: ubuntu-latest
|
||||
environment: issue-triage
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
|
||||
1
.vscode/extensions.json
vendored
1
.vscode/extensions.json
vendored
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"BazelBuild.vscode-bazel",
|
||||
"rust-lang.rust-analyzer",
|
||||
"charliermarsh.ruff",
|
||||
"tamasfe.even-better-toml",
|
||||
|
||||
2
codex-rs/Cargo.lock
generated
2
codex-rs/Cargo.lock
generated
@@ -2430,8 +2430,6 @@ dependencies = [
|
||||
name = "codex-code-mode"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-trait",
|
||||
"codex-protocol",
|
||||
"deno_core_icudata",
|
||||
"pretty_assertions",
|
||||
|
||||
@@ -2095,6 +2095,7 @@ mod tests {
|
||||
use codex_protocol::items::build_hook_prompt_message;
|
||||
use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions;
|
||||
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
@@ -2110,7 +2111,6 @@ mod tests {
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use codex_protocol::protocol::RateLimitWindow;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::TokenUsageInfo;
|
||||
@@ -2187,7 +2187,7 @@ mod tests {
|
||||
agent_path: None,
|
||||
git_info: None,
|
||||
approval_mode: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
token_usage: None,
|
||||
first_user_message: Some("before rollback".to_string()),
|
||||
history: Some(StoredThreadHistory {
|
||||
|
||||
@@ -1364,8 +1364,16 @@ impl PluginRequestProcessor {
|
||||
.await;
|
||||
}
|
||||
|
||||
let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await;
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await;
|
||||
let plugin_apps = codex_core_plugins::remote::resolve_remote_plugin_app_ids(
|
||||
&RemotePluginServiceConfig {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
},
|
||||
auth.as_ref(),
|
||||
&plugin_apps,
|
||||
)
|
||||
.await;
|
||||
let apps_needing_auth = self
|
||||
.plugin_apps_needing_auth_for_install(
|
||||
&config,
|
||||
@@ -1481,6 +1489,12 @@ impl PluginRequestProcessor {
|
||||
}
|
||||
|
||||
let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await;
|
||||
let plugin_apps = codex_core_plugins::remote::resolve_remote_plugin_app_ids(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
&plugin_apps,
|
||||
)
|
||||
.await;
|
||||
let apps_needing_auth = self
|
||||
.plugin_apps_needing_auth_for_install(
|
||||
&config,
|
||||
|
||||
@@ -68,13 +68,13 @@ mod thread_processor_behavior_tests {
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_state::ThreadMetadataBuilder;
|
||||
@@ -412,7 +412,7 @@ mod thread_processor_behavior_tests {
|
||||
agent_path: None,
|
||||
git_info: None,
|
||||
approval_mode: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
token_usage: None,
|
||||
first_user_message: Some("first user message".to_string()),
|
||||
history: None,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::time::Duration;
|
||||
@@ -13,6 +14,7 @@ use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::Path as AxumPath;
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::StatusCode;
|
||||
@@ -801,6 +803,56 @@ async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_plugin_install_resolves_template_app_ids() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
let bundle_url = mount_remote_plugin_bundle(
|
||||
&server,
|
||||
/*status_code*/ 200,
|
||||
remote_plugin_bundle_tar_gz_bytes_with_contents(
|
||||
r#"{"name":"linear"}"#,
|
||||
Some(r#"{"apps":{"databricks":{"id":"templated_apps_Databricks"}}}"#),
|
||||
)?,
|
||||
)
|
||||
.await;
|
||||
configure_remote_plugin_test(codex_home.path(), &server)?;
|
||||
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await;
|
||||
mount_empty_remote_installed_plugins(&server).await;
|
||||
mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await;
|
||||
mount_remote_template_connector_ids(
|
||||
&server,
|
||||
"templated_apps_Databricks",
|
||||
&["asdk_app_databricks_workspace"],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginInstallResponse = to_response(response)?;
|
||||
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
|
||||
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"GET",
|
||||
"/ps/connectors/by_template_id/templated_apps_Databricks",
|
||||
/*expected_count*/ 1,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -953,6 +1005,100 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_resolves_template_apps_for_apps_needing_auth() -> Result<()> {
|
||||
let connectors = vec![AppInfo {
|
||||
id: "asdk_app_databricks_workspace".to_string(),
|
||||
name: "Databricks".to_string(),
|
||||
description: Some("Workspace Databricks connector".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
let (server_url, server_handle) = start_apps_server_with_template_connector_ids(
|
||||
connectors,
|
||||
Vec::new(),
|
||||
HashMap::from([(
|
||||
"templated_apps_Databricks".to_string(),
|
||||
vec!["asdk_app_databricks_workspace".to_string()],
|
||||
)]),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
write_connectors_config(codex_home.path(), &server_url)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let repo_root = TempDir::new()?;
|
||||
write_plugin_marketplace(
|
||||
repo_root.path(),
|
||||
"debug",
|
||||
"sample-plugin",
|
||||
"./sample-plugin",
|
||||
/*install_policy*/ None,
|
||||
/*auth_policy*/ None,
|
||||
)?;
|
||||
write_plugin_source(
|
||||
repo_root.path(),
|
||||
"sample-plugin",
|
||||
&["templated_apps_Databricks"],
|
||||
)?;
|
||||
let marketplace_path =
|
||||
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path: Some(marketplace_path),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginInstallResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
PluginInstallResponse {
|
||||
auth_policy: PluginAuthPolicy::OnInstall,
|
||||
apps_needing_auth: vec![AppSummary {
|
||||
id: "asdk_app_databricks_workspace".to_string(),
|
||||
name: "Databricks".to_string(),
|
||||
description: Some("Workspace Databricks connector".to_string()),
|
||||
install_url: Some(
|
||||
"https://chatgpt.com/apps/databricks/asdk_app_databricks_workspace".to_string(),
|
||||
),
|
||||
needs_auth: true,
|
||||
}],
|
||||
}
|
||||
);
|
||||
|
||||
server_handle.abort();
|
||||
let _ = server_handle.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
|
||||
let connectors = vec![AppInfo {
|
||||
@@ -1113,6 +1259,7 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests
|
||||
#[derive(Clone)]
|
||||
struct AppsServerState {
|
||||
response: Arc<StdMutex<serde_json::Value>>,
|
||||
template_connector_ids: Arc<StdMutex<HashMap<String, Vec<String>>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -1149,11 +1296,20 @@ impl ServerHandler for PluginInstallMcpServer {
|
||||
async fn start_apps_server(
|
||||
connectors: Vec<AppInfo>,
|
||||
tools: Vec<Tool>,
|
||||
) -> Result<(String, JoinHandle<()>)> {
|
||||
start_apps_server_with_template_connector_ids(connectors, tools, HashMap::new()).await
|
||||
}
|
||||
|
||||
async fn start_apps_server_with_template_connector_ids(
|
||||
connectors: Vec<AppInfo>,
|
||||
tools: Vec<Tool>,
|
||||
template_connector_ids: HashMap<String, Vec<String>>,
|
||||
) -> Result<(String, JoinHandle<()>)> {
|
||||
let state = Arc::new(AppsServerState {
|
||||
response: Arc::new(StdMutex::new(
|
||||
json!({ "apps": connectors, "next_token": null }),
|
||||
)),
|
||||
template_connector_ids: Arc::new(StdMutex::new(template_connector_ids)),
|
||||
});
|
||||
let tools = Arc::new(StdMutex::new(tools));
|
||||
|
||||
@@ -1177,6 +1333,10 @@ async fn start_apps_server(
|
||||
"/connectors/directory/list_workspace",
|
||||
get(list_directory_connectors),
|
||||
)
|
||||
.route(
|
||||
"/ps/connectors/by_template_id/{template_id}",
|
||||
get(template_connector_ids_response),
|
||||
)
|
||||
.with_state(state)
|
||||
.nest_service("/api/codex/apps", mcp_service);
|
||||
|
||||
@@ -1187,6 +1347,33 @@ async fn start_apps_server(
|
||||
Ok((format!("http://{addr}"), handle))
|
||||
}
|
||||
|
||||
async fn template_connector_ids_response(
|
||||
State(state): State<Arc<AppsServerState>>,
|
||||
AxumPath(template_id): AxumPath<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl axum::response::IntoResponse, StatusCode> {
|
||||
let bearer_ok = headers
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value == "Bearer chatgpt-token");
|
||||
let account_ok = headers
|
||||
.get("chatgpt-account-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value == "account-123");
|
||||
if !bearer_ok || !account_ok {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
let connector_ids = state
|
||||
.template_connector_ids
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(&template_id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(Json(json!({ "connector_ids": connector_ids })))
|
||||
}
|
||||
|
||||
async fn list_directory_connectors(
|
||||
State(state): State<Arc<AppsServerState>>,
|
||||
headers: HeaderMap,
|
||||
@@ -1492,6 +1679,24 @@ async fn mount_remote_plugin_install(server: &MockServer, remote_plugin_id: &str
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_remote_template_connector_ids(
|
||||
server: &MockServer,
|
||||
template_id: &str,
|
||||
connector_ids: &[&str],
|
||||
) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!(
|
||||
"/backend-api/ps/connectors/by_template_id/{template_id}"
|
||||
)))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"connector_ids": connector_ids,
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CacheManifestExists {
|
||||
manifest_path: std::path::PathBuf,
|
||||
|
||||
@@ -140,6 +140,7 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> {
|
||||
);
|
||||
|
||||
let search_body = search_request_body(&server).await?;
|
||||
assert_eq!(search_body["model"], json!("mock-model"));
|
||||
assert_eq!(
|
||||
search_body["commands"],
|
||||
json!({
|
||||
|
||||
@@ -16,8 +16,6 @@ sandbox = ["v8/v8_enable_sandbox"]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-channel = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
deno_core_icudata = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -29,9 +29,18 @@ pub use runtime::WaitOutcome;
|
||||
pub use runtime::WaitRequest;
|
||||
pub use runtime::WaitToPendingOutcome;
|
||||
pub use runtime::WaitToPendingRequest;
|
||||
pub use service::CellId;
|
||||
pub use service::CodeModeService;
|
||||
pub use service::CodeModeTurnHost;
|
||||
pub use service::CodeModeTurnWorker;
|
||||
pub use service::CodeModeSession;
|
||||
pub use service::CodeModeSessionDelegate;
|
||||
pub use service::CodeModeSessionProvider;
|
||||
pub use service::CodeModeSessionProviderFuture;
|
||||
pub use service::CodeModeSessionResultFuture;
|
||||
pub use service::InProcessCodeModeSessionProvider;
|
||||
pub use service::NoopCodeModeSessionDelegate;
|
||||
pub use service::NotificationFuture;
|
||||
pub use service::StartedCell;
|
||||
pub use service::ToolInvocationFuture;
|
||||
|
||||
pub const PUBLIC_TOOL_NAME: &str = "exec";
|
||||
pub const WAIT_TOOL_NAME: &str = "wait";
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::description::EnabledToolMetadata;
|
||||
use crate::description::ToolDefinition;
|
||||
use crate::description::enabled_tool_metadata;
|
||||
use crate::response::FunctionCallOutputContentItem;
|
||||
use crate::service::CellId;
|
||||
|
||||
pub const DEFAULT_EXEC_YIELD_TIME_MS: u64 = 10_000;
|
||||
pub const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000;
|
||||
@@ -27,11 +28,6 @@ const EXIT_SENTINEL: &str = "__codex_code_mode_exit__";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExecuteRequest {
|
||||
/// Runtime cell id for this execution.
|
||||
///
|
||||
/// Callers allocate this before execution so tracing, waits, and nested tool
|
||||
/// calls can refer to the cell as soon as JavaScript starts.
|
||||
pub cell_id: String,
|
||||
pub tool_call_id: String,
|
||||
pub enabled_tools: Vec<ToolDefinition>,
|
||||
pub source: String,
|
||||
@@ -41,14 +37,13 @@ pub struct ExecuteRequest {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WaitRequest {
|
||||
pub cell_id: String,
|
||||
pub cell_id: CellId,
|
||||
pub yield_time_ms: u64,
|
||||
pub terminate: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WaitToPendingRequest {
|
||||
pub cell_id: String,
|
||||
pub cell_id: CellId,
|
||||
}
|
||||
|
||||
/// Result of waiting on a code-mode cell.
|
||||
@@ -73,7 +68,7 @@ pub enum ExecuteToPendingOutcome {
|
||||
/// The cell is waiting for more runtime input after draining the runtime
|
||||
/// input queue that was ready at the pending boundary.
|
||||
Pending {
|
||||
cell_id: String,
|
||||
cell_id: CellId,
|
||||
content_items: Vec<FunctionCallOutputContentItem>,
|
||||
/// Runtime tool-call ids emitted before this paused execution frontier
|
||||
/// sealed. Hosts can use these ids to drain their tool-call transport
|
||||
@@ -105,15 +100,15 @@ impl From<WaitOutcome> for RuntimeResponse {
|
||||
#[derive(Debug, PartialEq, Serialize)]
|
||||
pub enum RuntimeResponse {
|
||||
Yielded {
|
||||
cell_id: String,
|
||||
cell_id: CellId,
|
||||
content_items: Vec<FunctionCallOutputContentItem>,
|
||||
},
|
||||
Terminated {
|
||||
cell_id: String,
|
||||
cell_id: CellId,
|
||||
content_items: Vec<FunctionCallOutputContentItem>,
|
||||
},
|
||||
Result {
|
||||
cell_id: String,
|
||||
cell_id: CellId,
|
||||
content_items: Vec<FunctionCallOutputContentItem>,
|
||||
error_text: Option<String>,
|
||||
},
|
||||
@@ -126,23 +121,13 @@ pub enum RuntimeResponse {
|
||||
/// if their tool-call graph requires globally unique ids.
|
||||
#[derive(Debug)]
|
||||
pub struct CodeModeNestedToolCall {
|
||||
pub cell_id: String,
|
||||
pub cell_id: CellId,
|
||||
pub runtime_tool_call_id: String,
|
||||
pub tool_name: ToolName,
|
||||
pub tool_kind: CodeModeToolKind,
|
||||
pub input: Option<JsonValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum TurnMessage {
|
||||
ToolCall(CodeModeNestedToolCall),
|
||||
Notify {
|
||||
cell_id: String,
|
||||
call_id: String,
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum RuntimeCommand {
|
||||
ToolResponse { id: String, result: JsonValue },
|
||||
@@ -460,7 +445,6 @@ mod tests {
|
||||
|
||||
fn execute_request(source: &str) -> ExecuteRequest {
|
||||
ExecuteRequest {
|
||||
cell_id: "1".to_string(),
|
||||
tool_call_id: "call_1".to_string(),
|
||||
enabled_tools: Vec::new(),
|
||||
source: source.to_string(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -145,7 +145,7 @@ mod tests {
|
||||
.search(
|
||||
&SearchRequest {
|
||||
id: "search-session".to_string(),
|
||||
model: Some("gpt-test".to_string()),
|
||||
model: "gpt-test".to_string(),
|
||||
reasoning: None,
|
||||
input: Some(SearchInput::Items(vec![ResponseItem::Message {
|
||||
id: None,
|
||||
|
||||
@@ -7,8 +7,7 @@ use serde::Serialize;
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct SearchRequest {
|
||||
pub id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning: Option<Reasoning>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -223,6 +223,8 @@ pub struct TuiVimNormalKeymap {
|
||||
pub move_line_end: Option<KeybindingsSpec>,
|
||||
/// Delete character under cursor (`x`).
|
||||
pub delete_char: Option<KeybindingsSpec>,
|
||||
/// Delete character under cursor and enter insert mode (`s`).
|
||||
pub substitute_char: Option<KeybindingsSpec>,
|
||||
/// Delete from cursor to end of line (`D`).
|
||||
pub delete_to_line_end: Option<KeybindingsSpec>,
|
||||
/// Change from cursor to end of line and enter insert mode (`C`).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
@@ -77,6 +78,8 @@ pub struct DirectoryApp {
|
||||
logo_url_dark: Option<String>,
|
||||
#[serde(alias = "distributionChannel")]
|
||||
distribution_channel: Option<String>,
|
||||
#[serde(alias = "templateId")]
|
||||
template_id: Option<String>,
|
||||
visibility: Option<String>,
|
||||
}
|
||||
|
||||
@@ -152,16 +155,7 @@ where
|
||||
.into_iter()
|
||||
.map(directory_app_to_app_info)
|
||||
.collect::<Vec<_>>();
|
||||
for connector in &mut connectors {
|
||||
let install_url = match connector.install_url.take() {
|
||||
Some(install_url) => install_url,
|
||||
None => connector_install_url(&connector.name, &connector.id),
|
||||
};
|
||||
connector.name = normalize_connector_name(&connector.name, &connector.id);
|
||||
connector.description = normalize_connector_value(connector.description.as_deref());
|
||||
connector.install_url = Some(install_url);
|
||||
connector.is_accessible = false;
|
||||
}
|
||||
normalize_directory_app_infos(&mut connectors);
|
||||
connectors.sort_by(|left, right| {
|
||||
left.name
|
||||
.cmp(&right.name)
|
||||
@@ -248,6 +242,35 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_workspace_template_connectors<F, Fut>(
|
||||
template_ids: &HashSet<String>,
|
||||
mut fetch_page: F,
|
||||
) -> anyhow::Result<Vec<AppInfo>>
|
||||
where
|
||||
F: FnMut(String) -> Fut,
|
||||
Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,
|
||||
{
|
||||
let mut connectors = list_workspace_connectors(&mut fetch_page)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|app| {
|
||||
app.template_id
|
||||
.as_deref()
|
||||
.is_some_and(|template_id| template_ids.contains(template_id))
|
||||
})
|
||||
.map(directory_app_to_app_info)
|
||||
.collect::<Vec<_>>();
|
||||
for connector in &mut connectors {
|
||||
normalize_directory_app_info(connector);
|
||||
}
|
||||
connectors.sort_by(|left, right| {
|
||||
left.name
|
||||
.cmp(&right.name)
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
Ok(connectors)
|
||||
}
|
||||
|
||||
fn merge_directory_apps(apps: Vec<DirectoryApp>) -> Vec<DirectoryApp> {
|
||||
let mut merged: HashMap<String, DirectoryApp> = HashMap::new();
|
||||
for app in apps {
|
||||
@@ -271,6 +294,7 @@ fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) {
|
||||
logo_url,
|
||||
logo_url_dark,
|
||||
distribution_channel,
|
||||
template_id,
|
||||
visibility: _,
|
||||
} = incoming;
|
||||
|
||||
@@ -296,6 +320,9 @@ fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) {
|
||||
if existing.distribution_channel.is_none() && distribution_channel.is_some() {
|
||||
existing.distribution_channel = distribution_channel;
|
||||
}
|
||||
if existing.template_id.is_none() && template_id.is_some() {
|
||||
existing.template_id = template_id;
|
||||
}
|
||||
|
||||
if let Some(incoming_branding) = branding {
|
||||
if let Some(existing_branding) = existing.branding.as_mut() {
|
||||
@@ -422,6 +449,23 @@ fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_directory_app_infos(connectors: &mut [AppInfo]) {
|
||||
for connector in connectors {
|
||||
normalize_directory_app_info(connector);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_directory_app_info(connector: &mut AppInfo) {
|
||||
let install_url = match connector.install_url.take() {
|
||||
Some(install_url) => install_url,
|
||||
None => connector_install_url(&connector.name, &connector.id),
|
||||
};
|
||||
connector.name = normalize_connector_name(&connector.name, &connector.id);
|
||||
connector.description = normalize_connector_value(connector.description.as_deref());
|
||||
connector.install_url = Some(install_url);
|
||||
connector.is_accessible = false;
|
||||
}
|
||||
|
||||
fn connector_install_url(name: &str, connector_id: &str) -> String {
|
||||
let slug = connector_name_slug(name);
|
||||
format!("https://chatgpt.com/apps/{slug}/{connector_id}")
|
||||
@@ -504,6 +548,7 @@ mod tests {
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
template_id: None,
|
||||
visibility: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use codex_app_server_protocol::PluginInterface;
|
||||
use codex_app_server_protocol::SkillInterface;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
use codex_plugin::AppConnectorId;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use reqwest::RequestBuilder;
|
||||
@@ -19,6 +20,7 @@ use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tracing::warn;
|
||||
use url::Url;
|
||||
|
||||
mod remote_installed_plugin_sync;
|
||||
@@ -65,6 +67,7 @@ const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200;
|
||||
const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128;
|
||||
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
|
||||
const TEMPLATE_APP_ID_PREFIX: &str = "templated_apps_";
|
||||
const REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER: [(&str, &str); 5] = [
|
||||
(
|
||||
REMOTE_GLOBAL_MARKETPLACE_NAME,
|
||||
@@ -485,6 +488,20 @@ struct RemotePluginMutationResponse {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
struct RemoteWorkspaceConnectorDirectoryResponse {
|
||||
#[serde(default)]
|
||||
apps: Vec<RemoteWorkspaceConnectorDirectoryApp>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
struct RemoteWorkspaceConnectorDirectoryApp {
|
||||
id: String,
|
||||
#[serde(alias = "templateId")]
|
||||
template_id: Option<String>,
|
||||
visibility: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn fetch_remote_marketplaces(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
@@ -703,6 +720,67 @@ pub(crate) async fn fetch_remote_installed_plugins(
|
||||
Ok(installed_plugins)
|
||||
}
|
||||
|
||||
pub async fn resolve_remote_plugin_app_ids(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
app_ids: &[AppConnectorId],
|
||||
) -> Vec<AppConnectorId> {
|
||||
let mut resolved_app_ids = Vec::new();
|
||||
let mut seen_app_ids = HashSet::new();
|
||||
let mut template_connector_ids = BTreeMap::<String, Option<Vec<String>>>::new();
|
||||
|
||||
for app_id in app_ids {
|
||||
if !app_id.0.starts_with(TEMPLATE_APP_ID_PREFIX) {
|
||||
if seen_app_ids.insert(app_id.clone()) {
|
||||
resolved_app_ids.push(app_id.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let connector_ids = if let Some(connector_ids) = template_connector_ids.get(&app_id.0) {
|
||||
connector_ids.clone()
|
||||
} else {
|
||||
let connector_ids = match ensure_chatgpt_auth(auth) {
|
||||
Ok(auth) => {
|
||||
match fetch_template_connector_ids(config, auth, app_id.0.as_str()).await {
|
||||
Ok(connector_ids) => Some(connector_ids),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
template_app_id = %app_id.0,
|
||||
error = %err,
|
||||
"failed to resolve remote plugin template app id; dropping it"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
template_app_id = %app_id.0,
|
||||
error = %err,
|
||||
"cannot resolve remote plugin template app id without ChatGPT auth; dropping it"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
template_connector_ids.insert(app_id.0.clone(), connector_ids.clone());
|
||||
connector_ids
|
||||
};
|
||||
|
||||
let Some(connector_ids) = connector_ids else {
|
||||
continue;
|
||||
};
|
||||
for connector_id in connector_ids {
|
||||
let connector_id = AppConnectorId(connector_id);
|
||||
if seen_app_ids.insert(connector_id.clone()) {
|
||||
resolved_app_ids.push(connector_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolved_app_ids
|
||||
}
|
||||
|
||||
pub fn group_remote_installed_plugins_by_marketplaces(
|
||||
plugins: &[RemoteInstalledPlugin],
|
||||
visible_scopes: &[RemotePluginScope],
|
||||
@@ -1378,6 +1456,27 @@ async fn fetch_plugin_detail(
|
||||
send_and_decode(request, &url).await
|
||||
}
|
||||
|
||||
async fn fetch_template_connector_ids(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: &CodexAuth,
|
||||
template_id: &str,
|
||||
) -> Result<Vec<String>, RemotePluginCatalogError> {
|
||||
let url = remote_workspace_connector_directory_url(config)?;
|
||||
let client = build_reqwest_client();
|
||||
let request = authenticated_request(client.get(&url), auth)?;
|
||||
let response: RemoteWorkspaceConnectorDirectoryResponse =
|
||||
send_and_decode(request, &url).await?;
|
||||
Ok(response
|
||||
.apps
|
||||
.into_iter()
|
||||
.filter(|app| {
|
||||
app.template_id.as_deref() == Some(template_id)
|
||||
&& !matches!(app.visibility.as_deref(), Some("HIDDEN"))
|
||||
})
|
||||
.map(|app| app.id)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn remote_plugin_skill_detail_url(
|
||||
config: &RemotePluginServiceConfig,
|
||||
plugin_id: &str,
|
||||
@@ -1399,6 +1498,24 @@ fn remote_plugin_skill_detail_url(
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
fn remote_workspace_connector_directory_url(
|
||||
config: &RemotePluginServiceConfig,
|
||||
) -> Result<String, RemotePluginCatalogError> {
|
||||
let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/'))
|
||||
.map_err(RemotePluginCatalogError::InvalidBaseUrl)?;
|
||||
{
|
||||
let mut segments = url
|
||||
.path_segments_mut()
|
||||
.map_err(|()| RemotePluginCatalogError::InvalidBaseUrlPath)?;
|
||||
segments.pop_if_empty();
|
||||
segments.push("connectors");
|
||||
segments.push("directory");
|
||||
segments.push("list_workspace");
|
||||
}
|
||||
url.set_query(Some("external_logos=true"));
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginCatalogError> {
|
||||
let Some(auth) = auth else {
|
||||
return Err(RemotePluginCatalogError::AuthRequired);
|
||||
@@ -1444,3 +1561,6 @@ async fn send_and_decode<T: for<'de> Deserialize<'de>>(
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
110
codex-rs/core-plugins/src/remote/tests.rs
Normal file
110
codex-rs/core-plugins/src/remote/tests.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use super::*;
|
||||
use codex_login::CodexAuth;
|
||||
use pretty_assertions::assert_eq;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
fn test_config(server: &MockServer) -> RemotePluginServiceConfig {
|
||||
RemotePluginServiceConfig {
|
||||
chatgpt_base_url: format!("{}/backend-api", server.uri()),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_auth() -> CodexAuth {
|
||||
CodexAuth::create_dummy_chatgpt_auth_for_testing()
|
||||
}
|
||||
|
||||
fn app(id: &str) -> AppConnectorId {
|
||||
AppConnectorId(id.to_string())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_remote_plugin_app_ids_expands_templates_and_dedupes_stably() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/connectors/directory/list_workspace"))
|
||||
.and(header("authorization", "Bearer Access Token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(
|
||||
r#"{"apps":[
|
||||
{"id":"connector_ghe","template_id":"templated_apps_GitHubEnterprise"},
|
||||
{"id":"asdk_app_ghe","template_id":"templated_apps_GitHubEnterprise"},
|
||||
{"id":"asdk_app_other","template_id":"templated_apps_Other"},
|
||||
{"id":"asdk_app_hidden","template_id":"templated_apps_GitHubEnterprise","visibility":"HIDDEN"}
|
||||
]}"#,
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let resolved = resolve_remote_plugin_app_ids(
|
||||
&test_config(&server),
|
||||
Some(&test_auth()),
|
||||
&[
|
||||
app("asdk_app_linear"),
|
||||
app("templated_apps_GitHubEnterprise"),
|
||||
app("asdk_app_linear"),
|
||||
app("asdk_app_ghe"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
vec![
|
||||
app("asdk_app_linear"),
|
||||
app("connector_ghe"),
|
||||
app("asdk_app_ghe"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_remote_plugin_app_ids_drops_missing_template_mappings() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/connectors/directory/list_workspace"))
|
||||
.and(header("authorization", "Bearer Access Token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(
|
||||
r#"{"apps":[{"id":"asdk_app_other","template_id":"templated_apps_Other"}]}"#,
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let resolved = resolve_remote_plugin_app_ids(
|
||||
&test_config(&server),
|
||||
Some(&test_auth()),
|
||||
&[app("templated_apps_GitHubEnterprise")],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(resolved, Vec::<AppConnectorId>::new());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_remote_plugin_app_ids_drops_templates_when_lookup_fails() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/connectors/directory/list_workspace"))
|
||||
.and(header("authorization", "Bearer Access Token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.respond_with(ResponseTemplate::new(500).set_body_string("lookup failed"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let resolved = resolve_remote_plugin_app_ids(
|
||||
&test_config(&server),
|
||||
Some(&test_auth()),
|
||||
&[
|
||||
app("asdk_app_linear"),
|
||||
app("templated_apps_GitHubEnterprise"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(resolved, vec![app("asdk_app_linear")]);
|
||||
}
|
||||
@@ -2858,6 +2858,7 @@
|
||||
"start_change_operator": null,
|
||||
"start_delete_operator": null,
|
||||
"start_yank_operator": null,
|
||||
"substitute_char": null,
|
||||
"yank_line": null
|
||||
},
|
||||
"vim_operator": {
|
||||
@@ -3548,6 +3549,7 @@
|
||||
"start_change_operator": null,
|
||||
"start_delete_operator": null,
|
||||
"start_yank_operator": null,
|
||||
"substitute_char": null,
|
||||
"yank_line": null
|
||||
}
|
||||
},
|
||||
@@ -3975,6 +3977,14 @@
|
||||
],
|
||||
"description": "Begin yank operator; next key selects motion (`y`)."
|
||||
},
|
||||
"substitute_char": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Delete character under cursor and enter insert mode (`s`)."
|
||||
},
|
||||
"yank_line": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ use tracing::warn;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::mcp::McpManager;
|
||||
use crate::plugins::list_tool_suggest_discoverable_plugins;
|
||||
use crate::plugins::list_tool_suggest_discoverable_plugins_with_connector_candidates;
|
||||
use crate::session::INITIAL_SUBMIT_ID;
|
||||
use codex_config::AppsRequirementsToml;
|
||||
use codex_config::types::AppToolApproval;
|
||||
@@ -32,6 +32,7 @@ use codex_core_plugins::PluginsManager;
|
||||
use codex_features::Feature;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
use codex_login::default_client::originator;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::McpConnectionManager;
|
||||
@@ -42,8 +43,11 @@ use codex_mcp::codex_apps_tools_cache_key;
|
||||
use codex_mcp::compute_auth_statuses;
|
||||
use codex_mcp::host_owned_codex_apps_enabled;
|
||||
use codex_mcp::with_codex_apps_mcp;
|
||||
use codex_plugin::AppConnectorId;
|
||||
use url::Url;
|
||||
|
||||
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
|
||||
const TEMPLATE_APP_ID_PREFIX: &str = "templated_apps_";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct AppToolPolicy {
|
||||
@@ -113,10 +117,26 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
accessible_connectors: &[AppInfo],
|
||||
loaded_plugin_app_connector_ids: &[String],
|
||||
) -> anyhow::Result<Vec<DiscoverableTool>> {
|
||||
let connector_ids = tool_suggest_connector_ids(config).await;
|
||||
let connector_selection = tool_suggest_connector_selection(config).await;
|
||||
let mut connector_ids = connector_selection.connector_ids.clone();
|
||||
let mut directory_connectors =
|
||||
cached_directory_connectors_for_tool_suggest_with_auth(config, auth).await;
|
||||
let template_directory_connectors = workspace_template_directory_connectors(
|
||||
config,
|
||||
auth,
|
||||
&connector_selection.template_ids,
|
||||
&connector_selection.disabled_template_ids,
|
||||
&connector_selection.disabled_connector_ids,
|
||||
)
|
||||
.await;
|
||||
for connector in template_directory_connectors {
|
||||
connector_ids.insert(connector.id.clone());
|
||||
directory_connectors.push(connector);
|
||||
}
|
||||
let directory_connectors = codex_connectors::merge::merge_plugin_connectors(
|
||||
cached_directory_connectors_for_tool_suggest_with_auth(config, auth).await,
|
||||
directory_connectors,
|
||||
connector_ids.iter().cloned(),
|
||||
);
|
||||
let discoverable_connectors =
|
||||
@@ -128,10 +148,16 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
|
||||
)
|
||||
.into_iter()
|
||||
.map(DiscoverableTool::from);
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(config)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(DiscoverableTool::from);
|
||||
let candidate_app_connector_ids = connector_ids.iter().cloned().collect::<Vec<_>>();
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins_with_connector_candidates(
|
||||
config,
|
||||
auth,
|
||||
loaded_plugin_app_connector_ids,
|
||||
&candidate_app_connector_ids,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(DiscoverableTool::from);
|
||||
Ok(discoverable_connectors
|
||||
.chain(discoverable_plugins)
|
||||
.collect())
|
||||
@@ -404,33 +430,168 @@ fn write_cached_accessible_connectors(
|
||||
});
|
||||
}
|
||||
|
||||
async fn tool_suggest_connector_ids(config: &Config) -> HashSet<String> {
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct ToolSuggestConnectorSelection {
|
||||
connector_ids: HashSet<String>,
|
||||
template_ids: HashSet<String>,
|
||||
disabled_connector_ids: HashSet<String>,
|
||||
disabled_template_ids: HashSet<String>,
|
||||
}
|
||||
|
||||
async fn tool_suggest_connector_selection(config: &Config) -> ToolSuggestConnectorSelection {
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let mut connector_ids = PluginsManager::new(config.codex_home.to_path_buf())
|
||||
let connector_ids = PluginsManager::new(config.codex_home.to_path_buf())
|
||||
.plugins_for_config(&plugins_input)
|
||||
.await
|
||||
.capability_summaries()
|
||||
.iter()
|
||||
.flat_map(|plugin| plugin.app_connector_ids.iter())
|
||||
.map(|connector_id| connector_id.0.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
connector_ids.extend(
|
||||
config
|
||||
.tool_suggest
|
||||
.discoverables
|
||||
.iter()
|
||||
.filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Connector)
|
||||
.map(|discoverable| discoverable.id.clone()),
|
||||
);
|
||||
.cloned()
|
||||
.chain(
|
||||
config
|
||||
.tool_suggest
|
||||
.discoverables
|
||||
.iter()
|
||||
.filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Connector)
|
||||
.map(|discoverable| AppConnectorId(discoverable.id.clone())),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let disabled_connector_ids = config
|
||||
.tool_suggest
|
||||
.disabled_tools
|
||||
.iter()
|
||||
.filter(|disabled_tool| disabled_tool.kind == ToolSuggestDiscoverableType::Connector)
|
||||
.map(|disabled_tool| disabled_tool.id.as_str())
|
||||
.map(|disabled_tool| AppConnectorId(disabled_tool.id.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut selection = ToolSuggestConnectorSelection::default();
|
||||
for connector_id in connector_ids {
|
||||
selection.insert_connector_id(connector_id.0);
|
||||
}
|
||||
for connector_id in disabled_connector_ids {
|
||||
selection.insert_disabled_connector_id(connector_id.0);
|
||||
}
|
||||
selection
|
||||
.connector_ids
|
||||
.retain(|connector_id| !selection.disabled_connector_ids.contains(connector_id));
|
||||
selection
|
||||
}
|
||||
|
||||
impl ToolSuggestConnectorSelection {
|
||||
fn insert_connector_id(&mut self, connector_id: String) {
|
||||
insert_connector_or_template_id(
|
||||
connector_id,
|
||||
&mut self.connector_ids,
|
||||
&mut self.template_ids,
|
||||
);
|
||||
}
|
||||
|
||||
fn insert_disabled_connector_id(&mut self, connector_id: String) {
|
||||
insert_connector_or_template_id(
|
||||
connector_id,
|
||||
&mut self.disabled_connector_ids,
|
||||
&mut self.disabled_template_ids,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_connector_or_template_id(
|
||||
connector_id: String,
|
||||
connector_ids: &mut HashSet<String>,
|
||||
template_ids: &mut HashSet<String>,
|
||||
) {
|
||||
let connector_id = connector_id.trim();
|
||||
if connector_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
if is_template_app_id(connector_id) {
|
||||
template_ids.insert(connector_id.to_string());
|
||||
} else {
|
||||
connector_ids.insert(connector_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn is_template_app_id(connector_id: &str) -> bool {
|
||||
connector_id.starts_with(TEMPLATE_APP_ID_PREFIX)
|
||||
}
|
||||
|
||||
async fn workspace_template_directory_connectors(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
template_ids: &HashSet<String>,
|
||||
disabled_template_ids: &HashSet<String>,
|
||||
disabled_connector_ids: &HashSet<String>,
|
||||
) -> Vec<AppInfo> {
|
||||
if template_ids.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let active_template_ids = template_ids
|
||||
.difference(disabled_template_ids)
|
||||
.cloned()
|
||||
.collect::<HashSet<_>>();
|
||||
connector_ids.retain(|connector_id| !disabled_connector_ids.contains(connector_id.as_str()));
|
||||
connector_ids
|
||||
if active_template_ids.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let client = build_reqwest_client();
|
||||
let base_url = config.chatgpt_base_url.clone();
|
||||
let auth_headers = codex_model_provider::auth_provider_from_auth(auth).to_auth_headers();
|
||||
match codex_connectors::list_workspace_template_connectors(&active_template_ids, move |path| {
|
||||
let client = client.clone();
|
||||
let base_url = base_url.clone();
|
||||
let auth_headers = auth_headers.clone();
|
||||
async move {
|
||||
let url = chatgpt_backend_path_url(&base_url, &path)?;
|
||||
let response = client
|
||||
.get(&url)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.headers(auth_headers)
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("connector directory request failed with status {status}: {body}");
|
||||
}
|
||||
Ok(serde_json::from_str(&body)?)
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(connectors) => connectors
|
||||
.into_iter()
|
||||
.filter(|connector| !disabled_connector_ids.contains(&connector.id))
|
||||
.collect(),
|
||||
Err(err) => {
|
||||
warn!("failed to load workspace connector directory for template resolution: {err:#}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn chatgpt_backend_path_url(base_url: &str, path: &str) -> anyhow::Result<String> {
|
||||
let mut url = Url::parse(base_url.trim_end_matches('/'))?;
|
||||
let (path, query) = path
|
||||
.trim_start_matches('/')
|
||||
.split_once('?')
|
||||
.map_or((path.trim_start_matches('/'), None), |(path, query)| {
|
||||
(path, Some(query))
|
||||
});
|
||||
{
|
||||
let mut segments = url
|
||||
.path_segments_mut()
|
||||
.map_err(|()| anyhow::anyhow!("invalid ChatGPT base URL path"))?;
|
||||
segments.pop_if_empty();
|
||||
for segment in path.split('/').filter(|segment| !segment.is_empty()) {
|
||||
segments.push(segment);
|
||||
}
|
||||
}
|
||||
url.set_query(query);
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
async fn cached_directory_connectors_for_tool_suggest_with_auth(
|
||||
|
||||
@@ -29,6 +29,12 @@ use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
fn annotations(destructive_hint: Option<bool>, open_world_hint: Option<bool>) -> ToolAnnotations {
|
||||
ToolAnnotations::from_raw(
|
||||
@@ -1159,7 +1165,7 @@ fn app_tool_policy_matches_prefix_stripped_tool_name_for_tool_config() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_suggest_connector_ids_include_configured_tool_suggest_discoverables() {
|
||||
async fn tool_suggest_connector_selection_includes_configured_tool_suggest_discoverables() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
@@ -1180,13 +1186,18 @@ discoverables = [
|
||||
.expect("config should load");
|
||||
|
||||
assert_eq!(
|
||||
tool_suggest_connector_ids(&config).await,
|
||||
HashSet::from(["connector_2128aebfecb84f64a069897515042a44".to_string()])
|
||||
tool_suggest_connector_selection(&config).await,
|
||||
ToolSuggestConnectorSelection {
|
||||
connector_ids: HashSet::from(
|
||||
["connector_2128aebfecb84f64a069897515042a44".to_string()]
|
||||
),
|
||||
..ToolSuggestConnectorSelection::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_suggest_connector_ids_exclude_disabled_tool_suggestions() {
|
||||
async fn tool_suggest_connector_selection_excludes_disabled_tool_suggestions() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
@@ -1209,8 +1220,183 @@ disabled_tools = [
|
||||
.expect("config should load");
|
||||
|
||||
assert_eq!(
|
||||
tool_suggest_connector_ids(&config).await,
|
||||
HashSet::from(["connector_gmail".to_string()])
|
||||
tool_suggest_connector_selection(&config).await,
|
||||
ToolSuggestConnectorSelection {
|
||||
connector_ids: HashSet::from(["connector_gmail".to_string()]),
|
||||
disabled_connector_ids: HashSet::from(["connector_calendar".to_string()]),
|
||||
..ToolSuggestConnectorSelection::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_suggest_connector_selection_tracks_template_connectors_separately() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[tool_suggest]
|
||||
discoverables = [
|
||||
{ type = "connector", id = "templated_apps_Databricks" }
|
||||
]
|
||||
"#,
|
||||
)
|
||||
.expect("write config");
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("config should load");
|
||||
|
||||
assert_eq!(
|
||||
tool_suggest_connector_selection(&config).await,
|
||||
ToolSuggestConnectorSelection {
|
||||
template_ids: HashSet::from(["templated_apps_Databricks".to_string()]),
|
||||
..ToolSuggestConnectorSelection::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_suggest_resolves_template_connectors_before_returning_install_entries() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/connectors/directory/list_workspace"))
|
||||
.and(header("authorization", "Bearer Access Token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(
|
||||
r#"{"apps":[
|
||||
{
|
||||
"id":"asdk_app_databricks_workspace",
|
||||
"name":"Databricks Workspace",
|
||||
"description":"Query Databricks",
|
||||
"template_id":"templated_apps_Databricks"
|
||||
},
|
||||
{
|
||||
"id":"asdk_app_other",
|
||||
"name":"Other",
|
||||
"template_id":"templated_apps_Other"
|
||||
}
|
||||
]}"#,
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[features]
|
||||
apps = true
|
||||
|
||||
[tool_suggest]
|
||||
discoverables = [
|
||||
{ type = "connector", id = "templated_apps_Databricks" }
|
||||
]
|
||||
"#,
|
||||
)
|
||||
.expect("write config");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("config should load");
|
||||
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
|
||||
let discoverable_tools =
|
||||
list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[], &[])
|
||||
.await
|
||||
.expect("discoverable tools should load");
|
||||
|
||||
assert_eq!(
|
||||
discoverable_tools,
|
||||
vec![DiscoverableTool::from(AppInfo {
|
||||
id: "asdk_app_databricks_workspace".to_string(),
|
||||
name: "Databricks Workspace".to_string(),
|
||||
description: Some("Query Databricks".to_string()),
|
||||
install_url: Some(connector_install_url(
|
||||
"Databricks Workspace",
|
||||
"asdk_app_databricks_workspace",
|
||||
)),
|
||||
..app("asdk_app_databricks_workspace")
|
||||
})]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_suggest_returns_all_resolved_connectors_for_template() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/connectors/directory/list_workspace"))
|
||||
.and(header("authorization", "Bearer Access Token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(
|
||||
r#"{"apps":[
|
||||
{
|
||||
"id":"asdk_app_databricks_a",
|
||||
"name":"Databricks A",
|
||||
"template_id":"templated_apps_Databricks"
|
||||
},
|
||||
{
|
||||
"id":"asdk_app_databricks_b",
|
||||
"name":"Databricks B",
|
||||
"template_id":"templated_apps_Databricks"
|
||||
}
|
||||
]}"#,
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[features]
|
||||
apps = true
|
||||
|
||||
[tool_suggest]
|
||||
discoverables = [
|
||||
{ type = "connector", id = "templated_apps_Databricks" }
|
||||
]
|
||||
"#,
|
||||
)
|
||||
.expect("write config");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("config should load");
|
||||
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
|
||||
let discoverable_tools =
|
||||
list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[], &[])
|
||||
.await
|
||||
.expect("discoverable tools should load");
|
||||
|
||||
assert_eq!(
|
||||
discoverable_tools,
|
||||
vec![
|
||||
DiscoverableTool::from(AppInfo {
|
||||
id: "asdk_app_databricks_a".to_string(),
|
||||
name: "Databricks A".to_string(),
|
||||
install_url: Some(connector_install_url(
|
||||
"Databricks A",
|
||||
"asdk_app_databricks_a",
|
||||
)),
|
||||
..app("asdk_app_databricks_a")
|
||||
}),
|
||||
DiscoverableTool::from(AppInfo {
|
||||
id: "asdk_app_databricks_b".to_string(),
|
||||
name: "Databricks B".to_string(),
|
||||
install_url: Some(connector_install_url(
|
||||
"Databricks B",
|
||||
"asdk_app_databricks_b",
|
||||
)),
|
||||
..app("asdk_app_databricks_b")
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1238,7 +1424,7 @@ discoverables = [
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
|
||||
let discoverable_tools =
|
||||
list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[])
|
||||
list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[], &[])
|
||||
.await
|
||||
.expect("discoverable tools should load");
|
||||
|
||||
|
||||
@@ -8,8 +8,12 @@ use codex_config::types::ToolSuggestDiscoverableType;
|
||||
use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME;
|
||||
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use codex_core_plugins::PluginsManager;
|
||||
use codex_core_plugins::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST;
|
||||
use codex_core_plugins::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST as TOOL_SUGGEST_DISCOVERABLE_PLUGIN_FALLBACK_ALLOWLIST;
|
||||
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
|
||||
use codex_core_plugins::remote::RemotePluginServiceConfig;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_plugin::AppConnectorId;
|
||||
use codex_tools::DiscoverablePluginInfo;
|
||||
|
||||
const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[
|
||||
@@ -17,8 +21,26 @@ const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[
|
||||
OPENAI_CURATED_MARKETPLACE_NAME,
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn list_tool_suggest_discoverable_plugins(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
loaded_plugin_app_connector_ids: &[String],
|
||||
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
|
||||
list_tool_suggest_discoverable_plugins_with_connector_candidates(
|
||||
config,
|
||||
auth,
|
||||
loaded_plugin_app_connector_ids,
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn list_tool_suggest_discoverable_plugins_with_connector_candidates(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
loaded_plugin_app_connector_ids: &[String],
|
||||
candidate_app_connector_ids: &[String],
|
||||
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
|
||||
if !config.features.enabled(Feature::Plugins) {
|
||||
return Ok(Vec::new());
|
||||
@@ -44,18 +66,53 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins(
|
||||
.list_marketplaces_for_config(&plugins_input, &[])
|
||||
.context("failed to list plugin marketplaces for tool suggestions")?
|
||||
.marketplaces;
|
||||
let installed_app_connector_ids = plugins_manager
|
||||
.plugins_for_config(&plugins_input)
|
||||
.await
|
||||
.capability_summaries()
|
||||
.iter()
|
||||
.flat_map(|plugin| plugin.app_connector_ids.iter())
|
||||
.cloned()
|
||||
.chain(
|
||||
loaded_plugin_app_connector_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(AppConnectorId),
|
||||
)
|
||||
.chain(
|
||||
candidate_app_connector_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(AppConnectorId),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
};
|
||||
let installed_app_connector_ids = codex_core_plugins::remote::resolve_remote_plugin_app_ids(
|
||||
&remote_plugin_service_config,
|
||||
auth,
|
||||
&installed_app_connector_ids,
|
||||
)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|connector_id| connector_id.0)
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let mut discoverable_plugins = Vec::<DiscoverablePluginInfo>::new();
|
||||
for marketplace in marketplaces {
|
||||
let marketplace_name = marketplace.name;
|
||||
if !TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST.contains(&marketplace_name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let is_allowlisted_marketplace =
|
||||
TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST.contains(&marketplace_name.as_str());
|
||||
|
||||
for plugin in marketplace.plugins {
|
||||
let is_configured_plugin = configured_plugin_ids.contains(plugin.id.as_str());
|
||||
let is_fallback_plugin =
|
||||
TOOL_SUGGEST_DISCOVERABLE_PLUGIN_FALLBACK_ALLOWLIST.contains(&plugin.id.as_str());
|
||||
if plugin.installed
|
||||
|| plugin.policy.installation == MarketplacePluginInstallPolicy::NotAvailable
|
||||
|| disabled_plugin_ids.contains(plugin.id.as_str())
|
||||
|| (!TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str())
|
||||
&& !configured_plugin_ids.contains(plugin.id.as_str()))
|
||||
|| (!is_allowlisted_marketplace && !is_configured_plugin)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -72,14 +129,27 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins(
|
||||
{
|
||||
Ok(plugin) => {
|
||||
let plugin: PluginCapabilitySummary = plugin.into();
|
||||
let app_connector_ids =
|
||||
codex_core_plugins::remote::resolve_remote_plugin_app_ids(
|
||||
&remote_plugin_service_config,
|
||||
auth,
|
||||
&plugin.app_connector_ids,
|
||||
)
|
||||
.await;
|
||||
let matches_installed_app = app_connector_ids.iter().any(|connector_id| {
|
||||
installed_app_connector_ids.contains(connector_id.0.as_str())
|
||||
});
|
||||
if !is_configured_plugin && !is_fallback_plugin && !matches_installed_app {
|
||||
continue;
|
||||
}
|
||||
|
||||
discoverable_plugins.push(DiscoverablePluginInfo {
|
||||
id: plugin.config_name,
|
||||
name: plugin.display_name,
|
||||
description: plugin.description,
|
||||
has_skills: plugin.has_skills,
|
||||
mcp_server_names: plugin.mcp_server_names,
|
||||
app_connector_ids: plugin
|
||||
.app_connector_ids
|
||||
app_connector_ids: app_connector_ids
|
||||
.into_iter()
|
||||
.map(|connector_id| connector_id.0)
|
||||
.collect(),
|
||||
|
||||
@@ -5,57 +5,203 @@ use crate::plugins::test_support::write_curated_plugin_sha;
|
||||
use crate::plugins::test_support::write_file;
|
||||
use crate::plugins::test_support::write_openai_curated_marketplace;
|
||||
use crate::plugins::test_support::write_plugins_feature_config;
|
||||
use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME;
|
||||
use codex_core_plugins::PluginInstallRequest;
|
||||
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_tools::DiscoverablePluginInfo;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::fmt::format::FmtSpan;
|
||||
use tracing_test::internal::MockWriter;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plugins() {
|
||||
async fn list_tool_suggest_discoverable_plugins_returns_fallback_plugins_without_installed_apps() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let curated_root = curated_plugins_repo_path(codex_home.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["sample", "slack", "openai-developers"]);
|
||||
write_plugins_feature_config(codex_home.path());
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
discoverable_plugins,
|
||||
discoverable_plugins
|
||||
.into_iter()
|
||||
.map(|plugin| plugin.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
DiscoverablePluginInfo {
|
||||
id: "openai-developers@openai-curated".to_string(),
|
||||
name: "openai-developers".to_string(),
|
||||
description: Some(
|
||||
"Plugin that includes skills, MCP servers, and app connectors".to_string(),
|
||||
),
|
||||
has_skills: true,
|
||||
mcp_server_names: vec!["sample-docs".to_string()],
|
||||
app_connector_ids: vec!["connector_calendar".to_string()],
|
||||
},
|
||||
DiscoverablePluginInfo {
|
||||
id: "slack@openai-curated".to_string(),
|
||||
name: "slack".to_string(),
|
||||
description: Some(
|
||||
"Plugin that includes skills, MCP servers, and app connectors".to_string(),
|
||||
),
|
||||
has_skills: true,
|
||||
mcp_server_names: vec!["sample-docs".to_string()],
|
||||
app_connector_ids: vec!["connector_calendar".to_string()],
|
||||
},
|
||||
"openai-developers@openai-curated".to_string(),
|
||||
"slack@openai-curated".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tool_suggest_discoverable_plugins_returns_microsoft_curated_plugins() {
|
||||
async fn list_tool_suggest_discoverable_plugins_filters_non_fallback_by_installed_apps() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let curated_root = curated_plugins_repo_path(codex_home.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["sample", "slack", "hubspot"]);
|
||||
write_plugin_app(&curated_root, "sample", "sample", "connector_sample");
|
||||
write_plugins_feature_config(codex_home.path());
|
||||
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await;
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
discoverable_plugins
|
||||
.into_iter()
|
||||
.map(|plugin| plugin.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["hubspot@openai-curated".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tool_suggest_discoverable_plugins_filters_by_loaded_plugin_apps() {
|
||||
let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14";
|
||||
let granola_app_id = "asdk_app_697761cab6f48191b5ed345919a3ce8b";
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let curated_root = curated_plugins_repo_path(codex_home.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["hubspot", "granola"]);
|
||||
write_plugin_app(&curated_root, "hubspot", "hubspot", hubspot_app_id);
|
||||
write_plugin_app(&curated_root, "granola", "granola", granola_app_id);
|
||||
write_plugins_feature_config(codex_home.path());
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins =
|
||||
list_tool_suggest_discoverable_plugins(&config, None, &[hubspot_app_id.to_string()])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
discoverable_plugins
|
||||
.into_iter()
|
||||
.map(|plugin| plugin.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["hubspot@openai-curated".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tool_suggest_discoverable_plugins_matches_on_resolved_template_apps() {
|
||||
let databricks_app_id = "asdk_app_databricks_workspace";
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/connectors/directory/list_workspace"))
|
||||
.and(header("authorization", "Bearer Access Token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
|
||||
r#"{{"apps":[{{"id":"{databricks_app_id}","template_id":"templated_apps_Databricks"}}]}}"#
|
||||
)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let curated_root = curated_plugins_repo_path(codex_home.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["databricks-source"]);
|
||||
write_plugin_app(
|
||||
&curated_root,
|
||||
"databricks-source",
|
||||
"databricks",
|
||||
"templated_apps_Databricks",
|
||||
);
|
||||
write_plugins_feature_config(codex_home.path());
|
||||
|
||||
let mut config = load_plugins_config(codex_home.path()).await;
|
||||
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(
|
||||
&config,
|
||||
Some(&auth),
|
||||
&[databricks_app_id.to_string()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
discoverable_plugins,
|
||||
vec![DiscoverablePluginInfo {
|
||||
id: "databricks-source@openai-curated".to_string(),
|
||||
name: "databricks-source".to_string(),
|
||||
description: Some(
|
||||
"Plugin that includes skills, MCP servers, and app connectors".to_string(),
|
||||
),
|
||||
has_skills: true,
|
||||
mcp_server_names: vec!["sample-docs".to_string()],
|
||||
app_connector_ids: vec![databricks_app_id.to_string()],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tool_suggest_discoverable_plugins_matches_on_resolved_connector_candidate_apps() {
|
||||
let databricks_app_id = "asdk_app_databricks_workspace";
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/connectors/directory/list_workspace"))
|
||||
.and(header("authorization", "Bearer Access Token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
|
||||
r#"{{"apps":[{{"id":"{databricks_app_id}","template_id":"templated_apps_Databricks"}}]}}"#
|
||||
)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let curated_root = curated_plugins_repo_path(codex_home.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["databricks-source"]);
|
||||
write_plugin_app(
|
||||
&curated_root,
|
||||
"databricks-source",
|
||||
"databricks",
|
||||
"templated_apps_Databricks",
|
||||
);
|
||||
write_plugins_feature_config(codex_home.path());
|
||||
|
||||
let mut config = load_plugins_config(codex_home.path()).await;
|
||||
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins_with_connector_candidates(
|
||||
&config,
|
||||
Some(&auth),
|
||||
&[],
|
||||
&[databricks_app_id.to_string()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
discoverable_plugins,
|
||||
vec![DiscoverablePluginInfo {
|
||||
id: "databricks-source@openai-curated".to_string(),
|
||||
name: "databricks-source".to_string(),
|
||||
description: Some(
|
||||
"Plugin that includes skills, MCP servers, and app connectors".to_string(),
|
||||
),
|
||||
has_skills: true,
|
||||
mcp_server_names: vec!["sample-docs".to_string()],
|
||||
app_connector_ids: vec![databricks_app_id.to_string()],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tool_suggest_discoverable_plugins_filters_microsoft_by_installed_apps() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let curated_root = curated_plugins_repo_path(codex_home.path());
|
||||
write_openai_curated_marketplace(
|
||||
@@ -63,9 +209,10 @@ async fn list_tool_suggest_discoverable_plugins_returns_microsoft_curated_plugin
|
||||
&["teams", "sharepoint", "outlook-email", "outlook-calendar"],
|
||||
);
|
||||
write_plugins_feature_config(codex_home.path());
|
||||
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "teams").await;
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -78,28 +225,92 @@ async fn list_tool_suggest_discoverable_plugins_returns_microsoft_curated_plugin
|
||||
"outlook-calendar@openai-curated".to_string(),
|
||||
"outlook-email@openai-curated".to_string(),
|
||||
"sharepoint@openai-curated".to_string(),
|
||||
"teams@openai-curated".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tool_suggest_discoverable_plugins_deduplicates_allowlisted_configured_plugin() {
|
||||
async fn list_tool_suggest_discoverable_plugins_filters_sales_apps_by_marketplace() {
|
||||
let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14";
|
||||
let granola_app_id = "asdk_app_697761cab6f48191b5ed345919a3ce8b";
|
||||
let test_app_id = "asdk_app_test_source";
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let plugin_id = TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|plugin_id| {
|
||||
plugin_id
|
||||
.rsplit_once('@')
|
||||
.is_some_and(|(_plugin_name, marketplace_name)| {
|
||||
marketplace_name == OPENAI_BUNDLED_MARKETPLACE_NAME
|
||||
})
|
||||
})
|
||||
.expect("allowlist should include a bundled plugin");
|
||||
let (plugin_name, marketplace_name) = plugin_id
|
||||
.rsplit_once('@')
|
||||
.expect("plugin id should include a marketplace");
|
||||
let curated_root = curated_plugins_repo_path(codex_home.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["hubspot", "granola", "test-source"]);
|
||||
write_plugin_app(&curated_root, "hubspot", "hubspot", hubspot_app_id);
|
||||
write_plugin_app(&curated_root, "granola", "granola", granola_app_id);
|
||||
write_plugin_app(&curated_root, "test-source", "test_source", test_app_id);
|
||||
|
||||
let sales_marketplace_name = "oai-maintained-plugins";
|
||||
let sales_marketplace_root = codex_home
|
||||
.path()
|
||||
.join(format!(".tmp/marketplaces/{sales_marketplace_name}"));
|
||||
write_file(
|
||||
&sales_marketplace_root.join(".agents/plugins/marketplace.json"),
|
||||
&format!(
|
||||
r#"{{
|
||||
"name": "{sales_marketplace_name}",
|
||||
"plugins": [
|
||||
{{"name": "sales", "source": {{"source": "local", "path": "./plugins/sales"}}}}
|
||||
]
|
||||
}}
|
||||
"#
|
||||
),
|
||||
);
|
||||
write_curated_plugin(&sales_marketplace_root, "sales");
|
||||
write_file(
|
||||
&sales_marketplace_root.join("plugins/sales/.app.json"),
|
||||
&format!(
|
||||
r#"{{
|
||||
"apps": {{
|
||||
"hubspot": {{
|
||||
"id": "{hubspot_app_id}"
|
||||
}},
|
||||
"granola": {{
|
||||
"id": "{granola_app_id}"
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
),
|
||||
);
|
||||
write_file(
|
||||
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
|
||||
&format!(
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[marketplaces.{sales_marketplace_name}]
|
||||
source_type = "git"
|
||||
source = "/tmp/{sales_marketplace_name}"
|
||||
"#
|
||||
),
|
||||
);
|
||||
install_marketplace_plugin(codex_home.path(), sales_marketplace_root.as_path(), "sales").await;
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
discoverable_plugins
|
||||
.into_iter()
|
||||
.map(|plugin| plugin.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"granola@openai-curated".to_string(),
|
||||
"hubspot@openai-curated".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tool_suggest_discoverable_plugins_deduplicates_configured_marketplace_plugin() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let plugin_name = "sample";
|
||||
let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME;
|
||||
let plugin_id = format!("{plugin_name}@{marketplace_name}");
|
||||
let marketplace_root = codex_home
|
||||
.path()
|
||||
.join(format!(".tmp/marketplaces/{marketplace_name}"));
|
||||
@@ -133,28 +344,20 @@ discoverables = [{{ type = "plugin", id = "{plugin_id}" }}]
|
||||
);
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(discoverable_plugins.len(), 1);
|
||||
assert_eq!(discoverable_plugins[0].id, plugin_id);
|
||||
assert_eq!(discoverable_plugins[0].id, plugin_id.as_str());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tool_suggest_discoverable_plugins_ignores_missing_allowlisted_plugin() {
|
||||
async fn list_tool_suggest_discoverable_plugins_ignores_missing_marketplace_plugin() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let curated_root = curated_plugins_repo_path(codex_home.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["slack"]);
|
||||
let marketplace_name = TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST
|
||||
.iter()
|
||||
.copied()
|
||||
.filter_map(|plugin_id| plugin_id.rsplit_once('@'))
|
||||
.find(|(_plugin_name, marketplace_name)| {
|
||||
*marketplace_name == OPENAI_BUNDLED_MARKETPLACE_NAME
|
||||
})
|
||||
.map(|(_plugin_name, marketplace_name)| marketplace_name)
|
||||
.expect("allowlist should include a bundled plugin");
|
||||
write_openai_curated_marketplace(&curated_root, &["installed", "slack"]);
|
||||
let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME;
|
||||
let marketplace_root = codex_home
|
||||
.path()
|
||||
.join(format!(".tmp/marketplaces/{marketplace_name}"));
|
||||
@@ -182,9 +385,10 @@ source = "/tmp/{marketplace_name}"
|
||||
"#
|
||||
),
|
||||
);
|
||||
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await;
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -205,7 +409,7 @@ plugins = false
|
||||
);
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -216,7 +420,7 @@ plugins = false
|
||||
async fn list_tool_suggest_discoverable_plugins_normalizes_description() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let curated_root = curated_plugins_repo_path(codex_home.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["slack"]);
|
||||
write_openai_curated_marketplace(&curated_root, &["installed", "slack"]);
|
||||
write_plugins_feature_config(codex_home.path());
|
||||
write_file(
|
||||
&curated_root.join("plugins/slack/.codex-plugin/plugin.json"),
|
||||
@@ -225,9 +429,10 @@ async fn list_tool_suggest_discoverable_plugins_normalizes_description() {
|
||||
"description": " Plugin\n with extra spacing "
|
||||
}"#,
|
||||
);
|
||||
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await;
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -264,7 +469,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins(
|
||||
.expect("plugin should install");
|
||||
|
||||
let refreshed_config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config, None, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -289,13 +494,70 @@ disabled_tools = [
|
||||
);
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tool_suggest_discoverable_plugins_omits_not_available_curated_plugins() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let curated_root = curated_plugins_repo_path(codex_home.path());
|
||||
write_file(
|
||||
&curated_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "openai-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "installed",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/installed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "slack",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/slack"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gmail",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/gmail"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "NOT_AVAILABLE"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
"#,
|
||||
);
|
||||
write_curated_plugin(&curated_root, "installed");
|
||||
write_curated_plugin(&curated_root, "slack");
|
||||
write_curated_plugin(&curated_root, "gmail");
|
||||
write_plugins_feature_config(codex_home.path());
|
||||
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await;
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
discoverable_plugins
|
||||
.into_iter()
|
||||
.map(|plugin| plugin.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["slack@openai-curated".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tool_suggest_discoverable_plugins_includes_configured_plugin_ids() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
@@ -312,7 +574,7 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }]
|
||||
);
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -335,14 +597,12 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }]
|
||||
async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_plugin() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let curated_root = curated_plugins_repo_path(codex_home.path());
|
||||
write_openai_curated_marketplace(
|
||||
&curated_root,
|
||||
&["slack", "build-ios-apps", "life-science-research"],
|
||||
);
|
||||
write_openai_curated_marketplace(&curated_root, &["slack", "gmail", "openai-developers"]);
|
||||
write_plugins_feature_config(codex_home.path());
|
||||
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await;
|
||||
|
||||
let too_long_prompt = "x".repeat(129);
|
||||
for plugin_name in ["build-ios-apps", "life-science-research"] {
|
||||
for plugin_name in ["gmail", "openai-developers"] {
|
||||
write_file(
|
||||
&curated_root.join(format!("plugins/{plugin_name}/.codex-plugin/plugin.json")),
|
||||
&format!(
|
||||
@@ -369,28 +629,63 @@ async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_
|
||||
.finish();
|
||||
let _guard = tracing::subscriber::set_default(subscriber);
|
||||
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(discoverable_plugins.len(), 1);
|
||||
assert_eq!(discoverable_plugins[0].id, "slack@openai-curated");
|
||||
assert_eq!(
|
||||
discoverable_plugins
|
||||
.iter()
|
||||
.map(|plugin| plugin.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["gmail@openai-curated", "openai-developers@openai-curated"]
|
||||
);
|
||||
|
||||
let logs = String::from_utf8(buffer.lock().expect("buffer lock").clone())
|
||||
.expect("utf8 logs")
|
||||
.replace('\\', "/");
|
||||
assert_eq!(logs.matches("ignoring interface.defaultPrompt").count(), 2);
|
||||
assert_eq!(logs.matches("ignoring interface.defaultPrompt").count(), 8);
|
||||
let normalized_logs = logs.replace('\\', "/");
|
||||
assert_eq!(
|
||||
normalized_logs
|
||||
.matches("build-ios-apps/.codex-plugin/plugin.json")
|
||||
.matches("gmail/.codex-plugin/plugin.json")
|
||||
.count(),
|
||||
1
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
normalized_logs
|
||||
.matches("life-science-research/.codex-plugin/plugin.json")
|
||||
.matches("openai-developers/.codex-plugin/plugin.json")
|
||||
.count(),
|
||||
1
|
||||
4
|
||||
);
|
||||
}
|
||||
|
||||
async fn install_marketplace_plugin(codex_home: &Path, marketplace_root: &Path, plugin_name: &str) {
|
||||
write_curated_plugin_sha(codex_home);
|
||||
PluginsManager::new(codex_home.to_path_buf())
|
||||
.install_plugin(PluginInstallRequest {
|
||||
plugin_name: plugin_name.to_string(),
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
marketplace_root.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.expect("marketplace path"),
|
||||
})
|
||||
.await
|
||||
.expect("plugin should install");
|
||||
}
|
||||
|
||||
fn write_plugin_app(root: &Path, plugin_name: &str, app_name: &str, app_id: &str) {
|
||||
write_file(
|
||||
&root.join(format!("plugins/{plugin_name}/.app.json")),
|
||||
&format!(
|
||||
r#"{{
|
||||
"apps": {{
|
||||
"{app_name}": {{
|
||||
"id": "{app_id}"
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ pub(crate) mod test_support;
|
||||
|
||||
pub(crate) use codex_plugin::PluginCapabilitySummary;
|
||||
|
||||
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
|
||||
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins_with_connector_candidates;
|
||||
pub(crate) use injection::build_plugin_injections;
|
||||
pub(crate) use render::render_explicit_plugin_instructions;
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@ use chrono::Utc;
|
||||
use codex_git_utils::GitSha;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::GitInfo;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_thread_store::StoredThread;
|
||||
use core_test_support::PathBufExt;
|
||||
@@ -59,7 +59,7 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre
|
||||
repository_url: None,
|
||||
}),
|
||||
approval_mode: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
token_usage: None,
|
||||
first_user_message: Some(first_user_message.to_string()),
|
||||
history: None,
|
||||
|
||||
@@ -630,6 +630,9 @@ async fn shutdown_session_runtime(sess: &Arc<Session>) {
|
||||
.unified_exec_manager
|
||||
.terminate_all_processes()
|
||||
.await;
|
||||
if let Err(err) = sess.services.code_mode_service.shutdown().await {
|
||||
warn!("failed to shutdown code mode session: {err}");
|
||||
}
|
||||
let mcp_shutdown = {
|
||||
let mut manager = sess.services.mcp_connection_manager.write().await;
|
||||
manager.begin_shutdown()
|
||||
|
||||
@@ -943,16 +943,12 @@ async fn run_sampling_request(
|
||||
Arc::clone(&turn_context),
|
||||
Arc::clone(&turn_diff_tracker),
|
||||
);
|
||||
let _code_mode_worker = sess
|
||||
.services
|
||||
.code_mode_service
|
||||
.start_turn_worker(
|
||||
&sess,
|
||||
&turn_context,
|
||||
Arc::clone(&router),
|
||||
Arc::clone(&turn_diff_tracker),
|
||||
)
|
||||
.await;
|
||||
let _code_mode_worker = sess.services.code_mode_service.start_turn_worker(
|
||||
&sess,
|
||||
&turn_context,
|
||||
Arc::clone(&router),
|
||||
Arc::clone(&turn_diff_tracker),
|
||||
);
|
||||
let max_retries = turn_context.provider.info().stream_max_retries();
|
||||
let mut retries = 0;
|
||||
let mut initial_input = Some(input);
|
||||
@@ -1075,12 +1071,18 @@ pub(crate) async fn built_tools(
|
||||
None
|
||||
};
|
||||
let auth = sess.services.auth_manager.auth().await;
|
||||
let loaded_plugin_app_connector_ids = loaded_plugins
|
||||
.effective_apps()
|
||||
.into_iter()
|
||||
.map(|connector_id| connector_id.0)
|
||||
.collect::<Vec<_>>();
|
||||
let discoverable_tools = if apps_enabled && tool_suggest_enabled(turn_context) {
|
||||
if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() {
|
||||
match connectors::list_tool_suggest_discoverable_tools_with_auth(
|
||||
&turn_context.config,
|
||||
auth.as_ref(),
|
||||
accessible_connectors.as_slice(),
|
||||
&loaded_plugin_app_connector_ids,
|
||||
)
|
||||
.await
|
||||
.map(|discoverable_tools| {
|
||||
|
||||
311
codex-rs/core/src/tools/code_mode/delegate.rs
Normal file
311
codex-rs/core/src/tools/code_mode/delegate.rs
Normal file
@@ -0,0 +1,311 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use codex_code_mode::CellId;
|
||||
use codex_code_mode::CodeModeNestedToolCall;
|
||||
use codex_code_mode::CodeModeSessionDelegate;
|
||||
use codex_code_mode::NotificationFuture;
|
||||
use codex_code_mode::ToolInvocationFuture;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::sync::watch;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::ExecContext;
|
||||
use super::PUBLIC_TOOL_NAME;
|
||||
use super::call_nested_tool;
|
||||
use crate::tools::ToolRouter;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::parallel::ToolCallRuntime;
|
||||
|
||||
pub(super) struct CodeModeDispatchBroker {
|
||||
dispatch_tx: async_channel::Sender<DispatchMessage>,
|
||||
dispatch_rx: async_channel::Receiver<DispatchMessage>,
|
||||
dispatch_gates: Arc<Mutex<HashMap<CellId, watch::Sender<bool>>>>,
|
||||
}
|
||||
|
||||
impl CodeModeDispatchBroker {
|
||||
pub(super) fn new() -> Self {
|
||||
let (dispatch_tx, dispatch_rx) = async_channel::unbounded();
|
||||
Self {
|
||||
dispatch_tx,
|
||||
dispatch_rx,
|
||||
dispatch_gates: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mark_cell_ready_for_dispatch(&self, cell_id: &CellId) {
|
||||
dispatch_gate(&self.dispatch_gates, cell_id).send_replace(true);
|
||||
}
|
||||
|
||||
pub(super) fn close_cell(&self, cell_id: &CellId) {
|
||||
remove_dispatch_gate(&self.dispatch_gates, cell_id);
|
||||
}
|
||||
|
||||
pub(super) fn start_turn_worker(
|
||||
&self,
|
||||
exec: ExecContext,
|
||||
router: Arc<ToolRouter>,
|
||||
tracker: SharedTurnDiffTracker,
|
||||
) -> CodeModeDispatchWorker {
|
||||
let tool_runtime = ToolCallRuntime::new(
|
||||
router,
|
||||
Arc::clone(&exec.session),
|
||||
Arc::clone(&exec.turn),
|
||||
tracker,
|
||||
);
|
||||
let host = Arc::new(CoreTurnHost { exec, tool_runtime });
|
||||
let dispatch_rx = self.dispatch_rx.clone();
|
||||
let dispatch_gates = Arc::clone(&self.dispatch_gates);
|
||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let message = tokio::select! {
|
||||
_ = &mut shutdown_rx => break,
|
||||
message = dispatch_rx.recv() => message.ok(),
|
||||
};
|
||||
let Some(message) = message else {
|
||||
break;
|
||||
};
|
||||
match message {
|
||||
DispatchMessage::Notify {
|
||||
call_id,
|
||||
cell_id,
|
||||
text,
|
||||
cancellation_token,
|
||||
response_tx,
|
||||
} => {
|
||||
let response = if wait_until_cell_ready_for_dispatch(
|
||||
&dispatch_gates,
|
||||
&cell_id,
|
||||
&cancellation_token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
host.notify(call_id, cell_id, text).await
|
||||
} else {
|
||||
remove_dispatch_gate(&dispatch_gates, &cell_id);
|
||||
Err("code mode notification cancelled".to_string())
|
||||
};
|
||||
let _ = response_tx.send(response);
|
||||
}
|
||||
DispatchMessage::InvokeTool {
|
||||
invocation,
|
||||
cancellation_token,
|
||||
response_tx,
|
||||
} => {
|
||||
let cell_id = invocation.cell_id.clone();
|
||||
if !wait_until_cell_ready_for_dispatch(
|
||||
&dispatch_gates,
|
||||
&cell_id,
|
||||
&cancellation_token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
remove_dispatch_gate(&dispatch_gates, &cell_id);
|
||||
continue;
|
||||
}
|
||||
let host = Arc::clone(&host);
|
||||
tokio::spawn(async move {
|
||||
let response = tokio::select! {
|
||||
response = host.invoke_tool(
|
||||
invocation,
|
||||
cancellation_token.clone(),
|
||||
) => response,
|
||||
_ = cancellation_token.cancelled() => return,
|
||||
};
|
||||
let _ = response_tx.send(response);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
CodeModeDispatchWorker {
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_gate(
|
||||
dispatch_gates: &Mutex<HashMap<CellId, watch::Sender<bool>>>,
|
||||
cell_id: &CellId,
|
||||
) -> watch::Sender<bool> {
|
||||
let mut dispatch_gates = match dispatch_gates.lock() {
|
||||
Ok(dispatch_gates) => dispatch_gates,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
dispatch_gates
|
||||
.entry(cell_id.clone())
|
||||
.or_insert_with(|| watch::channel(false).0)
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn remove_dispatch_gate(
|
||||
dispatch_gates: &Mutex<HashMap<CellId, watch::Sender<bool>>>,
|
||||
cell_id: &CellId,
|
||||
) {
|
||||
let mut dispatch_gates = match dispatch_gates.lock() {
|
||||
Ok(dispatch_gates) => dispatch_gates,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
dispatch_gates.remove(cell_id);
|
||||
}
|
||||
|
||||
async fn wait_until_cell_ready_for_dispatch(
|
||||
dispatch_gates: &Mutex<HashMap<CellId, watch::Sender<bool>>>,
|
||||
cell_id: &CellId,
|
||||
cancellation_token: &CancellationToken,
|
||||
) -> bool {
|
||||
if cancellation_token.is_cancelled() {
|
||||
return false;
|
||||
}
|
||||
let mut ready_rx = dispatch_gate(dispatch_gates, cell_id).subscribe();
|
||||
loop {
|
||||
if *ready_rx.borrow_and_update() {
|
||||
return true;
|
||||
}
|
||||
tokio::select! {
|
||||
changed = ready_rx.changed() => {
|
||||
if changed.is_err() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_ = cancellation_token.cancelled() => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CodeModeSessionDelegate for CodeModeDispatchBroker {
|
||||
fn invoke_tool<'a>(
|
||||
&'a self,
|
||||
invocation: CodeModeNestedToolCall,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> ToolInvocationFuture<'a> {
|
||||
Box::pin(async move {
|
||||
if cancellation_token.is_cancelled() {
|
||||
return Err("code mode nested tool call cancelled".to_string());
|
||||
}
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
self.dispatch_tx
|
||||
.send(DispatchMessage::InvokeTool {
|
||||
invocation,
|
||||
cancellation_token: cancellation_token.clone(),
|
||||
response_tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "code mode nested tool dispatcher is unavailable".to_string())?;
|
||||
tokio::select! {
|
||||
response = response_rx => response
|
||||
.map_err(|_| "code mode nested tool dispatcher stopped".to_string())?,
|
||||
_ = cancellation_token.cancelled() => {
|
||||
Err("code mode nested tool call cancelled".to_string())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn notify<'a>(
|
||||
&'a self,
|
||||
call_id: String,
|
||||
cell_id: CellId,
|
||||
text: String,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> NotificationFuture<'a> {
|
||||
Box::pin(async move {
|
||||
if cancellation_token.is_cancelled() {
|
||||
return Err("code mode notification cancelled".to_string());
|
||||
}
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
self.dispatch_tx
|
||||
.send(DispatchMessage::Notify {
|
||||
call_id,
|
||||
cell_id,
|
||||
text,
|
||||
cancellation_token: cancellation_token.clone(),
|
||||
response_tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "code mode notification dispatcher is unavailable".to_string())?;
|
||||
tokio::select! {
|
||||
response = response_rx => response
|
||||
.map_err(|_| "code mode notification dispatcher stopped".to_string())?,
|
||||
_ = cancellation_token.cancelled() => {
|
||||
Err("code mode notification cancelled".to_string())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn cell_closed(&self, cell_id: &CellId) {
|
||||
self.close_cell(cell_id);
|
||||
}
|
||||
}
|
||||
|
||||
enum DispatchMessage {
|
||||
InvokeTool {
|
||||
invocation: CodeModeNestedToolCall,
|
||||
cancellation_token: CancellationToken,
|
||||
response_tx: oneshot::Sender<Result<JsonValue, String>>,
|
||||
},
|
||||
Notify {
|
||||
call_id: String,
|
||||
cell_id: CellId,
|
||||
text: String,
|
||||
cancellation_token: CancellationToken,
|
||||
response_tx: oneshot::Sender<Result<(), String>>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct CodeModeDispatchWorker {
|
||||
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
impl Drop for CodeModeDispatchWorker {
|
||||
fn drop(&mut self) {
|
||||
if let Some(shutdown_tx) = self.shutdown_tx.take() {
|
||||
let _ = shutdown_tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CoreTurnHost {
|
||||
exec: ExecContext,
|
||||
tool_runtime: ToolCallRuntime,
|
||||
}
|
||||
|
||||
impl CoreTurnHost {
|
||||
async fn invoke_tool(
|
||||
&self,
|
||||
invocation: CodeModeNestedToolCall,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<JsonValue, String> {
|
||||
call_nested_tool(
|
||||
self.exec.clone(),
|
||||
self.tool_runtime.clone(),
|
||||
invocation,
|
||||
cancellation_token,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
async fn notify(&self, call_id: String, cell_id: CellId, text: String) -> Result<(), String> {
|
||||
if text.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.exec
|
||||
.session
|
||||
.inject_if_running(vec![ResponseItem::CustomToolCallOutput {
|
||||
call_id,
|
||||
name: Some(PUBLIC_TOOL_NAME.to_string()),
|
||||
output: FunctionCallOutputPayload::from_text(text),
|
||||
}])
|
||||
.await
|
||||
.map_err(|_| {
|
||||
format!("failed to inject exec notify message for cell {cell_id}: no active turn")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -38,9 +38,22 @@ impl CodeModeExecuteHandler {
|
||||
let exec = ExecContext { session, turn };
|
||||
let enabled_tools =
|
||||
codex_tools::collect_code_mode_tool_definitions(&self.nested_tool_specs);
|
||||
// Allocate before starting V8 so the trace can create the parent
|
||||
// CodeCell before model-authored JavaScript issues nested tool calls.
|
||||
let runtime_cell_id = exec.session.services.code_mode_service.allocate_cell_id();
|
||||
let started_at = std::time::Instant::now();
|
||||
let started_cell = exec
|
||||
.session
|
||||
.services
|
||||
.code_mode_service
|
||||
.execute(codex_code_mode::ExecuteRequest {
|
||||
tool_call_id: call_id.clone(),
|
||||
enabled_tools,
|
||||
source: args.code.clone(),
|
||||
yield_time_ms: args.yield_time_ms,
|
||||
max_output_tokens: args.max_output_tokens,
|
||||
})
|
||||
.await
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
let cell_id = started_cell.cell_id.clone();
|
||||
let runtime_cell_id = cell_id.to_string();
|
||||
let code_cell_trace = exec
|
||||
.session
|
||||
.services
|
||||
@@ -51,19 +64,12 @@ impl CodeModeExecuteHandler {
|
||||
call_id.as_str(),
|
||||
args.code.as_str(),
|
||||
);
|
||||
let started_at = std::time::Instant::now();
|
||||
let response = exec
|
||||
.session
|
||||
exec.session
|
||||
.services
|
||||
.code_mode_service
|
||||
.execute(codex_code_mode::ExecuteRequest {
|
||||
cell_id: runtime_cell_id,
|
||||
tool_call_id: call_id,
|
||||
enabled_tools,
|
||||
source: args.code,
|
||||
yield_time_ms: args.yield_time_ms,
|
||||
max_output_tokens: args.max_output_tokens,
|
||||
})
|
||||
.mark_cell_ready_for_dispatch(&cell_id);
|
||||
let response = started_cell
|
||||
.initial_response()
|
||||
.await
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
// Record the raw runtime boundary. The model-visible custom-tool output
|
||||
@@ -74,6 +80,10 @@ impl CodeModeExecuteHandler {
|
||||
// here when the first response also ended the runtime.
|
||||
if !matches!(response, codex_code_mode::RuntimeResponse::Yielded { .. }) {
|
||||
code_cell_trace.record_ended(&response);
|
||||
exec.session
|
||||
.services
|
||||
.code_mode_service
|
||||
.finish_cell_dispatch(&cell_id);
|
||||
}
|
||||
handle_runtime_response(&exec, response, args.max_output_tokens, started_at)
|
||||
.await
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod delegate;
|
||||
mod execute_handler;
|
||||
pub(crate) mod execute_spec;
|
||||
mod response_adapter;
|
||||
@@ -7,13 +8,12 @@ pub(crate) mod wait_spec;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_code_mode::CellId;
|
||||
use codex_code_mode::CodeModeNestedToolCall;
|
||||
use codex_code_mode::CodeModeSession;
|
||||
use codex_code_mode::CodeModeToolKind;
|
||||
use codex_code_mode::CodeModeTurnHost;
|
||||
use codex_code_mode::RuntimeResponse;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
@@ -36,6 +36,8 @@ use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::formatted_truncate_text_content_items_with_policy;
|
||||
use codex_utils_output_truncation::truncate_function_output_items_with_policy;
|
||||
|
||||
use delegate::CodeModeDispatchBroker;
|
||||
use delegate::CodeModeDispatchWorker;
|
||||
pub(crate) use execute_handler::CodeModeExecuteHandler;
|
||||
use response_adapter::into_function_call_output_content_items;
|
||||
pub(crate) use wait_handler::CodeModeWaitHandler;
|
||||
@@ -56,42 +58,67 @@ pub(crate) struct ExecContext {
|
||||
}
|
||||
|
||||
pub(crate) struct CodeModeService {
|
||||
inner: codex_code_mode::CodeModeService,
|
||||
session: Option<Arc<dyn CodeModeSession>>,
|
||||
dispatch_broker: Arc<CodeModeDispatchBroker>,
|
||||
}
|
||||
|
||||
impl CodeModeService {
|
||||
pub(crate) fn new() -> Self {
|
||||
let dispatch_broker = Arc::new(CodeModeDispatchBroker::new());
|
||||
Self {
|
||||
inner: codex_code_mode::CodeModeService::new(),
|
||||
session: Some(Arc::new(codex_code_mode::CodeModeService::with_delegate(
|
||||
dispatch_broker.clone(),
|
||||
))),
|
||||
dispatch_broker,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn allocate_cell_id(&self) -> String {
|
||||
self.inner.allocate_cell_id()
|
||||
}
|
||||
|
||||
pub(crate) async fn execute(
|
||||
&self,
|
||||
request: codex_code_mode::ExecuteRequest,
|
||||
) -> Result<RuntimeResponse, String> {
|
||||
self.inner.execute(request).await
|
||||
) -> Result<codex_code_mode::StartedCell, String> {
|
||||
self.session()?.execute(request).await
|
||||
}
|
||||
|
||||
pub(crate) async fn wait(
|
||||
&self,
|
||||
request: codex_code_mode::WaitRequest,
|
||||
) -> Result<codex_code_mode::WaitOutcome, String> {
|
||||
self.inner.wait(request).await
|
||||
self.session()?.wait(request).await
|
||||
}
|
||||
|
||||
pub(crate) async fn start_turn_worker(
|
||||
pub(crate) async fn terminate(
|
||||
&self,
|
||||
cell_id: CellId,
|
||||
) -> Result<codex_code_mode::WaitOutcome, String> {
|
||||
self.session()?.terminate(cell_id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn shutdown(&self) -> Result<(), String> {
|
||||
match &self.session {
|
||||
Some(session) => session.shutdown().await,
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mark_cell_ready_for_dispatch(&self, cell_id: &codex_code_mode::CellId) {
|
||||
self.dispatch_broker.mark_cell_ready_for_dispatch(cell_id);
|
||||
}
|
||||
|
||||
pub(crate) fn finish_cell_dispatch(&self, cell_id: &CellId) {
|
||||
self.dispatch_broker.close_cell(cell_id);
|
||||
}
|
||||
|
||||
pub(crate) fn start_turn_worker(
|
||||
&self,
|
||||
session: &Arc<Session>,
|
||||
turn: &Arc<TurnContext>,
|
||||
router: Arc<ToolRouter>,
|
||||
tracker: SharedTurnDiffTracker,
|
||||
) -> Option<codex_code_mode::CodeModeTurnWorker> {
|
||||
if !matches!(turn.tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) {
|
||||
) -> Option<CodeModeDispatchWorker> {
|
||||
if !matches!(turn.tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly)
|
||||
|| self.session.is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -99,50 +126,16 @@ impl CodeModeService {
|
||||
session: Arc::clone(session),
|
||||
turn: Arc::clone(turn),
|
||||
};
|
||||
let tool_runtime =
|
||||
ToolCallRuntime::new(router, Arc::clone(session), Arc::clone(turn), tracker);
|
||||
let host = Arc::new(CoreTurnHost { exec, tool_runtime });
|
||||
Some(self.inner.start_turn_worker(host))
|
||||
}
|
||||
}
|
||||
|
||||
struct CoreTurnHost {
|
||||
exec: ExecContext,
|
||||
tool_runtime: ToolCallRuntime,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl CodeModeTurnHost for CoreTurnHost {
|
||||
async fn invoke_tool(
|
||||
&self,
|
||||
invocation: CodeModeNestedToolCall,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<JsonValue, String> {
|
||||
call_nested_tool(
|
||||
self.exec.clone(),
|
||||
self.tool_runtime.clone(),
|
||||
invocation,
|
||||
cancellation_token,
|
||||
Some(
|
||||
self.dispatch_broker
|
||||
.start_turn_worker(exec, router, tracker),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String> {
|
||||
if text.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.exec
|
||||
.session
|
||||
.inject_if_running(vec![ResponseItem::CustomToolCallOutput {
|
||||
call_id,
|
||||
name: Some(PUBLIC_TOOL_NAME.to_string()),
|
||||
output: FunctionCallOutputPayload::from_text(text),
|
||||
}])
|
||||
.await
|
||||
.map_err(|_| {
|
||||
format!("failed to inject exec notify message for cell {cell_id}: no active turn")
|
||||
})
|
||||
fn session(&self) -> Result<&Arc<dyn CodeModeSession>, String> {
|
||||
self.session
|
||||
.as_ref()
|
||||
.ok_or_else(|| "code mode is unavailable".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +266,7 @@ async fn call_nested_tool(
|
||||
.handle_tool_call_with_source(
|
||||
call,
|
||||
ToolCallSource::CodeMode {
|
||||
cell_id,
|
||||
cell_id: cell_id.to_string(),
|
||||
runtime_tool_call_id,
|
||||
},
|
||||
cancellation_token,
|
||||
|
||||
@@ -73,17 +73,24 @@ impl ToolExecutor<ToolInvocation> for CodeModeWaitHandler {
|
||||
let args: ExecWaitArgs = parse_arguments(&arguments)?;
|
||||
let exec = ExecContext { session, turn };
|
||||
let started_at = std::time::Instant::now();
|
||||
let wait_response = exec
|
||||
.session
|
||||
.services
|
||||
.code_mode_service
|
||||
.wait(codex_code_mode::WaitRequest {
|
||||
cell_id: args.cell_id,
|
||||
yield_time_ms: args.yield_time_ms,
|
||||
terminate: args.terminate,
|
||||
})
|
||||
.await
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
let cell_id = codex_code_mode::CellId::new(args.cell_id);
|
||||
let wait_response = if args.terminate {
|
||||
exec.session
|
||||
.services
|
||||
.code_mode_service
|
||||
.terminate(cell_id)
|
||||
.await
|
||||
} else {
|
||||
exec.session
|
||||
.services
|
||||
.code_mode_service
|
||||
.wait(codex_code_mode::WaitRequest {
|
||||
cell_id,
|
||||
yield_time_ms: args.yield_time_ms,
|
||||
})
|
||||
.await
|
||||
}
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
if let codex_code_mode::WaitOutcome::LiveCell(response) = &wait_response
|
||||
&& !matches!(response, codex_code_mode::RuntimeResponse::Yielded { .. })
|
||||
{
|
||||
@@ -98,8 +105,15 @@ impl ToolExecutor<ToolInvocation> for CodeModeWaitHandler {
|
||||
exec.session
|
||||
.services
|
||||
.rollout_thread_trace
|
||||
.code_cell_trace_context(exec.turn.sub_id.as_str(), runtime_cell_id)
|
||||
.code_cell_trace_context(
|
||||
exec.turn.sub_id.as_str(),
|
||||
runtime_cell_id.as_str(),
|
||||
)
|
||||
.record_ended(response);
|
||||
exec.session
|
||||
.services
|
||||
.code_mode_service
|
||||
.finish_cell_dispatch(runtime_cell_id);
|
||||
}
|
||||
handle_runtime_response(&exec, wait_response.into(), args.max_tokens, started_at)
|
||||
.await
|
||||
|
||||
@@ -147,6 +147,7 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall {
|
||||
turn_id: invocation.turn.sub_id.clone(),
|
||||
call_id: invocation.call_id.clone(),
|
||||
tool_name: invocation.tool_name.clone(),
|
||||
model: invocation.turn.model_info.slug.clone(),
|
||||
truncation_policy: invocation.turn.truncation_policy,
|
||||
conversation_history,
|
||||
turn_item_emitter: Arc::new(CoreTurnItemEmitter {
|
||||
@@ -307,6 +308,7 @@ mod tests {
|
||||
let weak_session = Arc::downgrade(&session);
|
||||
let weak_turn = Arc::downgrade(&turn);
|
||||
let turn_id = turn.sub_id.clone();
|
||||
let model = turn.model_info.slug.clone();
|
||||
let truncation_policy = turn.truncation_policy;
|
||||
let history_item = ResponseItem::Message {
|
||||
id: None,
|
||||
@@ -350,6 +352,7 @@ mod tests {
|
||||
captured_call.tool_name,
|
||||
codex_tools::ToolName::plain("extension_echo")
|
||||
);
|
||||
assert_eq!(captured_call.model, model);
|
||||
assert_eq!(captured_call.truncation_policy, truncation_policy);
|
||||
assert_eq!(
|
||||
captured_call.conversation_history.items(),
|
||||
|
||||
@@ -37,7 +37,15 @@ use crate::tools::handlers::request_plugin_install_spec::create_request_plugin_i
|
||||
use crate::tools::registry::CoreToolRuntime;
|
||||
use crate::tools::registry::ToolExecutor;
|
||||
|
||||
pub struct RequestPluginInstallHandler;
|
||||
pub struct RequestPluginInstallHandler {
|
||||
discoverable_tools: Vec<DiscoverableTool>,
|
||||
}
|
||||
|
||||
impl RequestPluginInstallHandler {
|
||||
pub(crate) fn new(discoverable_tools: Vec<DiscoverableTool>) -> Self {
|
||||
Self { discoverable_tools }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
|
||||
@@ -53,10 +61,6 @@ impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
|
||||
true
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "plugin install discovery reads through the session-owned manager guard"
|
||||
)]
|
||||
async fn handle(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
@@ -99,31 +103,10 @@ impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
|
||||
));
|
||||
}
|
||||
|
||||
let auth = session.services.auth_manager.auth().await;
|
||||
let manager = session.services.mcp_connection_manager.read().await;
|
||||
let mcp_tools = manager.list_all_tools().await;
|
||||
drop(manager);
|
||||
let accessible_connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn.config,
|
||||
let discoverable_tools = filter_request_plugin_install_discoverable_tools_for_client(
|
||||
self.discoverable_tools.clone(),
|
||||
turn.app_server_client_name.as_deref(),
|
||||
);
|
||||
let discoverable_tools = connectors::list_tool_suggest_discoverable_tools_with_auth(
|
||||
&turn.config,
|
||||
auth.as_ref(),
|
||||
&accessible_connectors,
|
||||
)
|
||||
.await
|
||||
.map(|discoverable_tools| {
|
||||
filter_request_plugin_install_discoverable_tools_for_client(
|
||||
discoverable_tools,
|
||||
turn.app_server_client_name.as_deref(),
|
||||
)
|
||||
})
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"plugin install requests are unavailable right now: {err}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let tool = discoverable_tools
|
||||
.into_iter()
|
||||
@@ -154,6 +137,7 @@ impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
|
||||
.as_ref()
|
||||
.is_some_and(|response| response.action == ElicitationAction::Accept);
|
||||
|
||||
let auth = session.services.auth_manager.auth().await;
|
||||
let completed = if user_confirmed {
|
||||
verify_request_plugin_install_completed(&session, &turn, &tool, auth.as_ref()).await
|
||||
} else {
|
||||
|
||||
@@ -615,7 +615,9 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
|
||||
planned_tools.add(ListAvailablePluginsToInstallHandler::new(
|
||||
collect_request_plugin_install_entries(discoverable_tools),
|
||||
));
|
||||
planned_tools.add(RequestPluginInstallHandler);
|
||||
planned_tools.add(RequestPluginInstallHandler::new(
|
||||
discoverable_tools.to_vec(),
|
||||
));
|
||||
}
|
||||
|
||||
if environment_mode.has_environment() && turn_context.model_info.apply_patch_tool_type.is_some()
|
||||
|
||||
@@ -230,6 +230,8 @@ fn helper_env_from_vars(
|
||||
|
||||
fn helper_env_key_is_allowed(key: &str) -> bool {
|
||||
FS_HELPER_ENV_ALLOWLIST.contains(&key)
|
||||
// CoreFoundation consults this before falling back to user lookup during helper startup.
|
||||
|| (cfg!(target_os = "macos") && key == "__CF_USER_TEXT_ENCODING")
|
||||
|| bazel_bwrap_env_key_is_allowed(key)
|
||||
|| (cfg!(windows) && key.eq_ignore_ascii_case("PATH"))
|
||||
}
|
||||
@@ -434,6 +436,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn helper_env_preserves_corefoundation_text_encoding() {
|
||||
let env = helper_env_from_vars(
|
||||
[
|
||||
("__CF_USER_TEXT_ENCODING", "0x1F6:0x0:0x0"),
|
||||
("HOME", "/Users/test"),
|
||||
]
|
||||
.map(|(key, value)| (OsString::from(key), OsString::from(value))),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
env,
|
||||
HashMap::from([(
|
||||
"__CF_USER_TEXT_ENCODING".to_string(),
|
||||
"0x1F6:0x0:0x0".to_string(),
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn helper_env_preserves_windows_path_key_for_system_bwrap_discovery() {
|
||||
|
||||
@@ -1129,6 +1129,7 @@ fn tool_call(tool_name: &str, call_id: &str, arguments: serde_json::Value) -> To
|
||||
turn_id: "turn-1".to_string(),
|
||||
call_id: call_id.to_string(),
|
||||
tool_name: codex_extension_api::ToolName::plain(tool_name),
|
||||
model: "gpt-test".to_string(),
|
||||
truncation_policy: TruncationPolicy::Bytes(1024),
|
||||
conversation_history: codex_extension_api::ConversationHistory::default(),
|
||||
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
||||
|
||||
@@ -211,6 +211,7 @@ async fn add_ad_hoc_note_tool_creates_note_file() {
|
||||
turn_id: "turn-1".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: memory_tool_name(crate::ADD_AD_HOC_NOTE_TOOL_NAME),
|
||||
model: "gpt-test".to_string(),
|
||||
truncation_policy: TruncationPolicy::Bytes(1024),
|
||||
conversation_history: codex_extension_api::ConversationHistory::default(),
|
||||
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
||||
@@ -253,6 +254,7 @@ async fn add_ad_hoc_note_tool_rejects_paths_as_filenames() {
|
||||
turn_id: "turn-1".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: memory_tool_name(crate::ADD_AD_HOC_NOTE_TOOL_NAME),
|
||||
model: "gpt-test".to_string(),
|
||||
truncation_policy: TruncationPolicy::Bytes(1024),
|
||||
conversation_history: codex_extension_api::ConversationHistory::default(),
|
||||
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
||||
@@ -296,6 +298,7 @@ async fn read_tool_reads_memory_file() {
|
||||
turn_id: "turn-1".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: memory_tool_name(crate::READ_TOOL_NAME),
|
||||
model: "gpt-test".to_string(),
|
||||
truncation_policy: TruncationPolicy::Bytes(1024),
|
||||
conversation_history: codex_extension_api::ConversationHistory::default(),
|
||||
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
||||
@@ -342,6 +345,7 @@ async fn search_tool_accepts_multiple_queries() {
|
||||
turn_id: "turn-1".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME),
|
||||
model: "gpt-test".to_string(),
|
||||
truncation_policy: TruncationPolicy::Bytes(1024),
|
||||
conversation_history: codex_extension_api::ConversationHistory::default(),
|
||||
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
||||
@@ -414,6 +418,7 @@ async fn search_tool_accepts_windowed_all_match_mode() {
|
||||
turn_id: "turn-1".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME),
|
||||
model: "gpt-test".to_string(),
|
||||
truncation_policy: TruncationPolicy::Bytes(1024),
|
||||
conversation_history: codex_extension_api::ConversationHistory::default(),
|
||||
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
||||
@@ -466,6 +471,7 @@ async fn search_tool_rejects_legacy_single_query() {
|
||||
turn_id: "turn-1".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME),
|
||||
model: "gpt-test".to_string(),
|
||||
truncation_policy: TruncationPolicy::Bytes(1024),
|
||||
conversation_history: codex_extension_api::ConversationHistory::default(),
|
||||
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
||||
|
||||
@@ -90,7 +90,7 @@ impl ToolExecutor<ToolCall> for WebSearchTool {
|
||||
);
|
||||
let request = SearchRequest {
|
||||
id: self.session_id.clone(),
|
||||
model: None,
|
||||
model: call.model.clone(),
|
||||
reasoning: None,
|
||||
input: recent_input(call.conversation_history.items()),
|
||||
commands: Some(commands),
|
||||
|
||||
@@ -76,7 +76,8 @@ fn apply_turn_context(metadata: &mut ThreadMetadata, turn_ctx: &TurnContextItem)
|
||||
}
|
||||
metadata.model = Some(turn_ctx.model.clone());
|
||||
metadata.reasoning_effort = turn_ctx.effort;
|
||||
metadata.sandbox_policy = enum_to_string(&turn_ctx.sandbox_policy);
|
||||
metadata.sandbox_policy =
|
||||
serde_json::to_string(&turn_ctx.permission_profile()).unwrap_or_default();
|
||||
metadata.approval_mode = enum_to_string(&turn_ctx.approval_policy);
|
||||
}
|
||||
|
||||
@@ -157,6 +158,7 @@ mod tests {
|
||||
use chrono::Utc;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
@@ -364,11 +366,46 @@ mod tests {
|
||||
assert_eq!(metadata.cwd, PathBuf::from("/child/worktree"));
|
||||
assert_eq!(
|
||||
metadata.sandbox_policy,
|
||||
super::enum_to_string(&SandboxPolicy::DangerFullAccess)
|
||||
serde_json::to_string(&PermissionProfile::Disabled)
|
||||
.expect("serialize permission profile")
|
||||
);
|
||||
assert_eq!(metadata.approval_mode, "never");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_context_sets_permission_profile_metadata() {
|
||||
let mut metadata = metadata_for_test();
|
||||
let permission_profile = PermissionProfile::workspace_write();
|
||||
|
||||
apply_rollout_item(
|
||||
&mut metadata,
|
||||
&RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
cwd: PathBuf::from("/workspace"),
|
||||
workspace_roots: None,
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: Some(permission_profile.clone()),
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: "gpt-5".to_string(),
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
realtime_active: None,
|
||||
effort: None,
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
}),
|
||||
"test-provider",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
metadata.sandbox_policy,
|
||||
serde_json::to_string(&permission_profile).expect("serialize permission profile")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_context_sets_cwd_when_session_cwd_missing() {
|
||||
let mut metadata = metadata_for_test();
|
||||
|
||||
@@ -8,9 +8,9 @@ use std::sync::OnceLock;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
|
||||
use crate::AppendThreadItemsParams;
|
||||
use crate::ArchiveThreadParams;
|
||||
@@ -363,9 +363,9 @@ fn stored_thread_from_state(
|
||||
approval_mode: metadata
|
||||
.and_then(|metadata| metadata.approval_mode)
|
||||
.unwrap_or(AskForApproval::Never),
|
||||
sandbox_policy: metadata
|
||||
.and_then(|metadata| metadata.sandbox_policy.clone())
|
||||
.unwrap_or_else(SandboxPolicy::new_read_only_policy),
|
||||
permission_profile: metadata
|
||||
.and_then(|metadata| metadata.permission_profile.clone())
|
||||
.unwrap_or_else(PermissionProfile::read_only),
|
||||
token_usage: metadata.and_then(|metadata| metadata.token_usage.clone()),
|
||||
first_user_message: metadata.and_then(|metadata| metadata.first_user_message.clone()),
|
||||
history,
|
||||
|
||||
@@ -9,8 +9,10 @@ use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use codex_git_utils::GitSha;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::GitInfo;
|
||||
use codex_protocol::protocol::NetworkAccess;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_rollout::ARCHIVED_SESSIONS_SUBDIR;
|
||||
@@ -140,13 +142,34 @@ pub(super) fn stored_thread_from_rollout_item(
|
||||
agent_path: None,
|
||||
git_info,
|
||||
approval_mode: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
token_usage: None,
|
||||
first_user_message: item.first_user_message,
|
||||
history: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn permission_profile_from_metadata_value(value: &str, cwd: &Path) -> PermissionProfile {
|
||||
serde_json::from_str::<PermissionProfile>(value)
|
||||
.or_else(|_| {
|
||||
parse_legacy_sandbox_policy(value)
|
||||
.map(|policy| PermissionProfile::from_legacy_sandbox_policy_for_cwd(&policy, cwd))
|
||||
})
|
||||
.unwrap_or_else(|_| PermissionProfile::read_only())
|
||||
}
|
||||
|
||||
pub(super) fn permission_profile_to_metadata_value(
|
||||
permission_profile: &PermissionProfile,
|
||||
) -> String {
|
||||
match serde_json::to_string(permission_profile) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to serialize permission profile metadata: {err}");
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn distinct_thread_metadata_title(metadata: &ThreadMetadata) -> Option<String> {
|
||||
let title = metadata.title.trim();
|
||||
if title.is_empty() || metadata.first_user_message.as_deref().map(str::trim) == Some(title) {
|
||||
@@ -169,6 +192,20 @@ fn parse_rfc3339(value: Option<&str>) -> Option<DateTime<Utc>> {
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
fn parse_legacy_sandbox_policy(value: &str) -> serde_json::Result<SandboxPolicy> {
|
||||
serde_json::from_str(value)
|
||||
.or_else(|_| serde_json::from_value(serde_json::Value::String(value.to_string())))
|
||||
.or_else(|_| match value {
|
||||
"danger-full-access" => Ok(SandboxPolicy::DangerFullAccess),
|
||||
"read-only" => Ok(SandboxPolicy::new_read_only_policy()),
|
||||
"workspace-write" => Ok(SandboxPolicy::new_workspace_write_policy()),
|
||||
"external-sandbox" => Ok(SandboxPolicy::ExternalSandbox {
|
||||
network_access: NetworkAccess::Restricted,
|
||||
}),
|
||||
_ => serde_json::from_value(serde_json::Value::String(value.to_string())),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn git_info_from_parts(
|
||||
sha: Option<String>,
|
||||
branch: Option<String>,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_rollout::RolloutRecorder;
|
||||
@@ -15,6 +15,7 @@ use codex_state::ThreadMetadata;
|
||||
use super::LocalThreadStore;
|
||||
use super::helpers::distinct_thread_metadata_title;
|
||||
use super::helpers::git_info_from_parts;
|
||||
use super::helpers::permission_profile_from_metadata_value;
|
||||
use super::helpers::rollout_path_is_archived;
|
||||
use super::helpers::set_thread_name_from_title;
|
||||
use super::helpers::stored_thread_from_rollout_item;
|
||||
@@ -45,6 +46,7 @@ pub(super) async fn read_thread(
|
||||
)
|
||||
.await)
|
||||
{
|
||||
let metadata_sandbox_policy = metadata.sandbox_policy.clone();
|
||||
let mut thread = stored_thread_from_sqlite_metadata(store, metadata).await;
|
||||
if !params.include_history
|
||||
&& let Some(rollout_path) = thread.rollout_path.clone()
|
||||
@@ -57,6 +59,10 @@ pub(super) async fn read_thread(
|
||||
rollout_thread.name = thread.name;
|
||||
}
|
||||
rollout_thread.git_info = thread.git_info;
|
||||
rollout_thread.permission_profile = permission_profile_from_metadata_value(
|
||||
&metadata_sandbox_policy,
|
||||
rollout_thread.cwd.as_path(),
|
||||
);
|
||||
thread = rollout_thread;
|
||||
}
|
||||
attach_history_if_requested(&mut thread, params.include_history).await?;
|
||||
@@ -286,6 +292,8 @@ async fn stored_thread_from_sqlite_metadata(
|
||||
.clone()
|
||||
.or_else(|| metadata.first_user_message.clone())
|
||||
.unwrap_or_default();
|
||||
let permission_profile =
|
||||
permission_profile_from_metadata_value(&metadata.sandbox_policy, metadata.cwd.as_path());
|
||||
StoredThread {
|
||||
thread_id: metadata.id,
|
||||
rollout_path: Some(metadata.rollout_path),
|
||||
@@ -315,10 +323,7 @@ async fn stored_thread_from_sqlite_metadata(
|
||||
metadata.git_origin_url,
|
||||
),
|
||||
approval_mode: parse_or_default(&metadata.approval_mode, AskForApproval::OnRequest),
|
||||
sandbox_policy: parse_or_default(
|
||||
&metadata.sandbox_policy,
|
||||
SandboxPolicy::new_read_only_policy(),
|
||||
),
|
||||
permission_profile,
|
||||
token_usage: None,
|
||||
first_user_message: metadata.first_user_message,
|
||||
history: None,
|
||||
@@ -377,7 +382,7 @@ fn stored_thread_from_meta_line(
|
||||
agent_path: meta_line.meta.agent_path,
|
||||
git_info: meta_line.git,
|
||||
approval_mode: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
token_usage: None,
|
||||
first_user_message: None,
|
||||
history: None,
|
||||
@@ -412,6 +417,7 @@ mod tests {
|
||||
|
||||
use chrono::Utc;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_state::ThreadMetadataBuilder;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -671,6 +677,84 @@ mod tests {
|
||||
assert_eq!(thread.name, Some("Saved title".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_thread_returns_permission_profile_from_sqlite_metadata() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let uuid = Uuid::from_u128(225);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
let rollout_path =
|
||||
write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file");
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
config.sqlite_home.clone(),
|
||||
config.default_model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
|
||||
let mut builder =
|
||||
ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli);
|
||||
builder.model_provider = Some(config.default_model_provider_id.clone());
|
||||
builder.cwd = home.path().to_path_buf();
|
||||
let mut metadata = builder.build(config.default_model_provider_id.as_str());
|
||||
metadata.sandbox_policy =
|
||||
serde_json::to_string(&PermissionProfile::Disabled).expect("serialize profile");
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
.expect("state db upsert should succeed");
|
||||
|
||||
let thread = store
|
||||
.read_thread(ReadThreadParams {
|
||||
thread_id,
|
||||
include_archived: false,
|
||||
include_history: false,
|
||||
})
|
||||
.await
|
||||
.expect("read thread");
|
||||
|
||||
assert_eq!(thread.preview, "Hello from user");
|
||||
assert_eq!(thread.permission_profile, PermissionProfile::Disabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_thread_accepts_legacy_sandbox_policy_metadata() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let uuid = Uuid::from_u128(226);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
let rollout_path =
|
||||
write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file");
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
config.sqlite_home.clone(),
|
||||
config.default_model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
|
||||
let mut builder =
|
||||
ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli);
|
||||
builder.model_provider = Some(config.default_model_provider_id.clone());
|
||||
builder.cwd = home.path().to_path_buf();
|
||||
let mut metadata = builder.build(config.default_model_provider_id.as_str());
|
||||
metadata.sandbox_policy = "danger-full-access".to_string();
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
.expect("state db upsert should succeed");
|
||||
|
||||
let thread = store
|
||||
.read_thread(ReadThreadParams {
|
||||
thread_id,
|
||||
include_archived: false,
|
||||
include_history: true,
|
||||
})
|
||||
.await
|
||||
.expect("read thread");
|
||||
|
||||
assert_eq!(thread.permission_profile, PermissionProfile::Disabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_thread_preserves_rollout_cwd_when_sqlite_metadata_exists() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
@@ -725,6 +809,7 @@ mod tests {
|
||||
let mut metadata = builder.build(config.default_model_provider_id.as_str());
|
||||
metadata.title = "Saved title".to_string();
|
||||
metadata.first_user_message = Some("Hello from sqlite".to_string());
|
||||
metadata.sandbox_policy = "workspace-write".to_string();
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
@@ -745,6 +830,19 @@ mod tests {
|
||||
assert_eq!(thread.name, Some("Saved title".to_string()));
|
||||
assert_eq!(thread.model_provider, "rollout-provider");
|
||||
assert_eq!(thread.cwd, rollout_cwd);
|
||||
let legacy_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
};
|
||||
assert_eq!(
|
||||
thread.permission_profile,
|
||||
PermissionProfile::from_legacy_sandbox_policy_for_cwd(
|
||||
&legacy_policy,
|
||||
rollout_cwd.as_path()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -18,6 +18,7 @@ use tracing::warn;
|
||||
|
||||
use super::LocalThreadStore;
|
||||
use super::helpers::git_info_from_parts;
|
||||
use super::helpers::permission_profile_to_metadata_value;
|
||||
use super::live_writer;
|
||||
use crate::GitInfoPatch;
|
||||
use crate::ReadThreadParams;
|
||||
@@ -275,8 +276,8 @@ async fn apply_metadata_update(
|
||||
if let Some(approval_mode) = patch.approval_mode {
|
||||
metadata.approval_mode = enum_to_string(&approval_mode);
|
||||
}
|
||||
if let Some(sandbox_policy) = patch.sandbox_policy {
|
||||
metadata.sandbox_policy = enum_to_string(&sandbox_policy);
|
||||
if let Some(permission_profile) = patch.permission_profile {
|
||||
metadata.sandbox_policy = permission_profile_to_metadata_value(&permission_profile);
|
||||
}
|
||||
if let Some(token_usage) = patch.token_usage {
|
||||
metadata.tokens_used = token_usage.total_tokens.max(0);
|
||||
@@ -382,7 +383,7 @@ fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool {
|
||||
|| patch.cwd.is_some()
|
||||
|| patch.cli_version.is_some()
|
||||
|| patch.approval_mode.is_some()
|
||||
|| patch.sandbox_policy.is_some()
|
||||
|| patch.permission_profile.is_some()
|
||||
|| patch.token_usage.is_some()
|
||||
|| patch.first_user_message.is_some()
|
||||
}
|
||||
@@ -604,6 +605,7 @@ fn rollout_path_is_archived(store: &LocalThreadStore, path: &Path) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
@@ -845,6 +847,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_thread_metadata_sets_permission_profile() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
config.sqlite_home.clone(),
|
||||
config.default_model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let store = LocalThreadStore::new(config, Some(runtime.clone()));
|
||||
let uuid = Uuid::from_u128(317);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
write_session_file(home.path(), "2025-01-03T20-30-00", uuid).expect("session file");
|
||||
|
||||
let thread = store
|
||||
.update_thread_metadata(UpdateThreadMetadataParams {
|
||||
thread_id,
|
||||
patch: ThreadMetadataPatch {
|
||||
permission_profile: Some(PermissionProfile::Disabled),
|
||||
..Default::default()
|
||||
},
|
||||
include_archived: false,
|
||||
})
|
||||
.await
|
||||
.expect("set permission profile");
|
||||
|
||||
assert_eq!(thread.permission_profile, PermissionProfile::Disabled);
|
||||
let metadata = runtime
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("sqlite metadata read")
|
||||
.expect("sqlite metadata");
|
||||
assert_eq!(
|
||||
metadata.sandbox_policy,
|
||||
serde_json::to_string(&PermissionProfile::Disabled).expect("serialize profile")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_thread_metadata_partially_updates_git_info() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
|
||||
@@ -234,7 +234,7 @@ impl ThreadMetadataSync {
|
||||
update.model = Some(turn_ctx.model.clone());
|
||||
update.reasoning_effort = turn_ctx.effort;
|
||||
update.approval_mode = Some(turn_ctx.approval_policy);
|
||||
update.sandbox_policy = Some(turn_ctx.sandbox_policy.clone());
|
||||
update.permission_profile = Some(turn_ctx.permission_profile());
|
||||
}
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(user)) => {
|
||||
if let Some(preview) = user_message_preview(user) {
|
||||
@@ -354,7 +354,7 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool {
|
||||
|| update.cwd.is_some()
|
||||
|| update.cli_version.is_some()
|
||||
|| update.approval_mode.is_some()
|
||||
|| update.sandbox_policy.is_some()
|
||||
|| update.permission_profile.is_some()
|
||||
|| update.token_usage.is_some()
|
||||
|| update.first_user_message.is_some()
|
||||
|| update.git_info.is_some()
|
||||
|
||||
@@ -5,11 +5,11 @@ use chrono::Utc;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::GitInfo;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::ThreadMemoryMode as MemoryMode;
|
||||
use codex_protocol::protocol::ThreadSource;
|
||||
@@ -396,8 +396,8 @@ pub struct StoredThread {
|
||||
pub git_info: Option<GitInfo>,
|
||||
/// Approval mode captured for the thread.
|
||||
pub approval_mode: AskForApproval,
|
||||
/// Sandbox policy captured for the thread.
|
||||
pub sandbox_policy: SandboxPolicy,
|
||||
/// Canonical runtime permissions captured for the thread.
|
||||
pub permission_profile: PermissionProfile,
|
||||
/// Last observed token usage.
|
||||
pub token_usage: Option<TokenUsage>,
|
||||
/// First user message observed for this thread, if any.
|
||||
@@ -519,8 +519,8 @@ pub struct ThreadMetadataPatch {
|
||||
pub cli_version: Option<String>,
|
||||
/// Approval mode.
|
||||
pub approval_mode: Option<AskForApproval>,
|
||||
/// Sandbox policy.
|
||||
pub sandbox_policy: Option<SandboxPolicy>,
|
||||
/// Canonical runtime permissions.
|
||||
pub permission_profile: Option<PermissionProfile>,
|
||||
/// Last observed token usage.
|
||||
pub token_usage: Option<TokenUsage>,
|
||||
/// First user message observed for this thread.
|
||||
@@ -589,8 +589,8 @@ impl ThreadMetadataPatch {
|
||||
if next.approval_mode.is_some() {
|
||||
self.approval_mode = next.approval_mode;
|
||||
}
|
||||
if next.sandbox_policy.is_some() {
|
||||
self.sandbox_policy = next.sandbox_policy;
|
||||
if next.permission_profile.is_some() {
|
||||
self.permission_profile = next.permission_profile;
|
||||
}
|
||||
if next.token_usage.is_some() {
|
||||
self.token_usage = next.token_usage;
|
||||
@@ -626,7 +626,7 @@ impl ThreadMetadataPatch {
|
||||
&& self.cwd.is_none()
|
||||
&& self.cli_version.is_none()
|
||||
&& self.approval_mode.is_none()
|
||||
&& self.sandbox_policy.is_none()
|
||||
&& self.permission_profile.is_none()
|
||||
&& self.token_usage.is_none()
|
||||
&& self.first_user_message.is_none()
|
||||
&& self.git_info.is_none()
|
||||
|
||||
@@ -87,6 +87,7 @@ pub struct ToolCall {
|
||||
pub turn_id: String,
|
||||
pub call_id: String,
|
||||
pub tool_name: ToolName,
|
||||
pub model: String,
|
||||
pub truncation_policy: TruncationPolicy,
|
||||
pub conversation_history: ConversationHistory,
|
||||
pub turn_item_emitter: Arc<dyn TurnItemEmitter>,
|
||||
@@ -99,6 +100,7 @@ impl std::fmt::Debug for ToolCall {
|
||||
.field("turn_id", &self.turn_id)
|
||||
.field("call_id", &self.call_id)
|
||||
.field("tool_name", &self.tool_name)
|
||||
.field("model", &self.model)
|
||||
.field("truncation_policy", &self.truncation_policy)
|
||||
.field("conversation_history", &self.conversation_history)
|
||||
.field("turn_item_emitter", &"<host turn item emitter>")
|
||||
|
||||
@@ -677,9 +677,10 @@ impl TextArea {
|
||||
}
|
||||
if self.vim_normal_keymap.open_line_below.is_pressed(event) {
|
||||
let eol = self.end_of_current_line();
|
||||
let insert_at = if eol < self.text.len() { eol + 1 } else { eol };
|
||||
let old_len = self.text.len();
|
||||
let insert_at = if eol < old_len { eol + 1 } else { eol };
|
||||
self.insert_str_at(insert_at, "\n");
|
||||
let cursor = if eol < self.text.len() {
|
||||
let cursor = if eol < old_len {
|
||||
insert_at
|
||||
} else {
|
||||
insert_at + 1
|
||||
@@ -735,6 +736,13 @@ impl TextArea {
|
||||
self.delete_forward_kill(/*n*/ 1);
|
||||
return;
|
||||
}
|
||||
if self.vim_normal_keymap.substitute_char.is_pressed(event) {
|
||||
if self.cursor_pos < self.end_of_current_line() {
|
||||
self.delete_forward_kill(/*n*/ 1);
|
||||
}
|
||||
self.vim_mode = VimMode::Insert;
|
||||
return;
|
||||
}
|
||||
if self.vim_normal_keymap.delete_to_line_end.is_pressed(event) {
|
||||
self.vim_kill_to_end_of_line();
|
||||
return;
|
||||
@@ -2360,6 +2368,38 @@ mod tests {
|
||||
assert_eq!(t.cursor(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_s_substitutes_current_character_and_enters_insert_mode() {
|
||||
let mut t = ta_with("abc");
|
||||
t.set_cursor(/*pos*/ 1);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "ac");
|
||||
assert_eq!(t.cursor(), 1);
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "aXc");
|
||||
assert_eq!(t.cursor(), 2);
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_s_on_empty_line_enters_insert_without_deleting_newline() {
|
||||
let mut t = ta_with("before\n\nnext");
|
||||
t.set_cursor(/*pos*/ "before\n".len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "before\n\nnext");
|
||||
assert_eq!(t.cursor(), "before\n".len());
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_d_at_line_end_does_not_remove_newline() {
|
||||
let mut t = ta_with("hello\nworld");
|
||||
@@ -2414,6 +2454,19 @@ mod tests {
|
||||
assert_eq!(t.cursor(), "one\n".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_o_opens_line_below_final_line_and_moves_to_new_line() {
|
||||
let mut t = ta_with("one");
|
||||
t.set_cursor(/*pos*/ 1);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "one\n");
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
assert_eq!(t.cursor(), "one\n".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_delete_word() {
|
||||
let mut t = ta_with("hello world");
|
||||
|
||||
@@ -158,6 +158,7 @@ pub(crate) struct VimNormalKeymap {
|
||||
pub(crate) move_line_start: Vec<KeyBinding>,
|
||||
pub(crate) move_line_end: Vec<KeyBinding>,
|
||||
pub(crate) delete_char: Vec<KeyBinding>,
|
||||
pub(crate) substitute_char: Vec<KeyBinding>,
|
||||
pub(crate) delete_to_line_end: Vec<KeyBinding>,
|
||||
pub(crate) change_to_line_end: Vec<KeyBinding>,
|
||||
pub(crate) yank_line: Vec<KeyBinding>,
|
||||
@@ -494,6 +495,7 @@ impl RuntimeKeymap {
|
||||
move_line_start: resolve_local!(keymap, defaults, vim_normal, move_line_start),
|
||||
move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end),
|
||||
delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char),
|
||||
substitute_char: resolve_local!(keymap, defaults, vim_normal, substitute_char),
|
||||
delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end),
|
||||
change_to_line_end: resolve_local!(keymap, defaults, vim_normal, change_to_line_end),
|
||||
yank_line: resolve_local!(keymap, defaults, vim_normal, yank_line),
|
||||
@@ -579,6 +581,10 @@ impl RuntimeKeymap {
|
||||
keymap.vim_normal.delete_char.as_ref(),
|
||||
vim_normal.delete_char.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.change_to_line_end.as_ref(),
|
||||
vim_normal.change_to_line_end.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.delete_to_line_end.as_ref(),
|
||||
vim_normal.delete_to_line_end.as_slice(),
|
||||
@@ -599,6 +605,10 @@ impl RuntimeKeymap {
|
||||
keymap.vim_normal.start_yank_operator.as_ref(),
|
||||
vim_normal.start_yank_operator.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.start_change_operator.as_ref(),
|
||||
vim_normal.start_change_operator.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.cancel_operator.as_ref(),
|
||||
vim_normal.cancel_operator.as_slice(),
|
||||
@@ -610,6 +620,11 @@ impl RuntimeKeymap {
|
||||
.start_change_operator
|
||||
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
|
||||
}
|
||||
if keymap.vim_normal.substitute_char.is_none() {
|
||||
vim_normal
|
||||
.substitute_char
|
||||
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
|
||||
}
|
||||
|
||||
let mut vim_operator = VimOperatorKeymap {
|
||||
delete_line: resolve_local!(keymap, defaults, vim_operator, delete_line),
|
||||
@@ -990,6 +1005,7 @@ impl RuntimeKeymap {
|
||||
shift(KeyCode::Char('$'))
|
||||
],
|
||||
delete_char: default_bindings![plain(KeyCode::Char('x'))],
|
||||
substitute_char: default_bindings![plain(KeyCode::Char('s'))],
|
||||
delete_to_line_end: default_bindings![
|
||||
shift(KeyCode::Char('d')),
|
||||
plain(KeyCode::Char('D'))
|
||||
@@ -1434,6 +1450,10 @@ impl RuntimeKeymap {
|
||||
),
|
||||
("move_line_end", self.vim_normal.move_line_end.as_slice()),
|
||||
("delete_char", self.vim_normal.delete_char.as_slice()),
|
||||
(
|
||||
"substitute_char",
|
||||
self.vim_normal.substitute_char.as_slice(),
|
||||
),
|
||||
(
|
||||
"delete_to_line_end",
|
||||
self.vim_normal.delete_to_line_end.as_slice(),
|
||||
@@ -2311,6 +2331,29 @@ mod tests {
|
||||
expect_conflict(&keymap, "move_left", "start_change_operator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_legacy_vim_normal_bindings_prune_new_substitute_default() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.vim_normal.move_left = Some(one("s"));
|
||||
|
||||
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
||||
|
||||
assert_eq!(
|
||||
runtime.vim_normal.move_left,
|
||||
vec![key_hint::plain(KeyCode::Char('s'))]
|
||||
);
|
||||
assert_eq!(runtime.vim_normal.substitute_char, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_new_vim_normal_substitute_binding_still_conflicts_with_legacy_binding() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.vim_normal.move_left = Some(one("s"));
|
||||
keymap.vim_normal.substitute_char = Some(one("s"));
|
||||
|
||||
expect_conflict(&keymap, "move_left", "substitute_char");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_legacy_vim_operator_bindings_prune_new_text_object_defaults() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
|
||||
@@ -135,6 +135,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
||||
action("vim_normal", "Vim normal", "move_line_start", "Move to the start of the line."),
|
||||
action("vim_normal", "Vim normal", "move_line_end", "Move to the end of the line."),
|
||||
action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."),
|
||||
action("vim_normal", "Vim normal", "substitute_char", "Delete the character under the cursor and enter insert mode."),
|
||||
action("vim_normal", "Vim normal", "delete_to_line_end", "Delete from cursor to end of line."),
|
||||
action("vim_normal", "Vim normal", "change_to_line_end", "Change from cursor to end of line and enter insert mode."),
|
||||
action("vim_normal", "Vim normal", "yank_line", "Yank the entire line."),
|
||||
@@ -277,6 +278,7 @@ pub(super) fn binding_slot<'a>(
|
||||
("vim_normal", "move_line_start") => Some(&mut keymap.vim_normal.move_line_start),
|
||||
("vim_normal", "move_line_end") => Some(&mut keymap.vim_normal.move_line_end),
|
||||
("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char),
|
||||
("vim_normal", "substitute_char") => Some(&mut keymap.vim_normal.substitute_char),
|
||||
("vim_normal", "delete_to_line_end") => Some(&mut keymap.vim_normal.delete_to_line_end),
|
||||
("vim_normal", "change_to_line_end") => Some(&mut keymap.vim_normal.change_to_line_end),
|
||||
("vim_normal", "yank_line") => Some(&mut keymap.vim_normal.yank_line),
|
||||
@@ -401,6 +403,7 @@ pub(super) fn bindings_for_action<'a>(
|
||||
("vim_normal", "move_line_start") => Some(runtime_keymap.vim_normal.move_line_start.as_slice()),
|
||||
("vim_normal", "move_line_end") => Some(runtime_keymap.vim_normal.move_line_end.as_slice()),
|
||||
("vim_normal", "delete_char") => Some(runtime_keymap.vim_normal.delete_char.as_slice()),
|
||||
("vim_normal", "substitute_char") => Some(runtime_keymap.vim_normal.substitute_char.as_slice()),
|
||||
("vim_normal", "delete_to_line_end") => Some(runtime_keymap.vim_normal.delete_to_line_end.as_slice()),
|
||||
("vim_normal", "change_to_line_end") => Some(runtime_keymap.vim_normal.change_to_line_end.as_slice()),
|
||||
("vim_normal", "yank_line") => Some(runtime_keymap.vim_normal.yank_line.as_slice()),
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
107 actions, 1 customized, 2 unbound.
|
||||
108 actions, 1 customized, 2 unbound.
|
||||
|
||||
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
108 actions, 0 customized, 3 unbound.
|
||||
109 actions, 0 customized, 3 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: snapshot
|
||||
---
|
||||
tab: All (107 selectable)
|
||||
tab: All (108 selectable)
|
||||
tab: Common (20 selectable)
|
||||
tab: Customized (0) (0 selectable)
|
||||
tab: Unbound (2) (2 selectable)
|
||||
tab: App (10 selectable)
|
||||
tab: Composer (5 selectable)
|
||||
tab: Editor (17 selectable)
|
||||
tab: Vim (47 selectable)
|
||||
tab: Vim (48 selectable)
|
||||
tab: Navigation (20 selectable)
|
||||
tab: Approval (8 selectable)
|
||||
tab: Debug (1 selectable)
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
107 actions, 0 customized, 2 unbound.
|
||||
108 actions, 0 customized, 2 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (2) App Composer Editor Vim
|
||||
Navigation Approval Debug
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
107 actions, 0 customized, 2 unbound.
|
||||
108 actions, 0 customized, 2 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug
|
||||
|
||||
|
||||
Reference in New Issue
Block a user