diff --git a/codex-rs/core/src/apps/mod.rs b/codex-rs/core/src/apps/mod.rs new file mode 100644 index 000000000..dcb5c4c20 --- /dev/null +++ b/codex-rs/core/src/apps/mod.rs @@ -0,0 +1,3 @@ +mod render; + +pub(crate) use render::render_apps_section; diff --git a/codex-rs/core/src/apps/render.rs b/codex-rs/core/src/apps/render.rs new file mode 100644 index 000000000..f450eb3a3 --- /dev/null +++ b/codex-rs/core/src/apps/render.rs @@ -0,0 +1,7 @@ +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; + +pub(crate) fn render_apps_section() -> String { + format!( + "## Apps\nApps are mentioned in the prompt in the format `[$app-name](apps://{{connector_id}})`.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nWhen you see an app mention, the app's MCP tools are either already provided in `{CODEX_APPS_MCP_SERVER_NAME}`, or do not exist because the user did not install it.\nDo not additionally call list_mcp_resources for apps that are already mentioned." + ) +} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2abb94165..53b8aa8d5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -15,6 +15,7 @@ use crate::agent::MAX_THREAD_SPAWN_DEPTH; use crate::agent::agent_status_from_event; use crate::analytics_client::AnalyticsEventsClient; use crate::analytics_client::build_track_events_context; +use crate::apps::render_apps_section; use crate::compact; use crate::compact::run_inline_auto_compact_task; use crate::compact::should_use_remote_compact_task; @@ -2280,6 +2281,9 @@ impl Session { ); } } + if turn_context.features.enabled(Feature::Apps) { + items.push(DeveloperInstructions::new(render_apps_section()).into()); + } if let Some(user_instructions) = turn_context.user_instructions.as_deref() { items.push( UserInstructions { diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 5f1920171..cfde230fe 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -8,6 +8,7 @@ mod analytics_client; pub mod api_bridge; mod apply_patch; +mod apps; pub mod auth; pub mod bash; mod client; diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 107477caa..101e900e1 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -547,6 +547,30 @@ mod tests { assert_eq!(res, expected); } + #[tokio::test] + async fn apps_feature_does_not_emit_user_instructions_by_itself() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut cfg = make_config(&tmp, 4096, None).await; + cfg.features.enable(Feature::Apps); + + let res = get_user_instructions(&cfg, None).await; + assert_eq!(res, None); + } + + #[tokio::test] + async fn apps_feature_does_not_append_to_project_doc_user_instructions() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "base doc").unwrap(); + + let mut cfg = make_config(&tmp, 4096, None).await; + cfg.features.enable(Feature::Apps); + + let res = get_user_instructions(&cfg, None) + .await + .expect("instructions expected"); + assert_eq!(res, "base doc"); + } + fn create_skill(codex_home: PathBuf, name: &str, description: &str) { let skill_dir = codex_home.join(format!("skills/{name}")); fs::create_dir_all(&skill_dir).unwrap(); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 2b7753c29..cb5668ec5 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -16,6 +16,7 @@ use codex_core::auth::AuthCredentialsStoreMode; use codex_core::built_in_model_providers; use codex_core::default_client::originator; use codex_core::error::CodexErr; +use codex_core::features::Feature; use codex_core::models_manager::manager::ModelsManager; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; @@ -666,6 +667,77 @@ async fn includes_user_instructions_message_in_request() { assert_message_ends_with(&request_body["input"][2], ""); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn includes_apps_guidance_as_developer_message_when_enabled() { + skip_if_no_network!(); + let server = MockServer::start().await; + + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + + let mut builder = test_codex() + .with_auth(CodexAuth::from_api_key("Test API Key")) + .with_config(|config| { + config.features.enable(Feature::Apps); + }); + let codex = builder + .build(&server) + .await + .expect("create new conversation") + .codex; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request = resp_mock.single_request(); + let request_body = request.body_json(); + let input = request_body["input"].as_array().expect("input array"); + let apps_snippet = "Apps are mentioned in the prompt in the format"; + + let has_developer_apps_guidance = input.iter().any(|item| { + item.get("role").and_then(|value| value.as_str()) == Some("developer") + && item + .get("content") + .and_then(|value| value.as_array()) + .and_then(|value| value.first()) + .and_then(|value| value.get("text")) + .and_then(|value| value.as_str()) + .is_some_and(|text| text.contains(apps_snippet)) + }); + assert!( + has_developer_apps_guidance, + "expected apps guidance in a developer message, got {input:#?}" + ); + + let has_user_apps_guidance = input.iter().any(|item| { + item.get("role").and_then(|value| value.as_str()) == Some("user") + && item + .get("content") + .and_then(|value| value.as_array()) + .and_then(|value| value.first()) + .and_then(|value| value.get("text")) + .and_then(|value| value.as_str()) + .is_some_and(|text| text.contains(apps_snippet)) + }); + assert!( + !has_user_apps_guidance, + "did not expect apps guidance in user messages, got {input:#?}" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn skills_append_to_instructions() { skip_if_no_network!();