mirror of
https://github.com/openai/codex.git
synced 2026-03-06 14:43:21 +00:00
Compare commits
1 Commits
codex-cli-
...
codex-apps
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc1bf14360 |
@@ -300,7 +300,19 @@ impl Codex {
|
||||
}
|
||||
|
||||
let enabled_skills = loaded_skills.enabled_skills();
|
||||
let user_instructions = get_user_instructions(&config, Some(&enabled_skills)).await;
|
||||
let apps = if config.features.enabled(Feature::Apps) {
|
||||
match connectors::list_accessible_connectors_from_mcp_tools(&config).await {
|
||||
Ok(connectors) => connectors,
|
||||
Err(err) => {
|
||||
warn!("failed to load apps for prompt: {err:#}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let user_instructions =
|
||||
get_user_instructions(&config, Some(&enabled_skills), Some(&apps)).await;
|
||||
|
||||
let exec_policy = ExecPolicyManager::load(&config.features, &config.config_layer_stack)
|
||||
.await
|
||||
@@ -4517,6 +4529,8 @@ mod tests {
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
@@ -4530,6 +4544,11 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
use crate::mentions::build_connector_slug_counts;
|
||||
use crate::mentions::build_skill_name_counts;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::collect_explicit_skill_mentions;
|
||||
|
||||
struct InstructionsTestCase {
|
||||
slug: &'static str,
|
||||
expects_apply_patch_instructions: bool,
|
||||
@@ -4559,6 +4578,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_skill(name: &str, path: &str) -> SkillMetadata {
|
||||
SkillMetadata {
|
||||
name: name.to_string(),
|
||||
description: "test skill".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
dependencies: None,
|
||||
path: PathBuf::from(path),
|
||||
scope: SkillScope::User,
|
||||
}
|
||||
}
|
||||
|
||||
fn user_input(text: &str) -> UserInput {
|
||||
UserInput::Text {
|
||||
text: text.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_base_instructions_no_user_content() {
|
||||
let prompt_with_apply_patch_instructions =
|
||||
@@ -4662,6 +4700,130 @@ mod tests {
|
||||
assert_eq!(selected, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_behavior_harness_routes_mentions_with_ambiguity() {
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
prompt: &'static str,
|
||||
skills: Vec<SkillMetadata>,
|
||||
connectors: Vec<AppInfo>,
|
||||
explicit_app_paths: Vec<String>,
|
||||
expected_skill_names: Vec<&'static str>,
|
||||
expected_app_ids: Vec<&'static str>,
|
||||
}
|
||||
|
||||
let cases = vec![
|
||||
Case {
|
||||
name: "unique_app_slug",
|
||||
prompt: "use $todoist to list tasks",
|
||||
skills: Vec::new(),
|
||||
connectors: vec![make_connector("app_todo", "Todoist")],
|
||||
explicit_app_paths: Vec::new(),
|
||||
expected_skill_names: Vec::new(),
|
||||
expected_app_ids: vec!["app_todo"],
|
||||
},
|
||||
Case {
|
||||
name: "duplicate_app_slug",
|
||||
prompt: "use $foo-bar",
|
||||
skills: Vec::new(),
|
||||
connectors: vec![
|
||||
make_connector("one", "Foo Bar"),
|
||||
make_connector("two", "Foo-Bar"),
|
||||
],
|
||||
explicit_app_paths: Vec::new(),
|
||||
expected_skill_names: Vec::new(),
|
||||
expected_app_ids: Vec::new(),
|
||||
},
|
||||
Case {
|
||||
name: "skill_conflict_blocks_app_and_skill",
|
||||
prompt: "use $calendar",
|
||||
skills: vec![make_skill("calendar", "/tmp/skills/calendar/SKILL.md")],
|
||||
connectors: vec![make_connector("app_cal", "Calendar")],
|
||||
explicit_app_paths: Vec::new(),
|
||||
expected_skill_names: Vec::new(),
|
||||
expected_app_ids: Vec::new(),
|
||||
},
|
||||
Case {
|
||||
name: "explicit_app_path_wins",
|
||||
prompt: "use [$calendar](app://app_cal)",
|
||||
skills: vec![make_skill("calendar", "/tmp/skills/calendar/SKILL.md")],
|
||||
connectors: vec![make_connector("app_cal", "Calendar")],
|
||||
explicit_app_paths: Vec::new(),
|
||||
expected_skill_names: Vec::new(),
|
||||
expected_app_ids: vec!["app_cal"],
|
||||
},
|
||||
Case {
|
||||
name: "explicit_skill_path_wins",
|
||||
prompt: "use [$calendar](skill:///tmp/skills/calendar/SKILL.md)",
|
||||
skills: vec![make_skill("calendar", "/tmp/skills/calendar/SKILL.md")],
|
||||
connectors: vec![make_connector("app_cal", "Calendar")],
|
||||
explicit_app_paths: Vec::new(),
|
||||
expected_skill_names: vec!["calendar"],
|
||||
expected_app_ids: Vec::new(),
|
||||
},
|
||||
Case {
|
||||
name: "mcp_path_does_not_select_skill_or_app",
|
||||
prompt: "use [$foo](mcp://server/foo)",
|
||||
skills: vec![make_skill("foo", "/tmp/skills/foo/SKILL.md")],
|
||||
connectors: vec![make_connector("app_foo", "Foo")],
|
||||
explicit_app_paths: Vec::new(),
|
||||
expected_skill_names: Vec::new(),
|
||||
expected_app_ids: Vec::new(),
|
||||
},
|
||||
Case {
|
||||
name: "plain_skill_mention_selects_skill",
|
||||
prompt: "run $linting now",
|
||||
skills: vec![make_skill("linting", "/tmp/skills/linting/SKILL.md")],
|
||||
connectors: Vec::new(),
|
||||
explicit_app_paths: Vec::new(),
|
||||
expected_skill_names: vec!["linting"],
|
||||
expected_app_ids: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
let disabled_paths = HashSet::new();
|
||||
let inputs = vec![user_input(case.prompt)];
|
||||
let (skill_name_counts, skill_name_counts_lower) =
|
||||
build_skill_name_counts(&case.skills, &disabled_paths);
|
||||
let connector_slug_counts = build_connector_slug_counts(&case.connectors);
|
||||
let selected_skills = collect_explicit_skill_mentions(
|
||||
&inputs,
|
||||
&case.skills,
|
||||
&disabled_paths,
|
||||
&skill_name_counts,
|
||||
&connector_slug_counts,
|
||||
);
|
||||
let selected_skill_names = selected_skills
|
||||
.iter()
|
||||
.map(|skill| skill.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let response_items = vec![user_message(case.prompt)];
|
||||
let selected_connectors = filter_connectors_for_input(
|
||||
case.connectors,
|
||||
&response_items,
|
||||
&case.explicit_app_paths,
|
||||
&skill_name_counts_lower,
|
||||
);
|
||||
let selected_app_ids = selected_connectors
|
||||
.iter()
|
||||
.map(|connector| connector.id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
selected_skill_names, case.expected_skill_names,
|
||||
"case {} selected unexpected skills",
|
||||
case.name
|
||||
);
|
||||
assert_eq!(
|
||||
selected_app_ids, case.expected_app_ids,
|
||||
"case {} selected unexpected apps",
|
||||
case.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconstruct_history_matches_live_compactions() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
|
||||
@@ -85,6 +85,42 @@ pub fn connector_mention_slug(connector: &AppInfo) -> String {
|
||||
connector_name_slug(&connector_display_label(connector))
|
||||
}
|
||||
|
||||
pub fn render_apps_section(connectors: &[AppInfo]) -> String {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push("## Apps".to_string());
|
||||
lines.push(
|
||||
"Apps (connectors) are MCP tools. Users may refer to apps, connectors, or MCPs interchangeably; treat those requests as app mentions when they match an available app, otherwise ask for clarification."
|
||||
.to_string(),
|
||||
);
|
||||
lines.push("### How to mention apps".to_string());
|
||||
lines.push(
|
||||
"- Use `$<app-slug>` to select a unique app by slug (derived from the display name)."
|
||||
.to_string(),
|
||||
);
|
||||
lines.push(
|
||||
"- Use `[$<name>](app://<app-id>)` when the name is ambiguous or collides with a skill."
|
||||
.to_string(),
|
||||
);
|
||||
lines.push(
|
||||
"- If the user asks to use an app/connector/MCP but did not mention it, ask them to add a `$` mention or pick from `/apps`."
|
||||
.to_string(),
|
||||
);
|
||||
lines.push("### Available apps".to_string());
|
||||
|
||||
if connectors.is_empty() {
|
||||
lines.push("- None detected. Ask the user to install an app or run /apps.".to_string());
|
||||
} else {
|
||||
for connector in connectors {
|
||||
let slug = connector_mention_slug(connector);
|
||||
let name = connector.name.as_str();
|
||||
let id = connector.id.as_str();
|
||||
lines.push(format!("- {slug}: {name} (id: {id})"));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn accessible_connectors_from_mcp_tools(
|
||||
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
|
||||
) -> Vec<AppInfo> {
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
//! 3. We do **not** walk past the Git root.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::connectors::AppInfo;
|
||||
use crate::connectors::render_apps_section;
|
||||
use crate::features::Feature;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::render_skills_section;
|
||||
@@ -39,6 +41,7 @@ const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
|
||||
pub(crate) async fn get_user_instructions(
|
||||
config: &Config,
|
||||
skills: Option<&[SkillMetadata]>,
|
||||
apps: Option<&[AppInfo]>,
|
||||
) -> Option<String> {
|
||||
let project_docs = read_project_docs(config).await;
|
||||
|
||||
@@ -69,6 +72,13 @@ pub(crate) async fn get_user_instructions(
|
||||
output.push_str(&skills_section);
|
||||
}
|
||||
|
||||
if config.features.enabled(Feature::Apps) {
|
||||
if !output.is_empty() {
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
output.push_str(&render_apps_section(apps.unwrap_or(&[])));
|
||||
}
|
||||
|
||||
if config.features.enabled(Feature::ChildAgentsMd) {
|
||||
if !output.is_empty() {
|
||||
output.push_str("\n\n");
|
||||
@@ -280,7 +290,7 @@ mod tests {
|
||||
async fn no_doc_file_returns_none() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let res = get_user_instructions(&make_config(&tmp, 4096, None).await, None).await;
|
||||
let res = get_user_instructions(&make_config(&tmp, 4096, None).await, None, None).await;
|
||||
assert!(
|
||||
res.is_none(),
|
||||
"Expected None when AGENTS.md is absent and no system instructions provided"
|
||||
@@ -294,7 +304,7 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("AGENTS.md"), "hello world").unwrap();
|
||||
|
||||
let res = get_user_instructions(&make_config(&tmp, 4096, None).await, None)
|
||||
let res = get_user_instructions(&make_config(&tmp, 4096, None).await, None, None)
|
||||
.await
|
||||
.expect("doc expected");
|
||||
|
||||
@@ -313,7 +323,7 @@ mod tests {
|
||||
let huge = "A".repeat(LIMIT * 2); // 2 KiB
|
||||
fs::write(tmp.path().join("AGENTS.md"), &huge).unwrap();
|
||||
|
||||
let res = get_user_instructions(&make_config(&tmp, LIMIT, None).await, None)
|
||||
let res = get_user_instructions(&make_config(&tmp, LIMIT, None).await, None, None)
|
||||
.await
|
||||
.expect("doc expected");
|
||||
|
||||
@@ -345,7 +355,7 @@ mod tests {
|
||||
let mut cfg = make_config(&repo, 4096, None).await;
|
||||
cfg.cwd = nested;
|
||||
|
||||
let res = get_user_instructions(&cfg, None)
|
||||
let res = get_user_instructions(&cfg, None, None)
|
||||
.await
|
||||
.expect("doc expected");
|
||||
assert_eq!(res, "root level doc");
|
||||
@@ -357,7 +367,7 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("AGENTS.md"), "something").unwrap();
|
||||
|
||||
let res = get_user_instructions(&make_config(&tmp, 0, None).await, None).await;
|
||||
let res = get_user_instructions(&make_config(&tmp, 0, None).await, None, None).await;
|
||||
assert!(
|
||||
res.is_none(),
|
||||
"With limit 0 the function should return None"
|
||||
@@ -373,9 +383,13 @@ mod tests {
|
||||
|
||||
const INSTRUCTIONS: &str = "base instructions";
|
||||
|
||||
let res = get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS)).await, None)
|
||||
.await
|
||||
.expect("should produce a combined instruction string");
|
||||
let res = get_user_instructions(
|
||||
&make_config(&tmp, 4096, Some(INSTRUCTIONS)).await,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("should produce a combined instruction string");
|
||||
|
||||
let expected = format!("{INSTRUCTIONS}{PROJECT_DOC_SEPARATOR}{}", "proj doc");
|
||||
|
||||
@@ -390,8 +404,12 @@ mod tests {
|
||||
|
||||
const INSTRUCTIONS: &str = "some instructions";
|
||||
|
||||
let res =
|
||||
get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS)).await, None).await;
|
||||
let res = get_user_instructions(
|
||||
&make_config(&tmp, 4096, Some(INSTRUCTIONS)).await,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(res, Some(INSTRUCTIONS.to_string()));
|
||||
}
|
||||
@@ -420,7 +438,7 @@ mod tests {
|
||||
let mut cfg = make_config(&repo, 4096, None).await;
|
||||
cfg.cwd = nested;
|
||||
|
||||
let res = get_user_instructions(&cfg, None)
|
||||
let res = get_user_instructions(&cfg, None, None)
|
||||
.await
|
||||
.expect("doc expected");
|
||||
assert_eq!(res, "root doc\n\ncrate doc");
|
||||
@@ -435,7 +453,7 @@ mod tests {
|
||||
|
||||
let cfg = make_config(&tmp, 4096, None).await;
|
||||
|
||||
let res = get_user_instructions(&cfg, None)
|
||||
let res = get_user_instructions(&cfg, None, None)
|
||||
.await
|
||||
.expect("local doc expected");
|
||||
|
||||
@@ -457,7 +475,7 @@ mod tests {
|
||||
|
||||
let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md"]).await;
|
||||
|
||||
let res = get_user_instructions(&cfg, None)
|
||||
let res = get_user_instructions(&cfg, None, None)
|
||||
.await
|
||||
.expect("fallback doc expected");
|
||||
|
||||
@@ -473,7 +491,7 @@ mod tests {
|
||||
|
||||
let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md", ".example.md"]).await;
|
||||
|
||||
let res = get_user_instructions(&cfg, None)
|
||||
let res = get_user_instructions(&cfg, None, None)
|
||||
.await
|
||||
.expect("AGENTS.md should win");
|
||||
|
||||
@@ -506,6 +524,7 @@ mod tests {
|
||||
let res = get_user_instructions(
|
||||
&cfg,
|
||||
skills.errors.is_empty().then_some(skills.skills.as_slice()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("instructions expected");
|
||||
@@ -523,6 +542,32 @@ mod tests {
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_are_appended_to_project_doc() {
|
||||
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 apps = vec![AppInfo {
|
||||
id: "app_123".to_string(),
|
||||
name: "Calendar".to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
}];
|
||||
|
||||
let res = get_user_instructions(&cfg, None, Some(&apps))
|
||||
.await
|
||||
.expect("instructions expected");
|
||||
let expected = format!("base doc\n\n{}", render_apps_section(&apps));
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_render_without_project_doc() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
@@ -533,6 +578,7 @@ mod tests {
|
||||
let res = get_user_instructions(
|
||||
&cfg,
|
||||
skills.errors.is_empty().then_some(skills.skills.as_slice()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("instructions expected");
|
||||
|
||||
Reference in New Issue
Block a user