Merge remote-tracking branch 'origin/dev/friel/agent-tools-namespace' into repair/watchdog-prompt-layering

This commit is contained in:
Friel
2026-03-29 18:07:00 +00:00
83 changed files with 4164 additions and 6229 deletions
+14
View File
@@ -2,3 +2,17 @@ exports_files([
"clippy.toml",
"node-version.txt",
])
filegroup(
name = "workspace-files",
srcs = glob(
[
"*",
".cargo/**",
],
exclude = [
"BUILD.bazel",
],
),
visibility = ["//visibility:public"],
)
+1 -9
View File
@@ -2613,6 +2613,7 @@ dependencies = [
name = "codex-tools"
version = "0.0.0"
dependencies = [
"codex-code-mode",
"codex-protocol",
"pretty_assertions",
"rmcp",
@@ -2636,7 +2637,6 @@ dependencies = [
"codex-arg0",
"codex-chatgpt",
"codex-cli",
"codex-client",
"codex-cloud-requirements",
"codex-core",
"codex-features",
@@ -2668,7 +2668,6 @@ dependencies = [
"diffy",
"dirs",
"dunce",
"hound",
"image",
"insta",
"itertools 0.14.0",
@@ -4818,12 +4817,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "hound"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
[[package]]
name = "http"
version = "0.2.12"
@@ -8090,7 +8083,6 @@ dependencies = [
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
+1 -9
View File
@@ -61,7 +61,6 @@ use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::LoaderOverrides;
use codex_core::default_client::DEFAULT_ORIGINATOR;
use codex_core::default_client::SetOriginatorError;
use codex_core::default_client::USER_AGENT_SUFFIX;
use codex_core::default_client::get_codex_user_agent;
@@ -88,7 +87,6 @@ use toml::Value as TomlValue;
use tracing::Instrument;
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
const TUI_CLIENT_NAME: &str = "codex-tui";
#[derive(Clone)]
struct ExternalAuthRefreshBridge {
@@ -569,13 +567,7 @@ impl MessageProcessor {
} = params.client_info;
session.app_server_client_name = Some(name.clone());
session.client_version = Some(version.clone());
let originator = if name == TUI_CLIENT_NAME {
// TODO: Remove this temporary workaround once the TUI no longer
// needs to retain the `codex_cli_rs` originator behavior.
DEFAULT_ORIGINATOR.to_string()
} else {
name.clone()
};
let originator = name.clone();
if let Err(error) = set_default_originator(originator) {
match error {
SetOriginatorError::InvalidHeaderValue => {
-6
View File
@@ -506,9 +506,6 @@
"use_linux_sandbox_bwrap": {
"type": "boolean"
},
"voice_transcription": {
"type": "boolean"
},
"web_search": {
"type": "boolean"
},
@@ -2154,9 +2151,6 @@
"use_linux_sandbox_bwrap": {
"type": "boolean"
},
"voice_transcription": {
"type": "boolean"
},
"web_search": {
"type": "boolean"
},
+1 -1
View File
@@ -72,6 +72,7 @@ use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::W3cTraceContext;
use codex_tools::create_tools_json_for_responses_api;
use eventsource_stream::Event;
use eventsource_stream::EventStreamError;
use futures::StreamExt;
@@ -107,7 +108,6 @@ use crate::response_debug_context::extract_response_debug_context;
use crate::response_debug_context::extract_response_debug_context_from_api_error;
use crate::response_debug_context::telemetry_api_error_message;
use crate::response_debug_context::telemetry_transport_error_message;
use crate::tools::spec::create_tools_json_for_responses_api;
use crate::util::FeedbackRequestTags;
use crate::util::emit_feedback_auth_recovery_tags;
use crate::util::emit_feedback_request_tags_with_auth_env;
+2 -98
View File
@@ -1,10 +1,10 @@
use crate::client_common::tools::ToolSpec;
use crate::config::types::Personality;
use crate::error::Result;
pub use codex_api::common::ResponseEvent;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::ResponseItem;
use codex_tools::ToolSpec;
use futures::Stream;
use serde::Deserialize;
use serde_json::Value;
@@ -157,107 +157,11 @@ fn strip_total_output_header(output: &str) -> Option<(&str, u32)> {
}
pub(crate) mod tools {
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
use codex_protocol::config_types::WebSearchUserLocationType;
pub(crate) use codex_tools::FreeformTool;
pub(crate) use codex_tools::FreeformToolFormat;
use codex_tools::JsonSchema;
pub(crate) use codex_tools::ResponsesApiTool;
pub(crate) use codex_tools::ToolSearchOutputTool;
use serde::Serialize;
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
/// Responses API.
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "type")]
pub(crate) enum ToolSpec {
#[serde(rename = "function")]
Function(ResponsesApiTool),
#[serde(rename = "tool_search")]
ToolSearch {
execution: String,
description: String,
parameters: JsonSchema,
},
#[serde(rename = "local_shell")]
LocalShell {},
#[serde(rename = "image_generation")]
ImageGeneration { output_format: String },
// TODO: Understand why we get an error on web_search although the API docs say it's supported.
// https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C
// The `external_web_access` field determines whether the web search is over cached or live content.
// https://platform.openai.com/docs/guides/tools-web-search#live-internet-access
#[serde(rename = "web_search")]
WebSearch {
#[serde(skip_serializing_if = "Option::is_none")]
external_web_access: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
filters: Option<ResponsesApiWebSearchFilters>,
#[serde(skip_serializing_if = "Option::is_none")]
user_location: Option<ResponsesApiWebSearchUserLocation>,
#[serde(skip_serializing_if = "Option::is_none")]
search_context_size: Option<WebSearchContextSize>,
#[serde(skip_serializing_if = "Option::is_none")]
search_content_types: Option<Vec<String>>,
},
#[serde(rename = "custom")]
Freeform(FreeformTool),
}
impl ToolSpec {
pub(crate) fn name(&self) -> &str {
match self {
ToolSpec::Function(tool) => tool.name.as_str(),
ToolSpec::ToolSearch { .. } => "tool_search",
ToolSpec::LocalShell {} => "local_shell",
ToolSpec::ImageGeneration { .. } => "image_generation",
ToolSpec::WebSearch { .. } => "web_search",
ToolSpec::Freeform(tool) => tool.name.as_str(),
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub(crate) struct ResponsesApiWebSearchFilters {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) allowed_domains: Option<Vec<String>>,
}
impl From<ConfigWebSearchFilters> for ResponsesApiWebSearchFilters {
fn from(filters: ConfigWebSearchFilters) -> Self {
Self {
allowed_domains: filters.allowed_domains,
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub(crate) struct ResponsesApiWebSearchUserLocation {
#[serde(rename = "type")]
pub(crate) r#type: WebSearchUserLocationType,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) country: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) region: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) city: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) timezone: Option<String>,
}
impl From<ConfigWebSearchUserLocation> for ResponsesApiWebSearchUserLocation {
fn from(user_location: ConfigWebSearchUserLocation) -> Self {
Self {
r#type: user_location.r#type,
country: user_location.country,
region: user_location.region,
city: user_location.city,
timezone: user_location.timezone,
}
}
}
pub(crate) use codex_tools::ToolSpec;
}
pub struct ResponseStream {
-24
View File
@@ -4392,10 +4392,6 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
handlers::reload_user_config(&sess).await;
false
}
Op::ListCustomPrompts => {
handlers::list_custom_prompts(&sess, sub.id.clone()).await;
false
}
Op::ListSkills { cwds, force_reload } => {
handlers::list_skills(&sess, sub.id.clone(), cwds, force_reload).await;
false
@@ -4521,13 +4517,11 @@ mod handlers {
use crate::tasks::UserShellCommandMode;
use crate::tasks::UserShellCommandTask;
use crate::tasks::execute_user_shell_command;
use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::ListCustomPromptsResponseEvent;
use codex_protocol::protocol::ListSkillsResponseEvent;
use codex_protocol::protocol::McpServerRefreshConfig;
use codex_protocol::protocol::Op;
@@ -4987,23 +4981,6 @@ mod handlers {
sess.send_event_raw(event).await;
}
pub async fn list_custom_prompts(sess: &Session, sub_id: String) {
let custom_prompts: Vec<CustomPrompt> =
if let Some(dir) = crate::custom_prompts::default_prompts_dir() {
crate::custom_prompts::discover_prompts_in(&dir).await
} else {
Vec::new()
};
let event = Event {
id: sub_id,
msg: EventMsg::ListCustomPromptsResponse(ListCustomPromptsResponseEvent {
custom_prompts,
}),
};
sess.send_event_raw(event).await;
}
pub async fn list_skills(
sess: &Session,
sub_id: String,
@@ -6925,7 +6902,6 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
| EventMsg::TurnDiff(_)
| EventMsg::GetHistoryEntryResponse(_)
| EventMsg::McpListToolsResponse(_)
| EventMsg::ListCustomPromptsResponse(_)
| EventMsg::ListSkillsResponse(_)
| EventMsg::SkillsUpdateAvailable
| EventMsg::PlanUpdate(_)
-149
View File
@@ -1,149 +0,0 @@
use codex_protocol::custom_prompts::CustomPrompt;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use tokio::fs;
/// Return the default prompts directory: `$CODEX_HOME/prompts`.
/// If `CODEX_HOME` cannot be resolved, returns `None`.
pub fn default_prompts_dir() -> Option<PathBuf> {
crate::config::find_codex_home()
.ok()
.map(|home| home.join("prompts"))
}
/// Discover prompt files in the given directory, returning entries sorted by name.
/// Non-files are ignored. If the directory does not exist or cannot be read, returns empty.
pub async fn discover_prompts_in(dir: &Path) -> Vec<CustomPrompt> {
discover_prompts_in_excluding(dir, &HashSet::new()).await
}
/// Discover prompt files in the given directory, excluding any with names in `exclude`.
/// Returns entries sorted by name. Non-files are ignored. Missing/unreadable dir yields empty.
pub async fn discover_prompts_in_excluding(
dir: &Path,
exclude: &HashSet<String>,
) -> Vec<CustomPrompt> {
let mut out: Vec<CustomPrompt> = Vec::new();
let mut entries = match fs::read_dir(dir).await {
Ok(entries) => entries,
Err(_) => return out,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
let is_file_like = fs::metadata(&path)
.await
.map(|m| m.is_file())
.unwrap_or(false);
if !is_file_like {
continue;
}
// Only include Markdown files with a .md extension.
let is_md = path
.extension()
.and_then(|s| s.to_str())
.map(|ext| ext.eq_ignore_ascii_case("md"))
.unwrap_or(false);
if !is_md {
continue;
}
let Some(name) = path
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string)
else {
continue;
};
if exclude.contains(&name) {
continue;
}
let content = match fs::read_to_string(&path).await {
Ok(s) => s,
Err(_) => continue,
};
let (description, argument_hint, body) = parse_frontmatter(&content);
out.push(CustomPrompt {
name,
path,
content: body,
description,
argument_hint,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
/// Parse optional YAML-like frontmatter at the beginning of `content`.
/// Supported keys:
/// - `description`: short description shown in the slash popup
/// - `argument-hint` or `argument_hint`: brief hint string shown after the description
/// Returns (description, argument_hint, body_without_frontmatter).
fn parse_frontmatter(content: &str) -> (Option<String>, Option<String>, String) {
let mut segments = content.split_inclusive('\n');
let Some(first_segment) = segments.next() else {
return (None, None, String::new());
};
let first_line = first_segment.trim_end_matches(['\r', '\n']);
if first_line.trim() != "---" {
return (None, None, content.to_string());
}
let mut desc: Option<String> = None;
let mut hint: Option<String> = None;
let mut frontmatter_closed = false;
let mut consumed = first_segment.len();
for segment in segments {
let line = segment.trim_end_matches(['\r', '\n']);
let trimmed = line.trim();
if trimmed == "---" {
frontmatter_closed = true;
consumed += segment.len();
break;
}
if trimmed.is_empty() || trimmed.starts_with('#') {
consumed += segment.len();
continue;
}
if let Some((k, v)) = trimmed.split_once(':') {
let key = k.trim().to_ascii_lowercase();
let mut val = v.trim().to_string();
if val.len() >= 2 {
let bytes = val.as_bytes();
let first = bytes[0];
let last = bytes[bytes.len() - 1];
if (first == b'\"' && last == b'\"') || (first == b'\'' && last == b'\'') {
val = val[1..val.len().saturating_sub(1)].to_string();
}
}
match key.as_str() {
"description" => desc = Some(val),
"argument-hint" | "argument_hint" => hint = Some(val),
_ => {}
}
}
consumed += segment.len();
}
if !frontmatter_closed {
// Unterminated frontmatter: treat input as-is.
return (None, None, content.to_string());
}
let body = if consumed >= content.len() {
String::new()
} else {
content[consumed..].to_string()
};
(desc, hint, body)
}
#[cfg(test)]
#[path = "custom_prompts_tests.rs"]
mod tests;
-95
View File
@@ -1,95 +0,0 @@
use super::*;
use std::fs;
use tempfile::tempdir;
#[tokio::test]
async fn empty_when_dir_missing() {
let tmp = tempdir().expect("create TempDir");
let missing = tmp.path().join("nope");
let found = discover_prompts_in(&missing).await;
assert!(found.is_empty());
}
#[tokio::test]
async fn discovers_and_sorts_files() {
let tmp = tempdir().expect("create TempDir");
let dir = tmp.path();
fs::write(dir.join("b.md"), b"b").unwrap();
fs::write(dir.join("a.md"), b"a").unwrap();
fs::create_dir(dir.join("subdir")).unwrap();
let found = discover_prompts_in(dir).await;
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
assert_eq!(names, vec!["a", "b"]);
}
#[tokio::test]
async fn excludes_builtins() {
let tmp = tempdir().expect("create TempDir");
let dir = tmp.path();
fs::write(dir.join("init.md"), b"ignored").unwrap();
fs::write(dir.join("foo.md"), b"ok").unwrap();
let mut exclude = HashSet::new();
exclude.insert("init".to_string());
let found = discover_prompts_in_excluding(dir, &exclude).await;
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
assert_eq!(names, vec!["foo"]);
}
#[tokio::test]
async fn skips_non_utf8_files() {
let tmp = tempdir().expect("create TempDir");
let dir = tmp.path();
// Valid UTF-8 file
fs::write(dir.join("good.md"), b"hello").unwrap();
// Invalid UTF-8 content in .md file (e.g., lone 0xFF byte)
fs::write(dir.join("bad.md"), vec![0xFF, 0xFE, b'\n']).unwrap();
let found = discover_prompts_in(dir).await;
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
assert_eq!(names, vec!["good"]);
}
#[tokio::test]
#[cfg(unix)]
async fn discovers_symlinked_md_files() {
let tmp = tempdir().expect("create TempDir");
let dir = tmp.path();
// Create a real file
fs::write(dir.join("real.md"), b"real content").unwrap();
// Create a symlink to the real file
std::os::unix::fs::symlink(dir.join("real.md"), dir.join("link.md")).unwrap();
let found = discover_prompts_in(dir).await;
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
// Both real and link should be discovered, sorted alphabetically
assert_eq!(names, vec!["link", "real"]);
}
#[tokio::test]
async fn parses_frontmatter_and_strips_from_body() {
let tmp = tempdir().expect("create TempDir");
let dir = tmp.path();
let file = dir.join("withmeta.md");
let text = "---\nname: ignored\ndescription: \"Quick review command\"\nargument-hint: \"[file] [priority]\"\n---\nActual body with $1 and $ARGUMENTS";
fs::write(&file, text).unwrap();
let found = discover_prompts_in(dir).await;
assert_eq!(found.len(), 1);
let p = &found[0];
assert_eq!(p.name, "withmeta");
assert_eq!(p.description.as_deref(), Some("Quick review command"));
assert_eq!(p.argument_hint.as_deref(), Some("[file] [priority]"));
// Body should not include the frontmatter delimiters.
assert_eq!(p.content, "Actual body with $1 and $ARGUMENTS");
}
#[test]
fn parse_frontmatter_preserves_body_newlines() {
let content = "---\r\ndescription: \"Line endings\"\r\nargument_hint: \"[arg]\"\r\n---\r\nFirst line\r\nSecond line\r\n";
let (desc, hint, body) = parse_frontmatter(content);
assert_eq!(desc.as_deref(), Some("Line endings"));
assert_eq!(hint.as_deref(), Some("[arg]"));
assert_eq!(body, "First line\r\nSecond line\r\n");
}
-1
View File
@@ -30,7 +30,6 @@ pub mod config_loader;
pub mod connectors;
mod context_manager;
mod contextual_user_message;
pub mod custom_prompts;
pub use codex_utils_path::env;
mod environment_context;
pub mod error;
+1 -1
View File
@@ -94,7 +94,7 @@ const MCP_TOOL_NAME_DELIMITER: &str = "__";
const MAX_TOOL_NAME_LENGTH: usize = 64;
/// Default timeout for initializing MCP server & initially listing tools.
pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
/// Default timeout for individual tool calls.
const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(120);
@@ -612,7 +612,7 @@ fn mcp_init_error_display_includes_startup_timeout_hint() {
let display = mcp_init_error_display(server_name, /*entry*/ None, &err);
assert_eq!(
"MCP client for `slow` timed out after 10 seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.slow]\nstartup_timeout_sec = XX",
"MCP client for `slow` timed out after 30 seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.slow]\nstartup_timeout_sec = XX",
display
);
}
+66 -24
View File
@@ -29,6 +29,7 @@ use super::sync_openai_plugins_repo;
use crate::AuthManager;
use crate::SkillMetadata;
use crate::auth::CodexAuth;
use crate::config::CONFIG_TOML_FILE;
use crate::config::Config;
use crate::config::ConfigService;
use crate::config::ConfigServiceError;
@@ -1008,28 +1009,7 @@ impl PluginsManager {
auth_manager: Arc<AuthManager>,
) {
if config.features.enabled(Feature::Plugins) {
let mut configured_curated_plugin_ids =
configured_plugins_from_stack(&config.config_layer_stack)
.into_keys()
.filter_map(|plugin_key| match PluginId::parse(&plugin_key) {
Ok(plugin_id)
if plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME =>
{
Some(plugin_id)
}
Ok(_) => None,
Err(err) => {
warn!(
plugin_key,
error = %err,
"ignoring invalid configured plugin key during curated sync setup"
);
None
}
})
.collect::<Vec<_>>();
configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
self.start_curated_repo_sync(configured_curated_plugin_ids);
self.start_curated_repo_sync();
start_startup_remote_plugin_sync_once(
Arc::clone(self),
self.codex_home.clone(),
@@ -1054,7 +1034,7 @@ impl PluginsManager {
}
}
fn start_curated_repo_sync(self: &Arc<Self>, configured_curated_plugin_ids: Vec<PluginId>) {
fn start_curated_repo_sync(self: &Arc<Self>) {
if CURATED_REPO_SYNC_STARTED.swap(true, Ordering::SeqCst) {
return;
}
@@ -1065,6 +1045,8 @@ impl PluginsManager {
.spawn(
move || match sync_openai_plugins_repo(codex_home.as_path()) {
Ok(curated_plugin_version) => {
let configured_curated_plugin_ids =
configured_curated_plugin_ids_from_codex_home(codex_home.as_path());
match refresh_curated_plugin_cache(
codex_home.as_path(),
&curated_plugin_version,
@@ -1333,7 +1315,13 @@ fn configured_plugins_from_stack(
let Some(user_layer) = config_layer_stack.get_user_layer() else {
return HashMap::new();
};
let Some(plugins_value) = user_layer.config.get("plugins") else {
configured_plugins_from_user_config_value(&user_layer.config)
}
fn configured_plugins_from_user_config_value(
user_config: &toml::Value,
) -> HashMap<String, PluginConfig> {
let Some(plugins_value) = user_config.get("plugins") else {
return HashMap::new();
};
match plugins_value.clone().try_into() {
@@ -1345,6 +1333,60 @@ fn configured_plugins_from_stack(
}
}
fn configured_curated_plugin_ids(
configured_plugins: HashMap<String, PluginConfig>,
) -> Vec<PluginId> {
let mut configured_curated_plugin_ids = configured_plugins
.into_keys()
.filter_map(|plugin_key| match PluginId::parse(&plugin_key) {
Ok(plugin_id) if plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME => {
Some(plugin_id)
}
Ok(_) => None,
Err(err) => {
warn!(
plugin_key,
error = %err,
"ignoring invalid configured plugin key during curated sync setup"
);
None
}
})
.collect::<Vec<_>>();
configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
configured_curated_plugin_ids
}
fn configured_curated_plugin_ids_from_codex_home(codex_home: &Path) -> Vec<PluginId> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let user_config = match fs::read_to_string(&config_path) {
Ok(user_config) => user_config,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(err) => {
warn!(
path = %config_path.display(),
error = %err,
"failed to read user config while refreshing curated plugin cache"
);
return Vec::new();
}
};
let user_config = match toml::from_str::<toml::Value>(&user_config) {
Ok(user_config) => user_config,
Err(err) => {
warn!(
path = %config_path.display(),
error = %err,
"failed to parse user config while refreshing curated plugin cache"
);
return Vec::new();
}
};
configured_curated_plugin_ids(configured_plugins_from_user_config_value(&user_config))
}
fn load_plugin(
config_name: String,
plugin: &PluginConfig,
@@ -2207,6 +2207,43 @@ fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_curren
);
}
#[test]
fn configured_curated_plugin_ids_from_codex_home_reads_latest_user_config() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."slack@openai-curated"]
enabled = true
[plugins."sample@debug"]
enabled = true
"#,
);
assert_eq!(
configured_curated_plugin_ids_from_codex_home(tmp.path())
.into_iter()
.map(|plugin_id| plugin_id.as_key())
.collect::<Vec<_>>(),
vec!["slack@openai-curated".to_string()]
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
assert_eq!(
configured_curated_plugin_ids_from_codex_home(tmp.path()),
Vec::<PluginId>::new()
);
}
#[test]
fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_current() {
let tmp = tempfile::tempdir().unwrap();
+2 -31
View File
@@ -19,7 +19,6 @@ use crate::codex::Session;
use crate::codex::TurnContext;
use crate::function_tool::FunctionCallError;
use crate::tools::ToolRouter;
use crate::tools::code_mode_description::augment_tool_spec_for_code_mode;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::context::ToolPayload;
@@ -29,6 +28,7 @@ use crate::tools::router::ToolCallSource;
use crate::tools::router::ToolRouterParams;
use crate::unified_exec::resolve_max_tokens;
use codex_features::Feature;
use codex_tools::tool_spec_to_code_mode_tool_definition;
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;
@@ -247,42 +247,13 @@ pub(super) async fn build_enabled_tools(
let mut out = router
.specs()
.into_iter()
.map(|spec| augment_tool_spec_for_code_mode(spec, /*code_mode_enabled*/ true))
.filter_map(enabled_tool_from_spec)
.filter_map(|spec| tool_spec_to_code_mode_tool_definition(&spec))
.collect::<Vec<_>>();
out.sort_by(|left, right| left.name.cmp(&right.name));
out.dedup_by(|left, right| left.name == right.name);
out
}
fn enabled_tool_from_spec(spec: ToolSpec) -> Option<codex_code_mode::ToolDefinition> {
let tool_name = spec.name().to_string();
if !codex_code_mode::is_code_mode_nested_tool(&tool_name) {
return None;
}
match spec {
ToolSpec::Function(tool) => Some(codex_code_mode::ToolDefinition {
name: tool_name,
description: tool.description,
kind: codex_code_mode::CodeModeToolKind::Function,
input_schema: serde_json::to_value(&tool.parameters).ok(),
output_schema: tool.output_schema,
}),
ToolSpec::Freeform(tool) => Some(codex_code_mode::ToolDefinition {
name: tool_name,
description: tool.description,
kind: codex_code_mode::CodeModeToolKind::Freeform,
input_schema: None,
output_schema: None,
}),
ToolSpec::LocalShell {}
| ToolSpec::ImageGeneration { .. }
| ToolSpec::ToolSearch { .. }
| ToolSpec::WebSearch { .. } => None,
}
}
async fn build_nested_router(exec: &ExecContext) -> ToolRouter {
let nested_tools_config = exec.turn.tools_config.for_code_mode_nested_tools();
let mcp_tools = exec
@@ -1,49 +0,0 @@
use crate::client_common::tools::ToolSpec;
#[allow(unused_imports)]
#[cfg(test)]
pub(crate) use codex_code_mode::append_code_mode_sample;
#[allow(unused_imports)]
#[cfg(test)]
pub(crate) use codex_code_mode::render_json_schema_to_typescript;
pub(crate) fn augment_tool_spec_for_code_mode(spec: ToolSpec, code_mode_enabled: bool) -> ToolSpec {
if !code_mode_enabled {
return spec;
}
match spec {
ToolSpec::Function(mut tool) => {
let input_type = serde_json::to_value(&tool.parameters)
.ok()
.map(|schema| codex_code_mode::render_json_schema_to_typescript(&schema))
.unwrap_or_else(|| "unknown".to_string());
let output_type = tool
.output_schema
.as_ref()
.map(codex_code_mode::render_json_schema_to_typescript)
.unwrap_or_else(|| "unknown".to_string());
tool.description = codex_code_mode::append_code_mode_sample(
&tool.description,
&tool.name,
"args",
input_type,
output_type,
);
ToolSpec::Function(tool)
}
ToolSpec::Freeform(mut tool) => {
if tool.name != codex_code_mode::PUBLIC_TOOL_NAME {
tool.description = codex_code_mode::append_code_mode_sample(
&tool.description,
&tool.name,
"input",
"string".to_string(),
"unknown".to_string(),
);
}
ToolSpec::Freeform(tool)
}
other => other,
}
}
@@ -1,104 +0,0 @@
use super::append_code_mode_sample;
use super::render_json_schema_to_typescript;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn render_json_schema_to_typescript_renders_object_properties() {
let schema = json!({
"type": "object",
"properties": {
"path": {"type": "string"},
"recursive": {"type": "boolean"}
},
"required": ["path"],
"additionalProperties": false
});
assert_eq!(
render_json_schema_to_typescript(&schema),
"{ path: string; recursive?: boolean; }"
);
}
#[test]
fn render_json_schema_to_typescript_renders_anyof_unions() {
let schema = json!({
"anyOf": [
{"const": "pending"},
{"const": "done"},
{"type": "number"}
]
});
assert_eq!(
render_json_schema_to_typescript(&schema),
"\"pending\" | \"done\" | number"
);
}
#[test]
fn render_json_schema_to_typescript_renders_additional_properties() {
let schema = json!({
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {"type": "string"}
}
},
"additionalProperties": {"type": "integer"}
});
assert_eq!(
render_json_schema_to_typescript(&schema),
"{ tags?: Array<string>; [key: string]: number; }"
);
}
#[test]
fn render_json_schema_to_typescript_sorts_object_properties() {
let schema = json!({
"type": "object",
"properties": {
"structuredContent": {"type": "string"},
"_meta": {"type": "string"},
"isError": {"type": "boolean"},
"content": {"type": "array", "items": {"type": "string"}}
},
"required": ["content"]
});
assert_eq!(
render_json_schema_to_typescript(&schema),
"{ _meta?: string; content: Array<string>; isError?: boolean; structuredContent?: string; }"
);
}
#[test]
fn append_code_mode_sample_uses_global_tools_for_valid_identifiers() {
assert_eq!(
append_code_mode_sample(
"desc",
"mcp__ologs__get_profile",
"args",
"{ foo: string }".to_string(),
"unknown".to_string(),
),
"desc\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__ologs__get_profile(args: { foo: string }): Promise<unknown>; };\n```"
);
}
#[test]
fn append_code_mode_sample_normalizes_invalid_identifiers() {
assert_eq!(
append_code_mode_sample(
"desc",
"mcp__rmcp__echo-tool",
"args",
"{ foo: string }".to_string(),
"unknown".to_string(),
),
"desc\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__rmcp__echo_tool(args: { foo: string }): Promise<unknown>; };\n```"
);
}
-1
View File
@@ -1,5 +1,4 @@
pub mod code_mode;
pub(crate) mod code_mode_description;
pub mod context;
pub(crate) mod discoverable;
pub mod events;
+1 -15
View File
@@ -24,6 +24,7 @@ use codex_hooks::HookToolInput;
use codex_hooks::HookToolInputLocalShell;
use codex_hooks::HookToolKind;
use codex_protocol::models::ResponseInputItem;
use codex_tools::ConfiguredToolSpec;
use codex_utils_readiness::Readiness;
use serde_json::Value;
use tracing::warn;
@@ -436,21 +437,6 @@ impl ToolRegistry {
}
}
#[derive(Debug, Clone)]
pub struct ConfiguredToolSpec {
pub spec: ToolSpec,
pub supports_parallel_tool_calls: bool,
}
impl ConfiguredToolSpec {
pub fn new(spec: ToolSpec, supports_parallel_tool_calls: bool) -> Self {
Self {
spec,
supports_parallel_tool_calls,
}
}
}
pub struct ToolRegistryBuilder {
handlers: HashMap<String, Arc<dyn AnyToolHandler>>,
specs: Vec<ConfiguredToolSpec>,
+4 -4
View File
@@ -9,7 +9,6 @@ use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::discoverable::DiscoverableTool;
use crate::tools::registry::AnyToolResult;
use crate::tools::registry::ConfiguredToolSpec;
use crate::tools::registry::ToolRegistry;
use crate::tools::spec::ToolsConfig;
use crate::tools::spec::build_specs_with_discoverable_tools;
@@ -18,6 +17,7 @@ use codex_protocol::models::LocalShellAction;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::SearchToolCallParams;
use codex_protocol::models::ShellToolCallParams;
use codex_tools::ConfiguredToolSpec;
use rmcp::model::Tool;
use std::collections::HashMap;
use std::sync::Arc;
@@ -66,7 +66,7 @@ impl ToolRouter {
specs
.iter()
.filter_map(|configured_tool| {
if !codex_code_mode::is_code_mode_nested_tool(configured_tool.spec.name()) {
if !codex_code_mode::is_code_mode_nested_tool(configured_tool.name()) {
Some(configured_tool.spec.clone())
} else {
None
@@ -101,7 +101,7 @@ impl ToolRouter {
pub fn find_spec(&self, tool_name: &str) -> Option<ToolSpec> {
self.specs
.iter()
.find(|config| config.spec.name() == tool_name)
.find(|config| config.name() == tool_name)
.map(|config| config.spec.clone())
}
@@ -109,7 +109,7 @@ impl ToolRouter {
self.specs
.iter()
.filter(|config| config.supports_parallel_tool_calls)
.any(|config| config.spec.name() == tool_name)
.any(|config| config.name() == tool_name)
}
#[instrument(level = "trace", skip_all, err)]
File diff suppressed because it is too large Load Diff
+79 -258
View File
@@ -1,20 +1,42 @@
use crate::client_common::tools::FreeformTool;
use crate::config::test_config;
use crate::models_manager::manager::ModelsManager;
use crate::models_manager::model_info::with_config_overrides;
use crate::shell::Shell;
use crate::shell::ShellType;
use crate::tools::ToolRouter;
use crate::tools::registry::ConfiguredToolSpec;
use crate::tools::router::ToolRouterParams;
use codex_app_server_protocol::AppInfo;
use codex_protocol::models::VIEW_IMAGE_TOOL_NAME;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelsResponse;
use codex_tools::AdditionalProperties;
use codex_tools::CommandToolOptions;
use codex_tools::ConfiguredToolSpec;
use codex_tools::FreeformTool;
use codex_tools::ResponsesApiWebSearchFilters;
use codex_tools::ResponsesApiWebSearchUserLocation;
use codex_tools::SpawnAgentToolOptions;
use codex_tools::ViewImageToolOptions;
use codex_tools::WaitAgentTimeoutOptions;
use codex_tools::create_close_agent_tool_v1;
use codex_tools::create_close_agent_tool_v2;
use codex_tools::create_exec_command_tool;
use codex_tools::create_request_permissions_tool;
use codex_tools::create_request_user_input_tool;
use codex_tools::create_resume_agent_tool;
use codex_tools::create_send_input_tool_v1;
use codex_tools::create_send_message_tool;
use codex_tools::create_spawn_agent_tool_v1;
use codex_tools::create_spawn_agent_tool_v2;
use codex_tools::create_view_image_tool;
use codex_tools::create_wait_agent_tool_v1;
use codex_tools::create_wait_agent_tool_v2;
use codex_tools::create_write_stdin_tool;
use codex_tools::mcp_tool_to_deferred_responses_api_tool;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::PathBuf;
use super::*;
@@ -52,10 +74,6 @@ fn discoverable_connector(id: &str, name: &str, description: &str) -> Discoverab
}))
}
fn windows_shell_safety_description() -> String {
format!("\n\n{}", super::windows_destructive_filesystem_guidance())
}
fn search_capable_model_info() -> ModelInfo {
let config = test_config();
let mut model_info =
@@ -105,23 +123,12 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() {
);
}
fn tool_name(tool: &ToolSpec) -> &str {
match tool {
ToolSpec::Function(ResponsesApiTool { name, .. }) => name,
ToolSpec::ToolSearch { .. } => "tool_search",
ToolSpec::LocalShell {} => "local_shell",
ToolSpec::ImageGeneration { .. } => "image_generation",
ToolSpec::WebSearch { .. } => "web_search",
ToolSpec::Freeform(FreeformTool { name, .. }) => name,
}
}
// Avoid order-based assertions; compare via set containment instead.
fn assert_contains_tool_names(tools: &[ConfiguredToolSpec], expected_subset: &[&str]) {
use std::collections::HashSet;
let mut names = HashSet::new();
let mut duplicates = Vec::new();
for name in tools.iter().map(|t| tool_name(&t.spec)) {
for name in tools.iter().map(ConfiguredToolSpec::name) {
if !names.insert(name) {
duplicates.push(name);
}
@@ -141,7 +148,7 @@ fn assert_contains_tool_names(tools: &[ConfiguredToolSpec], expected_subset: &[&
fn assert_lacks_tool_name(tools: &[ConfiguredToolSpec], expected_absent: &str) {
let names = tools
.iter()
.map(|tool| tool_name(&tool.spec))
.map(ConfiguredToolSpec::name)
.collect::<Vec<_>>();
assert!(
!names.contains(&expected_absent),
@@ -159,10 +166,31 @@ fn shell_tool_name(config: &ToolsConfig) -> Option<&'static str> {
}
}
fn request_user_input_tool_spec(default_mode_request_user_input: bool) -> ToolSpec {
create_request_user_input_tool(request_user_input_tool_description(
default_mode_request_user_input,
))
}
fn spawn_agent_tool_options(config: &ToolsConfig) -> SpawnAgentToolOptions<'_> {
SpawnAgentToolOptions {
available_models: &config.available_models,
agent_type_description: crate::agent::role::spawn_tool_spec::build(&config.agent_roles),
}
}
fn wait_agent_timeout_options() -> WaitAgentTimeoutOptions {
WaitAgentTimeoutOptions {
default_timeout_ms: DEFAULT_WAIT_TIMEOUT_MS,
min_timeout_ms: MIN_WAIT_TIMEOUT_MS,
max_timeout_ms: MAX_WAIT_TIMEOUT_MS,
}
}
fn find_tool<'a>(tools: &'a [ConfiguredToolSpec], expected_name: &str) -> &'a ConfiguredToolSpec {
tools
.iter()
.find(|tool| tool_name(&tool.spec) == expected_name)
.find(|tool| tool.name() == expected_name)
.unwrap_or_else(|| panic!("expected tool {expected_name}"))
}
@@ -198,7 +226,8 @@ fn strip_descriptions_tool(spec: &mut ToolSpec) {
ToolSpec::Function(ResponsesApiTool { parameters, .. }) => {
strip_descriptions_schema(parameters);
}
ToolSpec::Freeform(_)
ToolSpec::Namespace(_)
| ToolSpec::Freeform(_)
| ToolSpec::LocalShell {}
| ToolSpec::ImageGeneration { .. }
| ToolSpec::WebSearch { .. } => {}
@@ -294,7 +323,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
let mut actual: BTreeMap<String, ToolSpec> = BTreeMap::from([]);
let mut duplicate_names = Vec::new();
for t in &tools {
let name = tool_name(&t.spec).to_string();
let name = t.name().to_string();
if actual.insert(name.clone(), t.spec.clone()).is_some() {
duplicate_names.push(name);
}
@@ -307,12 +336,13 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
// Build expected from the same helpers used by the builder.
let mut expected: BTreeMap<String, ToolSpec> = BTreeMap::from([]);
for spec in [
create_exec_command_tool(
/*allow_login_shell*/ true, /*exec_permission_approvals_enabled*/ false,
),
create_exec_command_tool(CommandToolOptions {
allow_login_shell: true,
exec_permission_approvals_enabled: false,
}),
create_write_stdin_tool(),
PLAN_TOOL.clone(),
create_request_user_input_tool(CollaborationModesConfig::default()),
request_user_input_tool_spec(/*default_mode_request_user_input*/ false),
create_apply_patch_freeform_tool(),
ToolSpec::WebSearch {
external_web_access: Some(true),
@@ -321,36 +351,38 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
search_context_size: None,
search_content_types: None,
},
create_view_image_tool(config.can_request_original_image_detail),
create_view_image_tool(ViewImageToolOptions {
can_request_original_image_detail: config.can_request_original_image_detail,
}),
] {
expected.insert(tool_name(&spec).to_string(), spec);
expected.insert(spec.name().to_string(), spec);
}
let collab_specs = if config.multi_agent_v2 {
vec![
create_spawn_agent_tool_v2(&config),
create_spawn_agent_tool_v2(spawn_agent_tool_options(&config)),
create_send_message_tool(),
create_wait_agent_tool_v2(),
create_wait_agent_tool_v2(wait_agent_timeout_options()),
create_close_agent_tool_v2(),
]
} else {
vec![
create_spawn_agent_tool_v1(&config),
create_spawn_agent_tool_v1(spawn_agent_tool_options(&config)),
create_send_input_tool_v1(),
create_wait_agent_tool_v1(),
create_wait_agent_tool_v1(wait_agent_timeout_options()),
create_close_agent_tool_v1(),
]
};
for spec in collab_specs {
expected.insert(tool_name(&spec).to_string(), spec);
expected.insert(spec.name().to_string(), spec);
}
if !config.multi_agent_v2 {
let spec = create_resume_agent_tool();
expected.insert(tool_name(&spec).to_string(), spec);
expected.insert(spec.name().to_string(), spec);
}
if config.exec_permission_approvals_enabled {
let spec = create_request_permissions_tool();
expected.insert(tool_name(&spec).to_string(), spec);
let spec = create_request_permissions_tool(request_permissions_tool_description());
expected.insert(spec.name().to_string(), spec);
}
// Exact name set match — this is the only test allowed to fail when tools change.
@@ -737,7 +769,7 @@ fn request_user_input_description_reflects_default_mode_feature_flag() {
let request_user_input_tool = find_tool(&tools, "request_user_input");
assert_eq!(
request_user_input_tool.spec,
create_request_user_input_tool(CollaborationModesConfig::default())
request_user_input_tool_spec(/*default_mode_request_user_input*/ false)
);
features.enable(Feature::DefaultModeRequestUserInput);
@@ -761,9 +793,7 @@ fn request_user_input_description_reflects_default_mode_feature_flag() {
let request_user_input_tool = find_tool(&tools, "request_user_input");
assert_eq!(
request_user_input_tool.spec,
create_request_user_input_tool(CollaborationModesConfig {
default_mode_request_user_input: true,
})
request_user_input_tool_spec(/*default_mode_request_user_input*/ true)
);
}
@@ -813,7 +843,7 @@ fn request_permissions_requires_feature_flag() {
let request_permissions_tool = find_tool(&tools, "request_permissions");
assert_eq!(
request_permissions_tool.spec,
create_request_permissions_tool()
create_request_permissions_tool(request_permissions_tool_description())
);
}
@@ -1205,10 +1235,10 @@ fn web_search_config_is_forwarded_to_tool_spec() {
external_web_access: Some(true),
filters: web_search_config
.filters
.map(crate::client_common::tools::ResponsesApiWebSearchFilters::from),
.map(ResponsesApiWebSearchFilters::from),
user_location: web_search_config
.user_location
.map(crate::client_common::tools::ResponsesApiWebSearchUserLocation::from),
.map(ResponsesApiWebSearchUserLocation::from),
search_context_size: web_search_config.search_context_size,
search_content_types: None,
}
@@ -1684,11 +1714,7 @@ fn test_test_model_info_includes_sync_tool() {
)
.build();
assert!(
tools
.iter()
.any(|tool| tool_name(&tool.spec) == "test_sync_tool")
);
assert!(tools.iter().any(|tool| tool.name() == "test_sync_tool"));
}
#[test]
@@ -1822,7 +1848,7 @@ fn test_build_specs_mcp_tools_sorted_by_name() {
// Only assert that the MCP tools themselves are sorted by fully-qualified name.
let mcp_names: Vec<_> = tools
.iter()
.map(|t| tool_name(&t.spec).to_string())
.map(|t| t.name().to_string())
.filter(|n| n.starts_with("test_server/"))
.collect();
let expected = vec![
@@ -2069,7 +2095,7 @@ fn tool_suggest_is_not_registered_without_feature_flag() {
assert!(
!tools
.iter()
.any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME)
.any(|tool| tool.name() == TOOL_SUGGEST_TOOL_NAME)
);
}
@@ -2160,7 +2186,7 @@ fn tool_suggest_requires_apps_and_plugins_features() {
assert!(
!tools
.iter()
.any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME),
.any(|tool| tool.name() == TOOL_SUGGEST_TOOL_NAME),
"tool_suggest should be absent when {disabled_feature:?} is disabled"
);
}
@@ -2195,7 +2221,8 @@ fn search_tool_description_handles_no_enabled_apps() {
panic!("expected tool_search tool");
};
assert!(description.contains("None currently enabled."));
assert!(description.contains("agents"));
assert!(description.contains("Agent collaboration tools for spawning, messaging, waiting on, listing, and closing subagents."));
assert!(!description.contains("{{app_descriptions}}"));
}
@@ -2653,177 +2680,6 @@ fn test_mcp_tool_anyof_defaults_to_string() {
);
}
#[test]
fn test_shell_tool() {
let tool = super::create_shell_tool(/*exec_permission_approvals_enabled*/ false);
let ToolSpec::Function(ResponsesApiTool {
description, name, ..
}) = &tool
else {
panic!("expected function tool");
};
assert_eq!(name, "shell");
let expected = if cfg!(windows) {
r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"].
Examples of valid command strings:
- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"]
- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"]
- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"]
- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"]
- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"]
- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]"#
.to_string()
+ &windows_shell_safety_description()
} else {
r#"Runs a shell command and returns its output.
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."#
.to_string()
};
assert_eq!(description, &expected);
}
#[test]
fn test_exec_command_tool_windows_description_includes_shell_safety_guidance() {
let tool = super::create_exec_command_tool(
/*allow_login_shell*/ true, /*exec_permission_approvals_enabled*/ false,
);
let ToolSpec::Function(ResponsesApiTool {
description, name, ..
}) = &tool
else {
panic!("expected function tool");
};
assert_eq!(name, "exec_command");
let expected = if cfg!(windows) {
format!(
"Runs a command in a PTY, returning output or a session ID for ongoing interaction.{}",
windows_shell_safety_description()
)
} else {
"Runs a command in a PTY, returning output or a session ID for ongoing interaction."
.to_string()
};
assert_eq!(description, &expected);
}
#[test]
fn shell_tool_with_request_permission_includes_additional_permissions() {
let tool = super::create_shell_tool(/*exec_permission_approvals_enabled*/ true);
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else {
panic!("expected function tool");
};
let JsonSchema::Object { properties, .. } = parameters else {
panic!("expected object parameters");
};
assert!(properties.contains_key("additional_permissions"));
let Some(JsonSchema::String {
description: Some(description),
}) = properties.get("sandbox_permissions")
else {
panic!("expected sandbox_permissions description");
};
assert!(description.contains("with_additional_permissions"));
assert!(description.contains("filesystem or network permissions"));
let Some(JsonSchema::Object {
properties: additional_properties,
..
}) = properties.get("additional_permissions")
else {
panic!("expected additional_permissions schema");
};
assert!(additional_properties.contains_key("network"));
assert!(additional_properties.contains_key("file_system"));
assert!(!additional_properties.contains_key("macos"));
}
#[test]
fn request_permissions_tool_includes_full_permission_schema() {
let tool = super::create_request_permissions_tool();
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else {
panic!("expected function tool");
};
let JsonSchema::Object { properties, .. } = parameters else {
panic!("expected object parameters");
};
let Some(JsonSchema::Object {
properties: permission_properties,
additional_properties,
..
}) = properties.get("permissions")
else {
panic!("expected permissions object");
};
assert_eq!(additional_properties, &Some(false.into()));
assert!(permission_properties.contains_key("network"));
assert!(permission_properties.contains_key("file_system"));
assert!(!permission_properties.contains_key("macos"));
let Some(JsonSchema::Object {
properties: network_properties,
additional_properties,
..
}) = permission_properties.get("network")
else {
panic!("expected network object");
};
assert_eq!(additional_properties, &Some(false.into()));
assert!(network_properties.contains_key("enabled"));
let Some(JsonSchema::Object {
properties: file_system_properties,
additional_properties,
..
}) = permission_properties.get("file_system")
else {
panic!("expected file_system object");
};
assert_eq!(additional_properties, &Some(false.into()));
assert!(file_system_properties.contains_key("read"));
assert!(file_system_properties.contains_key("write"));
}
#[test]
fn test_shell_command_tool() {
let tool = super::create_shell_command_tool(
/*allow_login_shell*/ true, /*exec_permission_approvals_enabled*/ false,
);
let ToolSpec::Function(ResponsesApiTool {
description, name, ..
}) = &tool
else {
panic!("expected function tool");
};
assert_eq!(name, "shell_command");
let expected = if cfg!(windows) {
r#"Runs a Powershell command (Windows) and returns its output.
Examples of valid command strings:
- ls -a (show hidden): "Get-ChildItem -Force"
- recursive find by name: "Get-ChildItem -Recurse -Filter *.py"
- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"
- ps aux | grep python: "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"
- setting an env var: "$env:FOO='bar'; echo $env:FOO"
- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -""#
.to_string()
+ &windows_shell_safety_description()
} else {
r#"Runs a shell command and returns its output.
- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#.to_string()
};
assert_eq!(description, &expected);
}
#[test]
fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
let config = test_config();
@@ -3111,38 +2967,3 @@ fn code_mode_exec_description_omits_nested_tool_details_when_not_code_mode_only(
assert!(!description.contains("### `update_plan` (`update_plan`)"));
assert!(!description.contains("### `view_image` (`view_image`)"));
}
#[test]
fn chat_tools_include_top_level_name() {
let properties =
BTreeMap::from([("foo".to_string(), JsonSchema::String { description: None })]);
let tools = vec![ToolSpec::Function(ResponsesApiTool {
name: "demo".to_string(),
description: "A demo tool".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: None,
additional_properties: None,
},
output_schema: None,
})];
let responses_json = create_tools_json_for_responses_api(&tools).unwrap();
assert_eq!(
responses_json,
vec![json!({
"type": "function",
"name": "demo",
"description": "A demo tool",
"strict": false,
"parameters": {
"type": "object",
"properties": {
"foo": { "type": "string" }
},
},
})]
);
}
+8 -3
View File
@@ -36,6 +36,7 @@ const SEARCH_TOOL_DESCRIPTION_SNIPPETS: [&str; 2] = [
"- Calendar: Plan events and manage your calendar.",
];
const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
const AGENTS_NAMESPACE_TOOL_NAME: &str = "agents";
const CALENDAR_CREATE_TOOL: &str = "mcp__codex_apps__calendar_create_event";
const CALENDAR_LIST_TOOL: &str = "mcp__codex_apps__calendar_list_events";
const SEARCH_CALENDAR_NAMESPACE: &str = "mcp__codex_apps__calendar";
@@ -217,7 +218,7 @@ async fn tool_search_disabled_by_default_exposes_apps_tools_directly() -> Result
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn search_tool_is_hidden_for_api_key_auth() -> Result<()> {
async fn search_tool_keeps_agents_namespace_for_api_key_auth() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
@@ -247,8 +248,12 @@ async fn search_tool_is_hidden_for_api_key_auth() -> Result<()> {
let body = mock.single_request().body_json();
let tools = tool_names(&body);
assert!(
!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME),
"tools list should not include {TOOL_SEARCH_TOOL_NAME} for API key auth: {tools:?}"
tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME),
"tools list should include {TOOL_SEARCH_TOOL_NAME} for the always-present agents namespace: {tools:?}"
);
assert!(
tools.iter().any(|name| name == AGENTS_NAMESPACE_TOOL_NAME),
"tools list should include the {AGENTS_NAMESPACE_TOOL_NAME} namespace: {tools:?}"
);
Ok(())
+10 -7
View File
@@ -1975,10 +1975,14 @@ mod tests {
let cwd = tempdir().expect("create temp cwd");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
approvals_reviewer: Some(ApprovalsReviewer::User),
..Default::default()
})
.fallback_cwd(Some(cwd.path().to_path_buf()))
.build()
.await
.expect("build default config");
.expect("build config with manual-only review policy");
let params = thread_start_params_from_config(&config);
@@ -1992,17 +1996,16 @@ mod tests {
async fn thread_start_params_include_review_policy_when_auto_review_is_enabled() {
let codex_home = tempdir().expect("create temp codex home");
let cwd = tempdir().expect("create temp cwd");
std::fs::write(
codex_home.path().join("config.toml"),
"approvals_reviewer = \"guardian_subagent\"\n",
)
.expect("write auto-review config");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
approvals_reviewer: Some(ApprovalsReviewer::GuardianSubagent),
..Default::default()
})
.fallback_cwd(Some(cwd.path().to_path_buf()))
.build()
.await
.expect("build auto-review config");
.expect("build config with guardian review policy");
let params = thread_start_params_from_config(&config);
-8
View File
@@ -174,8 +174,6 @@ pub enum Feature {
Artifact,
/// Enable Fast mode selection in the TUI and request layer.
FastMode,
/// Enable voice transcription in the TUI composer.
VoiceTranscription,
/// Enable experimental realtime voice conversation mode in the TUI.
RealtimeConversation,
/// Removed compatibility flag. The TUI now always uses the app-server implementation.
@@ -821,12 +819,6 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::VoiceTranscription,
key: "voice_transcription",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::RealtimeConversation,
key: "realtime_conversation",
@@ -119,6 +119,7 @@ pub fn originator() -> Originator {
pub fn is_first_party_originator(originator_value: &str) -> bool {
originator_value == DEFAULT_ORIGINATOR
|| originator_value == "codex-tui"
|| originator_value == "codex_vscode"
|| originator_value.starts_with("Codex ")
}
@@ -14,6 +14,7 @@ fn test_get_codex_user_agent() {
#[test]
fn is_first_party_originator_matches_known_values() {
assert_eq!(is_first_party_originator(DEFAULT_ORIGINATOR), true);
assert_eq!(is_first_party_originator("codex-tui"), true);
assert_eq!(is_first_party_originator("codex_vscode"), true);
assert_eq!(is_first_party_originator("Codex Something Else"), true);
assert_eq!(is_first_party_originator("codex_cli"), false);
@@ -336,7 +336,6 @@ async fn run_codex_tool_session_inner(
| EventMsg::McpToolCallBegin(_)
| EventMsg::McpToolCallEnd(_)
| EventMsg::McpListToolsResponse(_)
| EventMsg::ListCustomPromptsResponse(_)
| EventMsg::ListSkillsResponse(_)
| EventMsg::ExecCommandBegin(_)
| EventMsg::TerminalInteraction(_)
-20
View File
@@ -1,20 +0,0 @@
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use std::path::PathBuf;
use ts_rs::TS;
/// Base namespace for custom prompt slash commands (without trailing colon).
/// Example usage forms constructed in code:
/// - Command token after '/': `"{PROMPTS_CMD_PREFIX}:name"`
/// - Full slash prefix: `"/{PROMPTS_CMD_PREFIX}:"`
pub const PROMPTS_CMD_PREFIX: &str = "prompts";
#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)]
pub struct CustomPrompt {
pub name: String,
pub path: PathBuf,
pub content: String,
pub description: Option<String>,
pub argument_hint: Option<String>,
}
-1
View File
@@ -5,7 +5,6 @@ pub use agent_path::AgentPath;
pub use thread_id::ThreadId;
pub mod approvals;
pub mod config_types;
pub mod custom_prompts;
pub mod dynamic_tools;
pub mod items;
pub mod mcp;
-14
View File
@@ -23,7 +23,6 @@ use crate::config_types::Personality;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::config_types::ServiceTier;
use crate::config_types::WindowsSandboxLevel;
use crate::custom_prompts::CustomPrompt;
use crate::dynamic_tools::DynamicToolCallOutputContentItem;
use crate::dynamic_tools::DynamicToolCallRequest;
use crate::dynamic_tools::DynamicToolResponse;
@@ -474,9 +473,6 @@ pub enum Op {
/// enable/disable state) without restarting the thread.
ReloadUserConfig,
/// Request the list of available custom prompts.
ListCustomPrompts,
/// Request the list of skills for the provided `cwd` values or the session default.
ListSkills {
/// Working directories to scope repo skills discovery.
@@ -619,7 +615,6 @@ impl Op {
Self::ListMcpTools => "list_mcp_tools",
Self::RefreshMcpServers { .. } => "refresh_mcp_servers",
Self::ReloadUserConfig => "reload_user_config",
Self::ListCustomPrompts => "list_custom_prompts",
Self::ListSkills { .. } => "list_skills",
Self::Compact => "compact",
Self::DropMemories => "drop_memories",
@@ -1394,9 +1389,6 @@ pub enum EventMsg {
/// List of MCP tools available to the agent.
McpListToolsResponse(McpListToolsResponseEvent),
/// List of custom prompts available to the agent.
ListCustomPromptsResponse(ListCustomPromptsResponseEvent),
/// List of skills available to the agent.
ListSkillsResponse(ListSkillsResponseEvent),
@@ -3126,12 +3118,6 @@ impl fmt::Display for McpAuthStatus {
}
}
/// Response payload for `Op::ListCustomPrompts`.
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ListCustomPromptsResponseEvent {
pub custom_prompts: Vec<CustomPrompt>,
}
/// Response payload for `Op::ListSkills`.
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ListSkillsResponseEvent {
-1
View File
@@ -161,7 +161,6 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
| EventMsg::McpListToolsResponse(_)
| EventMsg::McpStartupUpdate(_)
| EventMsg::McpStartupComplete(_)
| EventMsg::ListCustomPromptsResponse(_)
| EventMsg::ListSkillsResponse(_)
| EventMsg::PlanUpdate(_)
| EventMsg::ShutdownComplete
+1
View File
@@ -8,6 +8,7 @@ version.workspace = true
workspace = true
[dependencies]
codex-code-mode = { workspace = true }
codex-protocol = { workspace = true }
rmcp = { workspace = true, default-features = false, features = [
"base64",
+11
View File
@@ -11,20 +11,31 @@ schema and Responses API tool primitives that no longer need to live in
- `JsonSchema`
- `AdditionalProperties`
- `ToolDefinition`
- `ToolSpec`
- `ConfiguredToolSpec`
- `ResponsesApiTool`
- `FreeformTool`
- `FreeformToolFormat`
- `ToolSearchOutputTool`
- `ResponsesApiWebSearchFilters`
- `ResponsesApiWebSearchUserLocation`
- `ResponsesApiNamespace`
- `ResponsesApiNamespaceTool`
- code-mode `ToolSpec` adapters
- local host tool spec builders for shell/exec/request-permissions/view-image
- collaboration and agent-job `ToolSpec` builders for spawn/send/wait/close,
`request_user_input`, and CSV fanout/reporting
- `parse_tool_input_schema()`
- `parse_dynamic_tool()`
- `parse_mcp_tool()`
- `create_tools_json_for_responses_api()`
- `mcp_call_tool_result_output_schema()`
- `tool_definition_to_responses_api_tool()`
- `dynamic_tool_to_responses_api_tool()`
- `mcp_tool_to_responses_api_tool()`
- `mcp_tool_to_deferred_responses_api_tool()`
- `augment_tool_spec_for_code_mode()`
- `tool_spec_to_code_mode_tool_definition()`
That extraction is the first step in a longer migration. The goal is not to
move all of `core/src/tools` into this crate in one shot. Instead, the plan is
+141
View File
@@ -0,0 +1,141 @@
use crate::JsonSchema;
use crate::ResponsesApiTool;
use crate::ToolSpec;
use std::collections::BTreeMap;
pub fn create_spawn_agents_on_csv_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"csv_path".to_string(),
JsonSchema::String {
description: Some("Path to the CSV file containing input rows.".to_string()),
},
),
(
"instruction".to_string(),
JsonSchema::String {
description: Some(
"Instruction template to apply to each CSV row. Use {column_name} placeholders to inject values from the row."
.to_string(),
),
},
),
(
"id_column".to_string(),
JsonSchema::String {
description: Some("Optional column name to use as stable item id.".to_string()),
},
),
(
"output_csv_path".to_string(),
JsonSchema::String {
description: Some("Optional output CSV path for exported results.".to_string()),
},
),
(
"max_concurrency".to_string(),
JsonSchema::Number {
description: Some(
"Maximum concurrent workers for this job. Defaults to 16 and is capped by config."
.to_string(),
),
},
),
(
"max_workers".to_string(),
JsonSchema::Number {
description: Some(
"Alias for max_concurrency. Set to 1 to run sequentially.".to_string(),
),
},
),
(
"max_runtime_seconds".to_string(),
JsonSchema::Number {
description: Some(
"Maximum runtime per worker before it is failed. Defaults to 1800 seconds."
.to_string(),
),
},
),
(
"output_schema".to_string(),
JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "spawn_agents_on_csv".to_string(),
description: "Process a CSV by spawning one worker sub-agent per row. The instruction string is a template where `{column}` placeholders are replaced with row values. Each worker must call `report_agent_job_result` with a JSON object (matching `output_schema` when provided); missing reports are treated as failures. This call blocks until all rows finish and automatically exports results to `output_csv_path` (or a default path)."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["csv_path".to_string(), "instruction".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
pub fn create_report_agent_job_result_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"job_id".to_string(),
JsonSchema::String {
description: Some("Identifier of the job.".to_string()),
},
),
(
"item_id".to_string(),
JsonSchema::String {
description: Some("Identifier of the job item.".to_string()),
},
),
(
"result".to_string(),
JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
),
(
"stop".to_string(),
JsonSchema::Boolean {
description: Some(
"Optional. When true, cancels the remaining job items after this result is recorded."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "report_agent_job_result".to_string(),
description:
"Worker-only tool to report a result for an agent job item. Main agents should not call this."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec![
"job_id".to_string(),
"item_id".to_string(),
"result".to_string(),
]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
#[cfg(test)]
#[path = "agent_job_tool_tests.rs"]
mod tests;
+140
View File
@@ -0,0 +1,140 @@
use super::*;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
#[test]
fn spawn_agents_on_csv_tool_requires_csv_and_instruction() {
assert_eq!(
create_spawn_agents_on_csv_tool(),
ToolSpec::Function(ResponsesApiTool {
name: "spawn_agents_on_csv".to_string(),
description: "Process a CSV by spawning one worker sub-agent per row. The instruction string is a template where `{column}` placeholders are replaced with row values. Each worker must call `report_agent_job_result` with a JSON object (matching `output_schema` when provided); missing reports are treated as failures. This call blocks until all rows finish and automatically exports results to `output_csv_path` (or a default path)."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([
(
"csv_path".to_string(),
JsonSchema::String {
description: Some("Path to the CSV file containing input rows.".to_string()),
},
),
(
"instruction".to_string(),
JsonSchema::String {
description: Some(
"Instruction template to apply to each CSV row. Use {column_name} placeholders to inject values from the row."
.to_string(),
),
},
),
(
"id_column".to_string(),
JsonSchema::String {
description: Some("Optional column name to use as stable item id.".to_string()),
},
),
(
"output_csv_path".to_string(),
JsonSchema::String {
description: Some("Optional output CSV path for exported results.".to_string()),
},
),
(
"max_concurrency".to_string(),
JsonSchema::Number {
description: Some(
"Maximum concurrent workers for this job. Defaults to 16 and is capped by config."
.to_string(),
),
},
),
(
"max_workers".to_string(),
JsonSchema::Number {
description: Some(
"Alias for max_concurrency. Set to 1 to run sequentially.".to_string(),
),
},
),
(
"max_runtime_seconds".to_string(),
JsonSchema::Number {
description: Some(
"Maximum runtime per worker before it is failed. Defaults to 1800 seconds."
.to_string(),
),
},
),
(
"output_schema".to_string(),
JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
),
]),
required: Some(vec!["csv_path".to_string(), "instruction".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
);
}
#[test]
fn report_agent_job_result_tool_requires_result_payload() {
assert_eq!(
create_report_agent_job_result_tool(),
ToolSpec::Function(ResponsesApiTool {
name: "report_agent_job_result".to_string(),
description:
"Worker-only tool to report a result for an agent job item. Main agents should not call this."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([
(
"job_id".to_string(),
JsonSchema::String {
description: Some("Identifier of the job.".to_string()),
},
),
(
"item_id".to_string(),
JsonSchema::String {
description: Some("Identifier of the job item.".to_string()),
},
),
(
"result".to_string(),
JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
),
(
"stop".to_string(),
JsonSchema::Boolean {
description: Some(
"Optional. When true, cancels the remaining job items after this result is recorded."
.to_string(),
),
},
),
]),
required: Some(vec![
"job_id".to_string(),
"item_id".to_string(),
"result".to_string(),
]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
);
}
+729
View File
@@ -0,0 +1,729 @@
use crate::JsonSchema;
use crate::ResponsesApiTool;
use crate::ToolSpec;
use codex_protocol::openai_models::ModelPreset;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;
#[derive(Debug, Clone)]
pub struct SpawnAgentToolOptions<'a> {
pub available_models: &'a [ModelPreset],
pub agent_type_description: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WaitAgentTimeoutOptions {
pub default_timeout_ms: i64,
pub min_timeout_ms: i64,
pub max_timeout_ms: i64,
}
pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions<'_>) -> ToolSpec {
let available_models_description = spawn_agent_models_description(options.available_models);
let return_value_description =
"Returns the spawned agent id plus the user-facing nickname when available.";
let properties = spawn_agent_common_properties(&options.agent_type_description);
ToolSpec::Function(ResponsesApiTool {
name: "spawn_agent".to_string(),
description: spawn_agent_tool_description(
&available_models_description,
return_value_description,
),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: None,
additional_properties: Some(false.into()),
},
output_schema: Some(spawn_agent_output_schema_v1()),
})
}
pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions<'_>) -> ToolSpec {
let available_models_description = spawn_agent_models_description(options.available_models);
let return_value_description = "Returns the canonical task name for the spawned agent, plus the user-facing nickname when available.";
let mut properties = spawn_agent_common_properties(&options.agent_type_description);
properties.insert(
"task_name".to_string(),
JsonSchema::String {
description: Some(
"Task name for the new agent. Use lowercase letters, digits, and underscores."
.to_string(),
),
},
);
ToolSpec::Function(ResponsesApiTool {
name: "spawn_agent".to_string(),
description: spawn_agent_tool_description(
&available_models_description,
return_value_description,
),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["task_name".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(spawn_agent_output_schema_v2()),
})
}
pub fn create_send_input_tool_v1() -> ToolSpec {
let properties = BTreeMap::from([
(
"target".to_string(),
JsonSchema::String {
description: Some("Agent id to message (from spawn_agent).".to_string()),
},
),
(
"message".to_string(),
JsonSchema::String {
description: Some(
"Legacy plain-text message to send to the agent. Use either message or items."
.to_string(),
),
},
),
("items".to_string(), create_collab_input_items_schema()),
(
"interrupt".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "send_input".to_string(),
description: "Send a message to an existing agent. Use interrupt=true to redirect work immediately. You should reuse the agent by send_input if you believe your assigned task is highly dependent on the context of a previous task."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(send_input_output_schema()),
})
}
pub fn create_send_message_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"target".to_string(),
JsonSchema::String {
description: Some(
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
),
},
),
("items".to_string(), create_collab_input_items_schema()),
(
"interrupt".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "send_message".to_string(),
description: "Add a message to an existing agent without triggering a new turn. Use interrupt=true to stop the current task first. In MultiAgentV2, this tool currently supports text content only."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string(), "items".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(send_input_output_schema()),
})
}
pub fn create_assign_task_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"target".to_string(),
JsonSchema::String {
description: Some(
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
),
},
),
("items".to_string(), create_collab_input_items_schema()),
(
"interrupt".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "assign_task".to_string(),
description: "Add a message to an existing agent and trigger a turn in the target. Use interrupt=true to redirect work immediately. In MultiAgentV2, this tool currently supports text content only."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string(), "items".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(send_input_output_schema()),
})
}
pub fn create_resume_agent_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"id".to_string(),
JsonSchema::String {
description: Some("Agent id to resume.".to_string()),
},
)]);
ToolSpec::Function(ResponsesApiTool {
name: "resume_agent".to_string(),
description:
"Resume a previously closed agent by id so it can receive send_input and wait_agent calls."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["id".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(resume_agent_output_schema()),
})
}
pub fn create_wait_agent_tool_v1(options: WaitAgentTimeoutOptions) -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "wait_agent".to_string(),
description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status."
.to_string(),
strict: false,
defer_loading: None,
parameters: wait_agent_tool_parameters_v1(options),
output_schema: Some(wait_output_schema_v1()),
})
}
pub fn create_wait_agent_tool_v2(options: WaitAgentTimeoutOptions) -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "wait_agent".to_string(),
description: "Wait for agents to reach a final status. Returns a brief wait summary instead of the agent's final content. Returns a timeout summary when no agent reaches a final status before the deadline."
.to_string(),
strict: false,
defer_loading: None,
parameters: wait_agent_tool_parameters_v2(options),
output_schema: Some(wait_output_schema_v2()),
})
}
pub fn create_list_agents_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"path_prefix".to_string(),
JsonSchema::String {
description: Some(
"Optional task-path prefix. Accepts the same relative or absolute task-path syntax as other MultiAgentV2 agent targets."
.to_string(),
),
},
)]);
ToolSpec::Function(ResponsesApiTool {
name: "list_agents".to_string(),
description:
"List live agents in the current root thread tree. Optionally filter by task-path prefix."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: None,
additional_properties: Some(false.into()),
},
output_schema: Some(list_agents_output_schema()),
})
}
pub fn create_close_agent_tool_v1() -> ToolSpec {
let properties = BTreeMap::from([(
"target".to_string(),
JsonSchema::String {
description: Some("Agent id to close (from spawn_agent).".to_string()),
},
)]);
ToolSpec::Function(ResponsesApiTool {
name: "close_agent".to_string(),
description: "Close an agent and any open descendants when they are no longer needed, and return the target agent's previous status before shutdown was requested. Don't keep agents open for too long if they are not needed anymore.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(close_agent_output_schema()),
})
}
pub fn create_close_agent_tool_v2() -> ToolSpec {
let properties = BTreeMap::from([(
"target".to_string(),
JsonSchema::String {
description: Some(
"Agent id or canonical task name to close (from spawn_agent).".to_string(),
),
},
)]);
ToolSpec::Function(ResponsesApiTool {
name: "close_agent".to_string(),
description: "Close an agent and any open descendants when they are no longer needed, and return the target agent's previous status before shutdown was requested. Don't keep agents open for too long if they are not needed anymore.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(close_agent_output_schema()),
})
}
fn agent_status_output_schema() -> Value {
json!({
"oneOf": [
{
"type": "string",
"enum": ["pending_init", "running", "shutdown", "not_found"]
},
{
"type": "object",
"properties": {
"completed": {
"type": ["string", "null"]
}
},
"required": ["completed"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"errored": {
"type": "string"
}
},
"required": ["errored"],
"additionalProperties": false
}
]
})
}
fn spawn_agent_output_schema_v1() -> Value {
json!({
"type": "object",
"properties": {
"agent_id": {
"type": "string",
"description": "Thread identifier for the spawned agent."
},
"nickname": {
"type": ["string", "null"],
"description": "User-facing nickname for the spawned agent when available."
}
},
"required": ["agent_id", "nickname"],
"additionalProperties": false
})
}
fn spawn_agent_output_schema_v2() -> Value {
json!({
"type": "object",
"properties": {
"agent_id": {
"type": ["string", "null"],
"description": "Legacy thread identifier for the spawned agent."
},
"task_name": {
"type": "string",
"description": "Canonical task name for the spawned agent."
},
"nickname": {
"type": ["string", "null"],
"description": "User-facing nickname for the spawned agent when available."
}
},
"required": ["agent_id", "task_name", "nickname"],
"additionalProperties": false
})
}
fn send_input_output_schema() -> Value {
json!({
"type": "object",
"properties": {
"submission_id": {
"type": "string",
"description": "Identifier for the queued input submission."
}
},
"required": ["submission_id"],
"additionalProperties": false
})
}
fn list_agents_output_schema() -> Value {
json!({
"type": "object",
"properties": {
"agents": {
"type": "array",
"items": {
"type": "object",
"properties": {
"agent_name": {
"type": "string",
"description": "Canonical task name for the agent when available, otherwise the agent id."
},
"agent_status": {
"description": "Last known status of the agent.",
"allOf": [agent_status_output_schema()]
},
"last_task_message": {
"type": ["string", "null"],
"description": "Most recent user or inter-agent instruction received by the agent, when available."
}
},
"required": ["agent_name", "agent_status", "last_task_message"],
"additionalProperties": false
},
"description": "Live agents visible in the current root thread tree."
}
},
"required": ["agents"],
"additionalProperties": false
})
}
fn resume_agent_output_schema() -> Value {
json!({
"type": "object",
"properties": {
"status": agent_status_output_schema()
},
"required": ["status"],
"additionalProperties": false
})
}
fn wait_output_schema_v1() -> Value {
json!({
"type": "object",
"properties": {
"status": {
"type": "object",
"description": "Final statuses keyed by agent id.",
"additionalProperties": agent_status_output_schema()
},
"timed_out": {
"type": "boolean",
"description": "Whether the wait call returned due to timeout before any agent reached a final status."
}
},
"required": ["status", "timed_out"],
"additionalProperties": false
})
}
fn wait_output_schema_v2() -> Value {
json!({
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Brief wait summary without the agent's final content."
},
"timed_out": {
"type": "boolean",
"description": "Whether the wait call returned due to timeout before any agent reached a final status."
}
},
"required": ["message", "timed_out"],
"additionalProperties": false
})
}
fn close_agent_output_schema() -> Value {
json!({
"type": "object",
"properties": {
"previous_status": {
"description": "The agent status observed before shutdown was requested.",
"allOf": [agent_status_output_schema()]
}
},
"required": ["previous_status"],
"additionalProperties": false
})
}
fn create_collab_input_items_schema() -> JsonSchema {
let properties = BTreeMap::from([
(
"type".to_string(),
JsonSchema::String {
description: Some(
"Input item type: text, image, local_image, skill, or mention.".to_string(),
),
},
),
(
"text".to_string(),
JsonSchema::String {
description: Some("Text content when type is text.".to_string()),
},
),
(
"image_url".to_string(),
JsonSchema::String {
description: Some("Image URL when type is image.".to_string()),
},
),
(
"path".to_string(),
JsonSchema::String {
description: Some(
"Path when type is local_image/skill, or structured mention target such as app://<connector-id> or plugin://<plugin-name>@<marketplace-name> when type is mention."
.to_string(),
),
},
),
(
"name".to_string(),
JsonSchema::String {
description: Some("Display name when type is skill or mention.".to_string()),
},
),
]);
JsonSchema::Array {
items: Box::new(JsonSchema::Object {
properties,
required: None,
additional_properties: Some(false.into()),
}),
description: Some(
"Structured input items. Use this to pass explicit mentions (for example app:// connector paths)."
.to_string(),
),
}
}
fn spawn_agent_common_properties(agent_type_description: &str) -> BTreeMap<String, JsonSchema> {
BTreeMap::from([
(
"message".to_string(),
JsonSchema::String {
description: Some(
"Initial plain-text task for the new agent. Use either message or items."
.to_string(),
),
},
),
("items".to_string(), create_collab_input_items_schema()),
(
"agent_type".to_string(),
JsonSchema::String {
description: Some(agent_type_description.to_string()),
},
),
(
"fork_context".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, fork the current thread history into the new agent before sending the initial prompt. This must be used when you want the new agent to have exactly the same context as you."
.to_string(),
),
},
),
(
"model".to_string(),
JsonSchema::String {
description: Some(
"Optional model override for the new agent. Replaces the inherited model."
.to_string(),
),
},
),
(
"reasoning_effort".to_string(),
JsonSchema::String {
description: Some(
"Optional reasoning effort override for the new agent. Replaces the inherited reasoning effort."
.to_string(),
),
},
),
])
}
fn spawn_agent_tool_description(
available_models_description: &str,
return_value_description: &str,
) -> String {
format!(
r#"
Only use `spawn_agent` if and only if the user explicitly asks for sub-agents, delegation, or parallel agent work.
Requests for depth, thoroughness, research, investigation, or detailed codebase analysis do not count as permission to spawn.
Agent-role guidance below only helps choose which agent to use after spawning is already authorized; it never authorizes spawning by itself.
Spawn a sub-agent for a well-scoped task. {return_value_description} This spawn_agent tool provides you access to smaller but more efficient sub-agents. A mini model can solve many tasks faster than the main model. You should follow the rules and guidelines below to use this tool.
{available_models_description}
### When to delegate vs. do the subtask yourself
- First, quickly analyze the overall user task and form a succinct high-level plan. Identify which tasks are immediate blockers on the critical path, and which tasks are sidecar tasks that are needed but can run in parallel without blocking the next local step. As part of that plan, explicitly decide what immediate task you should do locally right now. Do this planning step before delegating to agents so you do not hand off the immediate blocking task to a submodel and then waste time waiting on it.
- Use the smaller subagent when a subtask is easy enough for it to handle and can run in parallel with your local work. Prefer delegating concrete, bounded sidecar tasks that materially advance the main task without blocking your immediate next local step.
- Do not delegate urgent blocking work when your immediate next step depends on that result. If the very next action is blocked on that task, the main rollout should usually do it locally to keep the critical path moving.
- Keep work local when the subtask is too difficult to delegate well and when it is tightly coupled, urgent, or likely to block your immediate next step.
### Designing delegated subtasks
- Subtasks must be concrete, well-defined, and self-contained.
- Delegated subtasks must materially advance the main task.
- Do not duplicate work between the main rollout and delegated subtasks.
- Avoid issuing multiple delegate calls on the same unresolved thread unless the new delegated task is genuinely different and necessary.
- Narrow the delegated ask to the concrete output you need next.
- For coding tasks, prefer delegating concrete code-change worker subtasks over read-only explorer analysis when the subagent can make a bounded patch in a clear write scope.
- When delegating coding work, instruct the submodel to edit files directly in its forked workspace and list the file paths it changed in the final answer.
- For code-edit subtasks, decompose work so each delegated task has a disjoint write set.
### After you delegate
- Call wait_agent very sparingly. Only call wait_agent when you need the result immediately for the next critical-path step and you are blocked until it returns.
- Do not redo delegated subagent tasks yourself; focus on integrating results or tackling non-overlapping work.
- While the subagent is running in the background, do meaningful non-overlapping work immediately.
- Do not repeatedly wait by reflex.
- When a delegated coding task returns, quickly review the uploaded changes, then integrate or refine them.
### Parallel delegation patterns
- Run multiple independent information-seeking subtasks in parallel when you have distinct questions that can be answered independently.
- Split implementation into disjoint codebase slices and spawn multiple agents for them in parallel when the write scopes do not overlap.
- Delegate verification only when it can run in parallel with ongoing implementation and is likely to catch a concrete risk before final integration.
- The key is to find opportunities to spawn multiple independent subtasks in parallel within the same round, while ensuring each subtask is well-defined, self-contained, and materially advances the main task."#
)
}
fn spawn_agent_models_description(models: &[ModelPreset]) -> String {
let visible_models: Vec<&ModelPreset> =
models.iter().filter(|model| model.show_in_picker).collect();
if visible_models.is_empty() {
return "No picker-visible models are currently loaded.".to_string();
}
visible_models
.into_iter()
.map(|model| {
let efforts = model
.supported_reasoning_efforts
.iter()
.map(|preset| format!("{} ({})", preset.effort, preset.description))
.collect::<Vec<_>>()
.join(", ");
format!(
"- {} (`{}`): {} Default reasoning effort: {}. Supported reasoning efforts: {}.",
model.display_name,
model.model,
model.description,
model.default_reasoning_effort,
efforts
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn wait_agent_tool_parameters_v1(options: WaitAgentTimeoutOptions) -> JsonSchema {
let properties = BTreeMap::from([
(
"targets".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"Agent ids to wait on. Pass multiple ids to wait for whichever finishes first."
.to_string(),
),
},
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some(format!(
"Optional timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.",
options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
)),
},
),
]);
JsonSchema::Object {
properties,
required: Some(vec!["targets".to_string()]),
additional_properties: Some(false.into()),
}
}
fn wait_agent_tool_parameters_v2(options: WaitAgentTimeoutOptions) -> JsonSchema {
let properties = BTreeMap::from([
(
"targets".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"Agent ids or canonical task names to wait on. Pass multiple targets to wait for whichever finishes first."
.to_string(),
),
},
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some(format!(
"Optional timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.",
options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
)),
},
),
]);
JsonSchema::Object {
properties,
required: Some(vec!["targets".to_string()]),
additional_properties: Some(false.into()),
}
}
#[cfg(test)]
#[path = "agent_tool_tests.rs"]
mod tests;
+152
View File
@@ -0,0 +1,152 @@
use super::*;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
use pretty_assertions::assert_eq;
use serde_json::json;
fn model_preset(id: &str, show_in_picker: bool) -> ModelPreset {
ModelPreset {
id: id.to_string(),
model: format!("{id}-model"),
display_name: format!("{id} display"),
description: format!("{id} description"),
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: vec![ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description: "Balanced".to_string(),
}],
supports_personality: false,
is_default: false,
upgrade: None,
show_in_picker,
availability_nux: None,
supported_in_api: true,
input_modalities: Vec::new(),
}
}
#[test]
fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions {
available_models: &[
model_preset("visible", /*show_in_picker*/ true),
model_preset("hidden", /*show_in_picker*/ false),
],
agent_type_description: "role help".to_string(),
});
let ToolSpec::Function(ResponsesApiTool {
description,
parameters,
output_schema,
..
}) = tool
else {
panic!("spawn_agent should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("spawn_agent should use object params");
};
assert!(description.contains("visible display (`visible-model`)"));
assert!(!description.contains("hidden display (`hidden-model`)"));
assert!(properties.contains_key("task_name"));
assert_eq!(
properties.get("agent_type"),
Some(&JsonSchema::String {
description: Some("role help".to_string()),
})
);
assert_eq!(required, Some(vec!["task_name".to_string()]));
assert_eq!(
output_schema.expect("spawn_agent output schema")["required"],
json!(["agent_id", "task_name", "nickname"])
);
}
#[test]
fn send_message_tool_requires_items_and_uses_submission_output() {
let ToolSpec::Function(ResponsesApiTool {
parameters,
output_schema,
..
}) = create_send_message_tool()
else {
panic!("send_message should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("send_message should use object params");
};
assert!(properties.contains_key("target"));
assert!(properties.contains_key("items"));
assert!(!properties.contains_key("message"));
assert_eq!(
required,
Some(vec!["target".to_string(), "items".to_string()])
);
assert_eq!(
output_schema.expect("send_message output schema")["required"],
json!(["submission_id"])
);
}
#[test]
fn wait_agent_tool_v2_uses_task_targets_and_summary_output() {
let ToolSpec::Function(ResponsesApiTool {
parameters,
output_schema,
..
}) = create_wait_agent_tool_v2(WaitAgentTimeoutOptions {
default_timeout_ms: 30_000,
min_timeout_ms: 10_000,
max_timeout_ms: 3_600_000,
})
else {
panic!("wait_agent should be a function tool");
};
let JsonSchema::Object { properties, .. } = parameters else {
panic!("wait_agent should use object params");
};
let Some(JsonSchema::Array {
description: Some(description),
..
}) = properties.get("targets")
else {
panic!("wait_agent should define targets array");
};
assert!(description.contains("canonical task names"));
assert_eq!(
output_schema.expect("wait output schema")["properties"]["message"]["description"],
json!("Brief wait summary without the agent's final content.")
);
}
#[test]
fn list_agents_tool_includes_path_prefix_and_agent_fields() {
let ToolSpec::Function(ResponsesApiTool {
parameters,
output_schema,
..
}) = create_list_agents_tool()
else {
panic!("list_agents should be a function tool");
};
let JsonSchema::Object { properties, .. } = parameters else {
panic!("list_agents should use object params");
};
assert!(properties.contains_key("path_prefix"));
assert_eq!(
output_schema.expect("list_agents output schema")["properties"]["agents"]["items"]["required"],
json!(["agent_name", "agent_status", "last_task_message"])
);
}
+61
View File
@@ -0,0 +1,61 @@
use crate::ToolSpec;
use codex_code_mode::CodeModeToolKind;
use codex_code_mode::ToolDefinition as CodeModeToolDefinition;
/// Augment tool descriptions with code-mode-specific exec samples.
pub fn augment_tool_spec_for_code_mode(spec: ToolSpec) -> ToolSpec {
let Some(description) = code_mode_tool_definition_for_spec(&spec)
.map(codex_code_mode::augment_tool_definition)
.map(|definition| definition.description)
else {
return spec;
};
match spec {
ToolSpec::Function(mut tool) => {
tool.description = description;
ToolSpec::Function(tool)
}
ToolSpec::Freeform(mut tool) => {
tool.description = description;
ToolSpec::Freeform(tool)
}
other => other,
}
}
/// Convert a supported nested tool spec into the code-mode runtime shape,
/// including the code-mode-specific description sample.
pub fn tool_spec_to_code_mode_tool_definition(spec: &ToolSpec) -> Option<CodeModeToolDefinition> {
let definition = code_mode_tool_definition_for_spec(spec)?;
codex_code_mode::is_code_mode_nested_tool(&definition.name)
.then(|| codex_code_mode::augment_tool_definition(definition))
}
fn code_mode_tool_definition_for_spec(spec: &ToolSpec) -> Option<CodeModeToolDefinition> {
match spec {
ToolSpec::Function(tool) => Some(CodeModeToolDefinition {
name: tool.name.clone(),
description: tool.description.clone(),
kind: CodeModeToolKind::Function,
input_schema: serde_json::to_value(&tool.parameters).ok(),
output_schema: tool.output_schema.clone(),
}),
ToolSpec::Freeform(tool) => Some(CodeModeToolDefinition {
name: tool.name.clone(),
description: tool.description.clone(),
kind: CodeModeToolKind::Freeform,
input_schema: None,
output_schema: None,
}),
ToolSpec::Namespace(_)
| ToolSpec::LocalShell {}
| ToolSpec::ImageGeneration { .. }
| ToolSpec::ToolSearch { .. }
| ToolSpec::WebSearch { .. } => None,
}
}
#[cfg(test)]
#[path = "code_mode_tests.rs"]
mod tests;
+123
View File
@@ -0,0 +1,123 @@
use super::augment_tool_spec_for_code_mode;
use super::tool_spec_to_code_mode_tool_definition;
use crate::AdditionalProperties;
use crate::FreeformTool;
use crate::FreeformToolFormat;
use crate::JsonSchema;
use crate::ResponsesApiTool;
use crate::ToolSpec;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
#[test]
fn augment_tool_spec_for_code_mode_augments_function_tools() {
assert_eq!(
augment_tool_spec_for_code_mode(ToolSpec::Function(ResponsesApiTool {
name: "lookup_order".to_string(),
description: "Look up an order".to_string(),
strict: false,
defer_loading: Some(true),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"order_id".to_string(),
JsonSchema::String { description: None },
)]),
required: Some(vec!["order_id".to_string()]),
additional_properties: Some(AdditionalProperties::Boolean(false)),
},
output_schema: Some(json!({
"type": "object",
"properties": {
"ok": {"type": "boolean"}
},
"required": ["ok"],
})),
})),
ToolSpec::Function(ResponsesApiTool {
name: "lookup_order".to_string(),
description: "Look up an order\n\nexec tool declaration:\n```ts\ndeclare const tools: { lookup_order(args: { order_id: string; }): Promise<{ ok: boolean; }>; };\n```".to_string(),
strict: false,
defer_loading: Some(true),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"order_id".to_string(),
JsonSchema::String { description: None },
)]),
required: Some(vec!["order_id".to_string()]),
additional_properties: Some(AdditionalProperties::Boolean(false)),
},
output_schema: Some(json!({
"type": "object",
"properties": {
"ok": {"type": "boolean"}
},
"required": ["ok"],
})),
})
);
}
#[test]
fn augment_tool_spec_for_code_mode_preserves_exec_tool_description() {
assert_eq!(
augment_tool_spec_for_code_mode(ToolSpec::Freeform(FreeformTool {
name: codex_code_mode::PUBLIC_TOOL_NAME.to_string(),
description: "Run code".to_string(),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: "start: \"exec\"".to_string(),
},
})),
ToolSpec::Freeform(FreeformTool {
name: codex_code_mode::PUBLIC_TOOL_NAME.to_string(),
description: "Run code".to_string(),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: "start: \"exec\"".to_string(),
},
})
);
}
#[test]
fn tool_spec_to_code_mode_tool_definition_returns_augmented_nested_tools() {
let spec = ToolSpec::Freeform(FreeformTool {
name: "apply_patch".to_string(),
description: "Apply a patch".to_string(),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: "start: \"patch\"".to_string(),
},
});
assert_eq!(
tool_spec_to_code_mode_tool_definition(&spec),
Some(codex_code_mode::ToolDefinition {
name: "apply_patch".to_string(),
description: "Apply a patch\n\nexec tool declaration:\n```ts\ndeclare const tools: { apply_patch(input: string): Promise<unknown>; };\n```".to_string(),
kind: codex_code_mode::CodeModeToolKind::Freeform,
input_schema: None,
output_schema: None,
})
);
}
#[test]
fn tool_spec_to_code_mode_tool_definition_skips_unsupported_variants() {
assert_eq!(
tool_spec_to_code_mode_tool_definition(&ToolSpec::ToolSearch {
execution: "sync".to_string(),
description: "Search".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
}),
None
);
}
+39
View File
@@ -1,18 +1,50 @@
//! Shared tool definitions and Responses API tool primitives that can live
//! outside `codex-core`.
mod agent_job_tool;
mod agent_tool;
mod code_mode;
mod dynamic_tool;
mod json_schema;
mod local_tool;
mod mcp_tool;
mod request_user_input_tool;
mod responses_api;
mod tool_definition;
mod tool_spec;
mod view_image;
pub use agent_job_tool::create_report_agent_job_result_tool;
pub use agent_job_tool::create_spawn_agents_on_csv_tool;
pub use agent_tool::SpawnAgentToolOptions;
pub use agent_tool::WaitAgentTimeoutOptions;
pub use agent_tool::create_assign_task_tool;
pub use agent_tool::create_close_agent_tool_v1;
pub use agent_tool::create_close_agent_tool_v2;
pub use agent_tool::create_list_agents_tool;
pub use agent_tool::create_resume_agent_tool;
pub use agent_tool::create_send_input_tool_v1;
pub use agent_tool::create_send_message_tool;
pub use agent_tool::create_spawn_agent_tool_v1;
pub use agent_tool::create_spawn_agent_tool_v2;
pub use agent_tool::create_wait_agent_tool_v1;
pub use agent_tool::create_wait_agent_tool_v2;
pub use code_mode::augment_tool_spec_for_code_mode;
pub use code_mode::tool_spec_to_code_mode_tool_definition;
pub use dynamic_tool::parse_dynamic_tool;
pub use json_schema::AdditionalProperties;
pub use json_schema::JsonSchema;
pub use json_schema::parse_tool_input_schema;
pub use local_tool::CommandToolOptions;
pub use local_tool::ShellToolOptions;
pub use local_tool::create_exec_command_tool;
pub use local_tool::create_request_permissions_tool;
pub use local_tool::create_shell_command_tool;
pub use local_tool::create_shell_tool;
pub use local_tool::create_write_stdin_tool;
pub use mcp_tool::mcp_call_tool_result_output_schema;
pub use mcp_tool::parse_mcp_tool;
pub use request_user_input_tool::create_request_user_input_tool;
pub use responses_api::FreeformTool;
pub use responses_api::FreeformToolFormat;
pub use responses_api::ResponsesApiNamespace;
@@ -24,3 +56,10 @@ pub use responses_api::mcp_tool_to_deferred_responses_api_tool;
pub use responses_api::mcp_tool_to_responses_api_tool;
pub use responses_api::tool_definition_to_responses_api_tool;
pub use tool_definition::ToolDefinition;
pub use tool_spec::ConfiguredToolSpec;
pub use tool_spec::ResponsesApiWebSearchFilters;
pub use tool_spec::ResponsesApiWebSearchUserLocation;
pub use tool_spec::ToolSpec;
pub use tool_spec::create_tools_json_for_responses_api;
pub use view_image::ViewImageToolOptions;
pub use view_image::create_view_image_tool;
+463
View File
@@ -0,0 +1,463 @@
use crate::JsonSchema;
use crate::ResponsesApiTool;
use crate::ToolSpec;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CommandToolOptions {
pub allow_login_shell: bool,
pub exec_permission_approvals_enabled: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShellToolOptions {
pub exec_permission_approvals_enabled: bool,
}
pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec {
let mut properties = BTreeMap::from([
(
"cmd".to_string(),
JsonSchema::String {
description: Some("Shell command to execute.".to_string()),
},
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some(
"Optional working directory to run the command in; defaults to the turn cwd."
.to_string(),
),
},
),
(
"shell".to_string(),
JsonSchema::String {
description: Some(
"Shell binary to launch. Defaults to the user's default shell.".to_string(),
),
},
),
(
"tty".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to true to open a PTY and access TTY process."
.to_string(),
),
},
),
(
"yield_time_ms".to_string(),
JsonSchema::Number {
description: Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
),
},
),
(
"max_output_tokens".to_string(),
JsonSchema::Number {
description: Some(
"Maximum number of tokens to return. Excess output will be truncated."
.to_string(),
),
},
),
]);
if options.allow_login_shell {
properties.insert(
"login".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
),
},
);
}
properties.extend(create_approval_parameters(
options.exec_permission_approvals_enabled,
));
ToolSpec::Function(ResponsesApiTool {
name: "exec_command".to_string(),
description: if cfg!(windows) {
format!(
"Runs a command in a PTY, returning output or a session ID for ongoing interaction.\n\n{}",
windows_destructive_filesystem_guidance()
)
} else {
"Runs a command in a PTY, returning output or a session ID for ongoing interaction."
.to_string()
},
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["cmd".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(unified_exec_output_schema()),
})
}
pub fn create_write_stdin_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"session_id".to_string(),
JsonSchema::Number {
description: Some("Identifier of the running unified exec session.".to_string()),
},
),
(
"chars".to_string(),
JsonSchema::String {
description: Some("Bytes to write to stdin (may be empty to poll).".to_string()),
},
),
(
"yield_time_ms".to_string(),
JsonSchema::Number {
description: Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
),
},
),
(
"max_output_tokens".to_string(),
JsonSchema::Number {
description: Some(
"Maximum number of tokens to return. Excess output will be truncated."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "write_stdin".to_string(),
description:
"Writes characters to an existing unified exec session and returns recent output."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["session_id".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(unified_exec_output_schema()),
})
}
pub fn create_shell_tool(options: ShellToolOptions) -> ToolSpec {
let mut properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some("The command to execute".to_string()),
},
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
},
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
),
]);
properties.extend(create_approval_parameters(
options.exec_permission_approvals_enabled,
));
let description = if cfg!(windows) {
format!(
r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"].
Examples of valid command strings:
- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"]
- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"]
- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"]
- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"]
- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"]
- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]
{}"#,
windows_destructive_filesystem_guidance()
)
} else {
r#"Runs a shell command and returns its output.
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."#
.to_string()
};
ToolSpec::Function(ResponsesApiTool {
name: "shell".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
pub fn create_shell_command_tool(options: CommandToolOptions) -> ToolSpec {
let mut properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::String {
description: Some(
"The shell script to execute in the user's default shell".to_string(),
),
},
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
},
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
),
]);
if options.allow_login_shell {
properties.insert(
"login".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to run the shell with login shell semantics. Defaults to true."
.to_string(),
),
},
);
}
properties.extend(create_approval_parameters(
options.exec_permission_approvals_enabled,
));
let description = if cfg!(windows) {
format!(
r#"Runs a Powershell command (Windows) and returns its output.
Examples of valid command strings:
- ls -a (show hidden): "Get-ChildItem -Force"
- recursive find by name: "Get-ChildItem -Recurse -Filter *.py"
- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"
- ps aux | grep python: "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"
- setting an env var: "$env:FOO='bar'; echo $env:FOO"
- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -"
{}"#,
windows_destructive_filesystem_guidance()
)
} else {
r#"Runs a shell command and returns its output.
- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#
.to_string()
};
ToolSpec::Function(ResponsesApiTool {
name: "shell_command".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
pub fn create_request_permissions_tool(description: String) -> ToolSpec {
let properties = BTreeMap::from([
(
"reason".to_string(),
JsonSchema::String {
description: Some(
"Optional short explanation for why additional permissions are needed."
.to_string(),
),
},
),
("permissions".to_string(), permission_profile_schema()),
]);
ToolSpec::Function(ResponsesApiTool {
name: "request_permissions".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["permissions".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
fn unified_exec_output_schema() -> Value {
json!({
"type": "object",
"properties": {
"chunk_id": {
"type": "string",
"description": "Chunk identifier included when the response reports one."
},
"wall_time_seconds": {
"type": "number",
"description": "Elapsed wall time spent waiting for output in seconds."
},
"exit_code": {
"type": "number",
"description": "Process exit code when the command finished during this call."
},
"session_id": {
"type": "number",
"description": "Session identifier to pass to write_stdin when the process is still running."
},
"original_token_count": {
"type": "number",
"description": "Approximate token count before output truncation."
},
"output": {
"type": "string",
"description": "Command output text, possibly truncated."
}
},
"required": ["wall_time_seconds", "output"],
"additionalProperties": false
})
}
fn create_approval_parameters(
exec_permission_approvals_enabled: bool,
) -> BTreeMap<String, JsonSchema> {
let mut properties = BTreeMap::from([
(
"sandbox_permissions".to_string(),
JsonSchema::String {
description: Some(
if exec_permission_approvals_enabled {
"Sandbox permissions for the command. Use \"with_additional_permissions\" to request additional sandboxed filesystem or network permissions (preferred), or \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
} else {
"Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
}
.to_string(),
),
},
),
(
"justification".to_string(),
JsonSchema::String {
description: Some(
r#"Only set if sandbox_permissions is \"require_escalated\".
Request approval from the user to run this command outside the sandbox.
Phrased as a simple question that summarizes the purpose of the
command as it relates to the task at hand - e.g. 'Do you want to
fetch and pull the latest version of this git branch?'"#
.to_string(),
),
},
),
(
"prefix_rule".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
r#"Only specify when sandbox_permissions is `require_escalated`.
Suggest a prefix command pattern that will allow you to fulfill similar requests from the user in the future.
Should be a short but reasonable prefix, e.g. [\"git\", \"pull\"] or [\"uv\", \"run\"] or [\"pytest\"]."#.to_string(),
),
},
),
]);
if exec_permission_approvals_enabled {
properties.insert(
"additional_permissions".to_string(),
permission_profile_schema(),
);
}
properties
}
fn permission_profile_schema() -> JsonSchema {
JsonSchema::Object {
properties: BTreeMap::from([
("network".to_string(), network_permissions_schema()),
("file_system".to_string(), file_system_permissions_schema()),
]),
required: None,
additional_properties: Some(false.into()),
}
}
fn network_permissions_schema() -> JsonSchema {
JsonSchema::Object {
properties: BTreeMap::from([(
"enabled".to_string(),
JsonSchema::Boolean {
description: Some("Set to true to request network access.".to_string()),
},
)]),
required: None,
additional_properties: Some(false.into()),
}
}
fn file_system_permissions_schema() -> JsonSchema {
JsonSchema::Object {
properties: BTreeMap::from([
(
"read".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some("Absolute paths to grant read access to.".to_string()),
},
),
(
"write".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some("Absolute paths to grant write access to.".to_string()),
},
),
]),
required: None,
additional_properties: Some(false.into()),
}
}
fn windows_destructive_filesystem_guidance() -> &'static str {
r#"Windows safety rules:
- Do not compose destructive filesystem commands across shells. Do not enumerate paths in PowerShell and then pass them to `cmd /c`, batch builtins, or another shell for deletion or moving. Use one shell end-to-end, prefer native PowerShell cmdlets such as `Remove-Item` / `Move-Item` with `-LiteralPath`, and avoid string-built shell commands for file operations.
- Before any recursive delete or move on Windows, verify the resolved absolute target paths stay within the intended workspace or explicitly named target directory. Never issue a recursive delete or move against a computed path if the final target has not been checked."#
}
#[cfg(test)]
#[path = "local_tool_tests.rs"]
mod tests;
+442
View File
@@ -0,0 +1,442 @@
use super::*;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
fn windows_shell_safety_description() -> String {
format!("\n\n{}", windows_destructive_filesystem_guidance())
}
#[test]
fn shell_tool_matches_expected_spec() {
let tool = create_shell_tool(ShellToolOptions {
exec_permission_approvals_enabled: false,
});
let description = if cfg!(windows) {
r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"].
Examples of valid command strings:
- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"]
- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"]
- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"]
- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"]
- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"]
- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]"#
.to_string()
+ &windows_shell_safety_description()
} else {
r#"Runs a shell command and returns its output.
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."#
.to_string()
};
let properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some("The command to execute".to_string()),
},
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
},
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
),
(
"sandbox_permissions".to_string(),
JsonSchema::String {
description: Some(
"Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
.to_string(),
),
},
),
(
"justification".to_string(),
JsonSchema::String {
description: Some(
r#"Only set if sandbox_permissions is \"require_escalated\".
Request approval from the user to run this command outside the sandbox.
Phrased as a simple question that summarizes the purpose of the
command as it relates to the task at hand - e.g. 'Do you want to
fetch and pull the latest version of this git branch?'"#
.to_string(),
),
},
),
(
"prefix_rule".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
r#"Only specify when sandbox_permissions is `require_escalated`.
Suggest a prefix command pattern that will allow you to fulfill similar requests from the user in the future.
Should be a short but reasonable prefix, e.g. [\"git\", \"pull\"] or [\"uv\", \"run\"] or [\"pytest\"]."#
.to_string(),
),
},
),
]);
assert_eq!(
tool,
ToolSpec::Function(ResponsesApiTool {
name: "shell".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
);
}
#[test]
fn exec_command_tool_matches_expected_spec() {
let tool = create_exec_command_tool(CommandToolOptions {
allow_login_shell: true,
exec_permission_approvals_enabled: false,
});
let description = if cfg!(windows) {
format!(
"Runs a command in a PTY, returning output or a session ID for ongoing interaction.{}",
windows_shell_safety_description()
)
} else {
"Runs a command in a PTY, returning output or a session ID for ongoing interaction."
.to_string()
};
let mut properties = BTreeMap::from([
(
"cmd".to_string(),
JsonSchema::String {
description: Some("Shell command to execute.".to_string()),
},
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some(
"Optional working directory to run the command in; defaults to the turn cwd."
.to_string(),
),
},
),
(
"shell".to_string(),
JsonSchema::String {
description: Some(
"Shell binary to launch. Defaults to the user's default shell.".to_string(),
),
},
),
(
"tty".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to true to open a PTY and access TTY process."
.to_string(),
),
},
),
(
"yield_time_ms".to_string(),
JsonSchema::Number {
description: Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
),
},
),
(
"max_output_tokens".to_string(),
JsonSchema::Number {
description: Some(
"Maximum number of tokens to return. Excess output will be truncated."
.to_string(),
),
},
),
(
"login".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
),
},
),
]);
properties.extend(create_approval_parameters(
/*exec_permission_approvals_enabled*/ false,
));
assert_eq!(
tool,
ToolSpec::Function(ResponsesApiTool {
name: "exec_command".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["cmd".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(unified_exec_output_schema()),
})
);
}
#[test]
fn write_stdin_tool_matches_expected_spec() {
let tool = create_write_stdin_tool();
let properties = BTreeMap::from([
(
"session_id".to_string(),
JsonSchema::Number {
description: Some("Identifier of the running unified exec session.".to_string()),
},
),
(
"chars".to_string(),
JsonSchema::String {
description: Some("Bytes to write to stdin (may be empty to poll).".to_string()),
},
),
(
"yield_time_ms".to_string(),
JsonSchema::Number {
description: Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
),
},
),
(
"max_output_tokens".to_string(),
JsonSchema::Number {
description: Some(
"Maximum number of tokens to return. Excess output will be truncated."
.to_string(),
),
},
),
]);
assert_eq!(
tool,
ToolSpec::Function(ResponsesApiTool {
name: "write_stdin".to_string(),
description:
"Writes characters to an existing unified exec session and returns recent output."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["session_id".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(unified_exec_output_schema()),
})
);
}
#[test]
fn shell_tool_with_request_permission_includes_additional_permissions() {
let tool = create_shell_tool(ShellToolOptions {
exec_permission_approvals_enabled: true,
});
let mut properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some("The command to execute".to_string()),
},
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
},
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
),
]);
properties.extend(create_approval_parameters(
/*exec_permission_approvals_enabled*/ true,
));
let description = if cfg!(windows) {
format!(
r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"].
Examples of valid command strings:
- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"]
- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"]
- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"]
- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"]
- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"]
- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]
{}"#,
windows_destructive_filesystem_guidance()
)
} else {
r#"Runs a shell command and returns its output.
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."#
.to_string()
};
assert_eq!(
tool,
ToolSpec::Function(ResponsesApiTool {
name: "shell".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
);
}
#[test]
fn request_permissions_tool_includes_full_permission_schema() {
let tool =
create_request_permissions_tool("Request extra permissions for this turn.".to_string());
let properties = BTreeMap::from([
(
"reason".to_string(),
JsonSchema::String {
description: Some(
"Optional short explanation for why additional permissions are needed."
.to_string(),
),
},
),
("permissions".to_string(), permission_profile_schema()),
]);
assert_eq!(
tool,
ToolSpec::Function(ResponsesApiTool {
name: "request_permissions".to_string(),
description: "Request extra permissions for this turn.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["permissions".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
);
}
#[test]
fn shell_command_tool_matches_expected_spec() {
let tool = create_shell_command_tool(CommandToolOptions {
allow_login_shell: true,
exec_permission_approvals_enabled: false,
});
let description = if cfg!(windows) {
r#"Runs a Powershell command (Windows) and returns its output.
Examples of valid command strings:
- ls -a (show hidden): "Get-ChildItem -Force"
- recursive find by name: "Get-ChildItem -Recurse -Filter *.py"
- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"
- ps aux | grep python: "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"
- setting an env var: "$env:FOO='bar'; echo $env:FOO"
- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -""#
.to_string()
+ &windows_shell_safety_description()
} else {
r#"Runs a shell command and returns its output.
- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#
.to_string()
};
let mut properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::String {
description: Some(
"The shell script to execute in the user's default shell".to_string(),
),
},
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
},
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
),
(
"login".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to run the shell with login shell semantics. Defaults to true."
.to_string(),
),
},
),
]);
properties.extend(create_approval_parameters(
/*exec_permission_approvals_enabled*/ false,
));
assert_eq!(
tool,
ToolSpec::Function(ResponsesApiTool {
name: "shell_command".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
);
}
@@ -0,0 +1,94 @@
use crate::JsonSchema;
use crate::ResponsesApiTool;
use crate::ToolSpec;
use std::collections::BTreeMap;
pub fn create_request_user_input_tool(description: String) -> ToolSpec {
let option_props = BTreeMap::from([
(
"label".to_string(),
JsonSchema::String {
description: Some("User-facing label (1-5 words).".to_string()),
},
),
(
"description".to_string(),
JsonSchema::String {
description: Some(
"One short sentence explaining impact/tradeoff if selected.".to_string(),
),
},
),
]);
let options_schema = JsonSchema::Array {
description: Some(
"Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; the client will add a free-form \"Other\" option automatically."
.to_string(),
),
items: Box::new(JsonSchema::Object {
properties: option_props,
required: Some(vec!["label".to_string(), "description".to_string()]),
additional_properties: Some(false.into()),
}),
};
let question_props = BTreeMap::from([
(
"id".to_string(),
JsonSchema::String {
description: Some(
"Stable identifier for mapping answers (snake_case).".to_string(),
),
},
),
(
"header".to_string(),
JsonSchema::String {
description: Some(
"Short header label shown in the UI (12 or fewer chars).".to_string(),
),
},
),
(
"question".to_string(),
JsonSchema::String {
description: Some("Single-sentence prompt shown to the user.".to_string()),
},
),
("options".to_string(), options_schema),
]);
let questions_schema = JsonSchema::Array {
description: Some("Questions to show the user. Prefer 1 and do not exceed 3".to_string()),
items: Box::new(JsonSchema::Object {
properties: question_props,
required: Some(vec![
"id".to_string(),
"header".to_string(),
"question".to_string(),
"options".to_string(),
]),
additional_properties: Some(false.into()),
}),
};
let properties = BTreeMap::from([("questions".to_string(), questions_schema)]);
ToolSpec::Function(ResponsesApiTool {
name: "request_user_input".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["questions".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
#[cfg(test)]
#[path = "request_user_input_tool_tests.rs"]
mod tests;
@@ -0,0 +1,102 @@
use super::*;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
#[test]
fn request_user_input_tool_includes_questions_schema() {
assert_eq!(
create_request_user_input_tool("Ask the user to choose.".to_string()),
ToolSpec::Function(ResponsesApiTool {
name: "request_user_input".to_string(),
description: "Ask the user to choose.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"questions".to_string(),
JsonSchema::Array {
description: Some(
"Questions to show the user. Prefer 1 and do not exceed 3".to_string(),
),
items: Box::new(JsonSchema::Object {
properties: BTreeMap::from([
(
"header".to_string(),
JsonSchema::String {
description: Some(
"Short header label shown in the UI (12 or fewer chars)."
.to_string(),
),
},
),
(
"id".to_string(),
JsonSchema::String {
description: Some(
"Stable identifier for mapping answers (snake_case)."
.to_string(),
),
},
),
(
"options".to_string(),
JsonSchema::Array {
description: Some(
"Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; the client will add a free-form \"Other\" option automatically."
.to_string(),
),
items: Box::new(JsonSchema::Object {
properties: BTreeMap::from([
(
"description".to_string(),
JsonSchema::String {
description: Some(
"One short sentence explaining impact/tradeoff if selected."
.to_string(),
),
},
),
(
"label".to_string(),
JsonSchema::String {
description: Some(
"User-facing label (1-5 words)."
.to_string(),
),
},
),
]),
required: Some(vec![
"label".to_string(),
"description".to_string(),
]),
additional_properties: Some(false.into()),
}),
},
),
(
"question".to_string(),
JsonSchema::String {
description: Some(
"Single-sentence prompt shown to the user.".to_string(),
),
},
),
]),
required: Some(vec![
"id".to_string(),
"header".to_string(),
"question".to_string(),
"options".to_string(),
]),
additional_properties: Some(false.into()),
}),
},
)]),
required: Some(vec!["questions".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
);
}
+145
View File
@@ -0,0 +1,145 @@
use crate::FreeformTool;
use crate::JsonSchema;
use crate::ResponsesApiNamespace;
use crate::ResponsesApiTool;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
use codex_protocol::config_types::WebSearchUserLocationType;
use serde::Serialize;
use serde_json::Value;
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
/// Responses API.
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "type")]
pub enum ToolSpec {
#[serde(rename = "function")]
Function(ResponsesApiTool),
#[serde(rename = "namespace")]
Namespace(ResponsesApiNamespace),
#[serde(rename = "tool_search")]
ToolSearch {
execution: String,
description: String,
parameters: JsonSchema,
},
#[serde(rename = "local_shell")]
LocalShell {},
#[serde(rename = "image_generation")]
ImageGeneration { output_format: String },
// TODO: Understand why we get an error on web_search although the API docs
// say it's supported.
// https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C
// The `external_web_access` field determines whether the web search is over
// cached or live content.
// https://platform.openai.com/docs/guides/tools-web-search#live-internet-access
#[serde(rename = "web_search")]
WebSearch {
#[serde(skip_serializing_if = "Option::is_none")]
external_web_access: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
filters: Option<ResponsesApiWebSearchFilters>,
#[serde(skip_serializing_if = "Option::is_none")]
user_location: Option<ResponsesApiWebSearchUserLocation>,
#[serde(skip_serializing_if = "Option::is_none")]
search_context_size: Option<WebSearchContextSize>,
#[serde(skip_serializing_if = "Option::is_none")]
search_content_types: Option<Vec<String>>,
},
#[serde(rename = "custom")]
Freeform(FreeformTool),
}
impl ToolSpec {
pub fn name(&self) -> &str {
match self {
ToolSpec::Function(tool) => tool.name.as_str(),
ToolSpec::Namespace(namespace) => namespace.name.as_str(),
ToolSpec::ToolSearch { .. } => "tool_search",
ToolSpec::LocalShell {} => "local_shell",
ToolSpec::ImageGeneration { .. } => "image_generation",
ToolSpec::WebSearch { .. } => "web_search",
ToolSpec::Freeform(tool) => tool.name.as_str(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ConfiguredToolSpec {
pub spec: ToolSpec,
pub supports_parallel_tool_calls: bool,
}
impl ConfiguredToolSpec {
pub fn new(spec: ToolSpec, supports_parallel_tool_calls: bool) -> Self {
Self {
spec,
supports_parallel_tool_calls,
}
}
pub fn name(&self) -> &str {
self.spec.name()
}
}
/// Returns JSON values that are compatible with Function Calling in the
/// Responses API:
/// https://platform.openai.com/docs/guides/function-calling?api-mode=responses
pub fn create_tools_json_for_responses_api(
tools: &[ToolSpec],
) -> Result<Vec<Value>, serde_json::Error> {
let mut tools_json = Vec::new();
for tool in tools {
let json = serde_json::to_value(tool)?;
tools_json.push(json);
}
Ok(tools_json)
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ResponsesApiWebSearchFilters {
#[serde(skip_serializing_if = "Option::is_none")]
pub allowed_domains: Option<Vec<String>>,
}
impl From<ConfigWebSearchFilters> for ResponsesApiWebSearchFilters {
fn from(filters: ConfigWebSearchFilters) -> Self {
Self {
allowed_domains: filters.allowed_domains,
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ResponsesApiWebSearchUserLocation {
#[serde(rename = "type")]
pub r#type: WebSearchUserLocationType,
#[serde(skip_serializing_if = "Option::is_none")]
pub country: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub city: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
}
impl From<ConfigWebSearchUserLocation> for ResponsesApiWebSearchUserLocation {
fn from(user_location: ConfigWebSearchUserLocation) -> Self {
Self {
r#type: user_location.r#type,
country: user_location.country,
region: user_location.region,
city: user_location.city,
timezone: user_location.timezone,
}
}
}
#[cfg(test)]
#[path = "tool_spec_tests.rs"]
mod tests;
+270
View File
@@ -0,0 +1,270 @@
use super::ConfiguredToolSpec;
use super::ResponsesApiWebSearchFilters;
use super::ResponsesApiWebSearchUserLocation;
use super::ToolSpec;
use crate::AdditionalProperties;
use crate::FreeformTool;
use crate::FreeformToolFormat;
use crate::JsonSchema;
use crate::ResponsesApiNamespace;
use crate::ResponsesApiTool;
use crate::create_tools_json_for_responses_api;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
use codex_protocol::config_types::WebSearchUserLocationType;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
#[test]
fn tool_spec_name_covers_all_variants() {
assert_eq!(
ToolSpec::Function(ResponsesApiTool {
name: "lookup_order".to_string(),
description: "Look up an order".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
output_schema: None,
})
.name(),
"lookup_order"
);
assert_eq!(
ToolSpec::ToolSearch {
execution: "sync".to_string(),
description: "Search for tools".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
}
.name(),
"tool_search"
);
assert_eq!(
ToolSpec::Namespace(ResponsesApiNamespace {
name: "agents".to_string(),
description: "Agent tools".to_string(),
tools: Vec::new(),
})
.name(),
"agents"
);
assert_eq!(ToolSpec::LocalShell {}.name(), "local_shell");
assert_eq!(
ToolSpec::ImageGeneration {
output_format: "png".to_string(),
}
.name(),
"image_generation"
);
assert_eq!(
ToolSpec::WebSearch {
external_web_access: Some(true),
filters: None,
user_location: None,
search_context_size: None,
search_content_types: None,
}
.name(),
"web_search"
);
assert_eq!(
ToolSpec::Freeform(FreeformTool {
name: "exec".to_string(),
description: "Run a command".to_string(),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: "start: \"exec\"".to_string(),
},
})
.name(),
"exec"
);
}
#[test]
fn configured_tool_spec_name_delegates_to_tool_spec() {
assert_eq!(
ConfiguredToolSpec::new(
ToolSpec::Function(ResponsesApiTool {
name: "lookup_order".to_string(),
description: "Look up an order".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
output_schema: None,
}),
/*supports_parallel_tool_calls*/ true,
)
.name(),
"lookup_order"
);
}
#[test]
fn web_search_config_converts_to_responses_api_types() {
assert_eq!(
ResponsesApiWebSearchFilters::from(ConfigWebSearchFilters {
allowed_domains: Some(vec!["example.com".to_string()]),
}),
ResponsesApiWebSearchFilters {
allowed_domains: Some(vec!["example.com".to_string()]),
}
);
assert_eq!(
ResponsesApiWebSearchUserLocation::from(ConfigWebSearchUserLocation {
r#type: WebSearchUserLocationType::Approximate,
country: Some("US".to_string()),
region: Some("California".to_string()),
city: Some("San Francisco".to_string()),
timezone: Some("America/Los_Angeles".to_string()),
}),
ResponsesApiWebSearchUserLocation {
r#type: WebSearchUserLocationType::Approximate,
country: Some("US".to_string()),
region: Some("California".to_string()),
city: Some("San Francisco".to_string()),
timezone: Some("America/Los_Angeles".to_string()),
}
);
}
#[test]
fn create_tools_json_for_responses_api_includes_top_level_name() {
assert_eq!(
create_tools_json_for_responses_api(&[ToolSpec::Function(ResponsesApiTool {
name: "demo".to_string(),
description: "A demo tool".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"foo".to_string(),
JsonSchema::String { description: None },
)]),
required: None,
additional_properties: None,
},
output_schema: None,
})])
.expect("serialize tools"),
vec![json!({
"type": "function",
"name": "demo",
"description": "A demo tool",
"strict": false,
"parameters": {
"type": "object",
"properties": {
"foo": { "type": "string" }
},
},
})]
);
}
#[test]
fn namespace_tool_spec_serializes_expected_wire_shape() {
assert_eq!(
serde_json::to_value(ToolSpec::Namespace(ResponsesApiNamespace {
name: "agents".to_string(),
description: "Agent collaboration tools.".to_string(),
tools: Vec::new(),
}))
.expect("serialize namespace"),
json!({
"type": "namespace",
"name": "agents",
"description": "Agent collaboration tools.",
"tools": []
})
);
}
#[test]
fn web_search_tool_spec_serializes_expected_wire_shape() {
assert_eq!(
serde_json::to_value(ToolSpec::WebSearch {
external_web_access: Some(true),
filters: Some(ResponsesApiWebSearchFilters {
allowed_domains: Some(vec!["example.com".to_string()]),
}),
user_location: Some(ResponsesApiWebSearchUserLocation {
r#type: WebSearchUserLocationType::Approximate,
country: Some("US".to_string()),
region: Some("California".to_string()),
city: Some("San Francisco".to_string()),
timezone: Some("America/Los_Angeles".to_string()),
}),
search_context_size: Some(WebSearchContextSize::High),
search_content_types: Some(vec!["text".to_string(), "image".to_string()]),
})
.expect("serialize web_search"),
json!({
"type": "web_search",
"external_web_access": true,
"filters": {
"allowed_domains": ["example.com"],
},
"user_location": {
"type": "approximate",
"country": "US",
"region": "California",
"city": "San Francisco",
"timezone": "America/Los_Angeles",
},
"search_context_size": "high",
"search_content_types": ["text", "image"],
})
);
}
#[test]
fn tool_search_tool_spec_serializes_expected_wire_shape() {
assert_eq!(
serde_json::to_value(ToolSpec::ToolSearch {
execution: "sync".to_string(),
description: "Search app tools".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"query".to_string(),
JsonSchema::String {
description: Some("Tool search query".to_string()),
},
)]),
required: Some(vec!["query".to_string()]),
additional_properties: Some(AdditionalProperties::Boolean(false)),
},
})
.expect("serialize tool_search"),
json!({
"type": "tool_search",
"execution": "sync",
"description": "Search app tools",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Tool search query",
}
},
"required": ["query"],
"additionalProperties": false,
},
})
);
}
+67
View File
@@ -0,0 +1,67 @@
use crate::JsonSchema;
use crate::ResponsesApiTool;
use crate::ToolSpec;
use codex_protocol::models::VIEW_IMAGE_TOOL_NAME;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ViewImageToolOptions {
pub can_request_original_image_detail: bool,
}
pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec {
let mut properties = BTreeMap::from([(
"path".to_string(),
JsonSchema::String {
description: Some("Local filesystem path to an image file".to_string()),
},
)]);
if options.can_request_original_image_detail {
properties.insert(
"detail".to_string(),
JsonSchema::String {
description: Some(
"Optional detail override. The only supported value is `original`; omit this field for default resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.".to_string(),
),
},
);
}
ToolSpec::Function(ResponsesApiTool {
name: VIEW_IMAGE_TOOL_NAME.to_string(),
description: "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags)."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["path".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(view_image_output_schema()),
})
}
fn view_image_output_schema() -> Value {
json!({
"type": "object",
"properties": {
"image_url": {
"type": "string",
"description": "Data URL for the loaded image."
},
"detail": {
"type": ["string", "null"],
"description": "Image detail hint returned by view_image. Returns `original` when original resolution is preserved, otherwise `null`."
}
},
"required": ["image_url", "detail"],
"additionalProperties": false
})
}
#[cfg(test)]
#[path = "view_image_tests.rs"]
mod tests;
+67
View File
@@ -0,0 +1,67 @@
use super::*;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
#[test]
fn view_image_tool_omits_detail_without_original_detail_feature() {
assert_eq!(
create_view_image_tool(ViewImageToolOptions {
can_request_original_image_detail: false,
}),
ToolSpec::Function(ResponsesApiTool {
name: "view_image".to_string(),
description: "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags)."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"path".to_string(),
JsonSchema::String {
description: Some("Local filesystem path to an image file".to_string()),
},
)]),
required: Some(vec!["path".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(view_image_output_schema()),
})
);
}
#[test]
fn view_image_tool_includes_detail_with_original_detail_feature() {
assert_eq!(
create_view_image_tool(ViewImageToolOptions {
can_request_original_image_detail: true,
}),
ToolSpec::Function(ResponsesApiTool {
name: "view_image".to_string(),
description: "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags)."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([
(
"detail".to_string(),
JsonSchema::String {
description: Some(
"Optional detail override. The only supported value is `original`; omit this field for default resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.".to_string(),
),
},
),
(
"path".to_string(),
JsonSchema::String {
description: Some("Local filesystem path to an image file".to_string()),
},
),
]),
required: Some(vec!["path".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(view_image_output_schema()),
})
);
}
+2 -4
View File
@@ -23,7 +23,7 @@ default = ["voice-input"]
vt100-tests = []
# Gate verbose debug logging inside the TUI implementation.
debug-logs = []
voice-input = ["dep:cpal", "dep:hound"]
voice-input = ["dep:cpal"]
[lints]
workspace = true
@@ -38,7 +38,6 @@ codex-app-server-client = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-arg0 = { workspace = true }
codex-chatgpt = { workspace = true }
codex-client = { workspace = true }
codex-cloud-requirements = { workspace = true }
codex-core = { workspace = true }
codex-features = { workspace = true }
@@ -80,7 +79,7 @@ ratatui = { workspace = true, features = [
] }
ratatui-macros = { workspace = true }
regex-lite = { workspace = true }
reqwest = { workspace = true, features = ["json", "multipart"] }
reqwest = { workspace = true, features = ["json"] }
rmcp = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["preserve_order"] }
@@ -118,7 +117,6 @@ tokio-util = { workspace = true, features = ["time"] }
[target.'cfg(not(target_os = "linux"))'.dependencies]
cpal = { version = "0.15", optional = true }
hound = { version = "3.5", optional = true }
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
+1 -9
View File
@@ -5028,17 +5028,9 @@ impl App {
}
},
#[cfg(not(target_os = "linux"))]
AppEvent::TranscriptionComplete { id, text } => {
self.chat_widget.replace_transcription(&id, &text);
}
#[cfg(not(target_os = "linux"))]
AppEvent::TranscriptionFailed { id, error: _ } => {
self.chat_widget.remove_transcription_placeholder(&id);
}
#[cfg(not(target_os = "linux"))]
AppEvent::UpdateRecordingMeter { id, text } => {
// Update in place to preserve the element id for subsequent frames.
let updated = self.chat_widget.update_transcription_in_place(&id, &text);
let updated = self.chat_widget.update_recording_meter_in_place(&id, &text);
if updated
|| self
.chat_widget
-16
View File
@@ -489,22 +489,6 @@ pub(crate) enum AppEvent {
text: String,
},
/// Voice transcription finished for the given placeholder id.
#[cfg(not(target_os = "linux"))]
#[cfg_attr(not(feature = "voice-input"), allow(dead_code))]
TranscriptionComplete {
id: String,
text: String,
},
/// Voice transcription failed; remove the placeholder identified by `id`.
#[cfg(not(target_os = "linux"))]
TranscriptionFailed {
id: String,
#[allow(dead_code)]
error: String,
},
/// Open the branch picker option from the review popup.
OpenReviewBranchPicker(PathBuf),
File diff suppressed because it is too large Load Diff
+75 -251
View File
@@ -10,27 +10,21 @@ use super::slash_commands;
use crate::render::Insets;
use crate::render::RectExt;
use crate::slash_command::SlashCommand;
use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
use std::collections::HashSet;
// Hide alias commands in the default popup list so each unique action appears once.
// `quit` is an alias of `exit`, so we skip `quit` here.
// `approvals` is an alias of `permissions`.
const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals];
/// A selectable item in the popup: either a built-in command or a user prompt.
/// A selectable item in the popup.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CommandItem {
Builtin(SlashCommand),
// Index into `prompts`
UserPrompt(usize),
}
pub(crate) struct CommandPopup {
command_filter: String,
builtins: Vec<(&'static str, SlashCommand)>,
prompts: Vec<CustomPrompt>,
state: ScrollState,
}
@@ -62,7 +56,7 @@ impl From<CommandPopupFlags> for slash_commands::BuiltinCommandFlags {
}
impl CommandPopup {
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, flags: CommandPopupFlags) -> Self {
pub(crate) fn new(flags: CommandPopupFlags) -> Self {
// Keep built-in availability in sync with the composer.
let builtins: Vec<(&'static str, SlashCommand)> =
slash_commands::builtins_for_input(flags.into())
@@ -70,34 +64,13 @@ impl CommandPopup {
.filter(|(name, _)| !name.starts_with("debug"))
.filter(|(_, cmd)| *cmd != SlashCommand::Apps)
.collect();
// Exclude prompts that collide with builtin command names and sort by name.
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
prompts.retain(|p| !exclude.contains(&p.name));
prompts.sort_by(|a, b| a.name.cmp(&b.name));
Self {
command_filter: String::new(),
builtins,
prompts,
state: ScrollState::new(),
}
}
#[cfg(test)]
pub(crate) fn set_prompts(&mut self, mut prompts: Vec<CustomPrompt>) {
let exclude: HashSet<String> = self
.builtins
.iter()
.map(|(n, _)| (*n).to_string())
.collect();
prompts.retain(|p| !exclude.contains(&p.name));
prompts.sort_by(|a, b| a.name.cmp(&b.name));
self.prompts = prompts;
}
pub(crate) fn prompt(&self, idx: usize) -> Option<&CustomPrompt> {
self.prompts.get(idx)
}
/// Update the filter string based on the current composer text. The text
/// passed in is expected to start with a leading '/'. Everything after the
/// *first* '/' on the *first* line becomes the active filter that is used
@@ -145,17 +118,12 @@ impl CommandPopup {
let filter = self.command_filter.trim();
let mut out: Vec<(CommandItem, Option<Vec<usize>>)> = Vec::new();
if filter.is_empty() {
// Built-ins first, in presentation order.
for (_, cmd) in self.builtins.iter() {
if ALIAS_COMMANDS.contains(cmd) {
continue;
}
out.push((CommandItem::Builtin(*cmd), None));
}
// Then prompts, already sorted by name.
for idx in 0..self.prompts.len() {
out.push((CommandItem::UserPrompt(idx), None));
}
return out;
}
@@ -163,7 +131,6 @@ impl CommandPopup {
let filter_chars = filter.chars().count();
let mut exact: Vec<(CommandItem, Option<Vec<usize>>)> = Vec::new();
let mut prefix: Vec<(CommandItem, Option<Vec<usize>>)> = Vec::new();
let prompt_prefix_len = PROMPTS_CMD_PREFIX.chars().count() + 1;
let indices_for = |offset| Some((offset..offset + filter_chars).collect());
let mut push_match =
@@ -190,18 +157,6 @@ impl CommandPopup {
for (_, cmd) in self.builtins.iter() {
push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0);
}
// Support both search styles:
// - Typing "name" should surface "/prompts:name" results.
// - Typing "prompts:name" should also work.
for (idx, p) in self.prompts.iter().enumerate() {
let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name);
push_match(
CommandItem::UserPrompt(idx),
&display,
Some(&p.name),
prompt_prefix_len,
);
}
out.extend(exact);
out.extend(prefix);
@@ -219,22 +174,9 @@ impl CommandPopup {
matches
.into_iter()
.map(|(item, indices)| {
let (name, description) = match item {
CommandItem::Builtin(cmd) => {
(format!("/{}", cmd.command()), cmd.description().to_string())
}
CommandItem::UserPrompt(i) => {
let prompt = &self.prompts[i];
let description = prompt
.description
.clone()
.unwrap_or_else(|| "send saved prompt".to_string());
(
format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name),
description,
)
}
};
let CommandItem::Builtin(cmd) = item;
let name = format!("/{}", cmd.command());
let description = cmd.description().to_string();
GenericDisplayRow {
name,
name_prefix_spans: Vec::new(),
@@ -297,7 +239,7 @@ mod tests {
#[test]
fn filter_includes_init_when_typing_prefix() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default());
// Simulate the composer line starting with '/in' so the popup filters
// matching commands by prefix.
popup.on_composer_text_change("/in".to_string());
@@ -307,7 +249,6 @@ mod tests {
let matches = popup.filtered_items();
let has_init = matches.iter().any(|item| match item {
CommandItem::Builtin(cmd) => cmd.command() == "init",
CommandItem::UserPrompt(_) => false,
});
assert!(
has_init,
@@ -317,7 +258,7 @@ mod tests {
#[test]
fn selecting_init_by_exact_match() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default());
popup.on_composer_text_change("/init".to_string());
// When an exact match exists, the selected command should be that
@@ -325,144 +266,46 @@ mod tests {
let selected = popup.selected_item();
match selected {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"),
Some(CommandItem::UserPrompt(_)) => panic!("unexpected prompt selected for '/init'"),
None => panic!("expected a selected command for exact match"),
}
}
#[test]
fn model_is_first_suggestion_for_mo() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default());
popup.on_composer_text_change("/mo".to_string());
let matches = popup.filtered_items();
match matches.first() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"),
Some(CommandItem::UserPrompt(_)) => {
panic!("unexpected prompt ranked before '/model' for '/mo'")
}
None => panic!("expected at least one match for '/mo'"),
}
}
#[test]
fn filtered_commands_keep_presentation_order_for_prefix() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default());
popup.on_composer_text_change("/m".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
})
.collect();
assert_eq!(cmds, vec!["model", "mention", "mcp"]);
}
#[test]
fn prompt_discovery_lists_custom_prompts() {
let prompts = vec![
CustomPrompt {
name: "foo".to_string(),
path: "/tmp/foo.md".to_string().into(),
content: "hello from foo".to_string(),
description: None,
argument_hint: None,
},
CustomPrompt {
name: "bar".to_string(),
path: "/tmp/bar.md".to_string().into(),
content: "hello from bar".to_string(),
description: None,
argument_hint: None,
},
];
let popup = CommandPopup::new(prompts, CommandPopupFlags::default());
let items = popup.filtered_items();
let mut prompt_names: Vec<String> = items
.into_iter()
.filter_map(|it| match it {
CommandItem::UserPrompt(i) => popup.prompt(i).map(|p| p.name.clone()),
_ => None,
})
.collect();
prompt_names.sort();
assert_eq!(prompt_names, vec!["bar".to_string(), "foo".to_string()]);
}
#[test]
fn prompt_name_collision_with_builtin_is_ignored() {
// Create a prompt named like a builtin (e.g. "init").
let popup = CommandPopup::new(
vec![CustomPrompt {
name: "init".to_string(),
path: "/tmp/init.md".to_string().into(),
content: "should be ignored".to_string(),
description: None,
argument_hint: None,
}],
CommandPopupFlags::default(),
);
let items = popup.filtered_items();
let has_collision_prompt = items.into_iter().any(|it| match it {
CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"),
_ => false,
});
assert!(
!has_collision_prompt,
"prompt with builtin name should be ignored"
);
}
#[test]
fn prompt_description_uses_frontmatter_metadata() {
let popup = CommandPopup::new(
vec![CustomPrompt {
name: "draftpr".to_string(),
path: "/tmp/draftpr.md".to_string().into(),
content: "body".to_string(),
description: Some("Create feature branch, commit and open draft PR.".to_string()),
argument_hint: None,
}],
CommandPopupFlags::default(),
);
let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None)]);
let description = rows.first().and_then(|row| row.description.as_deref());
assert_eq!(
description,
Some("Create feature branch, commit and open draft PR.")
);
}
#[test]
fn prompt_description_falls_back_when_missing() {
let popup = CommandPopup::new(
vec![CustomPrompt {
name: "foo".to_string(),
path: "/tmp/foo.md".to_string().into(),
content: "body".to_string(),
description: None,
argument_hint: None,
}],
CommandPopupFlags::default(),
);
let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None)]);
let description = rows.first().and_then(|row| row.description.as_deref());
assert_eq!(description, Some("send saved prompt"));
}
#[test]
fn prefix_filter_limits_matches_for_ac() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default());
popup.on_composer_text_change("/ac".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
})
.collect();
assert!(
@@ -473,7 +316,7 @@ mod tests {
#[test]
fn quit_hidden_in_empty_filter_but_shown_for_prefix() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default());
popup.on_composer_text_change("/".to_string());
let items = popup.filtered_items();
assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Quit)));
@@ -485,15 +328,14 @@ mod tests {
#[test]
fn collab_command_hidden_when_collaboration_modes_disabled() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default());
popup.on_composer_text_change("/".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
})
.collect();
assert!(
@@ -508,19 +350,16 @@ mod tests {
#[test]
fn collab_command_visible_when_collaboration_modes_enabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
});
popup.on_composer_text_change("/collab".to_string());
match popup.selected_item() {
@@ -531,19 +370,16 @@ mod tests {
#[test]
fn plan_command_visible_when_collaboration_modes_enabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
});
popup.on_composer_text_change("/plan".to_string());
match popup.selected_item() {
@@ -554,27 +390,23 @@ mod tests {
#[test]
fn personality_command_hidden_when_disabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: false,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: false,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
});
popup.on_composer_text_change("/pers".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
})
.collect();
assert!(
@@ -585,19 +417,16 @@ mod tests {
#[test]
fn personality_command_visible_when_enabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
});
popup.on_composer_text_change("/personality".to_string());
match popup.selected_item() {
@@ -608,27 +437,23 @@ mod tests {
#[test]
fn settings_command_hidden_when_audio_device_selection_is_disabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: false,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: true,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: false,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: true,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
});
popup.on_composer_text_change("/aud".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
})
.collect();
@@ -640,13 +465,12 @@ mod tests {
#[test]
fn debug_commands_are_hidden_from_popup() {
let popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
let popup = CommandPopup::new(CommandPopupFlags::default());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
})
.collect();
+6 -32
View File
@@ -329,11 +329,6 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_voice_transcription_enabled(&mut self, enabled: bool) {
self.composer.set_voice_transcription_enabled(enabled);
self.request_redraw();
}
/// Update the key hint shown next to queued messages so it matches the
/// binding that `ChatWidget` actually listens for.
pub(crate) fn set_queued_message_edit_binding(&mut self, binding: KeyBinding) {
@@ -374,17 +369,6 @@ impl BottomPane {
/// Forward a key event to the active view or the composer.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
// Do not globally intercept space; only composer handles hold-to-talk.
// While recording, route all keys to the composer so it can stop on release or next key.
#[cfg(not(target_os = "linux"))]
if self.composer.is_recording() {
let (_ir, needs_redraw) = self.composer.handle_key_event(key_event);
if needs_redraw {
self.request_redraw();
}
return InputResult::None;
}
// If a modal/view is active, handle it here; otherwise forward to composer.
if !self.view_stack.is_empty() {
if key_event.kind == KeyEventKind::Release {
@@ -516,11 +500,7 @@ impl BottomPane {
self.request_redraw();
}
// Space hold timeout is handled inside ChatComposer via an internal timer.
pub(crate) fn pre_draw_tick(&mut self) {
// Allow composer to process any time-based transitions before drawing
#[cfg(not(target_os = "linux"))]
self.composer.process_space_hold_trigger();
self.composer.sync_popups();
}
@@ -1202,21 +1182,15 @@ impl BottomPane {
#[cfg(not(target_os = "linux"))]
impl BottomPane {
pub(crate) fn insert_transcription_placeholder(&mut self, text: &str) -> String {
let id = self.composer.insert_transcription_placeholder(text);
pub(crate) fn insert_recording_meter_placeholder(&mut self, text: &str) -> String {
let id = self.composer.insert_recording_meter_placeholder(text);
self.composer.sync_popups();
self.request_redraw();
id
}
pub(crate) fn replace_transcription(&mut self, id: &str, text: &str) {
self.composer.replace_transcription(id, text);
self.composer.sync_popups();
self.request_redraw();
}
pub(crate) fn update_transcription_in_place(&mut self, id: &str, text: &str) -> bool {
let updated = self.composer.update_transcription_in_place(id, text);
pub(crate) fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool {
let updated = self.composer.update_recording_meter_in_place(id, text);
if updated {
self.composer.sync_popups();
self.request_redraw();
@@ -1224,8 +1198,8 @@ impl BottomPane {
updated
}
pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) {
self.composer.remove_transcription_placeholder(id);
pub(crate) fn remove_recording_meter_placeholder(&mut self, id: &str) {
self.composer.remove_recording_meter_placeholder(id);
self.composer.sync_popups();
self.request_redraw();
}
-829
View File
@@ -1,63 +1,3 @@
use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use lazy_static::lazy_static;
use regex_lite::Regex;
use shlex::Shlex;
use std::collections::HashMap;
use std::collections::HashSet;
lazy_static! {
static ref PROMPT_ARG_REGEX: Regex =
Regex::new(r"\$[A-Z][A-Z0-9_]*").unwrap_or_else(|_| std::process::abort());
}
#[derive(Debug)]
pub enum PromptArgsError {
MissingAssignment { token: String },
MissingKey { token: String },
}
impl PromptArgsError {
fn describe(&self, command: &str) -> String {
match self {
PromptArgsError::MissingAssignment { token } => format!(
"Could not parse {command}: expected key=value but found '{token}'. Wrap values in double quotes if they contain spaces."
),
PromptArgsError::MissingKey { token } => {
format!("Could not parse {command}: expected a name before '=' in '{token}'.")
}
}
}
}
#[derive(Debug)]
pub enum PromptExpansionError {
Args {
command: String,
error: PromptArgsError,
},
MissingArgs {
command: String,
missing: Vec<String>,
},
}
impl PromptExpansionError {
pub fn user_message(&self) -> String {
match self {
PromptExpansionError::Args { command, error } => error.describe(command),
PromptExpansionError::MissingArgs { command, missing } => {
let list = missing.join(", ");
format!(
"Missing required args for {command}: {list}. Provide as key=value (quote values with spaces)."
)
}
}
}
}
/// Parse a first-line slash command of the form `/name <rest>`.
/// Returns `(name, rest_after_name, rest_offset)` if the line begins with `/`
/// and contains a non-empty name; otherwise returns `None`.
@@ -80,775 +20,6 @@ pub fn parse_slash_name(line: &str) -> Option<(&str, &str, usize)> {
let rest_untrimmed = &stripped[name_end_in_stripped..];
let rest = rest_untrimmed.trim_start();
let rest_start_in_stripped = name_end_in_stripped + (rest_untrimmed.len() - rest.len());
// `stripped` is `line` without the leading '/', so add 1 to get the original offset.
let rest_offset = rest_start_in_stripped + 1;
Some((name, rest, rest_offset))
}
#[derive(Debug, Clone, PartialEq)]
pub struct PromptArg {
pub text: String,
pub text_elements: Vec<TextElement>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PromptExpansion {
pub text: String,
pub text_elements: Vec<TextElement>,
}
/// Parse positional arguments using shlex semantics (supports quoted tokens).
///
/// `text_elements` must be relative to `rest`.
pub fn parse_positional_args(rest: &str, text_elements: &[TextElement]) -> Vec<PromptArg> {
parse_tokens_with_elements(rest, text_elements)
}
/// Extracts the unique placeholder variable names from a prompt template.
///
/// A placeholder is any token that matches the pattern `$[A-Z][A-Z0-9_]*`
/// (for example `$USER`). The function returns the variable names without
/// the leading `$`, de-duplicated and in the order of first appearance.
pub fn prompt_argument_names(content: &str) -> Vec<String> {
let mut seen = HashSet::new();
let mut names = Vec::new();
for m in PROMPT_ARG_REGEX.find_iter(content) {
if m.start() > 0 && content.as_bytes()[m.start() - 1] == b'$' {
continue;
}
let name = &content[m.start() + 1..m.end()];
// Exclude special positional aggregate token from named args.
if name == "ARGUMENTS" {
continue;
}
let name = name.to_string();
if seen.insert(name.clone()) {
names.push(name);
}
}
names
}
/// Shift a text element's byte range left by `offset`, returning `None` if empty.
///
/// `offset` is the byte length of the prefix removed from the original text.
fn shift_text_element_left(elem: &TextElement, offset: usize) -> Option<TextElement> {
if elem.byte_range.end <= offset {
return None;
}
let start = elem.byte_range.start.saturating_sub(offset);
let end = elem.byte_range.end.saturating_sub(offset);
(start < end).then_some(elem.map_range(|_| ByteRange { start, end }))
}
/// Parses the `key=value` pairs that follow a custom prompt name.
///
/// The input is split using shlex rules, so quoted values are supported
/// (for example `USER="Alice Smith"`). The function returns a map of parsed
/// arguments, or an error if a token is missing `=` or if the key is empty.
pub fn parse_prompt_inputs(
rest: &str,
text_elements: &[TextElement],
) -> Result<HashMap<String, PromptArg>, PromptArgsError> {
let mut map = HashMap::new();
if rest.trim().is_empty() {
return Ok(map);
}
// Tokenize the rest of the command using shlex rules, but keep text element
// ranges relative to each emitted token.
for token in parse_tokens_with_elements(rest, text_elements) {
let Some((key, value)) = token.text.split_once('=') else {
return Err(PromptArgsError::MissingAssignment { token: token.text });
};
if key.is_empty() {
return Err(PromptArgsError::MissingKey { token: token.text });
}
// The token is `key=value`; translate element ranges into the value-only
// coordinate space by subtracting the `key=` prefix length.
let value_start = key.len() + 1;
let value_elements = token
.text_elements
.iter()
.filter_map(|elem| shift_text_element_left(elem, value_start))
.collect();
map.insert(
key.to_string(),
PromptArg {
text: value.to_string(),
text_elements: value_elements,
},
);
}
Ok(map)
}
/// Expands a message of the form `/prompts:name [value] [value] …` using a matching saved prompt.
///
/// If the text does not start with `/prompts:`, or if no prompt named `name` exists,
/// the function returns `Ok(None)`. On success it returns
/// `Ok(Some(expanded))`; otherwise it returns a descriptive error.
pub fn expand_custom_prompt(
text: &str,
text_elements: &[TextElement],
custom_prompts: &[CustomPrompt],
) -> Result<Option<PromptExpansion>, PromptExpansionError> {
let Some((name, rest, rest_offset)) = parse_slash_name(text) else {
return Ok(None);
};
// Only handle custom prompts when using the explicit prompts prefix with a colon.
let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else {
return Ok(None);
};
let prompt = match custom_prompts.iter().find(|p| p.name == prompt_name) {
Some(prompt) => prompt,
None => return Ok(None),
};
// If there are named placeholders, expect key=value inputs.
let required = prompt_argument_names(&prompt.content);
let local_elements: Vec<TextElement> = text_elements
.iter()
.filter_map(|elem| {
let mut shifted = shift_text_element_left(elem, rest_offset)?;
if shifted.byte_range.start >= rest.len() {
return None;
}
let end = shifted.byte_range.end.min(rest.len());
shifted.byte_range.end = end;
(shifted.byte_range.start < shifted.byte_range.end).then_some(shifted)
})
.collect();
if !required.is_empty() {
let inputs = parse_prompt_inputs(rest, &local_elements).map_err(|error| {
PromptExpansionError::Args {
command: format!("/{name}"),
error,
}
})?;
let missing: Vec<String> = required
.into_iter()
.filter(|k| !inputs.contains_key(k))
.collect();
if !missing.is_empty() {
return Err(PromptExpansionError::MissingArgs {
command: format!("/{name}"),
missing,
});
}
let (text, elements) = expand_named_placeholders_with_elements(&prompt.content, &inputs);
return Ok(Some(PromptExpansion {
text,
text_elements: elements,
}));
}
// Otherwise, treat it as numeric/positional placeholder prompt (or none).
let pos_args = parse_positional_args(rest, &local_elements);
Ok(Some(expand_numeric_placeholders(
&prompt.content,
&pos_args,
)))
}
/// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`.
pub fn prompt_has_numeric_placeholders(content: &str) -> bool {
if content.contains("$ARGUMENTS") {
return true;
}
let bytes = content.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'$' {
let b1 = bytes[i + 1];
if (b'1'..=b'9').contains(&b1) {
return true;
}
}
i += 1;
}
false
}
/// Extract positional arguments from a composer first line like "/name a b" for a given prompt name.
/// Returns empty when the command name does not match or when there are no args.
pub fn extract_positional_args_for_prompt_line(
line: &str,
prompt_name: &str,
text_elements: &[TextElement],
) -> Vec<PromptArg> {
let trimmed = line.trim_start();
let trim_offset = line.len() - trimmed.len();
let Some((name, rest, rest_offset)) = parse_slash_name(trimmed) else {
return Vec::new();
};
// Require the explicit prompts prefix for custom prompt invocations.
let Some(after_prefix) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else {
return Vec::new();
};
if after_prefix != prompt_name {
return Vec::new();
}
let rest_trimmed_start = rest.trim_start();
let args_str = rest_trimmed_start.trim_end();
if args_str.is_empty() {
return Vec::new();
}
let args_offset = trim_offset + rest_offset + (rest.len() - rest_trimmed_start.len());
let local_elements: Vec<TextElement> = text_elements
.iter()
.filter_map(|elem| {
let mut shifted = shift_text_element_left(elem, args_offset)?;
if shifted.byte_range.start >= args_str.len() {
return None;
}
let end = shifted.byte_range.end.min(args_str.len());
shifted.byte_range.end = end;
(shifted.byte_range.start < shifted.byte_range.end).then_some(shifted)
})
.collect();
parse_positional_args(args_str, &local_elements)
}
/// If the prompt only uses numeric placeholders and the first line contains
/// positional args for it, expand and return Some(expanded); otherwise None.
pub fn expand_if_numeric_with_positional_args(
prompt: &CustomPrompt,
first_line: &str,
text_elements: &[TextElement],
) -> Option<PromptExpansion> {
if !prompt_argument_names(&prompt.content).is_empty() {
return None;
}
if !prompt_has_numeric_placeholders(&prompt.content) {
return None;
}
let args = extract_positional_args_for_prompt_line(first_line, &prompt.name, text_elements);
if args.is_empty() {
return None;
}
Some(expand_numeric_placeholders(&prompt.content, &args))
}
/// Expand `$1..$9` and `$ARGUMENTS` in `content` with values from `args`.
pub fn expand_numeric_placeholders(content: &str, args: &[PromptArg]) -> PromptExpansion {
let mut out = String::with_capacity(content.len());
let mut out_elements = Vec::new();
let mut i = 0;
while let Some(off) = content[i..].find('$') {
let j = i + off;
out.push_str(&content[i..j]);
let rest = &content[j..];
let bytes = rest.as_bytes();
if bytes.len() >= 2 {
match bytes[1] {
b'$' => {
out.push_str("$$");
i = j + 2;
continue;
}
b'1'..=b'9' => {
let idx = (bytes[1] - b'1') as usize;
if let Some(arg) = args.get(idx) {
append_arg_with_elements(&mut out, &mut out_elements, arg);
}
i = j + 2;
continue;
}
_ => {}
}
}
if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") {
if !args.is_empty() {
append_joined_args_with_elements(&mut out, &mut out_elements, args);
}
i = j + 1 + "ARGUMENTS".len();
continue;
}
out.push('$');
i = j + 1;
}
out.push_str(&content[i..]);
PromptExpansion {
text: out,
text_elements: out_elements,
}
}
fn parse_tokens_with_elements(rest: &str, text_elements: &[TextElement]) -> Vec<PromptArg> {
let mut elements = text_elements.to_vec();
elements.sort_by_key(|elem| elem.byte_range.start);
// Keep element placeholders intact across shlex splitting by replacing
// each element range with a unique sentinel token first.
let (rest_for_shlex, replacements) = replace_text_elements_with_sentinels(rest, &elements);
Shlex::new(&rest_for_shlex)
.map(|token| apply_replacements_to_token(token, &replacements))
.collect()
}
#[derive(Debug, Clone)]
struct ElementReplacement {
sentinel: String,
text: String,
placeholder: Option<String>,
}
/// Replace each text element range with a unique sentinel token.
///
/// The sentinel is chosen so it will survive shlex tokenization as a single word.
fn replace_text_elements_with_sentinels(
rest: &str,
elements: &[TextElement],
) -> (String, Vec<ElementReplacement>) {
let mut out = String::with_capacity(rest.len());
let mut replacements = Vec::new();
let mut cursor = 0;
for (idx, elem) in elements.iter().enumerate() {
let start = elem.byte_range.start;
let end = elem.byte_range.end;
out.push_str(&rest[cursor..start]);
let mut sentinel = format!("__CODEX_ELEM_{idx}__");
// Ensure we never collide with user content so a sentinel can't be mistaken for text.
while rest.contains(&sentinel) {
sentinel.push('_');
}
out.push_str(&sentinel);
replacements.push(ElementReplacement {
sentinel,
text: rest[start..end].to_string(),
placeholder: elem.placeholder(rest).map(str::to_string),
});
cursor = end;
}
out.push_str(&rest[cursor..]);
(out, replacements)
}
/// Rehydrate a shlex token by swapping sentinels back to the original text
/// and rebuilding text element ranges relative to the resulting token.
fn apply_replacements_to_token(token: String, replacements: &[ElementReplacement]) -> PromptArg {
if replacements.is_empty() {
return PromptArg {
text: token,
text_elements: Vec::new(),
};
}
let mut out = String::with_capacity(token.len());
let mut out_elements = Vec::new();
let mut cursor = 0;
while cursor < token.len() {
let Some((offset, replacement)) = next_replacement(&token, cursor, replacements) else {
out.push_str(&token[cursor..]);
break;
};
let start_in_token = cursor + offset;
out.push_str(&token[cursor..start_in_token]);
let start = out.len();
out.push_str(&replacement.text);
let end = out.len();
if start < end {
out_elements.push(TextElement::new(
ByteRange { start, end },
replacement.placeholder.clone(),
));
}
cursor = start_in_token + replacement.sentinel.len();
}
PromptArg {
text: out,
text_elements: out_elements,
}
}
/// Find the earliest sentinel occurrence at or after `cursor`.
fn next_replacement<'a>(
token: &str,
cursor: usize,
replacements: &'a [ElementReplacement],
) -> Option<(usize, &'a ElementReplacement)> {
let slice = &token[cursor..];
let mut best: Option<(usize, &'a ElementReplacement)> = None;
for replacement in replacements {
if let Some(pos) = slice.find(&replacement.sentinel) {
match best {
Some((best_pos, _)) if best_pos <= pos => {}
_ => best = Some((pos, replacement)),
}
}
}
best
}
fn expand_named_placeholders_with_elements(
content: &str,
args: &HashMap<String, PromptArg>,
) -> (String, Vec<TextElement>) {
let mut out = String::with_capacity(content.len());
let mut out_elements = Vec::new();
let mut cursor = 0;
for m in PROMPT_ARG_REGEX.find_iter(content) {
let start = m.start();
let end = m.end();
if start > 0 && content.as_bytes()[start - 1] == b'$' {
out.push_str(&content[cursor..end]);
cursor = end;
continue;
}
out.push_str(&content[cursor..start]);
cursor = end;
let key = &content[start + 1..end];
if let Some(arg) = args.get(key) {
append_arg_with_elements(&mut out, &mut out_elements, arg);
} else {
out.push_str(&content[start..end]);
}
}
out.push_str(&content[cursor..]);
(out, out_elements)
}
fn append_arg_with_elements(
out: &mut String,
out_elements: &mut Vec<TextElement>,
arg: &PromptArg,
) {
let start = out.len();
out.push_str(&arg.text);
if arg.text_elements.is_empty() {
return;
}
out_elements.extend(arg.text_elements.iter().map(|elem| {
elem.map_range(|range| ByteRange {
start: start + range.start,
end: start + range.end,
})
}));
}
fn append_joined_args_with_elements(
out: &mut String,
out_elements: &mut Vec<TextElement>,
args: &[PromptArg],
) {
// `$ARGUMENTS` joins args with single spaces while preserving element ranges.
for (idx, arg) in args.iter().enumerate() {
if idx > 0 {
out.push(' ');
}
append_arg_with_elements(out, out_elements, arg);
}
}
/// Constructs a command text for a custom prompt with arguments.
/// Returns the text and the cursor position (inside the first double quote).
pub fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (String, usize) {
let mut text = format!("/{PROMPTS_CMD_PREFIX}:{name}");
let mut cursor: usize = text.len();
for (i, arg) in args.iter().enumerate() {
text.push_str(format!(" {arg}=\"\"").as_str());
if i == 0 {
cursor = text.len() - 1; // inside first ""
}
}
(text, cursor)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn expand_arguments_basic() {
let prompts = vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "Review $USER changes on $BRANCH".to_string(),
description: None,
argument_hint: None,
}];
let out = expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &[], &prompts)
.unwrap();
assert_eq!(
out,
Some(PromptExpansion {
text: "Review Alice changes on main".to_string(),
text_elements: Vec::new(),
})
);
}
#[test]
fn quoted_values_ok() {
let prompts = vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "Pair $USER with $BRANCH".to_string(),
description: None,
argument_hint: None,
}];
let out = expand_custom_prompt(
"/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main",
&[],
&prompts,
)
.unwrap();
assert_eq!(
out,
Some(PromptExpansion {
text: "Pair Alice Smith with dev-main".to_string(),
text_elements: Vec::new(),
})
);
}
#[test]
fn invalid_arg_token_reports_error() {
let prompts = vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "Review $USER changes".to_string(),
description: None,
argument_hint: None,
}];
let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &[], &prompts)
.unwrap_err()
.user_message();
assert!(err.contains("expected key=value"));
}
#[test]
fn missing_required_args_reports_error() {
let prompts = vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "Review $USER changes on $BRANCH".to_string(),
description: None,
argument_hint: None,
}];
let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &[], &prompts)
.unwrap_err()
.user_message();
assert!(err.to_lowercase().contains("missing required args"));
assert!(err.contains("BRANCH"));
}
#[test]
fn escaped_placeholder_is_ignored() {
assert_eq!(
prompt_argument_names("literal $$USER"),
Vec::<String>::new()
);
assert_eq!(
prompt_argument_names("literal $$USER and $REAL"),
vec!["REAL".to_string()]
);
}
#[test]
fn escaped_placeholder_remains_literal() {
let prompts = vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "literal $$USER".to_string(),
description: None,
argument_hint: None,
}];
let out = expand_custom_prompt("/prompts:my-prompt", &[], &prompts).unwrap();
assert_eq!(
out,
Some(PromptExpansion {
text: "literal $$USER".to_string(),
text_elements: Vec::new(),
})
);
}
#[test]
fn positional_args_treat_placeholder_with_spaces_as_single_token() {
let placeholder = "[Image #1]";
let rest = format!("alpha {placeholder} beta");
let start = rest.find(placeholder).expect("placeholder");
let end = start + placeholder.len();
let text_elements = vec![TextElement::new(
ByteRange { start, end },
Some(placeholder.to_string()),
)];
let args = parse_positional_args(&rest, &text_elements);
assert_eq!(
args,
vec![
PromptArg {
text: "alpha".to_string(),
text_elements: Vec::new(),
},
PromptArg {
text: placeholder.to_string(),
text_elements: vec![TextElement::new(
ByteRange {
start: 0,
end: placeholder.len(),
},
Some(placeholder.to_string()),
)],
},
PromptArg {
text: "beta".to_string(),
text_elements: Vec::new(),
}
]
);
}
#[test]
fn extract_positional_args_shifts_element_offsets_into_args_str() {
let placeholder = "[Image #1]";
let line = format!(" /{PROMPTS_CMD_PREFIX}:my-prompt alpha {placeholder} beta ");
let start = line.find(placeholder).expect("placeholder");
let end = start + placeholder.len();
let text_elements = vec![TextElement::new(
ByteRange { start, end },
Some(placeholder.to_string()),
)];
let args = extract_positional_args_for_prompt_line(&line, "my-prompt", &text_elements);
assert_eq!(
args,
vec![
PromptArg {
text: "alpha".to_string(),
text_elements: Vec::new(),
},
PromptArg {
text: placeholder.to_string(),
text_elements: vec![TextElement::new(
ByteRange {
start: 0,
end: placeholder.len(),
},
Some(placeholder.to_string()),
)],
},
PromptArg {
text: "beta".to_string(),
text_elements: Vec::new(),
}
]
);
}
#[test]
fn key_value_args_treat_placeholder_with_spaces_as_single_token() {
let placeholder = "[Image #1]";
let rest = format!("IMG={placeholder} NOTE=hello");
let start = rest.find(placeholder).expect("placeholder");
let end = start + placeholder.len();
let text_elements = vec![TextElement::new(
ByteRange { start, end },
Some(placeholder.to_string()),
)];
let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs");
assert_eq!(
args.get("IMG"),
Some(&PromptArg {
text: placeholder.to_string(),
text_elements: vec![TextElement::new(
ByteRange {
start: 0,
end: placeholder.len(),
},
Some(placeholder.to_string()),
)],
})
);
assert_eq!(
args.get("NOTE"),
Some(&PromptArg {
text: "hello".to_string(),
text_elements: Vec::new(),
})
);
}
#[test]
fn positional_args_allow_placeholder_inside_quotes() {
let placeholder = "[Image #1]";
let rest = format!("alpha \"see {placeholder} here\" beta");
let start = rest.find(placeholder).expect("placeholder");
let end = start + placeholder.len();
let text_elements = vec![TextElement::new(
ByteRange { start, end },
Some(placeholder.to_string()),
)];
let args = parse_positional_args(&rest, &text_elements);
assert_eq!(
args,
vec![
PromptArg {
text: "alpha".to_string(),
text_elements: Vec::new(),
},
PromptArg {
text: format!("see {placeholder} here"),
text_elements: vec![TextElement::new(
ByteRange {
start: "see ".len(),
end: "see ".len() + placeholder.len(),
},
Some(placeholder.to_string()),
)],
},
PromptArg {
text: "beta".to_string(),
text_elements: Vec::new(),
}
]
);
}
#[test]
fn key_value_args_allow_placeholder_inside_quotes() {
let placeholder = "[Image #1]";
let rest = format!("IMG=\"see {placeholder} here\" NOTE=ok");
let start = rest.find(placeholder).expect("placeholder");
let end = start + placeholder.len();
let text_elements = vec![TextElement::new(
ByteRange { start, end },
Some(placeholder.to_string()),
)];
let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs");
assert_eq!(
args.get("IMG"),
Some(&PromptArg {
text: format!("see {placeholder} here"),
text_elements: vec![TextElement::new(
ByteRange {
start: "see ".len(),
end: "see ".len() + placeholder.len(),
},
Some(placeholder.to_string()),
)],
})
);
assert_eq!(
args.get("NOTE"),
Some(&PromptArg {
text: "ok".to_string(),
text_elements: Vec::new(),
})
);
}
}
+22 -3
View File
@@ -180,7 +180,6 @@ impl SkillPopup {
})
});
out.truncate(MAX_POPUP_ROWS);
out
}
}
@@ -235,6 +234,8 @@ fn skill_popup_hint_line() -> Line<'static> {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
fn mention_item(index: usize) -> MentionItem {
MentionItem {
@@ -249,7 +250,7 @@ mod tests {
}
#[test]
fn filtered_mentions_are_capped_to_max_popup_rows() {
fn filtered_mentions_preserve_results_beyond_popup_height() {
let popup = SkillPopup::new((0..(MAX_POPUP_ROWS + 2)).map(mention_item).collect());
let filtered_names: Vec<String> = popup
@@ -260,7 +261,7 @@ mod tests {
assert_eq!(
filtered_names,
(0..MAX_POPUP_ROWS)
(0..(MAX_POPUP_ROWS + 2))
.map(|idx| format!("Mention {idx:02}"))
.collect::<Vec<_>>()
);
@@ -269,4 +270,22 @@ mod tests {
(MAX_POPUP_ROWS as u16) + 2
);
}
fn render_popup(popup: &SkillPopup, width: u16) -> String {
let area = Rect::new(0, 0, width, popup.calculate_required_height(width));
let mut buf = Buffer::empty(area);
popup.render_ref(area, &mut buf);
format!("{buf:?}")
}
#[test]
fn scrolling_mentions_shifts_rendered_window_snapshot() {
let mut popup = SkillPopup::new((0..(MAX_POPUP_ROWS + 2)).map(mention_item).collect());
for _ in 0..=MAX_POPUP_ROWS {
popup.move_down();
}
insta::assert_snapshot!("skill_popup_scrolled", render_popup(&popup, /*width*/ 72));
}
}
@@ -0,0 +1,43 @@
---
source: tui/src/bottom_pane/skill_popup.rs
assertion_line: 289
expression: "render_popup(&popup, /*width*/ 72)"
---
Buffer {
area: Rect { x: 0, y: 0, width: 72, height: 10 },
content: [
" Mention 01 [Skill] Description 01 ",
" Mention 02 [Skill] Description 02 ",
" Mention 03 [Skill] Description 03 ",
" Mention 04 [Skill] Description 04 ",
" Mention 05 [Skill] Description 05 ",
" Mention 06 [Skill] Description 06 ",
" Mention 07 [Skill] Description 07 ",
" Mention 08 [Skill] Description 08 ",
" ",
" Press enter to insert or esc to close ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 7, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 36, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 8, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 13, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 27, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 30, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
+1
View File
@@ -945,6 +945,7 @@ impl TextArea {
self.set_cursor(end);
}
#[cfg(not(target_os = "linux"))]
pub fn replace_element_by_id(&mut self, id: &str, text: &str) -> bool {
if let Some(idx) = self
.elements
+4 -19
View File
@@ -4617,9 +4617,6 @@ impl ChatWidget {
last_non_retry_error: None,
};
widget.bottom_pane.set_voice_transcription_enabled(
widget.config.features.enabled(Feature::VoiceTranscription),
);
widget
.bottom_pane
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
@@ -6821,9 +6818,6 @@ impl ChatWidget {
EventMsg::WebSearchEnd(ev) => self.on_web_search_end(ev),
EventMsg::GetHistoryEntryResponse(ev) => self.handle_history_entry_response(ev),
EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev),
EventMsg::ListCustomPromptsResponse(_) => {
tracing::warn!("ignoring unsupported custom prompt list response in TUI");
}
EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev),
EventMsg::SkillsUpdateAvailable => {
self.submit_op(AppCommand::list_skills(
@@ -9170,9 +9164,6 @@ impl ChatWidget {
);
}
let enabled = self.config.features.enabled(feature);
if feature == Feature::VoiceTranscription {
self.bottom_pane.set_voice_transcription_enabled(enabled);
}
if feature == Feature::RealtimeConversation {
let realtime_conversation_enabled = self.realtime_conversation_enabled();
self.bottom_pane
@@ -10660,22 +10651,16 @@ impl ChatWidget {
#[cfg(not(target_os = "linux"))]
impl ChatWidget {
pub(crate) fn replace_transcription(&mut self, id: &str, text: &str) {
self.bottom_pane.replace_transcription(id, text);
// Ensure the UI redraws to reflect the updated transcription.
self.request_redraw();
}
pub(crate) fn update_transcription_in_place(&mut self, id: &str, text: &str) -> bool {
let updated = self.bottom_pane.update_transcription_in_place(id, text);
pub(crate) fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool {
let updated = self.bottom_pane.update_recording_meter_in_place(id, text);
if updated {
self.request_redraw();
}
updated
}
pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) {
self.bottom_pane.remove_transcription_placeholder(id);
pub(crate) fn remove_recording_meter_placeholder(&mut self, id: &str) {
self.bottom_pane.remove_recording_meter_placeholder(id);
// Ensure the UI redraws to reflect placeholder removal.
self.request_redraw();
}
+4 -4
View File
@@ -363,7 +363,7 @@ impl ChatWidget {
return;
}
let placeholder_id = self.bottom_pane.insert_transcription_placeholder("⠤⠤⠤⠤");
let placeholder_id = self.bottom_pane.insert_recording_meter_placeholder("⠤⠤⠤⠤");
self.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone());
self.request_redraw();
@@ -374,7 +374,7 @@ impl ChatWidget {
Ok(capture) => capture,
Err(err) => {
self.realtime_conversation.meter_placeholder_id = None;
self.remove_transcription_placeholder(&placeholder_id);
self.remove_recording_meter_placeholder(&placeholder_id);
self.fail_realtime_conversation(format!(
"Failed to start microphone capture: {err}"
));
@@ -464,10 +464,10 @@ impl ChatWidget {
flag.store(true, Ordering::Relaxed);
}
if let Some(capture) = self.realtime_conversation.capture.take() {
let _ = capture.stop();
capture.stop();
}
if let Some(id) = self.realtime_conversation.meter_placeholder_id.take() {
self.remove_transcription_placeholder(&id);
self.remove_recording_meter_placeholder(&id);
}
}
+1 -1
View File
@@ -6087,7 +6087,7 @@ async fn realtime_error_closes_without_followup_closed_info() {
async fn deleted_realtime_meter_uses_shared_stop_path() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.realtime_conversation.phase = RealtimeConversationPhase::Active;
let placeholder_id = chat.bottom_pane.insert_transcription_placeholder("⠤⠤⠤⠤");
let placeholder_id = chat.bottom_pane.insert_recording_meter_placeholder("⠤⠤⠤⠤");
chat.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone());
assert!(chat.stop_realtime_conversation_for_deleted_meter(&placeholder_id));
+1 -39
View File
@@ -155,21 +155,13 @@ mod version;
mod voice;
#[cfg(all(not(target_os = "linux"), not(feature = "voice-input")))]
mod voice {
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use codex_core::config::Config;
use codex_protocol::protocol::RealtimeAudioFrame;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU16;
pub struct RecordedAudio {
pub data: Vec<i16>,
pub sample_rate: u32,
pub channels: u16,
}
pub struct VoiceCapture;
pub(crate) struct RecordingMeterState;
@@ -177,34 +169,16 @@ mod voice {
pub(crate) struct RealtimeAudioPlayer;
impl VoiceCapture {
pub fn start() -> Result<Self, String> {
Err("voice input is unavailable in this build".to_string())
}
pub fn start_realtime(_config: &Config, _tx: AppEventSender) -> Result<Self, String> {
Err("voice input is unavailable in this build".to_string())
}
pub fn stop(self) -> Result<RecordedAudio, String> {
Err("voice input is unavailable in this build".to_string())
}
pub fn data_arc(&self) -> Arc<Mutex<Vec<i16>>> {
Arc::new(Mutex::new(Vec::new()))
}
pub fn stop(self) {}
pub fn stopped_flag(&self) -> Arc<AtomicBool> {
Arc::new(AtomicBool::new(true))
}
pub fn sample_rate(&self) -> u32 {
0
}
pub fn channels(&self) -> u16 {
0
}
pub fn last_peak_arc(&self) -> Arc<AtomicU16> {
Arc::new(AtomicU16::new(0))
}
@@ -231,18 +205,6 @@ mod voice {
pub(crate) fn clear(&self) {}
}
pub fn transcribe_async(
id: String,
_audio: RecordedAudio,
_context: Option<String>,
tx: AppEventSender,
) {
tx.send(AppEvent::TranscriptionFailed {
id,
error: "voice input is unavailable in this build".to_string(),
});
}
}
mod wrapping;
+1 -423
View File
@@ -1,89 +1,33 @@
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use base64::Engine;
use codex_client::build_reqwest_client_with_custom_ca;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::config::Config;
use codex_core::config::find_codex_home;
use codex_core::default_client::get_codex_user_agent;
use codex_login::AuthMode;
use codex_login::CodexAuth;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::RealtimeAudioFrame;
use cpal::traits::DeviceTrait;
use cpal::traits::HostTrait;
use cpal::traits::StreamTrait;
use hound::SampleFormat;
use hound::WavSpec;
use hound::WavWriter;
use std::collections::VecDeque;
use std::io::Cursor;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU16;
use std::sync::atomic::Ordering;
use tracing::error;
use tracing::info;
use tracing::trace;
const AUDIO_MODEL: &str = "gpt-4o-mini-transcribe";
const MODEL_AUDIO_SAMPLE_RATE: u32 = 24_000;
const MODEL_AUDIO_CHANNELS: u16 = 1;
struct TranscriptionAuthContext {
mode: AuthMode,
bearer_token: String,
chatgpt_account_id: Option<String>,
chatgpt_base_url: String,
}
pub struct RecordedAudio {
pub data: Vec<i16>,
pub sample_rate: u32,
pub channels: u16,
}
pub struct VoiceCapture {
stream: Option<cpal::Stream>,
sample_rate: u32,
channels: u16,
data: Arc<Mutex<Vec<i16>>>,
stopped: Arc<AtomicBool>,
last_peak: Arc<AtomicU16>,
}
impl VoiceCapture {
pub fn start() -> Result<Self, String> {
let (device, config) = select_default_input_device_and_config()?;
let sample_rate = config.sample_rate().0;
let channels = config.channels();
let data: Arc<Mutex<Vec<i16>>> = Arc::new(Mutex::new(Vec::new()));
let stopped = Arc::new(AtomicBool::new(false));
let last_peak = Arc::new(AtomicU16::new(0));
let stream = build_input_stream(&device, &config, data.clone(), last_peak.clone())?;
stream
.play()
.map_err(|e| format!("failed to start input stream: {e}"))?;
Ok(Self {
stream: Some(stream),
sample_rate,
channels,
data,
stopped,
last_peak,
})
}
pub fn start_realtime(config: &Config, tx: AppEventSender) -> Result<Self, String> {
let (device, config) = select_realtime_input_device_and_config(config)?;
let sample_rate = config.sample_rate().0;
let channels = config.channels();
let data: Arc<Mutex<Vec<i16>>> = Arc::new(Mutex::new(Vec::new()));
let stopped = Arc::new(AtomicBool::new(false));
let last_peak = Arc::new(AtomicU16::new(0));
@@ -101,47 +45,22 @@ impl VoiceCapture {
Ok(Self {
stream: Some(stream),
sample_rate,
channels,
data,
stopped,
last_peak,
})
}
pub fn stop(mut self) -> Result<RecordedAudio, String> {
pub fn stop(mut self) {
// Mark stopped so any metering task can exit cleanly.
self.stopped.store(true, Ordering::SeqCst);
// Dropping the stream stops capture.
self.stream.take();
let data = self
.data
.lock()
.map_err(|_| "failed to lock audio buffer".to_string())?
.clone();
Ok(RecordedAudio {
data,
sample_rate: self.sample_rate,
channels: self.channels,
})
}
pub fn data_arc(&self) -> Arc<Mutex<Vec<i16>>> {
self.data.clone()
}
pub fn stopped_flag(&self) -> Arc<AtomicBool> {
self.stopped.clone()
}
pub fn sample_rate(&self) -> u32 {
self.sample_rate
}
pub fn channels(&self) -> u16 {
self.channels
}
pub fn last_peak_arc(&self) -> Arc<AtomicU16> {
self.last_peak.clone()
}
@@ -206,136 +125,16 @@ impl RecordingMeterState {
}
}
pub fn transcribe_async(
id: String,
audio: RecordedAudio,
context: Option<String>,
tx: AppEventSender,
) {
std::thread::spawn(move || {
// Enforce minimum duration to avoid garbage outputs.
const MIN_DURATION_SECONDS: f32 = 1.0;
let duration_seconds = clip_duration_seconds(&audio);
if duration_seconds < MIN_DURATION_SECONDS {
let msg = format!(
"recording too short ({duration_seconds:.2}s); minimum is {MIN_DURATION_SECONDS:.2}s"
);
info!("{msg}");
tx.send(AppEvent::TranscriptionFailed { id, error: msg });
return;
}
// Encode entire clip as normalized WAV.
let wav_bytes = match encode_wav_normalized(&audio) {
Ok(b) => b,
Err(e) => {
error!("failed to encode wav: {e}");
tx.send(AppEvent::TranscriptionFailed { id, error: e });
return;
}
};
// Run the HTTP request on a small, dedicated runtime.
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
error!("failed to create tokio runtime: {e}");
return;
}
};
let tx2 = tx.clone();
let id2 = id.clone();
let res: Result<String, String> = rt
.block_on(async move { transcribe_bytes(wav_bytes, context, duration_seconds).await });
match res {
Ok(text) => {
tx2.send(AppEvent::TranscriptionComplete { id: id2, text });
info!("voice transcription succeeded");
}
Err(e) => {
error!("voice transcription error: {e}");
tx.send(AppEvent::TranscriptionFailed { id, error: e });
}
}
});
}
// -------------------------
// Voice input helpers
// -------------------------
fn select_default_input_device_and_config()
-> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
let host = cpal::default_host();
let device = host
.default_input_device()
.ok_or_else(|| "no input audio device available".to_string())?;
let config = crate::audio_device::preferred_input_config(&device)?;
Ok((device, config))
}
fn select_realtime_input_device_and_config(
config: &Config,
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
crate::audio_device::select_configured_input_device_and_config(config)
}
fn build_input_stream(
device: &cpal::Device,
config: &cpal::SupportedStreamConfig,
data: Arc<Mutex<Vec<i16>>>,
last_peak: Arc<AtomicU16>,
) -> Result<cpal::Stream, String> {
match config.sample_format() {
cpal::SampleFormat::F32 => device
.build_input_stream(
&config.clone().into(),
move |input: &[f32], _| {
let peak = peak_f32(input);
last_peak.store(peak, Ordering::Relaxed);
if let Ok(mut buf) = data.lock() {
for &s in input {
buf.push(f32_to_i16(s));
}
}
},
move |err| error!("audio input error: {err}"),
None,
)
.map_err(|e| format!("failed to build input stream: {e}")),
cpal::SampleFormat::I16 => device
.build_input_stream(
&config.clone().into(),
move |input: &[i16], _| {
let peak = peak_i16(input);
last_peak.store(peak, Ordering::Relaxed);
if let Ok(mut buf) = data.lock() {
buf.extend_from_slice(input);
}
},
move |err| error!("audio input error: {err}"),
None,
)
.map_err(|e| format!("failed to build input stream: {e}")),
cpal::SampleFormat::U16 => device
.build_input_stream(
&config.clone().into(),
move |input: &[u16], _| {
if let Ok(mut buf) = data.lock() {
let peak = convert_u16_to_i16_and_peak(input, &mut buf);
last_peak.store(peak, Ordering::Relaxed);
}
},
move |err| error!("audio input error: {err}"),
None,
)
.map_err(|e| format!("failed to build input stream: {e}")),
_ => Err("unsupported input sample format".to_string()),
}
}
fn build_realtime_input_stream(
device: &cpal::Device,
config: &cpal::SupportedStreamConfig,
@@ -673,210 +472,10 @@ fn convert_pcm16(
out
}
// -------------------------
// Transcription helpers
// -------------------------
fn clip_duration_seconds(audio: &RecordedAudio) -> f32 {
let total_samples = audio.data.len() as f32;
let samples_per_second = (audio.sample_rate as f32) * (audio.channels as f32);
if samples_per_second > 0.0 {
total_samples / samples_per_second
} else {
0.0
}
}
fn encode_wav_normalized(audio: &RecordedAudio) -> Result<Vec<u8>, String> {
let converted;
let (channels, sample_rate, segment) =
if audio.channels == MODEL_AUDIO_CHANNELS && audio.sample_rate == MODEL_AUDIO_SAMPLE_RATE {
(audio.channels, audio.sample_rate, audio.data.as_slice())
} else {
converted = convert_pcm16(
&audio.data,
audio.sample_rate,
audio.channels,
MODEL_AUDIO_SAMPLE_RATE,
MODEL_AUDIO_CHANNELS,
);
(
MODEL_AUDIO_CHANNELS,
MODEL_AUDIO_SAMPLE_RATE,
converted.as_slice(),
)
};
let mut wav_bytes: Vec<u8> = Vec::new();
let spec = WavSpec {
channels,
sample_rate,
bits_per_sample: 16,
sample_format: SampleFormat::Int,
};
let mut cursor = Cursor::new(&mut wav_bytes);
let mut writer =
WavWriter::new(&mut cursor, spec).map_err(|_| "failed to create wav writer".to_string())?;
// Simple peak normalization with headroom to improve audibility on quiet inputs.
let mut peak: i16 = 0;
for &s in segment {
let a = s.unsigned_abs();
if a > peak.unsigned_abs() {
peak = s;
}
}
let peak_abs = (peak as i32).unsigned_abs() as i32;
let target = (i16::MAX as f32) * 0.9; // leave some headroom
let gain: f32 = if peak_abs > 0 {
target / (peak_abs as f32)
} else {
1.0
};
for &s in segment {
let v = ((s as f32) * gain)
.round()
.clamp(i16::MIN as f32, i16::MAX as f32) as i16;
writer
.write_sample(v)
.map_err(|_| "failed writing wav sample".to_string())?;
}
writer
.finalize()
.map_err(|_| "failed to finalize wav".to_string())?;
Ok(wav_bytes)
}
fn normalize_chatgpt_base_url(input: &str) -> String {
let mut base_url = input.to_string();
while base_url.ends_with('/') {
base_url.pop();
}
if (base_url.starts_with("https://chatgpt.com")
|| base_url.starts_with("https://chat.openai.com"))
&& !base_url.contains("/backend-api")
{
base_url = format!("{base_url}/backend-api");
}
base_url
}
async fn resolve_auth() -> Result<TranscriptionAuthContext, String> {
let codex_home = find_codex_home().map_err(|e| format!("failed to find codex home: {e}"))?;
let auth = CodexAuth::from_auth_storage(&codex_home, AuthCredentialsStoreMode::Auto)
.map_err(|e| format!("failed to read auth.json: {e}"))?
.ok_or_else(|| "No Codex auth is configured; please run `codex login`".to_string())?;
let chatgpt_account_id = auth.get_account_id();
let token = auth
.get_token()
.map_err(|e| format!("failed to get auth token: {e}"))?;
let config = Config::load_with_cli_overrides(Vec::new())
.await
.map_err(|e| format!("failed to load config: {e}"))?;
Ok(TranscriptionAuthContext {
mode: auth.api_auth_mode(),
bearer_token: token,
chatgpt_account_id,
chatgpt_base_url: normalize_chatgpt_base_url(&config.chatgpt_base_url),
})
}
async fn transcribe_bytes(
wav_bytes: Vec<u8>,
context: Option<String>,
duration_seconds: f32,
) -> Result<String, String> {
let auth = resolve_auth().await?;
let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())
.map_err(|error| format!("failed to build transcription HTTP client: {error}"))?;
let audio_bytes = wav_bytes.len();
let prompt_for_log = context.as_deref().unwrap_or("").to_string();
let (endpoint, request) =
if matches!(auth.mode, AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens) {
let part = reqwest::multipart::Part::bytes(wav_bytes)
.file_name("audio.wav")
.mime_str("audio/wav")
.map_err(|e| format!("failed to set mime: {e}"))?;
let form = reqwest::multipart::Form::new().part("file", part);
let endpoint = format!("{}/transcribe", auth.chatgpt_base_url);
let mut req = client
.post(&endpoint)
.bearer_auth(&auth.bearer_token)
.multipart(form)
.header("User-Agent", get_codex_user_agent());
if let Some(acc) = auth.chatgpt_account_id {
req = req.header("ChatGPT-Account-Id", acc);
}
(endpoint, req)
} else {
let part = reqwest::multipart::Part::bytes(wav_bytes)
.file_name("audio.wav")
.mime_str("audio/wav")
.map_err(|e| format!("failed to set mime: {e}"))?;
let mut form = reqwest::multipart::Form::new()
.text("model", AUDIO_MODEL)
.part("file", part);
if let Some(context) = context {
form = form.text("prompt", context);
}
let endpoint = "https://api.openai.com/v1/audio/transcriptions".to_string();
(
endpoint,
client
.post("https://api.openai.com/v1/audio/transcriptions")
.bearer_auth(&auth.bearer_token)
.multipart(form)
.header("User-Agent", get_codex_user_agent()),
)
};
let audio_kib = audio_bytes as f32 / 1024.0;
let mode = auth.mode;
trace!(
"sending transcription request: mode={mode:?} endpoint={endpoint} duration={duration_seconds:.2}s audio={audio_kib:.1}KiB prompt={prompt_for_log}"
);
let resp = request
.send()
.await
.map_err(|e| format!("transcription request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|_| "<failed to read body>".to_string());
return Err(format!("transcription failed: {status} {body}"));
}
let v: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("failed to parse json: {e}"))?;
let text = v
.get("text")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string();
if text.is_empty() {
Err("empty transcription result".to_string())
} else {
Ok(text)
}
}
#[cfg(test)]
mod tests {
use super::RecordedAudio;
use super::convert_pcm16;
use super::encode_wav_normalized;
use pretty_assertions::assert_eq;
use std::io::Cursor;
#[test]
fn convert_pcm16_downmixes_and_resamples_for_model_input() {
@@ -887,25 +486,4 @@ mod tests {
);
assert_eq!(converted, vec![200, 700]);
}
#[test]
fn encode_wav_normalized_outputs_24khz_mono_audio() {
let audio = RecordedAudio {
data: vec![100, 300, 200, 400, 500, 700, 600, 800],
sample_rate: 48_000,
channels: 2,
};
let wav = encode_wav_normalized(&audio).expect("wav should encode");
let reader = hound::WavReader::new(Cursor::new(wav)).expect("wav should parse");
let spec = reader.spec();
let samples = reader
.into_samples::<i16>()
.collect::<Result<Vec<_>, _>>()
.expect("samples should decode");
assert_eq!(spec.channels, 1);
assert_eq!(spec.sample_rate, 24_000);
assert_eq!(samples, vec![8_426, 29_490]);
}
}