Merge branch 'main' into codex/viyatb/fix-linux-sandbox-detached-children

This commit is contained in:
viyatb-oai
2026-05-28 12:59:18 -07:00
committed by GitHub
53 changed files with 3325 additions and 481 deletions
+14 -4
View File
@@ -141,7 +141,9 @@ jobs:
- 2
- 3
- 4
runs-on: windows-latest
runs-on:
group: codex-runners
labels: codex-windows-x64
name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm shard ${{ matrix.shard }}/4
steps:
@@ -246,7 +248,9 @@ jobs:
# it a larger timeout.
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
timeout-minutes: 40
runs-on: windows-latest
runs-on:
group: codex-runners
labels: codex-windows-x64
name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm (native main)
steps:
@@ -332,7 +336,10 @@ jobs:
target: aarch64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-gnullvm
runs-on: ${{ matrix.os }}
runs_on:
group: codex-runners
labels: codex-windows-x64
runs-on: ${{ matrix.runs_on || matrix.os }}
name: Bazel clippy on ${{ matrix.os }} for ${{ matrix.target }}
steps:
@@ -422,7 +429,10 @@ jobs:
target: aarch64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-gnullvm
runs-on: ${{ matrix.os }}
runs_on:
group: codex-runners
labels: codex-windows-x64
runs-on: ${{ matrix.runs_on || matrix.os }}
name: Verify release build on ${{ matrix.os }} for ${{ matrix.target }}
steps:
-7
View File
@@ -402,13 +402,6 @@ jobs:
- name: cargo clippy
run: cargo clippy --target ${{ matrix.target }} --tests --profile ${{ matrix.profile }} --timings -- -D warnings
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49
with:
tool: just
- name: End-to-end benchmark smoke test
run: just bench-e2e-smoke
- name: Upload Cargo timings (clippy)
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+26 -11
View File
@@ -1928,6 +1928,7 @@ dependencies = [
"codex-git-utils",
"codex-guardian",
"codex-hooks",
"codex-image-generation-extension",
"codex-login",
"codex-mcp",
"codex-memories-extension",
@@ -2058,17 +2059,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "codex-app-server-start-bench"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-app-server-protocol",
"divan",
"serde_json",
"tempfile",
]
[[package]]
name = "codex-app-server-test-client"
version = "0.0.0"
@@ -3050,6 +3040,31 @@ dependencies = [
"uuid",
]
[[package]]
name = "codex-image-generation-extension"
version = "0.0.0"
dependencies = [
"async-trait",
"base64 0.22.1",
"codex-api",
"codex-core",
"codex-extension-api",
"codex-features",
"codex-login",
"codex-model-provider",
"codex-model-provider-info",
"codex-protocol",
"codex-tools",
"http 1.4.0",
"pretty_assertions",
"schemars 0.8.22",
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
]
[[package]]
name = "codex-install-context"
version = "0.0.0"
+2 -1
View File
@@ -14,7 +14,6 @@ members = [
"app-server-client",
"app-server-protocol",
"app-server-test-client",
"benchmarks/app-server-start",
"debug-client",
"apply-patch",
"arg0",
@@ -48,6 +47,7 @@ members = [
"ext/extension-api",
"ext/goal",
"ext/guardian",
"ext/image-generation",
"ext/memories",
"ext/web-search",
"external-agent-migration",
@@ -166,6 +166,7 @@ codex-execpolicy = { path = "execpolicy" }
codex-extension-api = { path = "ext/extension-api" }
codex-goal-extension = { path = "ext/goal" }
codex-guardian = { path = "ext/guardian" }
codex-image-generation-extension = { path = "ext/image-generation" }
codex-external-agent-migration = { path = "external-agent-migration" }
codex-external-agent-sessions = { path = "external-agent-sessions" }
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
+1
View File
@@ -54,6 +54,7 @@ codex-backend-client = { workspace = true }
codex-file-search = { workspace = true }
codex-chatgpt = { workspace = true }
codex-login = { workspace = true }
codex-image-generation-extension = { workspace = true }
codex-memories-extension = { workspace = true }
codex-web-search-extension = { workspace = true }
codex-memories-write = { workspace = true }
+1 -1
View File
@@ -23,7 +23,7 @@ Similar to [MCP](https://modelcontextprotocol.io/), `codex app-server` supports
Supported transports:
- stdio (`--listen stdio://`, default): newline-delimited JSON (JSONL)
- stdio (`--stdio` or `--listen stdio://`, default): newline-delimited JSON (JSONL)
- websocket (`--listen ws://IP:PORT`): one JSON-RPC message per websocket text frame (**experimental / unsupported**)
- unix socket (`--listen unix://` or `--listen unix://PATH`): websocket connections over `$CODEX_HOME/app-server-control/app-server-control.sock` or a custom socket path, using the standard HTTP Upgrade handshake
- off (`--listen off`): do not expose a local transport
+2 -1
View File
@@ -31,7 +31,8 @@ where
let mut builder = ExtensionRegistryBuilder::<Config>::with_event_sink(event_sink);
codex_guardian::install(&mut builder, guardian_agent_spawner);
codex_memories_extension::install(&mut builder, codex_otel::global());
codex_web_search_extension::install(&mut builder, auth_manager);
codex_web_search_extension::install(&mut builder, auth_manager.clone());
codex_image_generation_extension::install(&mut builder, auth_manager);
Arc::new(builder.build())
}
@@ -316,6 +316,73 @@ connectors = false
Ok(())
}
#[tokio::test]
async fn list_apps_keeps_apps_with_app_only_tools_accessible() -> Result<()> {
let connectors = vec![AppInfo {
id: "beta".to_string(),
name: "Beta".to_string(),
description: Some("Beta connector".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}];
let mut app_only_tool = connector_tool("beta", "Beta App")?;
app_only_tool
.meta
.as_mut()
.expect("connector tool should include metadata")
.0
.insert("ui".to_string(), json!({ "visibility": ["app"] }));
let tools = vec![app_only_tool];
let (server_url, server_handle) =
start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?;
let codex_home = TempDir::new()?;
write_connectors_config(codex_home.path(), &server_url)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-app-only")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_apps_list_request(AppsListParams {
limit: None,
cursor: None,
thread_id: None,
force_refetch: true,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let AppsListResponse { data, next_cursor } = to_response(response)?;
assert_eq!(data.len(), 1);
assert_eq!(data[0].id, "beta");
assert!(data[0].is_accessible);
assert!(next_cursor.is_none());
server_handle.abort();
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn list_apps_reports_is_enabled_from_config() -> Result<()> {
let connectors = vec![AppInfo {
@@ -1,20 +0,0 @@
load("//:defs.bzl", "codex_rust_crate", "workspace_root_test")
codex_rust_crate(
name = "app-server-start-bench",
crate_name = "codex_app_server_start_bench",
)
workspace_root_test(
name = "startup-bench",
# Include the Bazel-built CLI in this target's runfiles and expose its
# resolved executable path through the environment consumed by the runner.
runfile_env = {
"//codex-rs/cli:codex": "CODEX_BIN",
},
size = "large",
tags = ["manual"],
test_bin = ":codex-app-server-start-bench",
timeout = "long",
workspace_root_marker = "//codex-rs/utils/cargo-bin:repo_root.marker",
)
@@ -1,16 +0,0 @@
[package]
name = "codex-app-server-start-bench"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
codex-app-server-protocol = { workspace = true }
serde_json = { workspace = true }
divan = { workspace = true }
tempfile = { workspace = true }
@@ -1,174 +0,0 @@
use std::io::BufRead;
use std::io::BufReader;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::process::Child;
use std::process::ChildStdin;
use std::process::ChildStdout;
use std::process::Command;
use std::process::Stdio;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use divan::Bencher;
fn main() {
divan::main();
}
// Process startup is slow enough that 30 samples keep runs practical, and
// sample size 1 lets each measured server be reaped before the next starts.
// The benchmark warms one CODEX_HOME before timing to measure normal restarts.
// This e2e runner receives its separately built Codex binary from just or Bazel.
#[divan::bench(sample_count = 30, sample_size = 1, skip_ext_time)]
#[allow(clippy::expect_used)]
fn initialize_response(bencher: Bencher) {
let codex_bin = std::env::var_os("CODEX_BIN")
.map(PathBuf::from)
.expect("CODEX_BIN must point to the codex binary; run via `just bench-e2e` or Bazel");
let codex_home = tempfile::tempdir().expect("benchmark CODEX_HOME should be created");
drop(
start_until_initialize_response(&codex_bin, codex_home.path())
.expect("benchmark CODEX_HOME should be initialized"),
);
bencher.bench_local(|| {
start_until_initialize_response(&codex_bin, codex_home.path())
.expect("codex app-server should return an initialize response")
});
}
/// A running app-server that has returned a valid `initialize` response.
///
/// Divan drops benchmark outputs after it stops each timer interval. Returning
/// this value from the measured closure keeps the `initialized` notification
/// and forced process reaping outside startup latency.
struct InitializedAppServer {
child: Child,
stdin: Option<ChildStdin>,
stdout: Option<BufReader<ChildStdout>>,
acknowledge_on_drop: bool,
}
/// Spawn a stdio app-server and return once it responds successfully to `initialize`.
fn start_until_initialize_response(
codex_bin: &Path,
codex_home: &Path,
) -> Result<InitializedAppServer> {
let request_id = RequestId::Integer(0);
let child = Command::new(codex_bin)
.arg("app-server")
.arg("--listen")
.arg("stdio://")
.env("CODEX_HOME", codex_home)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.with_context(|| format!("failed to spawn `{}` app-server", codex_bin.display()))?;
let mut server = InitializedAppServer {
child,
stdin: None,
stdout: None,
acknowledge_on_drop: false,
};
server.stdin = Some(
server
.child
.stdin
.take()
.context("app-server stdin unavailable")?,
);
server.stdout = Some(BufReader::new(
server
.child
.stdout
.take()
.context("app-server stdout unavailable")?,
));
let request = ClientRequest::Initialize {
request_id: request_id.clone(),
params: InitializeParams {
client_info: ClientInfo {
name: "codex-app-server-start-bench".to_string(),
title: Some("Codex App Server Start Benchmark".to_string()),
version: env!("CARGO_PKG_VERSION").to_string(),
},
capabilities: Some(InitializeCapabilities {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
}),
},
};
let stdin = server
.stdin
.as_mut()
.context("app-server stdin unavailable")?;
writeln!(stdin, "{}", serde_json::to_string(&request)?)?;
stdin
.flush()
.context("failed to flush initialize request")?;
let mut line = String::new();
loop {
line.clear();
if server
.stdout
.as_mut()
.context("app-server stdout unavailable")?
.read_line(&mut line)?
== 0
{
bail!("app-server closed stdout before returning initialize response");
}
match serde_json::from_str::<JSONRPCMessage>(line.trim())? {
JSONRPCMessage::Response(JSONRPCResponse { id, result }) if id == request_id => {
let _: InitializeResponse = serde_json::from_value(result)
.context("initialize response missing expected payload")?;
server.acknowledge_on_drop = true;
return Ok(server);
}
JSONRPCMessage::Error(error) if error.id == request_id => {
bail!("initialize failed: {error:?}");
}
JSONRPCMessage::Request(_)
| JSONRPCMessage::Response(_)
| JSONRPCMessage::Notification(_)
| JSONRPCMessage::Error(_) => {}
}
}
}
impl Drop for InitializedAppServer {
fn drop(&mut self) {
if self.acknowledge_on_drop
&& let Some(stdin) = self.stdin.as_mut()
{
let initialized = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
if let Ok(payload) = serde_json::to_string(&initialized) {
let _ = writeln!(stdin, "{payload}");
let _ = stdin.flush();
}
}
let _ = self.stdin.take();
let _ = self.child.kill();
let _ = self.child.wait();
}
}
+29 -1
View File
@@ -452,6 +452,10 @@ struct AppServerCommand {
)]
listen: codex_app_server::AppServerTransport,
/// Use stdio as the transport (equivalent to `--listen stdio://`).
#[arg(long = "stdio", conflicts_with = "listen")]
stdio: bool,
/// Enable remote control for this app-server process.
#[arg(long = "remote-control", hide = true)]
remote_control: bool,
@@ -965,6 +969,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
subcommand,
strict_config: app_server_strict_config,
listen,
stdio,
remote_control,
analytics_default_enabled,
auth,
@@ -978,7 +983,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
)?;
match subcommand {
None => {
let transport = listen;
let transport = if stdio {
codex_app_server::AppServerTransport::Stdio
} else {
listen
};
let auth = auth.try_into_settings()?;
let runtime_options = codex_app_server::AppServerRuntimeOptions {
remote_control_enabled: remote_control,
@@ -3197,6 +3206,25 @@ mod tests {
);
}
#[test]
fn app_server_stdio_flag_parses() {
let app_server = app_server_from_args(["codex", "app-server", "--stdio"].as_ref());
assert!(app_server.stdio);
}
#[test]
fn app_server_stdio_flag_conflicts_with_listen() {
let err = MultitoolCli::try_parse_from([
"codex",
"app-server",
"--stdio",
"--listen",
"stdio://",
])
.expect_err("--stdio and --listen should be rejected together");
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn app_server_listen_unix_socket_url_parses() {
let app_server =
@@ -64,6 +64,7 @@ use rmcp::model::ReadResourceResult;
use rmcp::model::RequestId;
use rmcp::model::Resource;
use rmcp::model::ResourceTemplate;
use serde_json::Value as JsonValue;
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use tracing::Instrument;
@@ -72,6 +73,34 @@ use tracing::trace;
use tracing::trace_span;
use tracing::warn;
const MCP_UI_META_KEY: &str = "ui";
const MCP_UI_VISIBILITY_META_KEY: &str = "visibility";
const MCP_UI_MODEL_VISIBILITY: &str = "model";
/// Returns whether a tool may be included in model-facing tool declarations.
///
/// Tools without visibility metadata remain visible.
/// Tools with visibility metadata are hidden unless they explicitly include `model`.
///
/// <https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#resource-discovery>
pub fn tool_is_model_visible(tool: &ToolInfo) -> bool {
let Some(visibility) = tool
.tool
.meta
.as_deref()
.and_then(|meta| meta.get(MCP_UI_META_KEY))
.and_then(JsonValue::as_object)
.and_then(|ui| ui.get(MCP_UI_VISIBILITY_META_KEY))
.and_then(JsonValue::as_array)
else {
return true;
};
visibility
.iter()
.any(|target| target.as_str() == Some(MCP_UI_MODEL_VISIBILITY))
}
/// A thin wrapper around a set of running [`RmcpClient`] instances.
pub struct McpConnectionManager {
clients: HashMap<String, AsyncManagedClient>,
+1
View File
@@ -1,4 +1,5 @@
pub use connection_manager::McpConnectionManager;
pub use connection_manager::tool_is_model_visible;
pub use elicitation::ElicitationReviewRequest;
pub use elicitation::ElicitationReviewer;
pub use elicitation::ElicitationReviewerHandle;
+6
View File
@@ -473,6 +473,9 @@
"image_generation": {
"type": "boolean"
},
"imagegenext": {
"type": "boolean"
},
"in_app_browser": {
"type": "boolean"
},
@@ -4564,6 +4567,9 @@
"image_generation": {
"type": "boolean"
},
"imagegenext": {
"type": "boolean"
},
"in_app_browser": {
"type": "boolean"
},
+7 -1
View File
@@ -3,6 +3,7 @@ use std::collections::HashSet;
use codex_features::Feature;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::ToolInfo as McpToolInfo;
use codex_mcp::tool_is_model_visible;
use crate::config::Config;
use crate::connectors;
@@ -51,7 +52,9 @@ pub(crate) fn build_mcp_tool_exposure(
fn filter_non_codex_apps_mcp_tools_only(mcp_tools: &[McpToolInfo]) -> Vec<McpToolInfo> {
mcp_tools
.iter()
.filter(|tool| tool.server_name != CODEX_APPS_MCP_SERVER_NAME)
.filter(|tool| {
tool.server_name != CODEX_APPS_MCP_SERVER_NAME && tool_is_model_visible(tool)
})
.cloned()
.collect()
}
@@ -72,6 +75,9 @@ fn filter_codex_apps_mcp_tools(
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
return false;
}
if !tool_is_model_visible(tool) {
return false;
}
let Some(connector_id) = tool.connector_id.as_deref() else {
return false;
};
@@ -7,6 +7,7 @@ use codex_mcp::ToolInfo;
use codex_tools::ToolName;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
use rmcp::model::Meta;
use rmcp::model::Tool;
use super::*;
@@ -80,6 +81,16 @@ fn tool_names(tools: &[ToolInfo]) -> HashSet<ToolName> {
.collect()
}
fn with_visibility(mut tool: ToolInfo, visibility: &[&str]) -> ToolInfo {
tool.tool.meta = Some(Meta(
serde_json::json!({ "ui": { "visibility": visibility } })
.as_object()
.expect("metadata object")
.clone(),
));
tool
}
#[tokio::test]
async fn directly_exposes_small_effective_tool_sets() {
let config = test_config().await;
@@ -93,6 +104,84 @@ async fn directly_exposes_small_effective_tool_sets() {
assert!(exposure.deferred_tools.is_none());
}
#[tokio::test]
async fn excludes_tools_hidden_from_model_exposure() {
let config = test_config().await;
let visible_tool = make_mcp_tool(
"rmcp",
"visible_tool",
"mcp__rmcp",
"visible_tool",
/*connector_id*/ None,
/*connector_name*/ None,
);
let hidden_tool = with_visibility(
make_mcp_tool(
"rmcp",
"hidden_tool",
"mcp__rmcp",
"hidden_tool",
/*connector_id*/ None,
/*connector_name*/ None,
),
&["app"],
);
let empty_visibility_tool = with_visibility(
make_mcp_tool(
"rmcp",
"empty_visibility_tool",
"mcp__rmcp",
"empty_visibility_tool",
/*connector_id*/ None,
/*connector_name*/ None,
),
&[],
);
let visible_app_tool = with_visibility(
make_mcp_tool(
CODEX_APPS_MCP_SERVER_NAME,
"calendar_read",
"mcp__codex_apps__calendar",
"read",
Some("calendar"),
Some("Calendar"),
),
&["app", "model"],
);
let hidden_app_tool = with_visibility(
make_mcp_tool(
CODEX_APPS_MCP_SERVER_NAME,
"calendar_open",
"mcp__codex_apps__calendar",
"open",
Some("calendar"),
Some("Calendar"),
),
&["app"],
);
let mcp_tools = vec![
visible_tool.clone(),
hidden_tool,
empty_visibility_tool,
visible_app_tool.clone(),
hidden_app_tool,
];
let connectors = vec![make_connector("calendar", "Calendar")];
let exposure = build_mcp_tool_exposure(
&mcp_tools,
Some(connectors.as_slice()),
&config,
/*search_tool_enabled*/ false,
);
assert_eq!(
tool_names(&exposure.direct_tools),
tool_names(&[visible_tool, visible_app_tool])
);
assert!(exposure.deferred_tools.is_none());
}
#[tokio::test]
async fn searches_large_effective_tool_sets() {
let config = test_config().await;
+103
View File
@@ -7894,6 +7894,29 @@ async fn realtime_conversation_list_voices_emits_builtin_list() {
);
}
#[derive(Clone, Copy)]
struct CompletingTask;
impl SessionTask for CompletingTask {
fn kind(&self) -> TaskKind {
TaskKind::Regular
}
fn span_name(&self) -> &'static str {
"session_task.completing"
}
async fn run(
self: Arc<Self>,
_session: Arc<SessionTaskContext>,
_ctx: Arc<TurnContext>,
_input: Vec<TurnInput>,
_cancellation_token: CancellationToken,
) -> Option<String> {
None
}
}
#[derive(Clone, Copy)]
struct NeverEndingTask {
kind: TaskKind,
@@ -8238,6 +8261,86 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input()
));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn task_finish_emits_thread_idle_lifecycle_after_active_turn_clears() {
struct ThreadIdleRecorder {
calls: Arc<std::sync::atomic::AtomicUsize>,
idle_tx: async_channel::Sender<()>,
expected_thread_id: ThreadId,
}
#[async_trait::async_trait]
impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for ThreadIdleRecorder {
async fn on_thread_idle(&self, input: codex_extension_api::ThreadIdleInput<'_>) {
assert_eq!(
self.expected_thread_id.to_string(),
input.thread_store.level_id()
);
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.idle_tx.send(()).await.expect("idle receiver open");
}
}
let (mut session, turn_context) = make_session_and_context().await;
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (idle_tx, idle_rx) = async_channel::bounded(1);
let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new();
builder.thread_lifecycle_contributor(Arc::new(ThreadIdleRecorder {
calls: Arc::clone(&calls),
idle_tx,
expected_thread_id: session.conversation_id,
}));
session.services.extensions = Arc::new(builder.build());
let session = Arc::new(session);
session
.spawn_task(Arc::new(turn_context), Vec::new(), CompletingTask)
.await;
timeout(StdDuration::from_secs(2), idle_rx.recv())
.await
.expect("thread idle lifecycle")
.expect("idle receiver open");
assert_eq!(1, calls.load(std::sync::atomic::Ordering::SeqCst));
assert!(session.active_turn.lock().await.is_none());
}
#[tokio::test]
async fn thread_idle_lifecycle_waits_for_trigger_turn_mailbox_work() {
struct ThreadIdleRecorder {
calls: Arc<std::sync::atomic::AtomicUsize>,
}
#[async_trait::async_trait]
impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for ThreadIdleRecorder {
async fn on_thread_idle(&self, _input: codex_extension_api::ThreadIdleInput<'_>) {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
}
let (mut session, _turn_context) = make_session_and_context().await;
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new();
builder.thread_lifecycle_contributor(Arc::new(ThreadIdleRecorder {
calls: Arc::clone(&calls),
}));
session.services.extensions = Arc::new(builder.build());
session
.input_queue
.enqueue_mailbox_communication(InterAgentCommunication::new(
AgentPath::root(),
AgentPath::root(),
Vec::new(),
"pending trigger".to_string(),
/*trigger_turn*/ true,
))
.await;
session.emit_thread_idle_lifecycle_if_idle().await;
assert_eq!(0, calls.load(std::sync::atomic::Ordering::SeqCst));
}
#[tokio::test]
async fn steer_input_requires_active_turn() {
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
+17
View File
@@ -38,6 +38,23 @@ impl Session {
}
}
pub(crate) async fn emit_thread_idle_lifecycle_if_idle(&self) {
if self.active_turn.lock().await.is_some()
|| self.input_queue.has_trigger_turn_mailbox_items().await
{
return;
}
for contributor in self.services.extensions.thread_lifecycle_contributors() {
contributor
.on_thread_idle(codex_extension_api::ThreadIdleInput {
session_store: &self.services.session_extension_data,
thread_store: &self.services.thread_extension_data,
})
.await;
}
}
pub(super) async fn emit_turn_abort_lifecycle(
&self,
reason: TurnAbortReason,
+1
View File
@@ -793,6 +793,7 @@ impl Session {
{
warn!("failed to apply goal runtime maybe-continue event: {err}");
}
self.emit_thread_idle_lifecycle_if_idle().await;
}
async fn take_active_turn(&self) -> Option<ActiveTurn> {
@@ -271,6 +271,11 @@ mod tests {
session
.record_conversation_items(&turn, std::slice::from_ref(&history_item))
.await;
let raw_history_event = rx.recv().await.expect("history raw response item event");
let EventMsg::RawResponseItem(raw_history_item) = raw_history_event.msg else {
panic!("expected raw response item event");
};
assert_eq!(raw_history_item.item, history_item);
let invocation = ToolInvocation {
session,
turn,
+34 -5
View File
@@ -85,6 +85,8 @@ use std::sync::Arc;
use tracing::warn;
const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents.";
const IMAGE_GEN_NAMESPACE: &str = "image_gen";
const IMAGEGEN_TOOL_NAME: &str = "imagegen";
type PlannedRuntime = Arc<dyn CoreToolRuntime>;
@@ -257,7 +259,9 @@ fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec<ToolSpec> {
}) {
specs.push(web_search_tool);
}
if image_generation_tool_enabled(turn_context) {
if image_generation_tool_enabled(turn_context)
&& !standalone_image_generation_available(turn_context, context.extension_tool_executors)
{
specs.push(create_image_generation_tool("png"));
}
specs
@@ -316,21 +320,41 @@ fn agent_jobs_worker_tools_enabled(turn_context: &TurnContext) -> bool {
}
fn image_generation_tool_enabled(turn_context: &TurnContext) -> bool {
image_generation_runtime_enabled(turn_context)
&& turn_context
.features
.get()
.enabled(Feature::ImageGeneration)
}
fn image_generation_runtime_enabled(turn_context: &TurnContext) -> bool {
turn_context
.auth_manager
.as_deref()
.is_some_and(AuthManager::current_auth_uses_codex_backend)
&& turn_context.provider.capabilities().image_generation
&& turn_context
.features
.get()
.enabled(Feature::ImageGeneration)
&& turn_context
.model_info
.input_modalities
.contains(&InputModality::Image)
}
fn standalone_image_generation_model_visible(turn_context: &TurnContext) -> bool {
image_generation_runtime_enabled(turn_context)
&& turn_context.features.get().enabled(Feature::ImageGenExt)
&& namespace_tools_enabled(turn_context)
}
fn standalone_image_generation_available(
turn_context: &TurnContext,
extension_tools: &[Arc<dyn ToolExecutor<ExtensionToolCall>>],
) -> bool {
standalone_image_generation_model_visible(turn_context)
&& extension_tools.iter().any(|executor| {
executor.tool_name() == ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME)
})
}
fn wait_agent_timeout_options(turn_context: &TurnContext) -> WaitAgentTimeoutOptions {
if multi_agent_v2_enabled(turn_context) {
return WaitAgentTimeoutOptions {
@@ -839,6 +863,11 @@ fn append_extension_tool_executors(
for executor in executors.iter().cloned() {
let tool_name = executor.tool_name();
if tool_name == ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME)
&& !standalone_image_generation_model_visible(turn_context)
{
continue;
}
if !reserved_tool_names.insert(tool_name.clone()) {
warn!("Skipping extension tool `{tool_name}`: tool already registered");
continue;
@@ -960,6 +960,16 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() {
.await;
image_generation.assert_visible_contains(&["image_generation"]);
let extension_flag_without_imagegen_tool = probe(|turn| {
use_chatgpt_auth(turn);
set_feature(turn, Feature::ImageGeneration, /*enabled*/ true);
set_feature(turn, Feature::ImageGenExt, /*enabled*/ true);
turn.model_info.input_modalities = vec![InputModality::Image];
})
.await;
extension_flag_without_imagegen_tool.assert_visible_contains(&["image_generation"]);
extension_flag_without_imagegen_tool.assert_visible_lacks(&["image_gen"]);
let live_web_search = probe(|turn| {
set_web_search_mode(turn, WebSearchMode::Live);
turn.model_info.web_search_tool_type = WebSearchToolType::TextAndImage;
+57 -1
View File
@@ -27,12 +27,15 @@ const SERVER_NAME: &str = "codex-apps-test";
const SERVER_VERSION: &str = "1.0.0";
const SEARCHABLE_TOOL_COUNT: usize = 100;
const CALENDAR_CREATE_EVENT_TOOL_NAME: &str = "calendar_create_event";
const CALENDAR_APP_ONLY_TOOL_NAME: &str = "calendar_app_only_action";
pub const CALENDAR_EXTRACT_TEXT_TOOL_NAME: &str = "calendar_extract_text";
const CALENDAR_LIST_EVENTS_TOOL_NAME: &str = "calendar_list_events";
pub const DIRECT_CALENDAR_CREATE_EVENT_TOOL: &str = "mcp__codex_apps__calendar__create_event";
pub const DIRECT_CALENDAR_APP_ONLY_TOOL: &str = "mcp__codex_apps__calendar__app_only_action";
pub const DIRECT_CALENDAR_LIST_EVENTS_TOOL: &str = "mcp__codex_apps__calendar__list_events";
pub const DIRECT_CALENDAR_EXTRACT_TEXT_TOOL: &str = "mcp__codex_apps__calendar__extract_text";
pub const SEARCH_CALENDAR_NAMESPACE: &str = "mcp__codex_apps__calendar";
pub const SEARCH_CALENDAR_APP_ONLY_TOOL: &str = "_app_only_action";
pub const SEARCH_CALENDAR_CREATE_TOOL: &str = "_create_event";
pub const SEARCH_CALENDAR_EXTRACT_TEXT_TOOL: &str = "_extract_text";
pub const SEARCH_CALENDAR_LIST_TOOL: &str = "_list_events";
@@ -49,6 +52,12 @@ pub struct AppsTestServer {
pub chatgpt_base_url: String,
}
#[derive(Clone, Copy)]
pub enum AppsTestToolLoading {
Direct,
Searchable,
}
impl AppsTestServer {
pub async fn mount(server: &MockServer) -> Result<Self> {
Self::mount_with_connector_name(server, CONNECTOR_NAME).await
@@ -62,6 +71,7 @@ impl AppsTestServer {
CONNECTOR_NAME.to_string(),
CONNECTOR_DESCRIPTION.to_string(),
/*searchable*/ true,
/*include_app_only_tool*/ false,
)
.await;
Ok(Self {
@@ -80,6 +90,26 @@ impl AppsTestServer {
connector_name.to_string(),
CONNECTOR_DESCRIPTION.to_string(),
/*searchable*/ false,
/*include_app_only_tool*/ false,
)
.await;
Ok(Self {
chatgpt_base_url: server.uri(),
})
}
pub async fn mount_with_app_only_tool(
server: &MockServer,
tool_loading: AppsTestToolLoading,
) -> Result<Self> {
mount_oauth_metadata(server).await;
mount_connectors_directory(server).await;
mount_streamable_http_json_rpc(
server,
CONNECTOR_NAME.to_string(),
CONNECTOR_DESCRIPTION.to_string(),
matches!(tool_loading, AppsTestToolLoading::Searchable),
/*include_app_only_tool*/ true,
)
.await;
Ok(Self {
@@ -136,7 +166,7 @@ fn apps_tool_call_id(body: &Value) -> Option<&str> {
.as_str()
}
async fn recorded_apps_tool_calls(server: &MockServer) -> Vec<Value> {
pub async fn recorded_apps_tool_calls(server: &MockServer) -> Vec<Value> {
server
.received_requests()
.await
@@ -233,6 +263,7 @@ async fn mount_streamable_http_json_rpc(
connector_name: String,
connector_description: String,
searchable: bool,
include_app_only_tool: bool,
) {
Mock::given(method("POST"))
.and(path_regex("^/api/codex/apps/?$"))
@@ -240,6 +271,7 @@ async fn mount_streamable_http_json_rpc(
connector_name,
connector_description,
searchable,
include_app_only_tool,
})
.mount(server)
.await;
@@ -249,6 +281,7 @@ struct CodexAppsJsonRpcResponder {
connector_name: String,
connector_description: String,
searchable: bool,
include_app_only_tool: bool,
}
impl Respond for CodexAppsJsonRpcResponder {
@@ -419,6 +452,29 @@ impl Respond for CodexAppsJsonRpcResponder {
}));
}
}
if self.include_app_only_tool
&& let Some(tools) = response
.pointer_mut("/result/tools")
.and_then(Value::as_array_mut)
{
tools.push(json!({
"name": CALENDAR_APP_ONLY_TOOL_NAME,
"description": "Open a calendar app-only action.",
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
},
"_meta": {
"connector_id": CONNECTOR_ID,
"connector_name": self.connector_name.clone(),
"connector_description": self.connector_description.clone(),
"ui": {
"visibility": ["app"]
}
}
}));
}
ResponseTemplate::new(200).set_body_json(response)
}
"tools/call" => {
+90
View File
@@ -18,6 +18,10 @@ use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::UserInput;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::apps_test_server::AppsTestToolLoading;
use core_test_support::apps_test_server::DIRECT_CALENDAR_APP_ONLY_TOOL;
use core_test_support::apps_test_server::recorded_apps_tool_calls;
use core_test_support::apps_test_server::search_capable_apps_builder;
use core_test_support::assert_regex_match;
use core_test_support::responses;
use core_test_support::responses::ResponseMock;
@@ -495,6 +499,92 @@ if (!tool) {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn app_only_tools_are_not_visible_or_runnable_by_code_mode_model() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let apps_server =
AppsTestServer::mount_with_app_only_tool(&server, AppsTestToolLoading::Searchable).await?;
let code = format!(
r#"
const visibleTool = ALL_TOOLS.find(({{ name }}) => name === {visible_tool_name:?});
const tool = ALL_TOOLS.find(({{ name }}) => name === {tool_name:?});
let error = null;
try {{
await tools[{tool_name:?}]({{}});
}} catch (caught) {{
error = String(caught);
}}
text(JSON.stringify({{
visibleListed: visibleTool !== undefined,
listed: tool !== undefined,
callable: typeof tools[{tool_name:?}] === "function",
error,
}}));
"#,
visible_tool_name = "mcp__codex_apps__calendar_timezone_option_99",
tool_name = DIRECT_CALENDAR_APP_ONLY_TOOL,
);
responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_custom_tool_call("call-1", "exec", &code),
ev_completed("resp-1"),
]),
)
.await;
let second_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
)
.await;
let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone())
.with_config(|config| {
config
.features
.enable(Feature::CodeMode)
.expect("test config should allow feature update");
config
.features
.enable(Feature::CodeModeOnly)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
test.submit_turn("try to call the app-only calendar tool through exec")
.await?;
let req = second_mock.single_request();
let (output, success) = custom_tool_output_body_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"code mode visibility check should complete successfully: {output}"
);
let parsed: Value = serde_json::from_str(&output)?;
assert_eq!(parsed["visibleListed"], true);
assert_eq!(parsed["listed"], false);
assert_eq!(parsed["callable"], false);
assert!(
parsed["error"]
.as_str()
.is_some_and(|error| error.contains("is not a function")),
"app-only code mode call should fail before MCP dispatch: {parsed:?}"
);
assert!(
recorded_apps_tool_calls(&server).await.is_empty(),
"app-only code mode call should not reach the MCP server"
);
Ok(())
}
#[cfg_attr(windows, ignore = "no exec_command on Windows")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_only_can_call_nested_tools() -> Result<()> {
+78
View File
@@ -17,16 +17,20 @@ use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::UserInput;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::apps_test_server::AppsTestToolLoading;
use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI;
use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_RESOURCE_URI;
use core_test_support::apps_test_server::DIRECT_CALENDAR_CREATE_EVENT_TOOL as CALENDAR_CREATE_TOOL;
use core_test_support::apps_test_server::DIRECT_CALENDAR_LIST_EVENTS_TOOL as CALENDAR_LIST_TOOL;
use core_test_support::apps_test_server::SEARCH_CALENDAR_APP_ONLY_TOOL;
use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL;
use core_test_support::apps_test_server::SEARCH_CALENDAR_LIST_TOOL;
use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE;
use core_test_support::apps_test_server::apps_enabled_builder;
use core_test_support::apps_test_server::configure_search_capable_apps;
use core_test_support::apps_test_server::configure_search_capable_model;
use core_test_support::apps_test_server::recorded_apps_tool_call_by_call_id;
use core_test_support::apps_test_server::recorded_apps_tool_calls;
use core_test_support::apps_test_server::search_capable_apps_builder as configured_builder;
use core_test_support::responses::ResponsesRequest;
use core_test_support::responses::ev_assistant_message;
@@ -216,6 +220,80 @@ async fn always_defer_feature_hides_small_app_tool_sets() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn app_only_tools_are_not_visible_or_runnable_by_direct_model_calls() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server =
AppsTestServer::mount_with_app_only_tool(&server, AppsTestToolLoading::Direct).await?;
let call_id = "app-only-direct-call";
let mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call_with_namespace(
call_id,
SEARCH_CALENDAR_NAMESPACE,
SEARCH_CALENDAR_APP_ONLY_TOOL,
"{}",
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url.clone());
let test = builder.build(&server).await?;
test.submit_turn_with_approval_and_permission_profile(
"Try to call the app-only calendar tool.",
AskForApproval::Never,
PermissionProfile::Disabled,
)
.await?;
let requests = mock.requests();
assert!(
namespace_child_tool(
&requests[0].body_json(),
SEARCH_CALENDAR_NAMESPACE,
SEARCH_CALENDAR_CREATE_TOOL
)
.is_some(),
"visible tool from the app-only tool's connector should be declared"
);
assert!(
namespace_child_tool(
&requests[0].body_json(),
SEARCH_CALENDAR_NAMESPACE,
SEARCH_CALENDAR_APP_ONLY_TOOL
)
.is_none(),
"app-only tool should not be declared to a direct model"
);
assert!(
requests[1]
.function_call_output(call_id)
.get("output")
.and_then(Value::as_str)
.is_some_and(|output| output.contains("unsupported call")),
"forced app-only direct call should not dispatch"
);
assert!(
recorded_apps_tool_calls(&server).await.is_empty(),
"forced app-only direct call should not reach the MCP server"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn app_search_sources_are_hidden_for_api_key_auth() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -0,0 +1,9 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "image-generation",
crate_name = "codex_image_generation_extension",
compile_data = [
"imagegen_description.md",
],
)
+37
View File
@@ -0,0 +1,37 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-image-generation-extension"
version.workspace = true
[lib]
name = "codex_image_generation_extension"
path = "src/lib.rs"
doctest = false
[lints]
workspace = true
[dependencies]
async-trait = { workspace = true }
base64 = { workspace = true }
codex-api = { workspace = true }
codex-core = { workspace = true }
codex-extension-api = { workspace = true }
codex-features = { workspace = true }
codex-login = { workspace = true }
codex-model-provider = { workspace = true }
codex-model-provider-info = { workspace = true }
codex-protocol = { workspace = true }
codex-tools = { workspace = true }
http = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["fs"] }
tracing = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
@@ -0,0 +1,11 @@
The `image_gen.imagegen` tool enables image generation from descriptions and editing of existing images based on specific instructions. Use it when:
- The user requests an image based on a scene description, such as a diagram, portrait, comic, meme, or any other visual.
- The user wants to modify an attached or previously generated image with specific changes, including adding or removing elements, altering colors, improving quality/resolution, or transforming the style (e.g., cartoon, oil painting).
Guidelines:
- Set `action` to `generate` when the user asks for a brand new image.
- Set `action` to `edit` when the user asks to modify an existing image from the conversation history.
- Directly generate the image without reconfirmation or clarification.
- After each image generation, do not mention anything related to download. Do not summarize the image. Do not ask followup question. Do not say ANYTHING after you generate an image.
- Always use this tool for image editing unless the user explicitly requests otherwise. Do not use the `python` tool for image editing unless specifically instructed.
@@ -0,0 +1,60 @@
use codex_api::ImageEditRequest;
use codex_api::ImageGenerationRequest;
use codex_api::ImageResponse;
use codex_api::ImagesClient;
use codex_api::ReqwestTransport;
use codex_login::default_client::build_reqwest_client;
use codex_model_provider::SharedModelProvider;
use http::HeaderMap;
#[derive(Clone)]
pub(crate) struct CodexImagesBackend {
provider: SharedModelProvider,
}
impl CodexImagesBackend {
/// Creates a backend that sends image requests through the active model provider.
pub(crate) fn new(provider: SharedModelProvider) -> Self {
Self { provider }
}
/// Resolves the provider and auth required for the current image API request.
async fn client(&self) -> Result<ImagesClient<ReqwestTransport>, String> {
let provider = self
.provider
.api_provider()
.await
.map_err(|err| err.to_string())?;
let auth = self
.provider
.api_auth()
.await
.map_err(|err| err.to_string())?;
Ok(ImagesClient::new(
ReqwestTransport::new(build_reqwest_client()),
provider,
auth,
))
}
/// Sends a standalone image generation request through the configured Images client.
pub(crate) async fn generate(
&self,
request: ImageGenerationRequest,
) -> Result<ImageResponse, String> {
self.client()
.await?
.generate(&request, HeaderMap::new())
.await
.map_err(|err| err.to_string())
}
/// Sends a standalone image edit request through the configured Images client.
pub(crate) async fn edit(&self, request: ImageEditRequest) -> Result<ImageResponse, String> {
self.client()
.await?
.edit(&request, HeaderMap::new())
.await
.map_err(|err| err.to_string())
}
}
@@ -0,0 +1,99 @@
use std::path::PathBuf;
use std::sync::Arc;
use codex_core::config::Config;
use codex_extension_api::ConfigContributor;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ThreadLifecycleContributor;
use codex_extension_api::ThreadStartInput;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolContributor;
use codex_extension_api::ToolExecutor;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_model_provider::create_model_provider;
use codex_model_provider_info::ModelProviderInfo;
use crate::backend::CodexImagesBackend;
use crate::tool::ImageGenerationTool;
use crate::tool::generated_image_output_dir;
#[derive(Clone)]
struct ImageGenerationExtension {
auth_manager: Arc<AuthManager>,
}
#[derive(Clone)]
struct ImageGenerationExtensionConfig {
enabled: bool,
provider: ModelProviderInfo,
codex_home: PathBuf,
}
impl From<&Config> for ImageGenerationExtensionConfig {
/// Resolves whether standalone image generation should be available for a thread.
fn from(config: &Config) -> Self {
Self {
enabled: config.features.enabled(Feature::ImageGenExt)
&& config.model_provider.is_openai(),
provider: config.model_provider.clone(),
codex_home: config.codex_home.to_path_buf(),
}
}
}
#[async_trait::async_trait]
impl ThreadLifecycleContributor<Config> for ImageGenerationExtension {
/// Seeds image-generation availability when a thread begins.
async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) {
input
.thread_store
.insert(ImageGenerationExtensionConfig::from(input.config));
}
}
impl ConfigContributor<Config> for ImageGenerationExtension {
/// Refreshes image-generation availability after thread configuration changes.
fn on_config_changed(
&self,
_session_store: &ExtensionData,
thread_store: &ExtensionData,
_previous_config: &Config,
new_config: &Config,
) {
thread_store.insert(ImageGenerationExtensionConfig::from(new_config));
}
}
impl ToolContributor for ImageGenerationExtension {
/// Creates the image-generation tool exposed by this installed extension.
fn tools(
&self,
_session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
let Some(config) = thread_store.get::<ImageGenerationExtensionConfig>() else {
return Vec::new();
};
if !config.enabled || !self.auth_manager.current_auth_uses_codex_backend() {
return Vec::new();
}
vec![Arc::new(ImageGenerationTool::new(
CodexImagesBackend::new(create_model_provider(
config.provider.clone(),
Some(self.auth_manager.clone()),
)),
generated_image_output_dir(&config.codex_home, thread_store.level_id()),
))]
}
}
/// Installs the feature-gated standalone image-generation extension contributors.
pub fn install(registry: &mut ExtensionRegistryBuilder<Config>, auth_manager: Arc<AuthManager>) {
let extension = Arc::new(ImageGenerationExtension { auth_manager });
registry.thread_lifecycle_contributor(extension.clone());
registry.config_contributor(extension.clone());
registry.tool_contributor(extension);
}
+8
View File
@@ -0,0 +1,8 @@
mod backend;
mod extension;
mod tool;
pub use extension::install;
pub(crate) const IMAGE_GEN_NAMESPACE: &str = "image_gen";
pub(crate) const IMAGEGEN_TOOL_NAME: &str = "imagegen";
+341
View File
@@ -0,0 +1,341 @@
use codex_api::ImageBackground;
use codex_api::ImageEditRequest;
use codex_api::ImageGenerationRequest;
use codex_api::ImageQuality;
use codex_api::ImageUrl;
use codex_extension_api::ToolOutput;
use codex_extension_api::ToolPayload;
use codex_extension_api::ToolSpec;
use codex_protocol::models::ContentItem;
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_tools::ResponsesApiNamespaceTool;
use pretty_assertions::assert_eq;
use super::GeneratedImageOutput;
use super::ImageRequest;
use super::ImagegenAction;
use super::ImagegenArgs;
use super::generated_image_output_dir;
use super::imagegen_tool_spec;
use super::persist_generated_image;
use super::request_for_action;
use crate::IMAGE_GEN_NAMESPACE;
use crate::IMAGEGEN_TOOL_NAME;
const RESULT: &str = "cG5n";
#[test]
fn uses_reserved_image_gen_namespace() {
let ToolSpec::Namespace(spec) = imagegen_tool_spec() else {
panic!("imagegen should advertise a namespace tool");
};
assert_eq!(spec.name, IMAGE_GEN_NAMESPACE);
let ResponsesApiNamespaceTool::Function(function) = &spec.tools[0];
assert_eq!(function.name, IMAGEGEN_TOOL_NAME);
}
#[test]
fn generate_uses_fixed_request_defaults() {
assert_eq!(
request_for_action(&args(ImagegenAction::Generate, "paint a moonlit lake"), &[])
.expect("generation request should build"),
ImageRequest::Generate(ImageGenerationRequest {
prompt: "paint a moonlit lake".to_string(),
background: Some(ImageBackground::Auto),
model: "gpt-image-2".to_string(),
n: None,
quality: Some(ImageQuality::Auto),
size: Some("auto".to_string()),
})
);
}
#[tokio::test]
async fn generated_output_returns_image_input_and_persists_artifact() {
let tempdir = tempfile::tempdir().expect("tempdir");
let output_hint = persist_generated_image(tempdir.path(), "call-1", RESULT)
.await
.expect("generated image should persist");
let output = GeneratedImageOutput {
result: RESULT.to_string(),
output_hint: Some(output_hint),
};
let ResponseInputItem::FunctionCallOutput {
output: response_output,
..
} = output.to_response_item("call-1", &function_payload())
else {
panic!("imagegen should return function tool output");
};
let FunctionCallOutputBody::ContentItems(content_items) = response_output.body else {
panic!("imagegen output should contain generated image bytes");
};
assert_eq!(
content_items,
vec![
FunctionCallOutputContentItem::InputImage {
image_url: format!("data:image/png;base64,{RESULT}"),
detail: Some(DEFAULT_IMAGE_DETAIL),
},
FunctionCallOutputContentItem::InputText {
text: format!(
"Generated images are saved to {} as {} by default.\n\
If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.",
tempdir.path().display(),
tempdir.path().join("call-1.png").display(),
),
},
]
);
assert_eq!(
std::fs::read(tempdir.path().join("call-1.png")).expect("saved generated image"),
b"png"
);
}
#[test]
fn edit_matches_context_selector_for_generated_images_after_latest_user_anchor() {
let history = vec![
generated_item("g1"),
generated_item("g2"),
generated_item("g3"),
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![
ContentItem::InputImage {
image_url: "data:image/png;base64,u1".to_string(),
detail: None,
},
ContentItem::InputImage {
image_url: "data:image/png;base64,u2".to_string(),
detail: None,
},
],
phase: None,
},
generated_item("g4"),
generated_item("g5"),
generated_item("g6"),
generated_item("g7"),
];
assert_eq!(
edit_request("change the lighting", &history),
expected_edit_request(
"change the lighting",
&[
"data:image/png;base64,u1",
"data:image/png;base64,u2",
"data:image/png;base64,g5",
"data:image/png;base64,g6",
"data:image/png;base64,g7",
]
)
);
}
#[test]
fn edit_preserves_a_generated_image_when_user_anchor_fills_the_limit() {
let history = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: ["a", "b", "c", "d", "e"]
.into_iter()
.map(|image| ContentItem::InputImage {
image_url: format!("data:image/png;base64,{image}"),
detail: None,
})
.collect(),
phase: None,
},
generated_item("generated"),
];
assert_eq!(
edit_request("edit the last generated image", &history),
expected_edit_request(
"edit the last generated image",
&[
"data:image/png;base64,b",
"data:image/png;base64,c",
"data:image/png;base64,d",
"data:image/png;base64,e",
"data:image/png;base64,generated",
]
)
);
}
#[test]
fn edit_uses_latest_user_upload_before_a_text_only_follow_up() {
let history = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage {
image_url: "data:image/png;base64,user".to_string(),
detail: None,
}],
phase: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "edit this image".to_string(),
}],
phase: None,
},
];
assert_eq!(
edit_request("change the lighting", &history),
expected_edit_request("change the lighting", &["data:image/png;base64,user"])
);
}
#[test]
fn edit_reuses_images_from_prior_standalone_imagegen_calls() {
let history = vec![
ResponseItem::FunctionCall {
id: None,
name: IMAGEGEN_TOOL_NAME.to_string(),
namespace: Some(IMAGE_GEN_NAMESPACE.to_string()),
arguments: "{}".to_string(),
call_id: "imagegen-1".to_string(),
},
generated_function_output("imagegen-1", "standalone"),
];
assert_eq!(
edit_request("change the lighting", &history),
expected_edit_request("change the lighting", &["data:image/png;base64,standalone"])
);
}
#[test]
fn edit_keeps_newest_standalone_generated_images_when_over_limit() {
let history = (1..=6)
.flat_map(|index| {
let call_id = format!("imagegen-{index}");
vec![
ResponseItem::FunctionCall {
id: None,
name: IMAGEGEN_TOOL_NAME.to_string(),
namespace: Some(IMAGE_GEN_NAMESPACE.to_string()),
arguments: "{}".to_string(),
call_id: call_id.clone(),
},
generated_function_output(&call_id, &index.to_string()),
]
})
.collect::<Vec<_>>();
assert_eq!(
edit_request("change the lighting", &history),
expected_edit_request(
"change the lighting",
&[
"data:image/png;base64,2",
"data:image/png;base64,3",
"data:image/png;base64,4",
"data:image/png;base64,5",
"data:image/png;base64,6",
]
)
);
}
#[test]
fn edit_without_image_history_returns_tool_error() {
let error = request_for_action(&args(ImagegenAction::Edit, "change the lighting"), &[])
.expect_err("edit should require image context");
assert_eq!(
error.to_string(),
"image edit requested without any usable image in conversation history"
);
}
#[test]
fn generated_image_output_dir_is_scoped_to_sanitized_thread_id() {
assert_eq!(
generated_image_output_dir(std::path::Path::new("/tmp/codex-home"), "thread/1"),
std::path::PathBuf::from("/tmp/codex-home/generated_images/thread_1")
);
}
fn args(action: ImagegenAction, prompt: &str) -> ImagegenArgs {
ImagegenArgs {
prompt: prompt.to_string(),
action,
}
}
fn edit_request(prompt: &str, history: &[ResponseItem]) -> ImageEditRequest {
let ImageRequest::Edit(request) =
request_for_action(&args(ImagegenAction::Edit, prompt), history)
.expect("edit request should build")
else {
panic!("expected edit request");
};
request
}
fn expected_edit_request(prompt: &str, images: &[&str]) -> ImageEditRequest {
ImageEditRequest {
images: images
.iter()
.map(|image_url| ImageUrl {
image_url: (*image_url).to_string(),
})
.collect(),
prompt: prompt.to_string(),
background: Some(ImageBackground::Auto),
model: "gpt-image-2".to_string(),
n: None,
quality: Some(ImageQuality::Auto),
size: Some("auto".to_string()),
}
}
fn generated_item(result: &str) -> ResponseItem {
ResponseItem::ImageGenerationCall {
id: format!("id-{result}"),
status: "completed".to_string(),
revised_prompt: None,
result: result.to_string(),
}
}
fn generated_function_output(call_id: &str, result: &str) -> ResponseItem {
ResponseItem::FunctionCallOutput {
call_id: call_id.to_string(),
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputImage {
image_url: format!("data:image/png;base64,{result}"),
detail: Some(DEFAULT_IMAGE_DETAIL),
},
FunctionCallOutputContentItem::InputText {
text: "generated image save hint".to_string(),
},
]),
success: Some(true),
},
}
}
fn function_payload() -> ToolPayload {
ToolPayload::Function {
arguments: "{}".to_string(),
}
}
+395
View File
@@ -0,0 +1,395 @@
use std::path::Path;
use std::path::PathBuf;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_api::ImageBackground;
use codex_api::ImageEditRequest;
use codex_api::ImageGenerationRequest;
use codex_api::ImageQuality;
use codex_api::ImageUrl;
use codex_extension_api::FunctionCallError;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolExecutor;
use codex_extension_api::ToolName;
use codex_extension_api::ToolOutput;
use codex_extension_api::ToolPayload;
use codex_extension_api::ToolSpec;
use codex_extension_api::parse_tool_input_schema;
use codex_protocol::models::ContentItem;
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolExposure;
use codex_tools::default_namespace_description;
use schemars::JsonSchema;
use schemars::r#gen::SchemaSettings;
use serde::Deserialize;
use serde_json::Map;
use serde_json::Value;
use crate::IMAGE_GEN_NAMESPACE;
use crate::IMAGEGEN_TOOL_NAME;
use crate::backend::CodexImagesBackend;
const IMAGE_MODEL: &str = "gpt-image-2";
const MAX_EDIT_IMAGES: usize = 5;
const IMAGEGEN_DESCRIPTION: &str = include_str!("../imagegen_description.md");
const GENERATED_IMAGE_ARTIFACTS_DIR: &str = "generated_images";
#[derive(Clone)]
pub(crate) struct ImageGenerationTool {
backend: CodexImagesBackend,
output_dir: PathBuf,
}
impl ImageGenerationTool {
/// Creates an image-generation tool backed by an image API executor.
pub(crate) fn new(backend: CodexImagesBackend, output_dir: PathBuf) -> Self {
Self {
backend,
output_dir,
}
}
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ImagegenArgs {
prompt: String,
action: ImagegenAction,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
enum ImagegenAction {
Generate,
Edit,
}
#[async_trait::async_trait]
impl ToolExecutor<ToolCall> for ImageGenerationTool {
/// Keeps the tool in the existing image-generation Responses namespace.
fn tool_name(&self) -> ToolName {
ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME)
}
/// Advertises the model contract: a rewritten prompt and semantic action.
fn spec(&self) -> ToolSpec {
imagegen_tool_spec()
}
/// Keeps this model-facing tool out of the nested code-mode tool surface.
fn exposure(&self) -> ToolExposure {
ToolExposure::DirectModelOnly
}
/// Executes the selected image operation and returns the completed image result.
async fn handle(&self, call: ToolCall) -> Result<Box<dyn ToolOutput>, FunctionCallError> {
let args = parse_args(&call)?;
let request = request_for_action(&args, call.conversation_history.items())?;
let response = match request {
ImageRequest::Generate(request) => self.backend.generate(request).await,
ImageRequest::Edit(request) => self.backend.edit(request).await,
}
.map_err(|err| {
FunctionCallError::RespondToModel(format!("image generation failed: {err}"))
})?;
let Some(result) = response.data.into_iter().next().map(|data| data.b64_json) else {
return Err(FunctionCallError::RespondToModel(
"image generation returned no image data".to_string(),
));
};
let output_hint =
match persist_generated_image(&self.output_dir, &call.call_id, &result).await {
Ok(output_hint) => Some(output_hint),
Err(err) => {
tracing::warn!(
call_id = %call.call_id,
output_dir = %self.output_dir.display(),
"failed to save generated image: {err}"
);
None
}
};
Ok(Box::new(GeneratedImageOutput {
result,
output_hint,
}))
}
}
#[derive(Debug, PartialEq)]
enum ImageRequest {
Generate(ImageGenerationRequest),
Edit(ImageEditRequest),
}
/// Maps the model-selected action to the fixed image API request parameters.
fn request_for_action(
args: &ImagegenArgs,
history: &[ResponseItem],
) -> Result<ImageRequest, FunctionCallError> {
match args.action {
ImagegenAction::Generate => Ok(ImageRequest::Generate(ImageGenerationRequest {
prompt: args.prompt.clone(),
background: Some(ImageBackground::Auto),
model: IMAGE_MODEL.to_string(),
n: None,
quality: Some(ImageQuality::Auto),
size: Some("auto".to_string()),
})),
ImagegenAction::Edit => {
let images = edit_images(history);
if images.is_empty() {
return Err(FunctionCallError::RespondToModel(
"image edit requested without any usable image in conversation history"
.to_string(),
));
}
Ok(ImageRequest::Edit(ImageEditRequest {
images,
prompt: args.prompt.clone(),
background: Some(ImageBackground::Auto),
model: IMAGE_MODEL.to_string(),
n: None,
quality: Some(ImageQuality::Auto),
size: Some("auto".to_string()),
}))
}
}
}
/// Selects edit context using the hosted imagegen anchor and truncation behavior.
fn edit_images(history: &[ResponseItem]) -> Vec<ImageUrl> {
let latest_uploaded_images = history.iter().enumerate().rev().find_map(|(index, item)| {
let ResponseItem::Message { role, content, .. } = item else {
return None;
};
if role != "user" {
return None;
}
let images = content
.iter()
.filter_map(|item| match item {
ContentItem::InputImage { image_url, .. } => Some(ImageUrl {
image_url: image_url.clone(),
}),
ContentItem::InputText { .. } | ContentItem::OutputText { .. } => None,
})
.collect::<Vec<_>>();
(!images.is_empty()).then_some((index, images))
});
let (user_images, follow_up_start) = latest_uploaded_images
.map_or_else(|| (Vec::new(), 0), |(index, images)| (images, index + 1));
let mut generated_images = Vec::new();
for item in &history[follow_up_start..] {
match item {
ResponseItem::ImageGenerationCall { result, .. } if !result.is_empty() => {
generated_images.push(ImageUrl {
image_url: format!("data:image/png;base64,{result}"),
});
}
ResponseItem::FunctionCallOutput { call_id, output }
if history.iter().any(|item| {
matches!(
item,
ResponseItem::FunctionCall {
name,
namespace: Some(namespace),
call_id: function_call_id,
..
} if function_call_id == call_id
&& name == IMAGEGEN_TOOL_NAME
&& namespace == IMAGE_GEN_NAMESPACE
)
}) =>
{
generated_images.extend(output.content_items().into_iter().flatten().filter_map(
|item| match item {
FunctionCallOutputContentItem::InputImage { image_url, .. } => {
Some(ImageUrl {
image_url: image_url.clone(),
})
}
FunctionCallOutputContentItem::InputText { .. }
| FunctionCallOutputContentItem::EncryptedContent { .. } => None,
},
));
}
ResponseItem::Message { .. }
| ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::CompactionTrigger
| ResponseItem::ContextCompaction { .. }
| ResponseItem::Other => {}
}
}
truncate_images(user_images, generated_images)
}
/// Truncates edit inputs while preserving the newest generated image when possible.
fn truncate_images(
mut user_images: Vec<ImageUrl>,
mut generated_images: Vec<ImageUrl>,
) -> Vec<ImageUrl> {
let mut excess = (user_images.len() + generated_images.len()).saturating_sub(MAX_EDIT_IMAGES);
let drop_generated = excess.min(generated_images.len().saturating_sub(1));
generated_images.drain(..drop_generated);
excess -= drop_generated;
let drop_user = excess.min(user_images.len());
user_images.drain(..drop_user);
excess -= drop_user;
generated_images.drain(..excess);
user_images.extend(generated_images);
user_images
}
/// Parses the strict model-facing arguments for an image-generation call.
fn parse_args(call: &ToolCall) -> Result<ImagegenArgs, FunctionCallError> {
serde_json::from_str(call.function_arguments()?)
.map_err(|err| FunctionCallError::RespondToModel(err.to_string()))
}
/// Resolves where generated images for one thread are persisted by the extension.
pub(crate) fn generated_image_output_dir(codex_home: &Path, thread_id: &str) -> PathBuf {
codex_home
.join(GENERATED_IMAGE_ARTIFACTS_DIR)
.join(sanitize_path_component(thread_id))
}
fn generated_image_output_path(output_dir: &Path, call_id: &str) -> PathBuf {
output_dir.join(format!("{}.png", sanitize_path_component(call_id)))
}
fn sanitize_path_component(value: &str) -> String {
let sanitized: String = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'_'
}
})
.collect();
if sanitized.is_empty() {
"generated_image".to_string()
} else {
sanitized
}
}
async fn persist_generated_image(
output_dir: &Path,
call_id: &str,
result: &str,
) -> Result<String, String> {
let bytes = BASE64_STANDARD
.decode(result.trim().as_bytes())
.map_err(|err| format!("invalid image generation payload: {err}"))?;
tokio::fs::create_dir_all(output_dir)
.await
.map_err(|err| err.to_string())?;
tokio::fs::write(generated_image_output_path(output_dir, call_id), bytes)
.await
.map_err(|err| err.to_string())?;
Ok(format!(
"Generated images are saved to {} as {} by default.\n\
If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.",
output_dir.display(),
generated_image_output_path(output_dir, call_id).display(),
))
}
/// Builds the namespace function schema exposed to the model.
fn imagegen_tool_spec() -> ToolSpec {
let mut schema_value = serde_json::to_value(
SchemaSettings::draft2019_09()
.with(|settings| settings.inline_subschemas = true)
.into_generator()
.into_root_schema_for::<ImagegenArgs>(),
)
.unwrap_or_else(|err| panic!("imagegen schema should serialize: {err}"));
let Value::Object(ref mut schema) = schema_value else {
unreachable!("imagegen root schema must be an object");
};
let mut input_schema = Map::new();
for key in ["properties", "required", "type", "additionalProperties"] {
if let Some(value) = schema.remove(key) {
input_schema.insert(key.to_string(), value);
}
}
ToolSpec::Namespace(ResponsesApiNamespace {
name: IMAGE_GEN_NAMESPACE.to_string(),
description: default_namespace_description(IMAGE_GEN_NAMESPACE),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: IMAGEGEN_TOOL_NAME.to_string(),
description: IMAGEGEN_DESCRIPTION.to_string(),
strict: false,
parameters: parse_tool_input_schema(&Value::Object(input_schema))
.unwrap_or_else(|err| panic!("imagegen input schema should parse: {err}")),
output_schema: None,
defer_loading: None,
})],
})
}
struct GeneratedImageOutput {
result: String,
output_hint: Option<String>,
}
impl ToolOutput for GeneratedImageOutput {
/// Avoids copying image bytes into tool-call telemetry.
fn log_preview(&self) -> String {
"[generated image]".to_string()
}
/// Reports a completed images request as successful tool execution.
fn success_for_logging(&self) -> bool {
true
}
/// Returns generated bytes and persisted-artifact context for the model's follow-up response.
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
let mut content = vec![FunctionCallOutputContentItem::InputImage {
image_url: format!("data:image/png;base64,{}", self.result),
detail: Some(DEFAULT_IMAGE_DETAIL),
}];
if let Some(output_hint) = &self.output_hint {
content.push(FunctionCallOutputContentItem::InputText {
text: output_hint.clone(),
});
}
ResponseInputItem::FunctionCallOutput {
call_id: call_id.to_string(),
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(content),
success: Some(true),
},
}
}
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;
+9 -5
View File
@@ -170,6 +170,8 @@ pub enum Feature {
ExternalMigration,
/// Allow the model to invoke the built-in image generation tool.
ImageGeneration,
/// Replace hosted image generation with the standalone image-generation extension.
ImageGenExt,
/// Allow prompting and installing missing MCP dependencies.
SkillMcpDependencyInstall,
/// Removed compatibility flag for deleted skill env var dependency prompting.
@@ -1053,6 +1055,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::ImageGenExt,
key: "imagegenext",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::SkillMcpDependencyInstall,
key: "skill_mcp_dependency_install",
@@ -1068,11 +1076,7 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::MentionsV2,
key: "mentions_v2",
stage: Stage::Experimental {
name: "Mentions v2",
menu_description: "Use a unified @ mention popup for files, folders, apps, plugins, and skills.",
announcement: "",
},
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
+14
View File
@@ -244,6 +244,13 @@ fn image_generation_is_stable_and_enabled_by_default() {
assert_eq!(Feature::ImageGeneration.default_enabled(), true);
}
#[test]
fn image_generation_extension_is_under_development_and_disabled_by_default() {
assert_eq!(Feature::ImageGenExt.stage(), Stage::UnderDevelopment);
assert_eq!(Feature::ImageGenExt.default_enabled(), false);
assert_eq!(feature_for_key("imagegenext"), Some(Feature::ImageGenExt));
}
#[test]
fn use_legacy_landlock_config_records_deprecation_notice() {
let mut entries = BTreeMap::new();
@@ -304,6 +311,13 @@ fn auth_elicitation_is_under_development() {
);
}
#[test]
fn mentions_v2_is_under_development_and_disabled_by_default() {
assert_eq!(Feature::MentionsV2.stage(), Stage::UnderDevelopment);
assert_eq!(Feature::MentionsV2.default_enabled(), false);
assert_eq!(feature_for_key("mentions_v2"), Some(Feature::MentionsV2));
}
#[test]
fn remote_control_is_removed_and_disabled_by_default() {
assert_eq!(Feature::RemoteControl.stage(), Stage::Removed);
+1
View File
@@ -37,6 +37,7 @@ pub const OPENAI_PROVIDER_ID: &str = "openai";
pub const CHATGPT_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
const AMAZON_BEDROCK_PROVIDER_NAME: &str = "Amazon Bedrock";
pub const AMAZON_BEDROCK_PROVIDER_ID: &str = "amazon-bedrock";
pub const AMAZON_BEDROCK_GPT_5_5_MODEL_ID: &str = "openai.gpt-5.5";
pub const AMAZON_BEDROCK_GPT_5_4_MODEL_ID: &str = "openai.gpt-5.4";
pub const AMAZON_BEDROCK_DEFAULT_BASE_URL: &str =
"https://bedrock-mantle.us-east-1.api.aws/openai/v1";
@@ -1,83 +1,66 @@
use codex_model_provider_info::AMAZON_BEDROCK_GPT_5_4_MODEL_ID;
use codex_model_provider_info::AMAZON_BEDROCK_GPT_5_5_MODEL_ID;
use codex_models_manager::bundled_models_response;
use codex_models_manager::model_info::BASE_INSTRUCTIONS;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Verbosity;
use codex_protocol::openai_models::ApplyPatchToolType;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelServiceTier;
use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::openai_models::SPEED_TIER_FAST;
use codex_protocol::openai_models::TruncationPolicyConfig;
use codex_protocol::openai_models::WebSearchToolType;
const GPT_OSS_CONTEXT_WINDOW: i64 = 128_000;
const GPT_5_4_CONTEXT_WINDOW: i64 = 272_000;
const GPT_5_4_MAX_CONTEXT_WINDOW: i64 = 1_000_000;
const GPT_5_BEDROCK_CONTEXT_WINDOW: i64 = 272_000;
const GPT_5_5_OPENAI_MODEL_ID: &str = "gpt-5.5";
const GPT_5_4_OPENAI_MODEL_ID: &str = "gpt-5.4";
pub(crate) fn static_model_catalog() -> ModelsResponse {
ModelsResponse {
models: vec![
gpt_5_4_cmb_bedrock_model(/*priority*/ 0),
gpt_5_bedrock_model(
GPT_5_5_OPENAI_MODEL_ID,
AMAZON_BEDROCK_GPT_5_5_MODEL_ID,
/*priority*/ 0,
),
gpt_5_bedrock_model(
GPT_5_4_OPENAI_MODEL_ID,
AMAZON_BEDROCK_GPT_5_4_MODEL_ID,
/*priority*/ 1,
),
bedrock_oss_model(
"openai.gpt-oss-120b",
"GPT OSS 120B on Bedrock",
/*priority*/ 1,
/*priority*/ 2,
),
bedrock_oss_model(
"openai.gpt-oss-20b",
"GPT OSS 20B on Bedrock",
/*priority*/ 2,
/*priority*/ 3,
),
],
}
}
fn gpt_5_4_cmb_bedrock_model(priority: i32) -> ModelInfo {
ModelInfo {
slug: AMAZON_BEDROCK_GPT_5_4_MODEL_ID.to_string(),
display_name: "gpt-5.4".to_string(),
description: Some("Strong model for everyday coding.".to_string()),
default_reasoning_level: Some(ReasoningEffort::Medium),
supported_reasoning_levels: gpt_5_4_cmb_reasoning_levels(),
shell_type: ConfigShellToolType::ShellCommand,
visibility: ModelVisibility::List,
supported_in_api: true,
priority,
additional_speed_tiers: Vec::new(),
service_tiers: vec![ModelServiceTier {
id: ServiceTier::Fast.request_value().to_string(),
name: SPEED_TIER_FAST.to_string(),
description: "Fastest inference with increased plan usage".to_string(),
}],
default_service_tier: None,
availability_nux: None,
upgrade: None,
base_instructions: BASE_INSTRUCTIONS.to_string(),
model_messages: None,
supports_reasoning_summaries: true,
default_reasoning_summary: ReasoningSummary::None,
support_verbosity: true,
default_verbosity: Some(Verbosity::Medium),
apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
web_search_tool_type: WebSearchToolType::TextAndImage,
truncation_policy: TruncationPolicyConfig::tokens(/*limit*/ 10_000),
supports_parallel_tool_calls: true,
supports_image_detail_original: true,
context_window: Some(GPT_5_4_CONTEXT_WINDOW),
max_context_window: Some(GPT_5_4_MAX_CONTEXT_WINDOW),
auto_compact_token_limit: None,
effective_context_window_percent: 95,
experimental_supported_tools: Vec::new(),
input_modalities: vec![InputModality::Text, InputModality::Image],
used_fallback_model_metadata: false,
supports_search_tool: true,
}
fn gpt_5_bedrock_model(openai_slug: &str, bedrock_slug: &str, priority: i32) -> ModelInfo {
let mut model = bundled_openai_model(openai_slug);
model.slug = bedrock_slug.to_string();
model.priority = priority;
model.context_window = Some(GPT_5_BEDROCK_CONTEXT_WINDOW);
model.max_context_window = Some(GPT_5_BEDROCK_CONTEXT_WINDOW);
model
}
fn bundled_openai_model(slug: &str) -> ModelInfo {
bundled_models_response()
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"))
.models
.into_iter()
.find(|model| model.slug == slug)
.unwrap_or_else(|| panic!("bundled models.json should include {slug}"))
}
fn bedrock_oss_model(slug: &str, display_name: &str, priority: i32) -> ModelInfo {
@@ -122,15 +105,6 @@ fn bedrock_oss_model(slug: &str, display_name: &str, priority: i32) -> ModelInfo
}
}
fn gpt_5_4_cmb_reasoning_levels() -> Vec<ReasoningEffortPreset> {
vec![
reasoning_effort_preset(ReasoningEffort::Minimal),
reasoning_effort_preset(ReasoningEffort::Low),
reasoning_effort_preset(ReasoningEffort::Medium),
reasoning_effort_preset(ReasoningEffort::High),
]
}
fn reasoning_effort_preset(effort: ReasoningEffort) -> ReasoningEffortPreset {
ReasoningEffortPreset {
effort,
@@ -156,24 +130,40 @@ mod tests {
fn catalog_uses_mantle_model_ids_as_slugs() {
let catalog = static_model_catalog();
assert_eq!(catalog.models.len(), 3);
assert_eq!(catalog.models[0].slug, AMAZON_BEDROCK_GPT_5_4_MODEL_ID);
assert_eq!(catalog.models[1].slug, "openai.gpt-oss-120b");
assert_eq!(catalog.models[2].slug, "openai.gpt-oss-20b");
assert_eq!(catalog.models.len(), 4);
assert_eq!(catalog.models[0].slug, AMAZON_BEDROCK_GPT_5_5_MODEL_ID);
assert_eq!(catalog.models[1].slug, AMAZON_BEDROCK_GPT_5_4_MODEL_ID);
assert_eq!(catalog.models[2].slug, "openai.gpt-oss-120b");
assert_eq!(catalog.models[3].slug, "openai.gpt-oss-20b");
}
#[test]
fn gpt_5_4_cmb_advertises_only_bedrock_supported_reasoning_levels() {
fn gpt_5_bedrock_models_use_bedrock_context_window() {
let catalog = static_model_catalog();
let cmb_model = catalog
let gpt_5_5 = catalog
.models
.iter()
.find(|model| model.slug == AMAZON_BEDROCK_GPT_5_5_MODEL_ID)
.expect("Bedrock catalog should include GPT-5.5");
let gpt_5_4 = catalog
.models
.iter()
.find(|model| model.slug == AMAZON_BEDROCK_GPT_5_4_MODEL_ID)
.expect("Bedrock catalog should include GPT-5.4 CMB");
.expect("Bedrock catalog should include GPT-5.4");
assert_eq!(
cmb_model.supported_reasoning_levels,
gpt_5_4_cmb_reasoning_levels()
(gpt_5_5.context_window, gpt_5_5.max_context_window),
(
Some(GPT_5_BEDROCK_CONTEXT_WINDOW),
Some(GPT_5_BEDROCK_CONTEXT_WINDOW)
)
);
assert_eq!(
(gpt_5_4.context_window, gpt_5_4.max_context_window),
(
Some(GPT_5_BEDROCK_CONTEXT_WINDOW),
Some(GPT_5_BEDROCK_CONTEXT_WINDOW)
)
);
}
}
+2 -1
View File
@@ -510,6 +510,7 @@ mod tests {
assert_eq!(
model_ids,
vec![
"openai.gpt-5.5",
"openai.gpt-5.4",
"openai.gpt-oss-120b",
"openai.gpt-oss-20b"
@@ -523,7 +524,7 @@ mod tests {
.find(|preset| preset.is_default)
.expect("Bedrock catalog should have a default model");
assert_eq!(default_model.model, "openai.gpt-5.4");
assert_eq!(default_model.model, "openai.gpt-5.5");
}
#[tokio::test]
+635 -57
View File
@@ -256,7 +256,6 @@ use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
#[cfg(test)]
use ratatui::style::Color;
/// If the pasted content exceeds this number of characters, replace it with a
@@ -590,6 +589,7 @@ impl ChatComposer {
pub fn set_mentions_v2_enabled(&mut self, enabled: bool) {
self.mentions_v2_enabled = enabled;
self.history.set_at_mention_restore_enabled(enabled);
self.sync_popups();
}
@@ -609,11 +609,13 @@ impl ChatComposer {
pub(crate) fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
let elements = self.current_mention_elements();
let mut ordered = Vec::new();
for (id, mention) in elements {
for (id, sigil, mention) in elements {
if let Some(binding) = self.draft.mention_bindings.remove(&id)
&& binding.sigil == sigil
&& binding.mention == mention
{
ordered.push(MentionBinding {
sigil: binding.sigil,
mention: binding.mention,
path: binding.path,
});
@@ -1195,7 +1197,7 @@ impl ChatComposer {
///
/// This is the "fresh draft" path: it clears pending paste payloads and
/// mention link targets. Callers restoring a previously submitted draft
/// that must keep `$name -> path` resolution should use
/// that must keep sigiled mention target resolution should use
/// [`Self::set_text_content_with_mention_bindings`] instead.
pub(crate) fn set_text_content(
&mut self,
@@ -1649,7 +1651,7 @@ impl ChatComposer {
result
}
/// Return true if either the slash-command popup or the file-search popup is active.
/// Return true if any popup or history search is active.
pub(crate) fn popup_active(&self) -> bool {
self.history_search.is_some() || self.popups.active()
}
@@ -1802,7 +1804,6 @@ impl ChatComposer {
KeyEvent {
code: KeyCode::Esc, ..
} => {
// Hide popup without modifying text, remember token to avoid immediate reopen.
if let Some(tok) = Self::current_at_token(&self.draft.textarea) {
self.popups.dismissed_file_token = Some(tok);
}
@@ -1827,24 +1828,17 @@ impl ChatComposer {
};
let sel_path = sel.to_string_lossy().to_string();
// If selected path looks like an image (png/jpeg), attach as image instead of inserting text.
let is_image = Self::is_image_path(&sel_path);
if is_image {
// Determine dimensions; if that fails fall back to normal path insertion.
if Self::is_image_path(&sel_path) {
let path_buf = PathBuf::from(&sel_path);
match image::image_dimensions(&path_buf) {
Ok((width, height)) => {
tracing::debug!("selected image dimensions={}x{}", width, height);
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
// using the flat text and byte-offset cursor API.
let cursor_offset = self.draft.textarea.cursor();
let text = self.draft.textarea.text();
// Clamp to a valid char boundary to avoid panics when slicing.
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];
// Determine token boundaries in the full text.
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
@@ -1861,17 +1855,14 @@ impl ChatComposer {
self.draft.textarea.set_cursor(start_idx);
self.attach_image(path_buf);
// Add a trailing space to keep typing fluid.
self.draft.textarea.insert_str(" ");
}
Err(err) => {
tracing::trace!("image dimensions lookup failed: {err}");
// Fallback to plain path insertion if metadata read fails.
self.insert_selected_path(&sel_path);
}
}
} else {
// Non-image: inserting file path.
self.insert_selected_path(&sel_path);
}
self.popups.active = ActivePopup::None;
@@ -1881,6 +1872,7 @@ impl ChatComposer {
}
}
/// Handle key events when the legacy skill mention popup is visible.
fn handle_key_event_with_skill_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
@@ -1962,6 +1954,7 @@ impl ChatComposer {
return (InputResult::None, true);
}
self.footer.mode = reset_mode_after_activity(self.footer.mode);
let can_switch_search_mode = self.current_editable_at_token().is_some();
let ActivePopup::MentionV2(popup) = &mut self.popups.active else {
unreachable!();
@@ -1969,6 +1962,7 @@ impl ChatComposer {
let mut selected: Option<MentionV2Selection> = None;
let mut close_popup = false;
let mut submit_without_popup = false;
let result = match key_event {
KeyEvent {
@@ -1999,16 +1993,24 @@ impl ChatComposer {
modifiers: KeyModifiers::NONE,
..
} => {
popup.previous_search_mode();
(InputResult::None, true)
if can_switch_search_mode {
popup.previous_search_mode();
(InputResult::None, true)
} else {
self.handle_input_basic(key_event)
}
}
KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::NONE,
..
} => {
popup.next_search_mode();
(InputResult::None, true)
if can_switch_search_mode {
popup.next_search_mode();
(InputResult::None, true)
} else {
self.handle_input_basic(key_event)
}
}
KeyEvent {
code: KeyCode::Esc, ..
@@ -2021,14 +2023,19 @@ impl ChatComposer {
}
KeyEvent {
code: KeyCode::Tab, ..
} => {
selected = popup.selected();
close_popup = true;
(InputResult::None, true)
}
| KeyEvent {
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
selected = popup.selected();
close_popup = true;
submit_without_popup = selected.is_none();
(InputResult::None, true)
}
input => self.handle_input_basic(input),
@@ -2046,6 +2053,9 @@ impl ChatComposer {
}
}
self.popups.active = ActivePopup::None;
if submit_without_popup {
return self.handle_key_event_without_popup(key_event);
}
}
result
@@ -2241,14 +2251,14 @@ impl ChatComposer {
/// second `@` in `@scope/pkg@latest`), keep treating the surrounding
/// whitespace-delimited token as the active token rather than starting a
/// new token at that nested prefix.
/// - If the token under the cursor starts with `prefix`, that token is
/// returned without the leading prefix. When `allow_empty` is true, a
/// lone prefix character yields `Some(String::new())` to surface hints.
fn current_prefixed_token(
/// - If the token under the cursor starts with `prefix`, its byte range and
/// text without the leading prefix are returned. When `allow_empty` is
/// true, a lone prefix character yields `Some(String::new())` to surface hints.
fn current_prefixed_token_range(
textarea: &TextArea,
prefix: char,
allow_empty: bool,
) -> Option<String> {
) -> Option<(Range<usize>, String)> {
let cursor_offset = textarea.cursor();
let text = textarea.text();
@@ -2321,15 +2331,17 @@ impl ChatComposer {
let left_match = token_left.filter(|t| t.starts_with(prefix));
let right_match = token_right.filter(|t| t.starts_with(prefix));
let left_prefixed = left_match.map(|t| t[prefix.len_utf8()..].to_string());
let right_prefixed = right_match.map(|t| t[prefix.len_utf8()..].to_string());
let left_prefixed =
left_match.map(|t| (start_left..end_left, t[prefix.len_utf8()..].to_string()));
let right_prefixed =
right_match.map(|t| (start_right..end_right, t[prefix.len_utf8()..].to_string()));
if at_whitespace {
if right_prefixed.is_some() {
return right_prefixed;
}
if token_left.is_some_and(|t| t == prefix_str) {
return allow_empty.then(String::new);
return allow_empty.then(|| (start_left..end_left, String::new()));
}
return left_prefixed;
}
@@ -2347,6 +2359,14 @@ impl ChatComposer {
left_prefixed.or(right_prefixed)
}
fn current_prefixed_token(
textarea: &TextArea,
prefix: char,
allow_empty: bool,
) -> Option<String> {
Self::current_prefixed_token_range(textarea, prefix, allow_empty).map(|(_, token)| token)
}
/// Extract the `@token` that the cursor is currently positioned on, if any.
///
/// The returned string **does not** include the leading `@`.
@@ -2354,11 +2374,48 @@ impl ChatComposer {
Self::current_prefixed_token(textarea, '@', /*allow_empty*/ false)
}
fn current_editable_at_token_with_options(&self, allow_empty: bool) -> Option<String> {
let (range, token) =
Self::current_prefixed_token_range(&self.draft.textarea, '@', allow_empty)?;
if self
.draft
.textarea
.element_id_for_exact_range(range.clone())
.is_some()
{
return None;
}
let name_len = token
.as_bytes()
.iter()
.take_while(|byte| is_mention_name_char(**byte))
.count();
let mention_end = range.start + '@'.len_utf8() + name_len;
if name_len > 0
&& mention_end < range.end
&& ends_plaintext_at_mention(self.draft.textarea.text().as_bytes(), mention_end)
&& self
.draft
.textarea
.element_id_for_exact_range(range.start..mention_end)
.is_some()
{
return None;
}
Some(token)
}
fn current_editable_at_token(&self) -> Option<String> {
self.current_editable_at_token_with_options(/*allow_empty*/ false)
}
fn current_mentions_v2_token(&self) -> Option<String> {
if !self.mentions_v2_enabled {
return None;
}
Self::current_prefixed_token(&self.draft.textarea, '@', /*allow_empty*/ true)
self.current_editable_at_token_with_options(/*allow_empty*/ true)
}
fn current_mention_token(&self) -> Option<String> {
@@ -2441,12 +2498,13 @@ impl ChatComposer {
self.draft.textarea.set_cursor(start_idx);
let id = self.draft.textarea.insert_element(insert_text);
if let (Some(path), Some(mention)) =
(path, Self::mention_name_from_insert_text(insert_text))
if let (Some(path), Some((sigil, mention))) =
(path, Self::mention_token_from_insert_text(insert_text))
{
self.draft.mention_bindings.insert(
id,
ComposerMentionBinding {
sigil,
mention,
path: path.to_string(),
},
@@ -2460,8 +2518,12 @@ impl ChatComposer {
self.draft.textarea.set_cursor(new_cursor);
}
fn mention_name_from_insert_text(insert_text: &str) -> Option<String> {
let name = insert_text.strip_prefix('$')?;
fn mention_token_from_insert_text(insert_text: &str) -> Option<(char, String)> {
let sigil = insert_text.chars().next()?;
if !matches!(sigil, '$' | '@') {
return None;
}
let name = &insert_text[sigil.len_utf8()..];
if name.is_empty() {
return None;
}
@@ -2470,31 +2532,33 @@ impl ChatComposer {
.iter()
.all(|byte| is_mention_name_char(*byte))
{
Some(name.to_string())
Some((sigil, name.to_string()))
} else {
None
}
}
fn current_mention_elements(&self) -> Vec<(u64, String)> {
fn current_mention_elements(&self) -> Vec<(u64, char, String)> {
self.draft
.textarea
.text_element_snapshots()
.into_iter()
.filter_map(|snapshot| {
Self::mention_name_from_insert_text(snapshot.text.as_str())
.map(|mention| (snapshot.id, mention))
Self::mention_token_from_insert_text(snapshot.text.as_str())
.map(|(sigil, mention)| (snapshot.id, sigil, mention))
})
.collect()
}
fn snapshot_mention_bindings(&self) -> Vec<MentionBinding> {
let mut ordered = Vec::new();
for (id, mention) in self.current_mention_elements() {
for (id, sigil, mention) in self.current_mention_elements() {
if let Some(binding) = self.draft.mention_bindings.get(&id)
&& binding.sigil == sigil
&& binding.mention == mention
{
ordered.push(MentionBinding {
sigil: binding.sigil,
mention: binding.mention.clone(),
path: binding.path.clone(),
});
@@ -2512,7 +2576,7 @@ impl ChatComposer {
let text = self.draft.textarea.text().to_string();
let mut scan_from = 0usize;
for binding in mention_bindings {
let token = format!("${}", binding.mention);
let token = format!("{}{}", binding.sigil, binding.mention);
let Some(range) =
find_next_mention_token_range(text.as_str(), token.as_str(), scan_from)
else {
@@ -2531,6 +2595,7 @@ impl ChatComposer {
self.draft.mention_bindings.insert(
id,
ComposerMentionBinding {
sigil: binding.sigil,
mention: binding.mention,
path: binding.path,
},
@@ -2540,6 +2605,21 @@ impl ChatComposer {
}
}
fn plugin_at_mention_highlights(&self) -> Vec<(Range<usize>, Style)> {
self.draft
.textarea
.text_element_snapshots()
.into_iter()
.filter_map(|snapshot| {
let binding = self.draft.mention_bindings.get(&snapshot.id)?;
if !binding.path.starts_with("plugin://") || !snapshot.text.starts_with('@') {
return None;
}
Some((snapshot.range, Style::default().fg(Color::Magenta)))
})
.collect()
}
/// Prepare text for submission/queuing. Returns None if submission should be suppressed.
/// On success, clears pending paste payloads because placeholders have been expanded.
///
@@ -3411,7 +3491,7 @@ impl ChatComposer {
let file_token = if self.mentions_v2_enabled {
None
} else {
Self::current_at_token(&self.draft.textarea)
self.current_editable_at_token()
};
let browsing_history = self
.history
@@ -3537,10 +3617,8 @@ impl ChatComposer {
}
}
/// Synchronize `self.file_search_popup` with the current text in the textarea.
/// Note this is only called when the active popup is NOT Command.
/// Synchronize the legacy file-search popup with the current `@` token.
fn sync_file_search_popup(&mut self, query: String) {
// If user dismissed popup for this exact query, don't reopen until text changes.
if self.popups.dismissed_file_token.as_ref() == Some(&query) {
return;
}
@@ -3901,16 +3979,46 @@ fn is_mention_name_char(byte: u8) -> bool {
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-')
}
fn ends_plaintext_at_mention(bytes: &[u8], index: usize) -> bool {
bytes.get(index).is_none_or(|byte| {
byte.is_ascii_whitespace()
|| *byte == b'.'
&& bytes.get(index + 1).is_none_or(|next| {
next.is_ascii_whitespace()
|| !next.is_ascii_alphanumeric() && *next != b'_' && *next != b'-'
})
|| !matches!(*byte, b'.' | b'/' | b'\\')
&& !byte.is_ascii_alphanumeric()
&& *byte != b'_'
&& *byte != b'-'
})
}
fn starts_plaintext_at_mention(text: &str, index: usize) -> bool {
if index == 0 {
return true;
}
text.get(..index)
.and_then(|prefix| prefix.chars().next_back())
.is_some_and(|ch| ch.is_whitespace() || !is_mention_name_char_char(ch))
}
fn is_mention_name_char_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
}
fn find_next_mention_token_range(text: &str, token: &str, from: usize) -> Option<Range<usize>> {
if token.is_empty() || from >= text.len() {
return None;
}
let bytes = text.as_bytes();
let token_bytes = token.as_bytes();
let sigil = *token_bytes.first()?;
let mut index = from;
while index < bytes.len() {
if bytes[index] != b'$' {
if bytes[index] != sigil {
index += 1;
continue;
}
@@ -3924,10 +4032,24 @@ fn find_next_mention_token_range(text: &str, token: &str, from: usize) -> Option
continue;
}
if bytes
.get(end)
.is_none_or(|byte| !is_mention_name_char(*byte))
{
// Fix for restored `@` mentions: rebinding must not attach to embedded substrings such
// as email addresses, while preserving the existing `$` mention matching behavior.
let starts_plaintext_mention = if sigil == b'@' {
starts_plaintext_at_mention(text, index)
} else {
true
};
// Fix for restored `@` mentions: mirror history encoding's trailing boundary so path-like
// text such as `@sample/pkg` is not rebound as the plain `@sample` mention.
let ends_plaintext_mention = if sigil == b'@' {
ends_plaintext_at_mention(bytes, end)
} else {
bytes
.get(end)
.is_none_or(|byte| !is_mention_name_char(*byte))
};
if starts_plaintext_mention && ends_plaintext_mention {
return Some(index..end);
}
@@ -4286,8 +4408,15 @@ impl ChatComposer {
.textarea
.render_ref_masked(textarea_rect, buf, &mut state, mask_char);
} else {
let highlight_ranges = self.history_search_highlight_ranges();
if highlight_ranges.is_empty() {
let mut highlights = self.plugin_at_mention_highlights();
let search_highlight_style =
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD);
highlights.extend(
self.history_search_highlight_ranges()
.into_iter()
.map(|range| (range, search_highlight_style)),
);
if highlights.is_empty() {
StatefulWidgetRef::render_ref(
&(&self.draft.textarea),
textarea_rect,
@@ -4295,12 +4424,6 @@ impl ChatComposer {
&mut state,
);
} else {
let highlight_style =
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD);
let highlights = highlight_ranges
.into_iter()
.map(|range| (range, highlight_style))
.collect::<Vec<_>>();
self.draft.textarea.render_ref_styled_with_highlights(
textarea_rect,
buf,
@@ -4736,6 +4859,142 @@ mod tests {
);
}
fn plugin_mention_foreground_color(composer: &ChatComposer) -> Option<Color> {
let area = Rect::new(0, 0, 40, 5);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let textarea_row = 1;
let row_text = (0..area.width)
.map(|x| {
buf[(x, textarea_row)]
.symbol()
.chars()
.next()
.unwrap_or(' ')
})
.collect::<String>();
let mention_x = row_text
.find("@sample")
.expect("expected plugin mention in composer row");
buf[(mention_x as u16, textarea_row)].style().fg
}
#[test]
fn plugin_at_mentions_use_plugin_accent_style() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content_with_mention_bindings(
"@sample plugin".to_string(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
);
assert_eq!(
plugin_mention_foreground_color(&composer),
Some(Color::Magenta)
);
}
#[test]
fn plugin_at_mentions_render_with_plugin_accent_snapshot() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content_with_mention_bindings(
"@sample plugin".to_string(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
);
let area = Rect::new(0, 0, 40, 5);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let textarea_row = 1;
let mut text = String::new();
let mut magenta = String::new();
for x in 0..area.width {
let cell = &buf[(x, textarea_row)];
text.push(cell.symbol().chars().next().unwrap_or(' '));
magenta.push(if cell.style().fg == Some(Color::Magenta) {
'^'
} else {
' '
});
}
while text.ends_with(' ') {
text.pop();
}
while magenta.ends_with(' ') {
magenta.pop();
}
insta::assert_snapshot!(
"plugin_at_mentions_render_with_plugin_accent",
format!("text: {text}\nmagenta: {magenta}")
);
}
#[test]
fn recalled_plugin_at_mentions_keep_plugin_accent_style() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content_with_mention_bindings(
"@sample plugin".to_string(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
);
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
composer.set_text_content(String::new(), Vec::new(), Vec::new());
let (_, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(needs_redraw);
assert_eq!(
plugin_mention_foreground_color(&composer),
Some(Color::Magenta)
);
}
#[test]
fn status_line_hyperlink_marks_pr_number_cells() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
@@ -6446,6 +6705,291 @@ mod tests {
}
}
#[test]
fn set_text_content_rebinds_at_sigiled_mentions() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let mention_bindings = vec![MentionBinding {
sigil: '@',
mention: "figma".to_string(),
path: "/tmp/user/figma/SKILL.md".to_string(),
}];
composer.set_text_content_with_mention_bindings(
"@figma please".to_string(),
Vec::new(),
Vec::new(),
mention_bindings.clone(),
);
assert_eq!(composer.mention_bindings(), mention_bindings);
}
#[test]
fn set_text_content_rebinds_matching_sigil_only() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let mention_bindings = vec![MentionBinding {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma".to_string(),
}];
composer.set_text_content_with_mention_bindings(
"@figma then $figma".to_string(),
Vec::new(),
Vec::new(),
mention_bindings.clone(),
);
let bound_tokens = composer
.current_mention_elements()
.into_iter()
.map(|(_, sigil, mention)| (sigil, mention))
.collect::<Vec<_>>();
assert_eq!(bound_tokens, vec![('$', "figma".to_string())]);
assert_eq!(composer.mention_bindings(), mention_bindings);
}
#[test]
fn set_text_content_rebinds_both_sigil_forms() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let mention_bindings = vec![
MentionBinding {
sigil: '@',
mention: "figma".to_string(),
path: "plugin://figma@test".to_string(),
},
MentionBinding {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma".to_string(),
},
];
composer.set_text_content_with_mention_bindings(
"@figma then $figma".to_string(),
Vec::new(),
Vec::new(),
mention_bindings.clone(),
);
let bound_tokens = composer
.current_mention_elements()
.into_iter()
.map(|(_, sigil, mention)| (sigil, mention))
.collect::<Vec<_>>();
assert_eq!(
bound_tokens,
vec![('@', "figma".to_string()), ('$', "figma".to_string())]
);
assert_eq!(composer.mention_bindings(), mention_bindings);
}
#[test]
fn set_text_content_rebinds_at_mentions_after_email_substrings() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let text = "foo@sample.com then @sample".to_string();
let mention_start = text.rfind("@sample").expect("expected bound mention token");
let mention_range = mention_start..mention_start + "@sample".len();
let mention_bindings = vec![MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}];
composer.set_text_content_with_mention_bindings(
text,
Vec::new(),
Vec::new(),
mention_bindings.clone(),
);
// Fix coverage: only the plaintext `@sample` should be atomic; the email substring stays editable.
assert_eq!(
composer
.draft
.textarea
.text_element_snapshots()
.into_iter()
.map(|snapshot| snapshot.range)
.collect::<Vec<_>>(),
vec![mention_range]
);
assert_eq!(composer.mention_bindings(), mention_bindings);
}
#[test]
fn set_text_content_rebinds_at_mentions_after_punctuation() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let text = "Please ask (@sample)".to_string();
let mention_start = text.find("@sample").expect("expected bound mention token");
let mention_range = mention_start..mention_start + "@sample".len();
let mention_bindings = vec![MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}];
composer.set_text_content_with_mention_bindings(
text,
Vec::new(),
Vec::new(),
mention_bindings.clone(),
);
assert_eq!(
composer
.draft
.textarea
.text_element_snapshots()
.into_iter()
.map(|snapshot| snapshot.range)
.collect::<Vec<_>>(),
vec![mention_range]
);
assert_eq!(composer.mention_bindings(), mention_bindings);
}
#[test]
fn bound_at_mentions_do_not_block_arrow_navigation() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content_with_mention_bindings(
"go @figma now".to_string(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '@',
mention: "figma".to_string(),
path: "plugin://figma@debug".to_string(),
}],
);
composer.draft.textarea.set_cursor("go".len());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.cursor(), "go".len() + 1);
assert!(matches!(composer.popups.active, ActivePopup::None));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.cursor(), "go @figma".len());
assert!(matches!(composer.popups.active, ActivePopup::None));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.cursor(), "go ".len());
assert!(matches!(composer.popups.active, ActivePopup::None));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.cursor(), "go".len());
assert!(matches!(composer.popups.active, ActivePopup::None));
}
#[test]
fn restored_bound_at_mentions_do_not_open_mention_popup() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
for (text, move_cursor_to_end) in [
("@sample".to_string(), false),
("Please ask @sample.".to_string(), true),
] {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
config_name: "sample@test".to_string(),
display_name: "sample".to_string(),
description: None,
has_skills: true,
mcp_server_names: vec!["sample".to_string()],
app_connector_ids: Vec::new(),
}]));
composer.set_text_content_with_mention_bindings(
text.clone(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
);
if move_cursor_to_end {
composer.move_cursor_to_end();
}
assert!(matches!(composer.popups.active, ActivePopup::None));
let (result, consumed) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(consumed);
match result {
InputResult::Submitted {
text: submitted, ..
} => assert_eq!(submitted, text),
_ => panic!("expected restored bound mention to submit"),
}
}
}
#[test]
fn enter_submits_when_file_popup_has_no_selection() {
use crossterm::event::KeyCode;
@@ -6478,6 +7022,39 @@ mod tests {
}
}
#[test]
fn enter_submits_when_unified_mention_popup_has_no_selection() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_mentions_v2_enabled(/*enabled*/ true);
let input = "npx -y @kaeawc/auto-mobile@latest";
composer.draft.textarea.insert_str(input);
composer.draft.textarea.set_cursor(input.len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::MentionV2(_)));
let (result, consumed) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(consumed);
match result {
InputResult::Submitted { text, .. } => assert_eq!(text, input),
_ => panic!("expected Submitted"),
}
}
/// Behavior: if the ASCII path has a pending first char (flicker suppression) and a non-ASCII
/// char arrives next, the pending ASCII char should still be preserved and the overall input
/// should submit normally (i.e. we should not misclassify this as a paste burst).
@@ -8783,6 +9360,7 @@ mod tests {
);
let mention_bindings = vec![MentionBinding {
sigil: '$',
mention: "figma".to_string(),
path: "/tmp/user/figma/SKILL.md".to_string(),
}];
@@ -40,6 +40,7 @@ impl DraftState {
#[derive(Clone, Debug)]
pub(super) struct ComposerMentionBinding {
pub(super) sigil: char,
pub(super) mention: String,
pub(super) path: String,
}
@@ -18,7 +18,7 @@ use std::path::PathBuf;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::MentionBinding;
use crate::mention_codec::decode_history_mentions;
use crate::mention_codec::decode_history_mentions_with_at_mentions;
use codex_protocol::ThreadId;
use codex_protocol::user_input::TextElement;
@@ -47,7 +47,11 @@ impl HistoryEntry {
/// recorded with the full `HistoryEntry` value built by the composer; using `new` for a local
/// image or paste submission would make recall lose placeholder ownership.
pub(crate) fn new(text: String) -> Self {
let decoded = decode_history_mentions(&text);
Self::new_with_at_mentions(text, /*at_mentions_enabled*/ true)
}
pub(crate) fn new_with_at_mentions(text: String, at_mentions_enabled: bool) -> Self {
let decoded = decode_history_mentions_with_at_mentions(&text, at_mentions_enabled);
Self {
text: decoded.text,
text_elements: Vec::new(),
@@ -57,6 +61,7 @@ impl HistoryEntry {
.mentions
.into_iter()
.map(|mention| MentionBinding {
sigil: mention.sigil,
mention: mention.mention,
path: mention.path,
})
@@ -132,6 +137,8 @@ pub(crate) struct ChatComposerHistory {
/// Active incremental history search, if Ctrl+R search mode is open.
search: Option<HistorySearchState>,
/// Whether persistent history restore should rehydrate `@` tool mentions.
at_mention_restore_enabled: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -226,9 +233,21 @@ impl ChatComposerHistory {
history_cursor: None,
last_history_text: None,
search: None,
at_mention_restore_enabled: false,
}
}
pub fn set_at_mention_restore_enabled(&mut self, enabled: bool) {
if self.at_mention_restore_enabled == enabled {
return;
}
self.at_mention_restore_enabled = enabled;
self.fetched_history.clear();
self.history_cursor = None;
self.last_history_text = None;
self.search = None;
}
/// Updates persistent history metadata when a new session is configured.
///
/// This clears fetched entries, local entries, navigation cursors, and active search state
@@ -393,7 +412,9 @@ impl ChatComposerHistory {
return HistoryEntryResponse::Ignored;
}
let entry = entry.map(HistoryEntry::new);
let entry = entry.map(|entry| {
HistoryEntry::new_with_at_mentions(entry, self.at_mention_restore_enabled)
});
if let Some(entry) = entry.clone() {
self.fetched_history.insert(offset, entry);
}
@@ -840,6 +861,75 @@ mod tests {
);
}
#[test]
fn persistent_restore_gates_at_mentions() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut history = ChatComposerHistory::new();
history.set_metadata(test_thread_id(), /*log_id*/ 42, /*entry_count*/ 1);
assert!(history.navigate_up(&tx).is_none());
let disabled = history.on_entry_response(
/*log_id*/ 42,
/*offset*/ 0,
Some("[@sample](plugin://sample@test) and [$figma](app://figma)".to_string()),
&tx,
);
assert_eq!(
disabled,
HistoryEntryResponse::Found(HistoryEntry {
text: "$sample and $figma".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
mention_bindings: vec![
MentionBinding {
sigil: '$',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
},
MentionBinding {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma".to_string(),
},
],
pending_pastes: Vec::new(),
})
);
history.set_at_mention_restore_enabled(/*enabled*/ true);
assert!(history.navigate_up(&tx).is_none());
let enabled = history.on_entry_response(
/*log_id*/ 42,
/*offset*/ 0,
Some("[@sample](plugin://sample@test) and [$figma](app://figma)".to_string()),
&tx,
);
assert_eq!(
enabled,
HistoryEntryResponse::Found(HistoryEntry {
text: "@sample and $figma".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
mention_bindings: vec![
MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
},
MentionBinding {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma".to_string(),
},
],
pending_pastes: Vec::new(),
})
);
}
#[test]
fn navigation_with_async_fetch() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
@@ -50,6 +50,7 @@ fn plugin_candidate(plugin: &PluginCapabilitySummary) -> Candidate {
.config_name
.split_once('@')
.unwrap_or((plugin.config_name.as_str(), ""));
let mention_name = plugin_mention_name(plugin_name, plugin.display_name.as_str());
let mut search_terms = vec![plugin_name.to_string(), plugin.config_name.clone()];
if plugin.display_name != plugin_name {
search_terms.push(plugin.display_name.clone());
@@ -64,12 +65,88 @@ fn plugin_candidate(plugin: &PluginCapabilitySummary) -> Candidate {
search_terms,
mention_type: MentionType::Plugin,
selection: Selection::Tool {
insert_text: format!("${plugin_name}"),
insert_text: format!("@{mention_name}"),
path: Some(format!("plugin://{}", plugin.config_name)),
},
}
}
fn plugin_mention_name(plugin_name: &str, display_name: &str) -> String {
let plugin_segments = split_plugin_name_segments(plugin_name);
let display_segments = split_display_name_segments(display_name);
if plugin_segments.len() == display_segments.len()
&& plugin_segments.iter().zip(&display_segments).all(
|((plugin_segment, _), display_segment)| {
plugin_segment.eq_ignore_ascii_case(display_segment.as_str())
},
)
{
let mut result = String::new();
for ((_, separator), display_segment) in plugin_segments.into_iter().zip(display_segments) {
result.push_str(display_segment.as_str());
if let Some(separator) = separator {
result.push(separator);
}
}
return result;
}
title_case_plugin_name(plugin_name)
}
fn split_plugin_name_segments(plugin_name: &str) -> Vec<(String, Option<char>)> {
let mut segments = Vec::new();
let mut current = String::new();
for ch in plugin_name.chars() {
if matches!(ch, '-' | '_') {
if !current.is_empty() {
segments.push((std::mem::take(&mut current), Some(ch)));
}
} else {
current.push(ch);
}
}
if !current.is_empty() {
segments.push((current, None));
}
segments
}
fn split_display_name_segments(display_name: &str) -> Vec<String> {
display_name
.split(|ch: char| !ch.is_ascii_alphanumeric())
.filter(|segment| !segment.is_empty())
.map(ToString::to_string)
.collect()
}
fn title_case_plugin_name(plugin_name: &str) -> String {
let mut result = String::with_capacity(plugin_name.len());
let mut capitalize_next = true;
for ch in plugin_name.chars() {
if matches!(ch, '-' | '_') {
capitalize_next = true;
result.push(ch);
continue;
}
if capitalize_next && ch.is_ascii_alphabetic() {
result.push(ch.to_ascii_uppercase());
capitalize_next = false;
} else {
result.push(ch);
capitalize_next = false;
}
}
result
}
fn plugin_description(plugin: &PluginCapabilitySummary) -> Option<String> {
let capability_labels = plugin_capability_labels(plugin);
plugin.description.clone().or_else(|| {
@@ -109,3 +186,30 @@ fn optional_skill_description(skill: &SkillMetadata) -> Option<String> {
let description = skill_description(skill).trim();
(!description.is_empty()).then(|| description.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn plugin_mention_name_uses_display_segments_when_they_match_plugin_name() {
assert_eq!(
plugin_mention_name("mcp-search", "MCP Search"),
"MCP-Search"
);
assert_eq!(
plugin_mention_name("google_calendar", "Google Calendar"),
"Google_Calendar"
);
}
#[test]
fn plugin_mention_name_falls_back_to_title_cased_plugin_name() {
assert_eq!(plugin_mention_name("sample", "Sample Plugin"), "Sample");
assert_eq!(
plugin_mention_name("browser-use", "Browser Use"),
"Browser-Use"
);
}
}
+3 -1
View File
@@ -83,7 +83,9 @@ pub(crate) struct LocalImageAttachment {
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct MentionBinding {
/// Mention token text without the leading `$`.
/// Visible mention sigil (`$` or `@`).
pub(crate) sigil: char,
/// Mention token text without the leading sigil (`$` or `@`).
pub(crate) mention: String,
/// Canonical mention target (for example `app://...` or absolute SKILL.md path).
pub(crate) path: String,
@@ -0,0 +1,6 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: "format!(\"text: {text}\\nmagenta: {magenta}\")"
---
text: › @sample plugin
magenta: ^^^^^^^
@@ -363,6 +363,7 @@ impl ChatWidget {
let encoded_mentions = mention_bindings
.iter()
.map(|binding| LinkedMention {
sigil: binding.sigil,
mention: binding.mention.clone(),
path: binding.path.clone(),
})
@@ -570,6 +570,7 @@ async fn submission_prefers_selected_duplicate_skill_path() {
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '$',
mention: "figma".to_string(),
path: user_skill_path.to_string_lossy().into_owned(),
}],
@@ -605,6 +606,7 @@ async fn blocked_image_restore_preserves_mention_bindings() {
path: PathBuf::from("/tmp/blocked.png"),
}];
let mention_bindings = vec![MentionBinding {
sigil: '$',
mention: "file".to_string(),
path: "/tmp/skills/file/SKILL.md".to_string(),
}];
@@ -1236,6 +1238,7 @@ async fn submit_user_message_ignores_inaccessible_app_mentions_from_bindings() {
remote_image_urls: Vec::new(),
text_elements: Vec::new(),
mention_bindings: vec![MentionBinding {
sigil: '$',
mention: "arabica-uae".to_string(),
path: "app://arabica_uae".to_string(),
}],
@@ -1244,6 +1244,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() {
remote_image_urls: Vec::new(),
text_elements: Vec::new(),
mention_bindings: vec![MentionBinding {
sigil: '$',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
@@ -875,6 +875,7 @@ async fn manual_interrupt_restores_pending_steer_mention_bindings_to_composer()
chat.on_agent_message_delta("Final answer line\n".to_string());
let mention_bindings = vec![MentionBinding {
sigil: '$',
mention: "figma".to_string(),
path: "/tmp/skills/figma/SKILL.md".to_string(),
}];
@@ -656,6 +656,7 @@ async fn goal_slash_command_uses_plain_text_for_mentions() {
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma".to_string(),
}],
@@ -917,6 +918,7 @@ fn merged_history_record_preserves_raw_text_and_rebased_elements() {
remote_image_urls: Vec::new(),
text_elements: vec![TextElement::new((4..10).into(), Some("$figma".to_string()))],
mention_bindings: vec![MentionBinding {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma".to_string(),
}],
@@ -1034,6 +1036,7 @@ async fn interrupted_merged_message_history_encodes_mentions_once() {
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma".to_string(),
}],
+446 -47
View File
@@ -13,6 +13,13 @@ use crate::workspace_command::WorkspaceCommandExecutor;
use crate::workspace_command::WorkspaceCommandOutput;
const DIFF_COMMAND_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30);
const DISABLE_FSMONITOR_CONFIG: &str = "core.fsmonitor=false";
const DISABLE_HOOKS_CONFIG: &str = if cfg!(windows) {
"core.hooksPath=NUL"
} else {
"core.hooksPath=/dev/null"
};
const EXECUTABLE_FILTER_CONFIG_PATTERN: &str = r"^filter\..*\.(clean|process)$";
/// Return value of [`get_git_diff`].
///
@@ -27,9 +34,22 @@ pub(crate) async fn get_git_diff(
return Ok((false, String::new()));
}
// Run tracked diff and untracked file listing in parallel.
// Keep `/diff` informational: repository configuration must not select executable diff helpers.
let diff_config_overrides = diff_filter_config_overrides(runner, cwd).await?;
let (tracked_diff_res, untracked_output_res) = tokio::join!(
run_git_capture_diff(runner, cwd, &["diff", "--color"]),
run_git_capture_diff(
runner,
cwd,
&diff_config_overrides,
&[
"diff",
"--no-textconv",
"--no-ext-diff",
"--submodule=short",
"--ignore-submodules=dirty",
"--color",
]
),
run_git_capture_stdout(runner, cwd, &["ls-files", "--others", "--exclude-standard"]),
);
let tracked_diff = tracked_diff_res?;
@@ -48,8 +68,19 @@ pub(crate) async fn get_git_diff(
.map(str::trim)
.filter(|s| !s.is_empty())
{
let args = ["diff", "--color", "--no-index", "--", null_path, file];
let diff = run_git_capture_diff(runner, cwd, &args).await?;
let args = [
"diff",
"--no-textconv",
"--no-ext-diff",
"--submodule=short",
"--ignore-submodules=dirty",
"--color",
"--no-index",
"--",
null_path,
file,
];
let diff = run_git_capture_diff(runner, cwd, &diff_config_overrides, &args).await?;
untracked_diff.push_str(&diff);
}
@@ -63,7 +94,7 @@ async fn run_git_capture_stdout(
cwd: &Path,
args: &[&str],
) -> Result<String, String> {
let output = run_git_command(runner, cwd, args).await?;
let output = run_git_command(runner, cwd, &[], args).await?;
if output.success() {
Ok(output.stdout)
} else {
@@ -79,9 +110,10 @@ async fn run_git_capture_stdout(
async fn run_git_capture_diff(
runner: &dyn WorkspaceCommandExecutor,
cwd: &Path,
config_overrides: &[(String, String)],
args: &[&str],
) -> Result<String, String> {
let output = run_git_command(runner, cwd, args).await?;
let output = run_git_command(runner, cwd, config_overrides, args).await?;
if output.success() || output.exit_code == 1 {
Ok(output.stdout)
} else {
@@ -92,32 +124,88 @@ async fn run_git_capture_diff(
}
}
/// Return Git configuration overrides that prevent configured filter drivers
/// from executing while generating diffs.
async fn diff_filter_config_overrides(
runner: &dyn WorkspaceCommandExecutor,
cwd: &Path,
) -> Result<Vec<(String, String)>, String> {
let args = [
"config",
"--null",
"--name-only",
"--get-regexp",
EXECUTABLE_FILTER_CONFIG_PATTERN,
];
let output = run_git_command(runner, cwd, &[], &args).await?;
if output.exit_code != 0 && output.exit_code != 1 {
return Err(format!(
"git {:?} failed with status {}",
args, output.exit_code
));
}
let mut drivers = output
.stdout
.split('\0')
.filter_map(|key| {
key.strip_suffix(".clean")
.or_else(|| key.strip_suffix(".process"))
})
.map(str::to_string)
.collect::<Vec<_>>();
drivers.sort();
drivers.dedup();
Ok(drivers
.into_iter()
.flat_map(|driver| {
[
(format!("{driver}.clean"), String::new()),
(format!("{driver}.process"), String::new()),
(format!("{driver}.required"), "false".to_string()),
]
})
.collect())
}
/// Determine if the current directory is inside a Git repository.
async fn inside_git_repo(
runner: &dyn WorkspaceCommandExecutor,
cwd: &Path,
) -> Result<bool, String> {
let output = run_git_command(runner, cwd, &["rev-parse", "--is-inside-work-tree"]).await?;
let output = run_git_command(runner, cwd, &[], &["rev-parse", "--is-inside-work-tree"]).await?;
Ok(output.success())
}
async fn run_git_command(
runner: &dyn WorkspaceCommandExecutor,
cwd: &Path,
config_overrides: &[(String, String)],
args: &[&str],
) -> Result<WorkspaceCommandOutput, String> {
let mut argv = Vec::with_capacity(args.len() + 1);
let mut argv = Vec::with_capacity(args.len() + 5);
argv.push("git".to_string());
argv.extend([
"-c".to_string(),
DISABLE_FSMONITOR_CONFIG.to_string(),
"-c".to_string(),
DISABLE_HOOKS_CONFIG.to_string(),
]);
argv.extend(args.iter().map(|arg| (*arg).to_string()));
runner
.run(
WorkspaceCommand::new(argv)
.cwd(cwd.to_path_buf())
.timeout(DIFF_COMMAND_TIMEOUT)
.disable_output_cap(),
)
.await
.map_err(|err| err.to_string())
let mut command = WorkspaceCommand::new(argv)
.cwd(cwd.to_path_buf())
.timeout(DIFF_COMMAND_TIMEOUT)
.disable_output_cap();
if !config_overrides.is_empty() {
command = command.env("GIT_CONFIG_COUNT", config_overrides.len().to_string());
for (index, (key, value)) in config_overrides.iter().enumerate() {
command = command
.env(format!("GIT_CONFIG_KEY_{index}"), key)
.env(format!("GIT_CONFIG_VALUE_{index}"), value);
}
}
runner.run(command).await.map_err(|err| err.to_string())
}
#[cfg(test)]
@@ -125,17 +213,24 @@ mod tests {
use super::*;
use crate::workspace_command::WorkspaceCommandError;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::collections::VecDeque;
#[cfg(unix)]
use std::fs;
use std::future::Future;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::pin::Pin;
#[cfg(unix)]
use std::process::Command as ProcessCommand;
use std::sync::Mutex;
#[tokio::test]
async fn get_git_diff_returns_not_git_for_non_git_cwd() {
let cwd = PathBuf::from("/workspace");
let runner = FakeRunner::new(vec![response(
&["git", "rev-parse", "--is-inside-work-tree"],
git_command(&["rev-parse", "--is-inside-work-tree"]),
/*exit_code*/ 128,
"",
)]);
@@ -145,40 +240,61 @@ mod tests {
assert_eq!(result, Ok((false, String::new())));
assert_commands(
&runner.commands(),
&[&["git", "rev-parse", "--is-inside-work-tree"]],
&[git_command(&["rev-parse", "--is-inside-work-tree"])],
&cwd,
);
}
#[tokio::test]
async fn get_git_diff_concatenates_tracked_and_untracked_diffs() {
async fn get_git_diff_disables_helpers_for_tracked_and_untracked_diffs() {
let cwd = PathBuf::from("/workspace");
let runner = FakeRunner::new(vec![
response(
&["git", "rev-parse", "--is-inside-work-tree"],
git_command(&["rev-parse", "--is-inside-work-tree"]),
/*exit_code*/ 0,
"true\n",
),
response(
&["git", "diff", "--color"],
git_command(&[
"config",
"--null",
"--name-only",
"--get-regexp",
EXECUTABLE_FILTER_CONFIG_PATTERN,
]),
/*exit_code*/ 0,
"filter.evil.clean\0filter.evil.process\0",
),
response(
git_command(&[
"diff",
"--no-textconv",
"--no-ext-diff",
"--submodule=short",
"--ignore-submodules=dirty",
"--color",
]),
/*exit_code*/ 1,
"tracked\n",
),
response(
&["git", "ls-files", "--others", "--exclude-standard"],
git_command(&["ls-files", "--others", "--exclude-standard"]),
/*exit_code*/ 0,
"new.txt\n",
),
response(
&[
"git",
git_command(&[
"diff",
"--no-textconv",
"--no-ext-diff",
"--submodule=short",
"--ignore-submodules=dirty",
"--color",
"--no-index",
"--",
null_device(),
"new.txt",
],
]),
/*exit_code*/ 1,
"untracked\n",
),
@@ -187,24 +303,44 @@ mod tests {
let result = get_git_diff(&runner, &cwd).await;
assert_eq!(result, Ok((true, "tracked\nuntracked\n".to_string())));
let commands = runner.commands();
assert_commands(
&runner.commands(),
&commands,
&[
&["git", "rev-parse", "--is-inside-work-tree"],
&["git", "diff", "--color"],
&["git", "ls-files", "--others", "--exclude-standard"],
&[
"git",
git_command(&["rev-parse", "--is-inside-work-tree"]),
git_command(&[
"config",
"--null",
"--name-only",
"--get-regexp",
EXECUTABLE_FILTER_CONFIG_PATTERN,
]),
git_command(&[
"diff",
"--no-textconv",
"--no-ext-diff",
"--submodule=short",
"--ignore-submodules=dirty",
"--color",
]),
git_command(&["ls-files", "--others", "--exclude-standard"]),
git_command(&[
"diff",
"--no-textconv",
"--no-ext-diff",
"--submodule=short",
"--ignore-submodules=dirty",
"--color",
"--no-index",
"--",
null_device(),
"new.txt",
],
]),
],
&cwd,
);
assert_eq!(commands[2].env, filter_override_env("filter.evil"));
assert_eq!(commands[4].env, filter_override_env("filter.evil"));
}
#[tokio::test]
@@ -212,17 +348,35 @@ mod tests {
let cwd = PathBuf::from("/workspace");
let runner = FakeRunner::new(vec![
response(
&["git", "rev-parse", "--is-inside-work-tree"],
git_command(&["rev-parse", "--is-inside-work-tree"]),
/*exit_code*/ 0,
"true\n",
),
response(
&["git", "diff", "--color"],
git_command(&[
"config",
"--null",
"--name-only",
"--get-regexp",
EXECUTABLE_FILTER_CONFIG_PATTERN,
]),
/*exit_code*/ 1,
"",
),
response(
git_command(&[
"diff",
"--no-textconv",
"--no-ext-diff",
"--submodule=short",
"--ignore-submodules=dirty",
"--color",
]),
/*exit_code*/ 1,
"tracked\n",
),
response(
&["git", "ls-files", "--others", "--exclude-standard"],
git_command(&["ls-files", "--others", "--exclude-standard"]),
/*exit_code*/ 0,
"",
),
@@ -238,13 +392,35 @@ mod tests {
let cwd = PathBuf::from("/workspace");
let runner = FakeRunner::new(vec![
response(
&["git", "rev-parse", "--is-inside-work-tree"],
git_command(&["rev-parse", "--is-inside-work-tree"]),
/*exit_code*/ 0,
"true\n",
),
response(&["git", "diff", "--color"], /*exit_code*/ 2, ""),
response(
&["git", "ls-files", "--others", "--exclude-standard"],
git_command(&[
"config",
"--null",
"--name-only",
"--get-regexp",
EXECUTABLE_FILTER_CONFIG_PATTERN,
]),
/*exit_code*/ 1,
"",
),
response(
git_command(&[
"diff",
"--no-textconv",
"--no-ext-diff",
"--submodule=short",
"--ignore-submodules=dirty",
"--color",
]),
/*exit_code*/ 2,
"",
),
response(
git_command(&["ls-files", "--others", "--exclude-standard"]),
/*exit_code*/ 0,
"",
),
@@ -255,14 +431,180 @@ mod tests {
.expect_err("unexpected git diff status should fail");
assert!(
error.contains("git [\"diff\", \"--color\"] failed with status 2"),
error.contains(
"git [\"diff\", \"--no-textconv\", \"--no-ext-diff\", \"--submodule=short\", \"--ignore-submodules=dirty\", \"--color\"] failed with status 2"
),
"unexpected error: {error}",
);
}
fn response(argv: &[&str], exit_code: i32, stdout: &str) -> FakeResponse {
#[cfg(unix)]
#[tokio::test]
async fn get_git_diff_does_not_execute_configured_filters_fsmonitor_or_hooks() {
let tempdir = tempfile::tempdir().expect("create temp directory");
let repo = tempdir.path().join("repo");
fs::create_dir(&repo).expect("create test repository directory");
run_git_setup(&repo, &["init", "-q"]);
run_git_setup(&repo, &["config", "user.name", "test"]);
run_git_setup(&repo, &["config", "user.email", "test@example.com"]);
fs::write(repo.join(".gitattributes"), "*.txt filter=x=y\n").expect("write attributes");
fs::write(repo.join("tracked.txt"), "before\n").expect("write tracked file");
fs::write(repo.join("unchanged.txt"), "unchanged\n").expect("write unchanged file");
run_git_setup(
&repo,
&["add", ".gitattributes", "tracked.txt", "unchanged.txt"],
);
run_git_setup(&repo, &["commit", "-qm", "initial"]);
let filter_helper = tempdir.path().join("filter-helper.sh");
let fsmonitor_helper = tempdir.path().join("fsmonitor-helper.sh");
let hooks_dir = tempdir.path().join("hooks");
let hook_helper = hooks_dir.join("post-index-change");
fs::create_dir(&hooks_dir).expect("create hooks directory");
write_marker_helper(&filter_helper);
write_marker_helper(&fsmonitor_helper);
write_marker_helper(&hook_helper);
run_git_setup(
&repo,
&[
"config",
"filter.x=y.clean",
filter_helper.to_str().expect("filter helper path"),
],
);
run_git_setup(
&repo,
&[
"config",
"filter.x=y.process",
filter_helper.to_str().expect("filter helper path"),
],
);
run_git_setup(&repo, &["config", "filter.x=y.required", "true"]);
run_git_setup(
&repo,
&[
"config",
"core.fsmonitor",
fsmonitor_helper.to_str().expect("fsmonitor helper path"),
],
);
run_git_setup(
&repo,
&[
"config",
"core.hooksPath",
hooks_dir.to_str().expect("hooks directory path"),
],
);
std::thread::sleep(Duration::from_secs(/*secs*/ 1));
fs::write(repo.join("unchanged.txt"), "unchanged\n").expect("refresh unchanged file");
fs::write(repo.join("tracked.txt"), "after\n").expect("modify tracked file");
let result = get_git_diff(&LocalRunner, &repo)
.await
.expect("generate diff without invoking helpers");
assert!(result.1.contains("before"));
assert!(result.1.contains("after"));
assert!(!filter_helper.with_extension("sh.ran").exists());
assert!(!fsmonitor_helper.with_extension("sh.ran").exists());
assert!(!hook_helper.with_extension("sh.ran").exists());
}
#[cfg(unix)]
#[tokio::test]
async fn get_git_diff_does_not_execute_helpers_while_checking_dirty_submodules() {
let tempdir = tempfile::tempdir().expect("create temp directory");
let child = tempdir.path().join("child");
let repo = tempdir.path().join("repo");
fs::create_dir(&child).expect("create child repository directory");
fs::create_dir(&repo).expect("create parent repository directory");
run_git_setup(&child, &["init", "-q"]);
run_git_setup(&child, &["config", "user.name", "test"]);
run_git_setup(&child, &["config", "user.email", "test@example.com"]);
fs::write(child.join(".gitattributes"), "*.txt filter=evil\n")
.expect("write child attributes");
fs::write(child.join("tracked.txt"), "before\n").expect("write child tracked file");
run_git_setup(&child, &["add", ".gitattributes", "tracked.txt"]);
run_git_setup(&child, &["commit", "-qm", "initial"]);
run_git_setup(&repo, &["init", "-q"]);
run_git_setup(&repo, &["config", "user.name", "test"]);
run_git_setup(&repo, &["config", "user.email", "test@example.com"]);
run_git_setup(
&repo,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
"-q",
child.to_str().expect("child repository path"),
"child",
],
);
run_git_setup(&repo, &["commit", "-qm", "add submodule"]);
let helper = tempdir.path().join("submodule-helper.sh");
write_marker_helper(&helper);
let checkout = repo.join("child");
run_git_setup(
&checkout,
&[
"config",
"filter.evil.clean",
helper.to_str().expect("submodule helper path"),
],
);
run_git_setup(&checkout, &["config", "filter.evil.required", "true"]);
std::thread::sleep(Duration::from_secs(/*secs*/ 1));
fs::write(checkout.join("tracked.txt"), "before\n").expect("refresh child tracked file");
let result = get_git_diff(&LocalRunner, &repo)
.await
.expect("generate diff without inspecting submodule worktrees");
assert!(result.1.is_empty());
assert!(!helper.with_extension("sh.ran").exists());
}
fn git_command(args: &[&str]) -> Vec<String> {
let mut argv = vec![
"git".to_string(),
"-c".to_string(),
DISABLE_FSMONITOR_CONFIG.to_string(),
"-c".to_string(),
DISABLE_HOOKS_CONFIG.to_string(),
];
argv.extend(args.iter().map(|arg| (*arg).to_string()));
argv
}
fn filter_override_env(driver: &str) -> HashMap<String, Option<String>> {
HashMap::from([
("GIT_CONFIG_COUNT".to_string(), Some("3".to_string())),
(
"GIT_CONFIG_KEY_0".to_string(),
Some(format!("{driver}.clean")),
),
("GIT_CONFIG_VALUE_0".to_string(), Some(String::new())),
(
"GIT_CONFIG_KEY_1".to_string(),
Some(format!("{driver}.process")),
),
("GIT_CONFIG_VALUE_1".to_string(), Some(String::new())),
(
"GIT_CONFIG_KEY_2".to_string(),
Some(format!("{driver}.required")),
),
("GIT_CONFIG_VALUE_2".to_string(), Some("false".to_string())),
])
}
fn response(argv: Vec<String>, exit_code: i32, stdout: &str) -> FakeResponse {
FakeResponse {
argv: argv.iter().map(|arg| (*arg).to_string()).collect(),
argv,
output: WorkspaceCommandOutput {
exit_code,
stdout: stdout.to_string(),
@@ -275,15 +617,32 @@ mod tests {
if cfg!(windows) { "NUL" } else { "/dev/null" }
}
fn assert_commands(commands: &[WorkspaceCommand], expected: &[&[&str]], cwd: &Path) {
#[cfg(unix)]
fn run_git_setup(cwd: &Path, args: &[&str]) {
let status = ProcessCommand::new("git")
.args(args)
.current_dir(cwd)
.status()
.expect("run git setup command");
assert!(status.success(), "git setup command failed: {args:?}");
}
#[cfg(unix)]
fn write_marker_helper(path: &Path) {
fs::write(path, "#!/bin/sh\nprintf ran >> \"$0.ran\"\nexit 1\n")
.expect("write helper script");
let mut permissions = fs::metadata(path)
.expect("read helper metadata")
.permissions();
permissions.set_mode(/*mode*/ 0o755);
fs::set_permissions(path, permissions).expect("make helper executable");
}
fn assert_commands(commands: &[WorkspaceCommand], expected: &[Vec<String>], cwd: &Path) {
let actual: Vec<Vec<String>> = commands
.iter()
.map(|command| command.argv.clone())
.collect();
let expected: Vec<Vec<String>> = expected
.iter()
.map(|argv| argv.iter().map(|arg| (*arg).to_string()).collect())
.collect();
assert_eq!(actual, expected);
for command in commands {
@@ -336,4 +695,44 @@ mod tests {
})
}
}
#[cfg(unix)]
struct LocalRunner;
#[cfg(unix)]
impl WorkspaceCommandExecutor for LocalRunner {
fn run(
&self,
command: WorkspaceCommand,
) -> Pin<
Box<
dyn Future<Output = Result<WorkspaceCommandOutput, WorkspaceCommandError>>
+ Send
+ '_,
>,
> {
Box::pin(async move {
let mut process = ProcessCommand::new(&command.argv[0]);
process
.args(&command.argv[1..])
.current_dir(command.cwd.expect("test command cwd"));
for (key, value) in command.env {
match value {
Some(value) => {
process.env(key, value);
}
None => {
process.env_remove(key);
}
}
}
let output = process.output().expect("run test command");
Ok(WorkspaceCommandOutput {
exit_code: output.status.code().expect("test command exit code"),
stdout: String::from_utf8(output.stdout).expect("utf8 stdout"),
stderr: String::from_utf8(output.stderr).expect("utf8 stderr"),
})
})
}
}
}
+310 -37
View File
@@ -6,6 +6,7 @@ use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct LinkedMention {
pub(crate) sigil: char,
pub(crate) mention: String,
pub(crate) path: String,
}
@@ -22,10 +23,16 @@ pub(crate) fn encode_history_mentions(text: &str, mentions: &[LinkedMention]) ->
return text.to_string();
}
let mut mentions_by_name: HashMap<&str, VecDeque<&str>> = HashMap::new();
let mut mentions_by_token: HashMap<(char, &str), VecDeque<&str>> = HashMap::new();
for mention in mentions {
mentions_by_name
.entry(mention.mention.as_str())
if !matches!(
mention.sigil,
TOOL_MENTION_SIGIL | PLUGIN_TEXT_MENTION_SIGIL
) {
continue;
}
mentions_by_token
.entry((mention.sigil, mention.mention.as_str()))
.or_default()
.push_back(mention.path.as_str());
}
@@ -35,28 +42,38 @@ pub(crate) fn encode_history_mentions(text: &str, mentions: &[LinkedMention]) ->
let mut index = 0usize;
while index < bytes.len() {
if bytes[index] == TOOL_MENTION_SIGIL as u8 {
let name_start = index + 1;
if let Some(first) = bytes.get(name_start)
&& is_mention_name_char(*first)
{
let mut name_end = name_start + 1;
while let Some(next) = bytes.get(name_end)
&& is_mention_name_char(*next)
if matches!(
bytes[index],
byte if byte == TOOL_MENTION_SIGIL as u8 || byte == PLUGIN_TEXT_MENTION_SIGIL as u8
) {
let sigil = bytes[index] as char;
if sigil == TOOL_MENTION_SIGIL || starts_plaintext_mention(text, index) {
let name_start = index + 1;
if let Some(first) = bytes.get(name_start)
&& is_mention_name_char(*first)
{
name_end += 1;
}
let mut name_end = name_start + 1;
while let Some(next) = bytes.get(name_end)
&& is_mention_name_char(*next)
{
name_end += 1;
}
let name = &text[name_start..name_end];
if let Some(path) = mentions_by_name.get_mut(name).and_then(VecDeque::pop_front) {
out.push('[');
out.push(TOOL_MENTION_SIGIL);
out.push_str(name);
out.push_str("](");
out.push_str(path);
out.push(')');
index = name_end;
continue;
let name = &text[name_start..name_end];
if (sigil == TOOL_MENTION_SIGIL || ends_plaintext_mention(bytes, name_end))
&& let Some(path) = mentions_by_token
.get_mut(&(sigil, name))
.and_then(VecDeque::pop_front)
{
out.push('[');
out.push(sigil);
out.push_str(name);
out.push_str("](");
out.push_str(path);
out.push(')');
index = name_end;
continue;
}
}
}
}
@@ -71,7 +88,10 @@ pub(crate) fn encode_history_mentions(text: &str, mentions: &[LinkedMention]) ->
out
}
pub(crate) fn decode_history_mentions(text: &str) -> DecodedHistoryText {
pub(crate) fn decode_history_mentions_with_at_mentions(
text: &str,
at_mentions_enabled: bool,
) -> DecodedHistoryText {
let bytes = text.as_bytes();
let mut out = String::with_capacity(text.len());
let mut mentions = Vec::new();
@@ -79,11 +99,13 @@ pub(crate) fn decode_history_mentions(text: &str) -> DecodedHistoryText {
while index < bytes.len() {
if bytes[index] == b'['
&& let Some((name, path, end_index)) = parse_history_linked_mention(text, bytes, index)
&& let Some((sigil, name, path, end_index)) =
parse_history_linked_mention(text, bytes, index, at_mentions_enabled)
{
out.push(TOOL_MENTION_SIGIL);
out.push(sigil);
out.push_str(name);
mentions.push(LinkedMention {
sigil,
mention: name.to_string(),
path: path.to_string(),
});
@@ -108,22 +130,32 @@ fn parse_history_linked_mention<'a>(
text: &'a str,
text_bytes: &[u8],
start: usize,
) -> Option<(&'a str, &'a str, usize)> {
// TUI writes `$name`, but may read plugin `[@name](plugin://...)` links from other clients.
if let Some(mention @ (name, path, _)) =
at_mentions_enabled: bool,
) -> Option<(char, &'a str, &'a str, usize)> {
// TUI historically wrote `$name`, but selected unified `@` mentions should preserve `@` on
// history round-trip for any canonical tool path.
if let Some((name, path, end_index)) =
parse_linked_tool_mention(text, text_bytes, start, TOOL_MENTION_SIGIL)
&& !is_common_env_var(name)
&& is_tool_path(path)
{
return Some(mention);
return Some((TOOL_MENTION_SIGIL, name, path, end_index));
}
if let Some(mention @ (name, path, _)) =
if at_mentions_enabled {
if let Some((name, path, end_index)) =
parse_linked_tool_mention(text, text_bytes, start, PLUGIN_TEXT_MENTION_SIGIL)
&& !is_common_env_var(name)
&& is_tool_path(path)
{
return Some((PLUGIN_TEXT_MENTION_SIGIL, name, path, end_index));
}
} else if let Some((name, path, end_index)) =
parse_linked_tool_mention(text, text_bytes, start, PLUGIN_TEXT_MENTION_SIGIL)
&& !is_common_env_var(name)
&& path.starts_with("plugin://")
{
return Some(mention);
return Some((TOOL_MENTION_SIGIL, name, path, end_index));
}
None
@@ -190,6 +222,35 @@ fn is_mention_name_char(byte: u8) -> bool {
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-')
}
fn starts_plaintext_mention(text: &str, index: usize) -> bool {
if index == 0 {
return true;
}
text.get(..index)
.and_then(|prefix| prefix.chars().next_back())
.is_some_and(|ch| ch.is_whitespace() || !is_mention_name_char_char(ch))
}
fn ends_plaintext_mention(text_bytes: &[u8], index: usize) -> bool {
text_bytes.get(index).is_none_or(|byte| {
byte.is_ascii_whitespace()
|| *byte == b'.'
&& text_bytes.get(index + 1).is_none_or(|next| {
next.is_ascii_whitespace()
|| !next.is_ascii_alphanumeric() && *next != b'_' && *next != b'-'
})
|| !matches!(*byte, b'.' | b'/' | b'\\')
&& !byte.is_ascii_alphanumeric()
&& *byte != b'_'
&& *byte != b'-'
})
}
fn is_mention_name_char_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
}
fn is_common_env_var(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
matches!(
@@ -226,22 +287,26 @@ mod tests {
#[test]
fn decode_history_mentions_restores_visible_tokens() {
let decoded = decode_history_mentions(
let decoded = decode_history_mentions_with_at_mentions(
"Use [$figma](app://figma-1), [$sample](plugin://sample@test), and [$figma](/tmp/figma/SKILL.md).",
/*at_mentions_enabled*/ true,
);
assert_eq!(decoded.text, "Use $figma, $sample, and $figma.");
assert_eq!(
decoded.mentions,
vec![
LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma-1".to_string(),
},
LinkedMention {
sigil: '$',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
},
LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "/tmp/figma/SKILL.md".to_string(),
},
@@ -251,18 +316,21 @@ mod tests {
#[test]
fn decode_history_mentions_restores_plugin_links_with_at_sigil() {
let decoded = decode_history_mentions(
let decoded = decode_history_mentions_with_at_mentions(
"Use [@sample](plugin://sample@test) and [$figma](app://figma-1).",
/*at_mentions_enabled*/ true,
);
assert_eq!(decoded.text, "Use $sample and $figma.");
assert_eq!(decoded.text, "Use @sample and $figma.");
assert_eq!(
decoded.mentions,
vec![
LinkedMention {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
},
LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma-1".to_string(),
},
@@ -271,13 +339,58 @@ mod tests {
}
#[test]
fn decode_history_mentions_ignores_at_sigil_for_non_plugin_paths() {
let decoded = decode_history_mentions("Use [@figma](app://figma-1).");
fn decode_history_mentions_without_at_mentions_uses_legacy_plugin_fallback() {
let decoded = decode_history_mentions_with_at_mentions(
"Use [@sample](plugin://sample@test) and [$figma](app://figma-1).",
/*at_mentions_enabled*/ false,
);
assert_eq!(decoded.text, "Use $sample and $figma.");
assert_eq!(
decoded.mentions,
vec![
LinkedMention {
sigil: '$',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
},
LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma-1".to_string(),
},
]
);
}
#[test]
fn decode_history_mentions_without_at_mentions_ignores_at_non_plugin_paths() {
let decoded = decode_history_mentions_with_at_mentions(
"Use [@figma](app://figma-1).",
/*at_mentions_enabled*/ false,
);
assert_eq!(decoded.text, "Use [@figma](app://figma-1).");
assert_eq!(decoded.mentions, Vec::<LinkedMention>::new());
}
#[test]
fn decode_history_mentions_restores_at_sigil_for_tool_paths() {
let decoded = decode_history_mentions_with_at_mentions(
"Use [@figma](app://figma-1).",
/*at_mentions_enabled*/ true,
);
assert_eq!(decoded.text, "Use @figma.");
assert_eq!(
decoded.mentions,
vec![LinkedMention {
sigil: '@',
mention: "figma".to_string(),
path: "app://figma-1".to_string(),
}]
);
}
#[test]
fn encode_history_mentions_links_bound_mentions_in_order() {
let text = "$figma then $sample then $figma then $other";
@@ -285,14 +398,17 @@ mod tests {
text,
&[
LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma-app".to_string(),
},
LinkedMention {
sigil: '$',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
},
LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "/tmp/figma/SKILL.md".to_string(),
},
@@ -303,4 +419,161 @@ mod tests {
"[$figma](app://figma-app) then [$sample](plugin://sample@test) then [$figma](/tmp/figma/SKILL.md) then $other"
);
}
#[test]
fn encode_history_mentions_links_dollar_mentions_after_punctuation() {
let encoded = encode_history_mentions(
"($figma)",
&[LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma".to_string(),
}],
);
assert_eq!(encoded, "([$figma](app://figma))");
}
#[test]
fn encode_history_mentions_links_dollar_mentions_with_path_like_suffixes() {
let mention = LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma".to_string(),
};
assert_eq!(
encode_history_mentions("$figma/docs", std::slice::from_ref(&mention)),
"[$figma](app://figma)/docs"
);
assert_eq!(
encode_history_mentions("$figma.suffix", std::slice::from_ref(&mention)),
"[$figma](app://figma).suffix"
);
assert_eq!(
encode_history_mentions("$figma\\docs", &[mention]),
"[$figma](app://figma)\\docs"
);
}
#[test]
fn encode_history_mentions_preserves_at_sigils() {
let text = "@figma then @sample then $other";
let encoded = encode_history_mentions(
text,
&[
LinkedMention {
sigil: '@',
mention: "figma".to_string(),
path: "/tmp/figma/SKILL.md".to_string(),
},
LinkedMention {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
},
],
);
assert_eq!(
encoded,
"[@figma](/tmp/figma/SKILL.md) then [@sample](plugin://sample@test) then $other"
);
}
#[test]
fn encode_history_mentions_links_both_sigils_for_same_name() {
let text = "@figma then $figma";
let encoded = encode_history_mentions(
text,
&[
LinkedMention {
sigil: '@',
mention: "figma".to_string(),
path: "plugin://figma@test".to_string(),
},
LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma".to_string(),
},
],
);
assert_eq!(
encoded,
"[@figma](plugin://figma@test) then [$figma](app://figma)"
);
}
#[test]
fn encode_history_mentions_does_not_let_at_token_steal_later_tool_binding() {
let text = "@figma then $figma";
let encoded = encode_history_mentions(
text,
&[LinkedMention {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma-app".to_string(),
}],
);
assert_eq!(encoded, "@figma then [$figma](app://figma-app)");
}
#[test]
fn encode_history_mentions_links_at_mentions_after_unicode_whitespace() {
// Fix coverage: full-width space should remain a valid plaintext boundary for `@` links.
let text = "foo @sample";
let encoded = encode_history_mentions(
text,
&[LinkedMention {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
);
assert_eq!(encoded, "foo [@sample](plugin://sample@test)");
}
#[test]
fn encode_history_mentions_links_sentence_ending_at_mentions() {
let text = "Please ask @figma.";
let encoded = encode_history_mentions(
text,
&[LinkedMention {
sigil: '@',
mention: "figma".to_string(),
path: "/tmp/figma/SKILL.md".to_string(),
}],
);
assert_eq!(encoded, "Please ask [@figma](/tmp/figma/SKILL.md).");
}
#[test]
fn encode_history_mentions_links_parenthesized_at_mentions() {
let text = "Please ask (@figma)";
let encoded = encode_history_mentions(
text,
&[LinkedMention {
sigil: '@',
mention: "figma".to_string(),
path: "plugin://figma@test".to_string(),
}],
);
assert_eq!(encoded, "Please ask ([@figma](plugin://figma@test))");
}
#[test]
fn encode_history_mentions_skips_embedded_at_substrings() {
let text = "foo@sample.com npx @sample/pkg then @sample";
let encoded = encode_history_mentions(
text,
&[LinkedMention {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
);
assert_eq!(
encoded,
"foo@sample.com npx @sample/pkg then [@sample](plugin://sample@test)"
);
}
}
+4 -18
View File
@@ -2,9 +2,6 @@ set working-directory := "codex-rs"
set positional-arguments
rust_min_stack := "8388608" # 8 MiB
e2e_benchmark_packages := "codex-app-server-start-bench"
e2e_codex_bin := if os() == "windows" { "./target/release/codex.exe" } else { "./target/release/codex" }
e2e_smoke_codex_bin := if os() == "windows" { "./target/debug/codex.exe" } else { "./target/debug/codex" }
# Display help
help:
@@ -33,11 +30,6 @@ app-server-test-client *args:
cargo build -p codex-cli
cargo run -p codex-app-server-test-client -- --codex-bin ./target/debug/codex "$@"
# Run end-to-end performance benchmarks that require a built Codex binary.
bench-e2e *args:
cargo build --release -p codex-cli --bin codex
for package in {{ e2e_benchmark_packages }}; do CODEX_BIN="{{ e2e_codex_bin }}" cargo run --release -p "$package" -- --bench "$@"; done
# Format Rust and Python SDK code.
fmt:
cargo fmt -- --config imports_granularity=Item 2>/dev/null
@@ -63,19 +55,13 @@ test *args:
RUST_MIN_STACK={{ rust_min_stack }} cargo nextest run --no-fail-fast "$@"
just bench-smoke
# Run unit-test-style benchmark targets managed entirely by Cargo.
bench-unit *args:
# Run explicit workspace benchmark targets.
bench *args:
cargo bench --workspace --bench '*' "$@"
# Smoke Cargo-managed benchmarks and compile e2e runners without measured binaries.
# Run benchmark targets once to ensure they start successfully.
bench-smoke:
just bench-unit -- --test
for package in {{ e2e_benchmark_packages }}; do cargo build -p "$package" --bin "$package"; done
# Run end-to-end performance benchmark targets once.
bench-e2e-smoke:
cargo build -p codex-cli --bin codex
for package in {{ e2e_benchmark_packages }}; do CODEX_BIN="{{ e2e_smoke_codex_bin }}" cargo run -p "$package" -- --test; done
just bench -- --test
# Build and run Codex from source using Bazel.
# Note we have to use the combination of `[no-cd]` and `--run_under="cd $PWD &&"`