mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
Merge branch 'main' into jif/error-on-image-origin
This commit is contained in:
Generated
+73
-26
@@ -360,7 +360,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -891,9 +891,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.53"
|
||||
version = "4.5.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
|
||||
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -901,9 +901,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.53"
|
||||
version = "4.5.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
|
||||
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@@ -984,8 +984,10 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"url",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
@@ -1306,6 +1308,7 @@ dependencies = [
|
||||
"image",
|
||||
"include_dir",
|
||||
"indexmap 2.12.0",
|
||||
"indoc",
|
||||
"keyring",
|
||||
"landlock",
|
||||
"libc",
|
||||
@@ -2126,6 +2129,7 @@ dependencies = [
|
||||
"codex-protocol",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-cargo-bin",
|
||||
"futures",
|
||||
"notify",
|
||||
"pretty_assertions",
|
||||
"regex-lite",
|
||||
@@ -2134,6 +2138,7 @@ dependencies = [
|
||||
"shlex",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"walkdir",
|
||||
"wiremock",
|
||||
]
|
||||
@@ -2361,6 +2366,12 @@ dependencies = [
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.9"
|
||||
@@ -2763,6 +2774,12 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_home"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.8"
|
||||
@@ -2798,7 +2815,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2895,7 +2912,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rustix 1.0.8",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3836,7 +3853,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5347,7 +5364,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2 0.6.1",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5726,7 +5743,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5739,7 +5756,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.9.4",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7112,10 +7129,22 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.16"
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5"
|
||||
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -7473,9 +7502,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs"
|
||||
version = "11.0.1"
|
||||
version = "11.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ef1b7a6d914a34127ed8e1fa927eb7088903787bcded4fa3eef8f85ee1568be"
|
||||
checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
@@ -7485,9 +7514,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs-macros"
|
||||
version = "11.0.1"
|
||||
version = "11.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9d4ed7b4c18cc150a6a0a1e9ea1ecfa688791220781af6e119f9599a8502a0a"
|
||||
checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -7497,14 +7526,33 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tui-scrollbar"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c42613099915b2e30e9f144670666e858e2538366f77742e1cf1c2f230efcacd"
|
||||
checksum = "0e4267311b5c7999a996ea94939b6d2b1b44a9e5cc11e76cbbb6dcca4c281df4"
|
||||
dependencies = [
|
||||
"document-features",
|
||||
"ratatui-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http 1.3.1",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.8.5",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.18.0"
|
||||
@@ -7989,13 +8037,12 @@ checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3"
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "6.0.3"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f"
|
||||
checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"home",
|
||||
"rustix 0.38.44",
|
||||
"env_home",
|
||||
"rustix 1.0.8",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
@@ -8027,7 +8074,7 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+5
-3
@@ -142,6 +142,7 @@ icu_decimal = "2.1"
|
||||
icu_locale_core = "2.1"
|
||||
icu_provider = { version = "2.1", features = ["sync"] }
|
||||
ignore = "0.4.23"
|
||||
indoc = "2.0"
|
||||
image = { version = "^0.25.9", default-features = false }
|
||||
include_dir = "0.7.4"
|
||||
indexmap = "2.12.0"
|
||||
@@ -209,7 +210,8 @@ tiny_http = "0.12"
|
||||
tokio = "1"
|
||||
tokio-stream = "0.1.18"
|
||||
tokio-test = "0.4"
|
||||
tokio-util = "0.7.16"
|
||||
tokio-tungstenite = "0.21.0"
|
||||
tokio-util = "0.7.18"
|
||||
toml = "0.9.5"
|
||||
toml_edit = "0.24.0"
|
||||
tracing = "0.1.43"
|
||||
@@ -221,7 +223,7 @@ tree-sitter-bash = "0.25"
|
||||
zstd = "0.13"
|
||||
tree-sitter-highlight = "0.25.10"
|
||||
ts-rs = "11"
|
||||
tui-scrollbar = "0.2.1"
|
||||
tui-scrollbar = "0.2.2"
|
||||
uds_windows = "1.1.0"
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-width = "0.2"
|
||||
@@ -231,7 +233,7 @@ uuid = "1"
|
||||
vt100 = "0.16.2"
|
||||
walkdir = "2.5.0"
|
||||
webbrowser = "1.0"
|
||||
which = "6"
|
||||
which = "8"
|
||||
wildmatch = "2.6.1"
|
||||
|
||||
wiremock = "0.6"
|
||||
|
||||
@@ -156,6 +156,11 @@ client_request_definitions! {
|
||||
response: v2::McpServerOauthLoginResponse,
|
||||
},
|
||||
|
||||
McpServerRefresh => "config/mcpServer/reload" {
|
||||
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
||||
response: v2::McpServerRefreshResponse,
|
||||
},
|
||||
|
||||
McpServerStatusList => "mcpServerStatus/list" {
|
||||
params: v2::ListMcpServerStatusParams,
|
||||
response: v2::ListMcpServerStatusResponse,
|
||||
|
||||
@@ -940,6 +940,16 @@ pub struct ListMcpServerStatusResponse {
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct McpServerRefreshParams {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct McpServerRefreshResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -88,6 +88,7 @@ Example (from OpenAI's official VSCode extension):
|
||||
- `model/list` — list available models (with reasoning effort options).
|
||||
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
|
||||
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
|
||||
- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server.
|
||||
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination.
|
||||
- `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id.
|
||||
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
|
||||
|
||||
@@ -60,6 +60,7 @@ use codex_app_server_protocol::LogoutChatGptResponse;
|
||||
use codex_app_server_protocol::McpServerOauthLoginCompletedNotification;
|
||||
use codex_app_server_protocol::McpServerOauthLoginParams;
|
||||
use codex_app_server_protocol::McpServerOauthLoginResponse;
|
||||
use codex_app_server_protocol::McpServerRefreshResponse;
|
||||
use codex_app_server_protocol::McpServerStatus;
|
||||
use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::ModelListResponse;
|
||||
@@ -157,6 +158,7 @@ use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::GitInfo as CoreGitInfo;
|
||||
use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus;
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
@@ -425,6 +427,9 @@ impl CodexMessageProcessor {
|
||||
ClientRequest::McpServerOauthLogin { request_id, params } => {
|
||||
self.mcp_server_oauth_login(request_id, params).await;
|
||||
}
|
||||
ClientRequest::McpServerRefresh { request_id, params } => {
|
||||
self.mcp_server_refresh(request_id, params).await;
|
||||
}
|
||||
ClientRequest::McpServerStatusList { request_id, params } => {
|
||||
self.list_mcp_server_status(request_id, params).await;
|
||||
}
|
||||
@@ -2302,6 +2307,57 @@ impl CodexMessageProcessor {
|
||||
outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
|
||||
async fn mcp_server_refresh(&self, request_id: RequestId, _params: Option<()>) {
|
||||
let config = match self.load_latest_config().await {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mcp_servers = match serde_json::to_value(&config.mcp_servers) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to serialize MCP servers: {err}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mcp_oauth_credentials_store_mode =
|
||||
match serde_json::to_value(config.mcp_oauth_credentials_store_mode) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!(
|
||||
"failed to serialize MCP OAuth credentials store mode: {err}"
|
||||
),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let refresh_config = McpServerRefreshConfig {
|
||||
mcp_servers,
|
||||
mcp_oauth_credentials_store_mode,
|
||||
};
|
||||
|
||||
// Refresh requests are queued per thread; each thread rebuilds MCP connections on its next
|
||||
// active turn to avoid work for threads that never resume.
|
||||
let thread_manager = Arc::clone(&self.thread_manager);
|
||||
thread_manager.refresh_mcp_servers(refresh_config).await;
|
||||
let response = McpServerRefreshResponse {};
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
|
||||
async fn mcp_server_oauth_login(
|
||||
&self,
|
||||
request_id: RequestId,
|
||||
@@ -2358,6 +2414,7 @@ impl CodexMessageProcessor {
|
||||
env_http_headers,
|
||||
scopes.as_deref().unwrap_or_default(),
|
||||
timeout_secs,
|
||||
config.mcp_oauth_callback_port,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -13,11 +13,15 @@ use codex_app_server_protocol::SendUserMessageParams;
|
||||
use codex_app_server_protocol::SendUserMessageResponse;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::RawResponseItemEvent;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
@@ -194,6 +198,9 @@ async fn test_send_message_raw_notifications_opt_in() -> Result<()> {
|
||||
})
|
||||
.await?;
|
||||
|
||||
let permissions = read_raw_response_item(&mut mcp, conversation_id).await;
|
||||
assert_permissions_message(&permissions);
|
||||
|
||||
let developer = read_raw_response_item(&mut mcp, conversation_id).await;
|
||||
assert_developer_message(&developer, "Use the test harness tools.");
|
||||
|
||||
@@ -340,6 +347,27 @@ fn assert_instructions_message(item: &ResponseItem) {
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_permissions_message(item: &ResponseItem) {
|
||||
match item {
|
||||
ResponseItem::Message { role, content, .. } => {
|
||||
assert_eq!(role, "developer");
|
||||
let texts = content_texts(content);
|
||||
let expected = DeveloperInstructions::from_policy(
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
AskForApproval::Never,
|
||||
&PathBuf::from("/tmp"),
|
||||
)
|
||||
.into_text();
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec![expected.as_str()],
|
||||
"expected permissions developer message, got {texts:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected permissions message, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_developer_message(item: &ResponseItem, expected_text: &str) {
|
||||
match item {
|
||||
ResponseItem::Message { role, content, .. } => {
|
||||
|
||||
+149
-27
@@ -119,6 +119,9 @@ enum Subcommand {
|
||||
/// Resume a previous interactive session (picker by default; use --last to continue the most recent).
|
||||
Resume(ResumeCommand),
|
||||
|
||||
/// Fork a previous interactive session (picker by default; use --last to fork the most recent).
|
||||
Fork(ForkCommand),
|
||||
|
||||
/// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally.
|
||||
#[clap(name = "cloud", alias = "cloud-tasks")]
|
||||
Cloud(CloudTasksCli),
|
||||
@@ -161,6 +164,25 @@ struct ResumeCommand {
|
||||
config_overrides: TuiCli,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct ForkCommand {
|
||||
/// Conversation/session id (UUID). When provided, forks this session.
|
||||
/// If omitted, use --last to pick the most recent recorded session.
|
||||
#[arg(value_name = "SESSION_ID")]
|
||||
session_id: Option<String>,
|
||||
|
||||
/// Fork the most recent session without showing the picker.
|
||||
#[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
|
||||
last: bool,
|
||||
|
||||
/// Show all sessions (disables cwd filtering and shows CWD column).
|
||||
#[arg(long = "all", default_value_t = false)]
|
||||
all: bool,
|
||||
|
||||
#[clap(flatten)]
|
||||
config_overrides: TuiCli,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct SandboxArgs {
|
||||
#[command(subcommand)]
|
||||
@@ -508,6 +530,23 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?;
|
||||
handle_app_exit(exit_info)?;
|
||||
}
|
||||
Some(Subcommand::Fork(ForkCommand {
|
||||
session_id,
|
||||
last,
|
||||
all,
|
||||
config_overrides,
|
||||
})) => {
|
||||
interactive = finalize_fork_interactive(
|
||||
interactive,
|
||||
root_config_overrides.clone(),
|
||||
session_id,
|
||||
last,
|
||||
all,
|
||||
config_overrides,
|
||||
);
|
||||
let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?;
|
||||
handle_app_exit(exit_info)?;
|
||||
}
|
||||
Some(Subcommand::Login(mut login_cli)) => {
|
||||
prepend_config_flags(
|
||||
&mut login_cli.config_overrides,
|
||||
@@ -725,7 +764,7 @@ fn finalize_resume_interactive(
|
||||
interactive.resume_show_all = show_all;
|
||||
|
||||
// Merge resume-scoped flags and overrides with highest precedence.
|
||||
merge_resume_cli_flags(&mut interactive, resume_cli);
|
||||
merge_interactive_cli_flags(&mut interactive, resume_cli);
|
||||
|
||||
// Propagate any root-level config overrides (e.g. `-c key=value`).
|
||||
prepend_config_flags(&mut interactive.config_overrides, root_config_overrides);
|
||||
@@ -733,51 +772,77 @@ fn finalize_resume_interactive(
|
||||
interactive
|
||||
}
|
||||
|
||||
/// Merge flags provided to `codex resume` so they take precedence over any
|
||||
/// root-level flags. Only overrides fields explicitly set on the resume-scoped
|
||||
/// Build the final `TuiCli` for a `codex fork` invocation.
|
||||
fn finalize_fork_interactive(
|
||||
mut interactive: TuiCli,
|
||||
root_config_overrides: CliConfigOverrides,
|
||||
session_id: Option<String>,
|
||||
last: bool,
|
||||
show_all: bool,
|
||||
fork_cli: TuiCli,
|
||||
) -> TuiCli {
|
||||
// Start with the parsed interactive CLI so fork shares the same
|
||||
// configuration surface area as `codex` without additional flags.
|
||||
let fork_session_id = session_id;
|
||||
interactive.fork_picker = fork_session_id.is_none() && !last;
|
||||
interactive.fork_last = last;
|
||||
interactive.fork_session_id = fork_session_id;
|
||||
interactive.fork_show_all = show_all;
|
||||
|
||||
// Merge fork-scoped flags and overrides with highest precedence.
|
||||
merge_interactive_cli_flags(&mut interactive, fork_cli);
|
||||
|
||||
// Propagate any root-level config overrides (e.g. `-c key=value`).
|
||||
prepend_config_flags(&mut interactive.config_overrides, root_config_overrides);
|
||||
|
||||
interactive
|
||||
}
|
||||
|
||||
/// Merge flags provided to `codex resume`/`codex fork` so they take precedence over any
|
||||
/// root-level flags. Only overrides fields explicitly set on the subcommand-scoped
|
||||
/// CLI. Also appends `-c key=value` overrides with highest precedence.
|
||||
fn merge_resume_cli_flags(interactive: &mut TuiCli, resume_cli: TuiCli) {
|
||||
if let Some(model) = resume_cli.model {
|
||||
fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) {
|
||||
if let Some(model) = subcommand_cli.model {
|
||||
interactive.model = Some(model);
|
||||
}
|
||||
if resume_cli.oss {
|
||||
if subcommand_cli.oss {
|
||||
interactive.oss = true;
|
||||
}
|
||||
if let Some(profile) = resume_cli.config_profile {
|
||||
if let Some(profile) = subcommand_cli.config_profile {
|
||||
interactive.config_profile = Some(profile);
|
||||
}
|
||||
if let Some(sandbox) = resume_cli.sandbox_mode {
|
||||
if let Some(sandbox) = subcommand_cli.sandbox_mode {
|
||||
interactive.sandbox_mode = Some(sandbox);
|
||||
}
|
||||
if let Some(approval) = resume_cli.approval_policy {
|
||||
if let Some(approval) = subcommand_cli.approval_policy {
|
||||
interactive.approval_policy = Some(approval);
|
||||
}
|
||||
if resume_cli.full_auto {
|
||||
if subcommand_cli.full_auto {
|
||||
interactive.full_auto = true;
|
||||
}
|
||||
if resume_cli.dangerously_bypass_approvals_and_sandbox {
|
||||
if subcommand_cli.dangerously_bypass_approvals_and_sandbox {
|
||||
interactive.dangerously_bypass_approvals_and_sandbox = true;
|
||||
}
|
||||
if let Some(cwd) = resume_cli.cwd {
|
||||
if let Some(cwd) = subcommand_cli.cwd {
|
||||
interactive.cwd = Some(cwd);
|
||||
}
|
||||
if resume_cli.web_search {
|
||||
if subcommand_cli.web_search {
|
||||
interactive.web_search = true;
|
||||
}
|
||||
if !resume_cli.images.is_empty() {
|
||||
interactive.images = resume_cli.images;
|
||||
if !subcommand_cli.images.is_empty() {
|
||||
interactive.images = subcommand_cli.images;
|
||||
}
|
||||
if !resume_cli.add_dir.is_empty() {
|
||||
interactive.add_dir.extend(resume_cli.add_dir);
|
||||
if !subcommand_cli.add_dir.is_empty() {
|
||||
interactive.add_dir.extend(subcommand_cli.add_dir);
|
||||
}
|
||||
if let Some(prompt) = resume_cli.prompt {
|
||||
if let Some(prompt) = subcommand_cli.prompt {
|
||||
interactive.prompt = Some(prompt);
|
||||
}
|
||||
|
||||
interactive
|
||||
.config_overrides
|
||||
.raw_overrides
|
||||
.extend(resume_cli.config_overrides.raw_overrides);
|
||||
.extend(subcommand_cli.config_overrides.raw_overrides);
|
||||
}
|
||||
|
||||
fn print_completion(cmd: CompletionCommand) {
|
||||
@@ -794,7 +859,7 @@ mod tests {
|
||||
use codex_protocol::ThreadId;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn finalize_from_args(args: &[&str]) -> TuiCli {
|
||||
fn finalize_resume_from_args(args: &[&str]) -> TuiCli {
|
||||
let cli = MultitoolCli::try_parse_from(args).expect("parse");
|
||||
let MultitoolCli {
|
||||
interactive,
|
||||
@@ -823,6 +888,28 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn finalize_fork_from_args(args: &[&str]) -> TuiCli {
|
||||
let cli = MultitoolCli::try_parse_from(args).expect("parse");
|
||||
let MultitoolCli {
|
||||
interactive,
|
||||
config_overrides: root_overrides,
|
||||
subcommand,
|
||||
feature_toggles: _,
|
||||
} = cli;
|
||||
|
||||
let Subcommand::Fork(ForkCommand {
|
||||
session_id,
|
||||
last,
|
||||
all,
|
||||
config_overrides: fork_cli,
|
||||
}) = subcommand.expect("fork present")
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli)
|
||||
}
|
||||
|
||||
fn sample_exit_info(conversation: Option<&str>) -> AppExitInfo {
|
||||
let token_usage = TokenUsage {
|
||||
output_tokens: 2,
|
||||
@@ -871,7 +958,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resume_model_flag_applies_when_no_root_flags() {
|
||||
let interactive = finalize_from_args(["codex", "resume", "-m", "gpt-5.1-test"].as_ref());
|
||||
let interactive =
|
||||
finalize_resume_from_args(["codex", "resume", "-m", "gpt-5.1-test"].as_ref());
|
||||
|
||||
assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test"));
|
||||
assert!(interactive.resume_picker);
|
||||
@@ -881,7 +969,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resume_picker_logic_none_and_not_last() {
|
||||
let interactive = finalize_from_args(["codex", "resume"].as_ref());
|
||||
let interactive = finalize_resume_from_args(["codex", "resume"].as_ref());
|
||||
assert!(interactive.resume_picker);
|
||||
assert!(!interactive.resume_last);
|
||||
assert_eq!(interactive.resume_session_id, None);
|
||||
@@ -890,7 +978,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resume_picker_logic_last() {
|
||||
let interactive = finalize_from_args(["codex", "resume", "--last"].as_ref());
|
||||
let interactive = finalize_resume_from_args(["codex", "resume", "--last"].as_ref());
|
||||
assert!(!interactive.resume_picker);
|
||||
assert!(interactive.resume_last);
|
||||
assert_eq!(interactive.resume_session_id, None);
|
||||
@@ -899,7 +987,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resume_picker_logic_with_session_id() {
|
||||
let interactive = finalize_from_args(["codex", "resume", "1234"].as_ref());
|
||||
let interactive = finalize_resume_from_args(["codex", "resume", "1234"].as_ref());
|
||||
assert!(!interactive.resume_picker);
|
||||
assert!(!interactive.resume_last);
|
||||
assert_eq!(interactive.resume_session_id.as_deref(), Some("1234"));
|
||||
@@ -908,14 +996,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resume_all_flag_sets_show_all() {
|
||||
let interactive = finalize_from_args(["codex", "resume", "--all"].as_ref());
|
||||
let interactive = finalize_resume_from_args(["codex", "resume", "--all"].as_ref());
|
||||
assert!(interactive.resume_picker);
|
||||
assert!(interactive.resume_show_all);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_merges_option_flags_and_full_auto() {
|
||||
let interactive = finalize_from_args(
|
||||
let interactive = finalize_resume_from_args(
|
||||
[
|
||||
"codex",
|
||||
"resume",
|
||||
@@ -972,7 +1060,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resume_merges_dangerously_bypass_flag() {
|
||||
let interactive = finalize_from_args(
|
||||
let interactive = finalize_resume_from_args(
|
||||
[
|
||||
"codex",
|
||||
"resume",
|
||||
@@ -986,6 +1074,40 @@ mod tests {
|
||||
assert_eq!(interactive.resume_session_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_picker_logic_none_and_not_last() {
|
||||
let interactive = finalize_fork_from_args(["codex", "fork"].as_ref());
|
||||
assert!(interactive.fork_picker);
|
||||
assert!(!interactive.fork_last);
|
||||
assert_eq!(interactive.fork_session_id, None);
|
||||
assert!(!interactive.fork_show_all);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_picker_logic_last() {
|
||||
let interactive = finalize_fork_from_args(["codex", "fork", "--last"].as_ref());
|
||||
assert!(!interactive.fork_picker);
|
||||
assert!(interactive.fork_last);
|
||||
assert_eq!(interactive.fork_session_id, None);
|
||||
assert!(!interactive.fork_show_all);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_picker_logic_with_session_id() {
|
||||
let interactive = finalize_fork_from_args(["codex", "fork", "1234"].as_ref());
|
||||
assert!(!interactive.fork_picker);
|
||||
assert!(!interactive.fork_last);
|
||||
assert_eq!(interactive.fork_session_id.as_deref(), Some("1234"));
|
||||
assert!(!interactive.fork_show_all);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_all_flag_sets_show_all() {
|
||||
let interactive = finalize_fork_from_args(["codex", "fork", "--all"].as_ref());
|
||||
assert!(interactive.fork_picker);
|
||||
assert!(interactive.fork_show_all);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_toggles_known_features_generate_overrides() {
|
||||
let toggles = FeatureToggles {
|
||||
|
||||
@@ -274,6 +274,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
|
||||
http_headers.clone(),
|
||||
env_http_headers.clone(),
|
||||
&Vec::new(),
|
||||
config.mcp_oauth_callback_port,
|
||||
)
|
||||
.await?;
|
||||
println!("Successfully logged in.");
|
||||
@@ -352,6 +353,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
&scopes,
|
||||
config.mcp_oauth_callback_port,
|
||||
)
|
||||
.await?;
|
||||
println!("Successfully logged in to MCP server '{name}'.");
|
||||
|
||||
@@ -14,11 +14,13 @@ http = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] }
|
||||
tokio = { workspace = true, features = ["macros", "net", "rt", "sync", "time"] }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
eventsource-stream = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
tokio-util = { workspace = true, features = ["codec"] }
|
||||
url = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = { workspace = true }
|
||||
|
||||
@@ -136,6 +136,38 @@ pub struct ResponsesApiRequest<'a> {
|
||||
pub text: Option<TextControls>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ResponseCreateWsRequest {
|
||||
pub model: String,
|
||||
pub instructions: String,
|
||||
pub input: Vec<ResponseItem>,
|
||||
pub tools: Vec<Value>,
|
||||
pub tool_choice: String,
|
||||
pub parallel_tool_calls: bool,
|
||||
pub reasoning: Option<Reasoning>,
|
||||
pub store: bool,
|
||||
pub stream: bool,
|
||||
pub include: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<TextControls>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ResponseAppendWsRequest {
|
||||
pub input: Vec<ResponseItem>,
|
||||
}
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum ResponsesWsRequest {
|
||||
#[serde(rename = "response.create")]
|
||||
ResponseCreate(ResponseCreateWsRequest),
|
||||
#[serde(rename = "response.append")]
|
||||
ResponseAppend(ResponseAppendWsRequest),
|
||||
}
|
||||
|
||||
pub fn create_text_param_for_request(
|
||||
verbosity: Option<VerbosityConfig>,
|
||||
output_schema: &Option<Value>,
|
||||
|
||||
@@ -2,4 +2,5 @@ pub mod chat;
|
||||
pub mod compact;
|
||||
pub mod models;
|
||||
pub mod responses;
|
||||
pub mod responses_websocket;
|
||||
mod streaming;
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
use crate::auth::AuthProvider;
|
||||
use crate::common::ResponseEvent;
|
||||
use crate::common::ResponseStream;
|
||||
use crate::common::ResponsesWsRequest;
|
||||
use crate::error::ApiError;
|
||||
use crate::provider::Provider;
|
||||
use crate::sse::responses::ResponsesStreamEvent;
|
||||
use crate::sse::responses::process_responses_event;
|
||||
use codex_client::TransportError;
|
||||
use futures::SinkExt;
|
||||
use futures::StreamExt;
|
||||
use http::HeaderMap;
|
||||
use http::HeaderValue;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::MaybeTlsStream;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
use tokio_tungstenite::tungstenite::Error as WsError;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tracing::debug;
|
||||
use tracing::trace;
|
||||
use url::Url;
|
||||
|
||||
type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||
|
||||
pub struct ResponsesWebsocketConnection {
|
||||
stream: Arc<Mutex<Option<WsStream>>>,
|
||||
// TODO (pakrym): is this the right place for timeout?
|
||||
idle_timeout: Duration,
|
||||
}
|
||||
|
||||
impl ResponsesWebsocketConnection {
|
||||
fn new(stream: WsStream, idle_timeout: Duration) -> Self {
|
||||
Self {
|
||||
stream: Arc::new(Mutex::new(Some(stream))),
|
||||
idle_timeout,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_closed(&self) -> bool {
|
||||
self.stream.lock().await.is_none()
|
||||
}
|
||||
|
||||
pub async fn stream_request(
|
||||
&self,
|
||||
request: ResponsesWsRequest,
|
||||
) -> Result<ResponseStream, ApiError> {
|
||||
let (tx_event, rx_event) =
|
||||
mpsc::channel::<std::result::Result<ResponseEvent, ApiError>>(1600);
|
||||
let stream = Arc::clone(&self.stream);
|
||||
let idle_timeout = self.idle_timeout;
|
||||
let request_body = serde_json::to_value(&request).map_err(|err| {
|
||||
ApiError::Stream(format!("failed to encode websocket request: {err}"))
|
||||
})?;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut guard = stream.lock().await;
|
||||
let Some(ws_stream) = guard.as_mut() else {
|
||||
let _ = tx_event
|
||||
.send(Err(ApiError::Stream(
|
||||
"websocket connection is closed".to_string(),
|
||||
)))
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = run_websocket_response_stream(
|
||||
ws_stream,
|
||||
tx_event.clone(),
|
||||
request_body,
|
||||
idle_timeout,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = ws_stream.close(None).await;
|
||||
*guard = None;
|
||||
let _ = tx_event.send(Err(err)).await;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(ResponseStream { rx_event })
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ResponsesWebsocketClient<A: AuthProvider> {
|
||||
provider: Provider,
|
||||
auth: A,
|
||||
}
|
||||
|
||||
impl<A: AuthProvider> ResponsesWebsocketClient<A> {
|
||||
pub fn new(provider: Provider, auth: A) -> Self {
|
||||
Self { provider, auth }
|
||||
}
|
||||
|
||||
pub async fn connect(
|
||||
&self,
|
||||
extra_headers: HeaderMap,
|
||||
) -> Result<ResponsesWebsocketConnection, ApiError> {
|
||||
let ws_url = Url::parse(&self.provider.url_for_path("responses"))
|
||||
.map_err(|err| ApiError::Stream(format!("failed to build websocket URL: {err}")))?;
|
||||
|
||||
let mut headers = self.provider.headers.clone();
|
||||
headers.extend(extra_headers);
|
||||
apply_auth_headers(&mut headers, &self.auth);
|
||||
|
||||
let stream = connect_websocket(ws_url, headers).await?;
|
||||
Ok(ResponsesWebsocketConnection::new(
|
||||
stream,
|
||||
self.provider.stream_idle_timeout,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// TODO (pakrym): share with /auth
|
||||
fn apply_auth_headers(headers: &mut HeaderMap, auth: &impl AuthProvider) {
|
||||
if let Some(token) = auth.bearer_token()
|
||||
&& let Ok(header) = HeaderValue::from_str(&format!("Bearer {token}"))
|
||||
{
|
||||
let _ = headers.insert(http::header::AUTHORIZATION, header);
|
||||
}
|
||||
if let Some(account_id) = auth.account_id()
|
||||
&& let Ok(header) = HeaderValue::from_str(&account_id)
|
||||
{
|
||||
let _ = headers.insert("ChatGPT-Account-ID", header);
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_websocket(url: Url, headers: HeaderMap) -> Result<WsStream, ApiError> {
|
||||
let mut request = url
|
||||
.clone()
|
||||
.into_client_request()
|
||||
.map_err(|err| ApiError::Stream(format!("failed to build websocket request: {err}")))?;
|
||||
request.headers_mut().extend(headers);
|
||||
|
||||
let (stream, _) = tokio_tungstenite::connect_async(request)
|
||||
.await
|
||||
.map_err(|err| map_ws_error(err, &url))?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
fn map_ws_error(err: WsError, url: &Url) -> ApiError {
|
||||
match err {
|
||||
WsError::Http(response) => {
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let body = response
|
||||
.body()
|
||||
.as_ref()
|
||||
.and_then(|bytes| String::from_utf8(bytes.clone()).ok());
|
||||
ApiError::Transport(TransportError::Http {
|
||||
status,
|
||||
url: Some(url.to_string()),
|
||||
headers: Some(headers),
|
||||
body,
|
||||
})
|
||||
}
|
||||
WsError::ConnectionClosed | WsError::AlreadyClosed => {
|
||||
ApiError::Stream("websocket closed".to_string())
|
||||
}
|
||||
WsError::Io(err) => ApiError::Transport(TransportError::Network(err.to_string())),
|
||||
other => ApiError::Transport(TransportError::Network(other.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_websocket_response_stream(
|
||||
ws_stream: &mut WsStream,
|
||||
tx_event: mpsc::Sender<std::result::Result<ResponseEvent, ApiError>>,
|
||||
request_body: Value,
|
||||
idle_timeout: Duration,
|
||||
) -> Result<(), ApiError> {
|
||||
let request_text = match serde_json::to_string(&request_body) {
|
||||
Ok(text) => text,
|
||||
Err(err) => {
|
||||
return Err(ApiError::Stream(format!(
|
||||
"failed to encode websocket request: {err}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = ws_stream.send(Message::Text(request_text)).await {
|
||||
return Err(ApiError::Stream(format!(
|
||||
"failed to send websocket request: {err}"
|
||||
)));
|
||||
}
|
||||
|
||||
loop {
|
||||
let response = tokio::time::timeout(idle_timeout, ws_stream.next())
|
||||
.await
|
||||
.map_err(|_| ApiError::Stream("idle timeout waiting for websocket".into()));
|
||||
let message = match response {
|
||||
Ok(Some(Ok(msg))) => msg,
|
||||
Ok(Some(Err(err))) => {
|
||||
return Err(ApiError::Stream(err.to_string()));
|
||||
}
|
||||
Ok(None) => {
|
||||
return Err(ApiError::Stream(
|
||||
"stream closed before response.completed".into(),
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
match message {
|
||||
Message::Text(text) => {
|
||||
trace!("websocket event: {text}");
|
||||
let event = match serde_json::from_str::<ResponsesStreamEvent>(&text) {
|
||||
Ok(event) => event,
|
||||
Err(err) => {
|
||||
debug!("failed to parse websocket event: {err}, data: {text}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match process_responses_event(event) {
|
||||
Ok(Some(event)) => {
|
||||
let is_completed = matches!(event, ResponseEvent::Completed { .. });
|
||||
let _ = tx_event.send(Ok(event)).await;
|
||||
if is_completed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
return Err(error.into_api_error());
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Binary(_) => {
|
||||
return Err(ApiError::Stream("unexpected binary websocket event".into()));
|
||||
}
|
||||
Message::Ping(payload) => {
|
||||
if ws_stream.send(Message::Pong(payload)).await.is_err() {
|
||||
return Err(ApiError::Stream("websocket ping failed".into()));
|
||||
}
|
||||
}
|
||||
Message::Pong(_) => {}
|
||||
Message::Close(_) => {
|
||||
return Err(ApiError::Stream(
|
||||
"websocket closed before response.completed".into(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod requests;
|
||||
pub mod sse;
|
||||
pub mod telemetry;
|
||||
|
||||
pub use crate::requests::headers::build_conversation_headers;
|
||||
pub use codex_client::RequestTelemetry;
|
||||
pub use codex_client::ReqwestTransport;
|
||||
pub use codex_client::TransportError;
|
||||
@@ -15,6 +16,8 @@ pub use codex_client::TransportError;
|
||||
pub use crate::auth::AuthProvider;
|
||||
pub use crate::common::CompactionInput;
|
||||
pub use crate::common::Prompt;
|
||||
pub use crate::common::ResponseAppendWsRequest;
|
||||
pub use crate::common::ResponseCreateWsRequest;
|
||||
pub use crate::common::ResponseEvent;
|
||||
pub use crate::common::ResponseStream;
|
||||
pub use crate::common::ResponsesApiRequest;
|
||||
@@ -25,6 +28,8 @@ pub use crate::endpoint::compact::CompactClient;
|
||||
pub use crate::endpoint::models::ModelsClient;
|
||||
pub use crate::endpoint::responses::ResponsesClient;
|
||||
pub use crate::endpoint::responses::ResponsesOptions;
|
||||
pub use crate::endpoint::responses_websocket::ResponsesWebsocketClient;
|
||||
pub use crate::endpoint::responses_websocket::ResponsesWebsocketConnection;
|
||||
pub use crate::error::ApiError;
|
||||
pub use crate::provider::Provider;
|
||||
pub use crate::provider::WireApi;
|
||||
|
||||
@@ -393,10 +393,6 @@ mod tests {
|
||||
.build(&provider())
|
||||
.expect("request");
|
||||
|
||||
assert_eq!(
|
||||
req.headers.get("conversation_id"),
|
||||
Some(&HeaderValue::from_static("conv-1"))
|
||||
);
|
||||
assert_eq!(
|
||||
req.headers.get("session_id"),
|
||||
Some(&HeaderValue::from_static("conv-1"))
|
||||
|
||||
@@ -2,10 +2,9 @@ use codex_protocol::protocol::SessionSource;
|
||||
use http::HeaderMap;
|
||||
use http::HeaderValue;
|
||||
|
||||
pub(crate) fn build_conversation_headers(conversation_id: Option<String>) -> HeaderMap {
|
||||
pub fn build_conversation_headers(conversation_id: Option<String>) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(id) = conversation_id {
|
||||
insert_header(&mut headers, "conversation_id", &id);
|
||||
insert_header(&mut headers, "session_id", &id);
|
||||
}
|
||||
headers
|
||||
|
||||
@@ -249,10 +249,6 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(ids, vec![Some("m1".to_string()), None]);
|
||||
|
||||
assert_eq!(
|
||||
request.headers.get("conversation_id"),
|
||||
Some(&HeaderValue::from_static("conv-1"))
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("session_id"),
|
||||
Some(&HeaderValue::from_static("conv-1"))
|
||||
|
||||
@@ -126,7 +126,7 @@ struct ResponseCompletedOutputTokensDetails {
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct SseEvent {
|
||||
pub struct ResponsesStreamEvent {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
response: Option<Value>,
|
||||
@@ -136,6 +136,122 @@ struct SseEvent {
|
||||
content_index: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ResponsesEventError {
|
||||
Api(ApiError),
|
||||
}
|
||||
|
||||
impl ResponsesEventError {
|
||||
pub fn into_api_error(self) -> ApiError {
|
||||
match self {
|
||||
Self::Api(error) => error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_responses_event(
|
||||
event: ResponsesStreamEvent,
|
||||
) -> std::result::Result<Option<ResponseEvent>, ResponsesEventError> {
|
||||
match event.kind.as_str() {
|
||||
"response.output_item.done" => {
|
||||
if let Some(item_val) = event.item {
|
||||
if let Ok(item) = serde_json::from_value::<ResponseItem>(item_val) {
|
||||
return Ok(Some(ResponseEvent::OutputItemDone(item)));
|
||||
}
|
||||
debug!("failed to parse ResponseItem from output_item.done");
|
||||
}
|
||||
}
|
||||
"response.output_text.delta" => {
|
||||
if let Some(delta) = event.delta {
|
||||
return Ok(Some(ResponseEvent::OutputTextDelta(delta)));
|
||||
}
|
||||
}
|
||||
"response.reasoning_summary_text.delta" => {
|
||||
if let (Some(delta), Some(summary_index)) = (event.delta, event.summary_index) {
|
||||
return Ok(Some(ResponseEvent::ReasoningSummaryDelta {
|
||||
delta,
|
||||
summary_index,
|
||||
}));
|
||||
}
|
||||
}
|
||||
"response.reasoning_text.delta" => {
|
||||
if let (Some(delta), Some(content_index)) = (event.delta, event.content_index) {
|
||||
return Ok(Some(ResponseEvent::ReasoningContentDelta {
|
||||
delta,
|
||||
content_index,
|
||||
}));
|
||||
}
|
||||
}
|
||||
"response.created" => {
|
||||
if event.response.is_some() {
|
||||
return Ok(Some(ResponseEvent::Created {}));
|
||||
}
|
||||
}
|
||||
"response.failed" => {
|
||||
if let Some(resp_val) = event.response {
|
||||
let mut response_error = ApiError::Stream("response.failed event received".into());
|
||||
if let Some(error) = resp_val.get("error")
|
||||
&& let Ok(error) = serde_json::from_value::<Error>(error.clone())
|
||||
{
|
||||
if is_context_window_error(&error) {
|
||||
response_error = ApiError::ContextWindowExceeded;
|
||||
} else if is_quota_exceeded_error(&error) {
|
||||
response_error = ApiError::QuotaExceeded;
|
||||
} else if is_usage_not_included(&error) {
|
||||
response_error = ApiError::UsageNotIncluded;
|
||||
} else {
|
||||
let delay = try_parse_retry_after(&error);
|
||||
let message = error.message.unwrap_or_default();
|
||||
response_error = ApiError::Retryable { message, delay };
|
||||
}
|
||||
}
|
||||
return Err(ResponsesEventError::Api(response_error));
|
||||
}
|
||||
|
||||
return Err(ResponsesEventError::Api(ApiError::Stream(
|
||||
"response.failed event received".into(),
|
||||
)));
|
||||
}
|
||||
"response.completed" => {
|
||||
if let Some(resp_val) = event.response {
|
||||
match serde_json::from_value::<ResponseCompleted>(resp_val) {
|
||||
Ok(resp) => {
|
||||
return Ok(Some(ResponseEvent::Completed {
|
||||
response_id: resp.id,
|
||||
token_usage: resp.usage.map(Into::into),
|
||||
}));
|
||||
}
|
||||
Err(err) => {
|
||||
let error = format!("failed to parse ResponseCompleted: {err}");
|
||||
debug!("{error}");
|
||||
return Err(ResponsesEventError::Api(ApiError::Stream(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"response.output_item.added" => {
|
||||
if let Some(item_val) = event.item {
|
||||
if let Ok(item) = serde_json::from_value::<ResponseItem>(item_val) {
|
||||
return Ok(Some(ResponseEvent::OutputItemAdded(item)));
|
||||
}
|
||||
debug!("failed to parse ResponseItem from output_item.done");
|
||||
}
|
||||
}
|
||||
"response.reasoning_summary_part.added" => {
|
||||
if let Some(summary_index) = event.summary_index {
|
||||
return Ok(Some(ResponseEvent::ReasoningSummaryPartAdded {
|
||||
summary_index,
|
||||
}));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
trace!("unhandled responses event: {}", event.kind);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn process_sse(
|
||||
stream: ByteStream,
|
||||
tx_event: mpsc::Sender<Result<ResponseEvent, ApiError>>,
|
||||
@@ -143,7 +259,7 @@ pub async fn process_sse(
|
||||
telemetry: Option<Arc<dyn SseTelemetry>>,
|
||||
) {
|
||||
let mut stream = stream.eventsource();
|
||||
let mut response_completed: Option<ResponseCompleted> = None;
|
||||
let mut response_completed: Option<ResponseEvent> = None;
|
||||
let mut response_error: Option<ApiError> = None;
|
||||
|
||||
loop {
|
||||
@@ -161,11 +277,7 @@ pub async fn process_sse(
|
||||
}
|
||||
Ok(None) => {
|
||||
match response_completed.take() {
|
||||
Some(ResponseCompleted { id, usage }) => {
|
||||
let event = ResponseEvent::Completed {
|
||||
response_id: id,
|
||||
token_usage: usage.map(Into::into),
|
||||
};
|
||||
Some(event) => {
|
||||
let _ = tx_event.send(Ok(event)).await;
|
||||
}
|
||||
None => {
|
||||
@@ -188,7 +300,7 @@ pub async fn process_sse(
|
||||
let raw = sse.data.clone();
|
||||
trace!("SSE event: {raw}");
|
||||
|
||||
let event: SseEvent = match serde_json::from_str(&sse.data) {
|
||||
let event: ResponsesStreamEvent = match serde_json::from_str(&sse.data) {
|
||||
Ok(event) => event,
|
||||
Err(e) => {
|
||||
debug!("Failed to parse SSE event: {e}, data: {}", &sse.data);
|
||||
@@ -196,115 +308,19 @@ pub async fn process_sse(
|
||||
}
|
||||
};
|
||||
|
||||
match event.kind.as_str() {
|
||||
"response.output_item.done" => {
|
||||
let Some(item_val) = event.item else { continue };
|
||||
let Ok(item) = serde_json::from_value::<ResponseItem>(item_val) else {
|
||||
debug!("failed to parse ResponseItem from output_item.done");
|
||||
continue;
|
||||
};
|
||||
|
||||
let event = ResponseEvent::OutputItemDone(item);
|
||||
if tx_event.send(Ok(event)).await.is_err() {
|
||||
match process_responses_event(event) {
|
||||
Ok(Some(event)) => {
|
||||
if matches!(event, ResponseEvent::Completed { .. }) {
|
||||
response_completed = Some(event);
|
||||
} else if tx_event.send(Ok(event)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
"response.output_text.delta" => {
|
||||
if let Some(delta) = event.delta {
|
||||
let event = ResponseEvent::OutputTextDelta(delta);
|
||||
if tx_event.send(Ok(event)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
response_error = Some(error.into_api_error());
|
||||
}
|
||||
"response.reasoning_summary_text.delta" => {
|
||||
if let (Some(delta), Some(summary_index)) = (event.delta, event.summary_index) {
|
||||
let event = ResponseEvent::ReasoningSummaryDelta {
|
||||
delta,
|
||||
summary_index,
|
||||
};
|
||||
if tx_event.send(Ok(event)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
"response.reasoning_text.delta" => {
|
||||
if let (Some(delta), Some(content_index)) = (event.delta, event.content_index) {
|
||||
let event = ResponseEvent::ReasoningContentDelta {
|
||||
delta,
|
||||
content_index,
|
||||
};
|
||||
if tx_event.send(Ok(event)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
"response.created" => {
|
||||
if event.response.is_some() {
|
||||
let _ = tx_event.send(Ok(ResponseEvent::Created {})).await;
|
||||
}
|
||||
}
|
||||
"response.failed" => {
|
||||
if let Some(resp_val) = event.response {
|
||||
response_error =
|
||||
Some(ApiError::Stream("response.failed event received".into()));
|
||||
|
||||
if let Some(error) = resp_val.get("error")
|
||||
&& let Ok(error) = serde_json::from_value::<Error>(error.clone())
|
||||
{
|
||||
if is_context_window_error(&error) {
|
||||
response_error = Some(ApiError::ContextWindowExceeded);
|
||||
} else if is_quota_exceeded_error(&error) {
|
||||
response_error = Some(ApiError::QuotaExceeded);
|
||||
} else if is_usage_not_included(&error) {
|
||||
response_error = Some(ApiError::UsageNotIncluded);
|
||||
} else {
|
||||
let delay = try_parse_retry_after(&error);
|
||||
let message = error.message.clone().unwrap_or_default();
|
||||
response_error = Some(ApiError::Retryable { message, delay });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"response.completed" => {
|
||||
if let Some(resp_val) = event.response {
|
||||
match serde_json::from_value::<ResponseCompleted>(resp_val) {
|
||||
Ok(r) => {
|
||||
response_completed = Some(r);
|
||||
}
|
||||
Err(e) => {
|
||||
let error = format!("failed to parse ResponseCompleted: {e}");
|
||||
debug!(error);
|
||||
response_error = Some(ApiError::Stream(error));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
"response.output_item.added" => {
|
||||
let Some(item_val) = event.item else { continue };
|
||||
let Ok(item) = serde_json::from_value::<ResponseItem>(item_val) else {
|
||||
debug!("failed to parse ResponseItem from output_item.done");
|
||||
continue;
|
||||
};
|
||||
|
||||
let event = ResponseEvent::OutputItemAdded(item);
|
||||
if tx_event.send(Ok(event)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
"response.reasoning_summary_part.added" => {
|
||||
if let Some(summary_index) = event.summary_index {
|
||||
let event = ResponseEvent::ReasoningSummaryPartAdded { summary_index };
|
||||
if tx_event.send(Ok(event)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
trace!("unhandled SSE event: {:#?}", event.kind);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ eventsource-stream = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
http = { workspace = true }
|
||||
include_dir = { workspace = true }
|
||||
indoc = { workspace = true }
|
||||
indexmap = { workspace = true }
|
||||
keyring = { workspace = true, features = ["crypto-rust"] }
|
||||
libc = { workspace = true }
|
||||
|
||||
@@ -25,43 +25,6 @@ When using the planning tool:
|
||||
- Do not make single-step plans.
|
||||
- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.
|
||||
|
||||
## Codex CLI harness, sandboxing, and approvals
|
||||
|
||||
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
|
||||
|
||||
Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:
|
||||
- **read-only**: The sandbox only permits reading files.
|
||||
- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.
|
||||
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
|
||||
|
||||
Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are:
|
||||
- **restricted**: Requires approval
|
||||
- **enabled**: No approval needed
|
||||
|
||||
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are
|
||||
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
|
||||
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
|
||||
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
|
||||
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
|
||||
|
||||
When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
|
||||
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
|
||||
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
|
||||
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
|
||||
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.
|
||||
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
|
||||
- (for all of these, you should weigh alternative paths that do not require approval)
|
||||
|
||||
When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.
|
||||
|
||||
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
|
||||
|
||||
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
|
||||
|
||||
When requesting approval to execute a command that will require escalated privileges:
|
||||
- Provide the `sandbox_permissions` parameter with the value `"require_escalated"`
|
||||
- Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
|
||||
|
||||
## Special user requests
|
||||
|
||||
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
|
||||
|
||||
@@ -25,43 +25,6 @@ When using the planning tool:
|
||||
- Do not make single-step plans.
|
||||
- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.
|
||||
|
||||
## Codex CLI harness, sandboxing, and approvals
|
||||
|
||||
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
|
||||
|
||||
Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:
|
||||
- **read-only**: The sandbox only permits reading files.
|
||||
- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.
|
||||
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
|
||||
|
||||
Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are:
|
||||
- **restricted**: Requires approval
|
||||
- **enabled**: No approval needed
|
||||
|
||||
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are
|
||||
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
|
||||
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
|
||||
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
|
||||
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
|
||||
|
||||
When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
|
||||
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
|
||||
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
|
||||
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
|
||||
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.
|
||||
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
|
||||
- (for all of these, you should weigh alternative paths that do not require approval)
|
||||
|
||||
When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.
|
||||
|
||||
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
|
||||
|
||||
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
|
||||
|
||||
When requesting approval to execute a command that will require escalated privileges:
|
||||
- Provide the `sandbox_permissions` parameter with the value `"require_escalated"`
|
||||
- Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
|
||||
|
||||
## Special user requests
|
||||
|
||||
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
|
||||
|
||||
@@ -159,43 +159,6 @@ If completing the user's task requires writing or modifying files, your code and
|
||||
- Do not use one-letter variable names unless explicitly requested.
|
||||
- NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor.
|
||||
|
||||
## Codex CLI harness, sandboxing, and approvals
|
||||
|
||||
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
|
||||
|
||||
Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:
|
||||
- **read-only**: The sandbox only permits reading files.
|
||||
- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.
|
||||
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
|
||||
|
||||
Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are:
|
||||
- **restricted**: Requires approval
|
||||
- **enabled**: No approval needed
|
||||
|
||||
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are
|
||||
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
|
||||
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
|
||||
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for escalating in the tool definition.)
|
||||
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
|
||||
|
||||
When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
|
||||
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
|
||||
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
|
||||
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
|
||||
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters. Within this harness, prefer requesting approval via the tool over asking in natural language.
|
||||
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
|
||||
- (for all of these, you should weigh alternative paths that do not require approval)
|
||||
|
||||
When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.
|
||||
|
||||
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
|
||||
|
||||
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
|
||||
|
||||
When requesting approval to execute a command that will require escalated privileges:
|
||||
- Provide the `sandbox_permissions` parameter with the value `"require_escalated"`
|
||||
- Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
|
||||
|
||||
## Validating your work
|
||||
|
||||
If the codebase has tests or the ability to build or run, consider using them to verify changes once your work is complete.
|
||||
|
||||
@@ -133,43 +133,6 @@ If completing the user's task requires writing or modifying files, your code and
|
||||
- Do not use one-letter variable names unless explicitly requested.
|
||||
- NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor.
|
||||
|
||||
## Codex CLI harness, sandboxing, and approvals
|
||||
|
||||
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
|
||||
|
||||
Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:
|
||||
- **read-only**: The sandbox only permits reading files.
|
||||
- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.
|
||||
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
|
||||
|
||||
Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are:
|
||||
- **restricted**: Requires approval
|
||||
- **enabled**: No approval needed
|
||||
|
||||
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are
|
||||
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
|
||||
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
|
||||
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for escalating in the tool definition.)
|
||||
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
|
||||
|
||||
When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
|
||||
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
|
||||
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
|
||||
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
|
||||
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.
|
||||
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
|
||||
- (for all of these, you should weigh alternative paths that do not require approval)
|
||||
|
||||
When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.
|
||||
|
||||
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
|
||||
|
||||
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
|
||||
|
||||
When requesting approval to execute a command that will require escalated privileges:
|
||||
- Provide the `sandbox_permissions` parameter with the value `"require_escalated"`
|
||||
- Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
|
||||
|
||||
## Validating your work
|
||||
|
||||
If the codebase has tests, or the ability to build or run tests, consider using them to verify changes once your work is complete.
|
||||
|
||||
@@ -25,43 +25,6 @@ When using the planning tool:
|
||||
- Do not make single-step plans.
|
||||
- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.
|
||||
|
||||
## Codex CLI harness, sandboxing, and approvals
|
||||
|
||||
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
|
||||
|
||||
Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:
|
||||
- **read-only**: The sandbox only permits reading files.
|
||||
- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.
|
||||
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
|
||||
|
||||
Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are:
|
||||
- **restricted**: Requires approval
|
||||
- **enabled**: No approval needed
|
||||
|
||||
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are
|
||||
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
|
||||
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
|
||||
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
|
||||
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
|
||||
|
||||
When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
|
||||
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
|
||||
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
|
||||
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
|
||||
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.
|
||||
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
|
||||
- (for all of these, you should weigh alternative paths that do not require approval)
|
||||
|
||||
When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.
|
||||
|
||||
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
|
||||
|
||||
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
|
||||
|
||||
When requesting approval to execute a command that will require escalated privileges:
|
||||
- Provide the `sandbox_permissions` parameter with the value `"require_escalated"`
|
||||
- Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
|
||||
|
||||
## Special user requests
|
||||
|
||||
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
|
||||
|
||||
@@ -146,41 +146,6 @@ If completing the user's task requires writing or modifying files, your code and
|
||||
- Do not use one-letter variable names unless explicitly requested.
|
||||
- NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor.
|
||||
|
||||
## Sandbox and approvals
|
||||
|
||||
The Codex CLI harness supports several different sandboxing, and approval configurations that the user can choose from.
|
||||
|
||||
Filesystem sandboxing prevents you from editing files without user approval. The options are:
|
||||
|
||||
- **read-only**: You can only read files.
|
||||
- **workspace-write**: You can read files. You can write to files in your workspace folder, but not outside it.
|
||||
- **danger-full-access**: No filesystem sandboxing.
|
||||
|
||||
Network sandboxing prevents you from accessing network without approval. Options are
|
||||
|
||||
- **restricted**
|
||||
- **enabled**
|
||||
|
||||
Approvals are your mechanism to get user consent to perform more privileged actions. Although they introduce friction to the user because your work is paused until the user responds, you should leverage them to accomplish your important work. Do not let these settings or the sandbox deter you from attempting to accomplish the user's task. Approval options are
|
||||
|
||||
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
|
||||
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
|
||||
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
|
||||
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is pared with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
|
||||
|
||||
When you are running with approvals `on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
|
||||
|
||||
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /tmp)
|
||||
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
|
||||
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
|
||||
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval.
|
||||
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
|
||||
- (For all of these, you should weigh alternative paths that do not require approval.)
|
||||
|
||||
Note that when sandboxing is set to read-only, you'll need to request approval for any command that isn't a read.
|
||||
|
||||
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing ON, and approval on-failure.
|
||||
|
||||
## Validating your work
|
||||
|
||||
If the codebase has tests or the ability to build or run, consider using them to verify that your work is complete.
|
||||
|
||||
@@ -146,41 +146,6 @@ If completing the user's task requires writing or modifying files, your code and
|
||||
- Do not use one-letter variable names unless explicitly requested.
|
||||
- NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor.
|
||||
|
||||
## Sandbox and approvals
|
||||
|
||||
The Codex CLI harness supports several different sandboxing, and approval configurations that the user can choose from.
|
||||
|
||||
Filesystem sandboxing prevents you from editing files without user approval. The options are:
|
||||
|
||||
- **read-only**: You can only read files.
|
||||
- **workspace-write**: You can read files. You can write to files in your workspace folder, but not outside it.
|
||||
- **danger-full-access**: No filesystem sandboxing.
|
||||
|
||||
Network sandboxing prevents you from accessing network without approval. Options are
|
||||
|
||||
- **restricted**
|
||||
- **enabled**
|
||||
|
||||
Approvals are your mechanism to get user consent to perform more privileged actions. Although they introduce friction to the user because your work is paused until the user responds, you should leverage them to accomplish your important work. Do not let these settings or the sandbox deter you from attempting to accomplish the user's task. Approval options are
|
||||
|
||||
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
|
||||
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
|
||||
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
|
||||
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is pared with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
|
||||
|
||||
When you are running with approvals `on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
|
||||
|
||||
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /tmp)
|
||||
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
|
||||
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
|
||||
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval.
|
||||
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
|
||||
- (For all of these, you should weigh alternative paths that do not require approval.)
|
||||
|
||||
Note that when sandboxing is set to read-only, you'll need to request approval for any command that isn't a read.
|
||||
|
||||
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing ON, and approval on-failure.
|
||||
|
||||
## Validating your work
|
||||
|
||||
If the codebase has tests or the ability to build or run, consider using them to verify that your work is complete.
|
||||
|
||||
@@ -9,6 +9,7 @@ use codex_protocol::protocol::Op;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Weak;
|
||||
use tokio::sync::watch;
|
||||
|
||||
/// Control-plane handle for multi-agent operations.
|
||||
/// `AgentControl` is held by each session (via `SessionServices`). It provides capability to
|
||||
@@ -27,7 +28,6 @@ impl AgentControl {
|
||||
Self { manager }
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Used by upcoming multi-agent tooling.
|
||||
/// Spawn a new agent thread and submit the initial prompt.
|
||||
///
|
||||
/// If `headless` is true, a background drain task is spawned to prevent unbounded event growth
|
||||
@@ -50,7 +50,6 @@ impl AgentControl {
|
||||
Ok(new_thread.thread_id)
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Used by upcoming multi-agent tooling.
|
||||
/// Send a `user` prompt to an existing agent thread.
|
||||
pub(crate) async fn send_prompt(
|
||||
&self,
|
||||
@@ -69,7 +68,13 @@ impl AgentControl {
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Used by upcoming multi-agent tooling.
|
||||
/// Submit a shutdown request to an existing agent thread.
|
||||
pub(crate) async fn shutdown_agent(&self, agent_id: ThreadId) -> CodexResult<String> {
|
||||
let state = self.upgrade()?;
|
||||
state.send_op(agent_id, Op::Shutdown {}).await
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Will be used for collab tools.
|
||||
/// Fetch the last known status for `agent_id`, returning `NotFound` when unavailable.
|
||||
pub(crate) async fn get_status(&self, agent_id: ThreadId) -> AgentStatus {
|
||||
let Ok(state) = self.upgrade() else {
|
||||
@@ -82,6 +87,16 @@ impl AgentControl {
|
||||
thread.agent_status().await
|
||||
}
|
||||
|
||||
/// Subscribe to status updates for `agent_id`, yielding the latest value and changes.
|
||||
pub(crate) async fn subscribe_status(
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
) -> CodexResult<watch::Receiver<AgentStatus>> {
|
||||
let state = self.upgrade()?;
|
||||
let thread = state.get_thread(agent_id).await?;
|
||||
Ok(thread.subscribe_status())
|
||||
}
|
||||
|
||||
fn upgrade(&self) -> CodexResult<Arc<ThreadManagerState>> {
|
||||
self.manager
|
||||
.upgrade()
|
||||
@@ -114,13 +129,63 @@ fn spawn_headless_drain(thread: Arc<CodexThread>) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::CodexAuth;
|
||||
use crate::ThreadManager;
|
||||
use crate::agent::agent_status_from_event;
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigBuilder;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnAbortedEvent;
|
||||
use codex_protocol::protocol::TurnCompleteEvent;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn test_config() -> (TempDir, Config) {
|
||||
let home = TempDir::new().expect("create temp dir");
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("load default test config");
|
||||
(home, config)
|
||||
}
|
||||
|
||||
struct AgentControlHarness {
|
||||
_home: TempDir,
|
||||
config: Config,
|
||||
manager: ThreadManager,
|
||||
control: AgentControl,
|
||||
}
|
||||
|
||||
impl AgentControlHarness {
|
||||
async fn new() -> Self {
|
||||
let (home, config) = test_config().await;
|
||||
let manager = ThreadManager::with_models_provider_and_home(
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
);
|
||||
let control = manager.agent_control();
|
||||
Self {
|
||||
_home: home,
|
||||
config,
|
||||
manager,
|
||||
control,
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_thread(&self) -> (ThreadId, Arc<CodexThread>) {
|
||||
let new_thread = self
|
||||
.manager
|
||||
.start_thread(self.config.clone())
|
||||
.await
|
||||
.expect("start thread");
|
||||
(new_thread.thread_id, new_thread.thread)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_prompt_errors_when_manager_dropped() {
|
||||
@@ -185,4 +250,135 @@ mod tests {
|
||||
let status = agent_status_from_event(&EventMsg::ShutdownComplete);
|
||||
assert_eq!(status, Some(AgentStatus::Shutdown));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_errors_when_manager_dropped() {
|
||||
let control = AgentControl::default();
|
||||
let (_home, config) = test_config().await;
|
||||
let err = control
|
||||
.spawn_agent(config, "hello".to_string(), false)
|
||||
.await
|
||||
.expect_err("spawn_agent should fail without a manager");
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"unsupported operation: thread manager dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_prompt_errors_when_thread_missing() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let thread_id = ThreadId::new();
|
||||
let err = harness
|
||||
.control
|
||||
.send_prompt(thread_id, "hello".to_string())
|
||||
.await
|
||||
.expect_err("send_prompt should fail for missing thread");
|
||||
assert_matches!(err, CodexErr::ThreadNotFound(id) if id == thread_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_status_returns_not_found_for_missing_thread() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let status = harness.control.get_status(ThreadId::new()).await;
|
||||
assert_eq!(status, AgentStatus::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_status_returns_pending_init_for_new_thread() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (thread_id, _) = harness.start_thread().await;
|
||||
let status = harness.control.get_status(thread_id).await;
|
||||
assert_eq!(status, AgentStatus::PendingInit);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_status_errors_for_missing_thread() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let thread_id = ThreadId::new();
|
||||
let err = harness
|
||||
.control
|
||||
.subscribe_status(thread_id)
|
||||
.await
|
||||
.expect_err("subscribe_status should fail for missing thread");
|
||||
assert_matches!(err, CodexErr::ThreadNotFound(id) if id == thread_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_status_updates_on_shutdown() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (thread_id, thread) = harness.start_thread().await;
|
||||
let mut status_rx = harness
|
||||
.control
|
||||
.subscribe_status(thread_id)
|
||||
.await
|
||||
.expect("subscribe_status should succeed");
|
||||
assert_eq!(status_rx.borrow().clone(), AgentStatus::PendingInit);
|
||||
|
||||
let _ = thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("shutdown should submit");
|
||||
|
||||
let _ = status_rx.changed().await;
|
||||
assert_eq!(status_rx.borrow().clone(), AgentStatus::Shutdown);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_prompt_submits_user_message() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (thread_id, _thread) = harness.start_thread().await;
|
||||
|
||||
let submission_id = harness
|
||||
.control
|
||||
.send_prompt(thread_id, "hello from tests".to_string())
|
||||
.await
|
||||
.expect("send_prompt should succeed");
|
||||
assert!(!submission_id.is_empty());
|
||||
let expected = (
|
||||
thread_id,
|
||||
Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello from tests".to_string(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
);
|
||||
let captured = harness
|
||||
.manager
|
||||
.captured_ops()
|
||||
.into_iter()
|
||||
.find(|entry| *entry == expected);
|
||||
assert_eq!(captured, Some(expected));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_creates_thread_and_sends_prompt() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let thread_id = harness
|
||||
.control
|
||||
.spawn_agent(harness.config.clone(), "spawned".to_string(), false)
|
||||
.await
|
||||
.expect("spawn_agent should succeed");
|
||||
let _thread = harness
|
||||
.manager
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("thread should be registered");
|
||||
let expected = (
|
||||
thread_id,
|
||||
Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "spawned".to_string(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
);
|
||||
let captured = harness
|
||||
.manager
|
||||
.captured_ops()
|
||||
.into_iter()
|
||||
.find(|entry| *entry == expected);
|
||||
assert_eq!(captured, Some(expected));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,3 +13,7 @@ pub(crate) fn agent_status_from_event(msg: &EventMsg) -> Option<AgentStatus> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_final(status: &AgentStatus) -> bool {
|
||||
!matches!(status, AgentStatus::PendingInit | AgentStatus::Running)
|
||||
}
|
||||
|
||||
+368
-182
@@ -1,5 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::api_bridge::CoreAuthProvider;
|
||||
use crate::api_bridge::auth_provider_from_auth;
|
||||
use crate::api_bridge::map_api_error;
|
||||
use crate::auth::UnauthorizedRecovery;
|
||||
@@ -10,12 +11,18 @@ use codex_api::CompactionInput as ApiCompactionInput;
|
||||
use codex_api::Prompt as ApiPrompt;
|
||||
use codex_api::RequestTelemetry;
|
||||
use codex_api::ReqwestTransport;
|
||||
use codex_api::ResponseAppendWsRequest;
|
||||
use codex_api::ResponseCreateWsRequest;
|
||||
use codex_api::ResponseStream as ApiResponseStream;
|
||||
use codex_api::ResponsesClient as ApiResponsesClient;
|
||||
use codex_api::ResponsesOptions as ApiResponsesOptions;
|
||||
use codex_api::ResponsesWebsocketClient as ApiWebSocketResponsesClient;
|
||||
use codex_api::ResponsesWebsocketConnection as ApiWebSocketConnection;
|
||||
use codex_api::SseTelemetry;
|
||||
use codex_api::TransportError;
|
||||
use codex_api::build_conversation_headers;
|
||||
use codex_api::common::Reasoning;
|
||||
use codex_api::common::ResponsesWsRequest;
|
||||
use codex_api::create_text_param_for_request;
|
||||
use codex_api::error::ApiError;
|
||||
use codex_api::requests::responses::Compression;
|
||||
@@ -57,8 +64,8 @@ use crate::model_provider_info::WireApi;
|
||||
use crate::tools::spec::create_tools_json_for_chat_completions_api;
|
||||
use crate::tools::spec::create_tools_json_for_responses_api;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelClient {
|
||||
#[derive(Debug)]
|
||||
struct ModelClientState {
|
||||
config: Arc<Config>,
|
||||
auth_manager: Option<Arc<AuthManager>>,
|
||||
model_info: ModelInfo,
|
||||
@@ -70,6 +77,17 @@ pub struct ModelClient {
|
||||
session_source: SessionSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelClient {
|
||||
state: Arc<ModelClientState>,
|
||||
}
|
||||
|
||||
pub struct ModelClientSession {
|
||||
state: Arc<ModelClientState>,
|
||||
connection: Option<ApiWebSocketConnection>,
|
||||
websocket_last_items: Vec<ResponseItem>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
impl ModelClient {
|
||||
pub fn new(
|
||||
@@ -84,20 +102,32 @@ impl ModelClient {
|
||||
session_source: SessionSource,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
auth_manager,
|
||||
model_info,
|
||||
otel_manager,
|
||||
provider,
|
||||
conversation_id,
|
||||
effort,
|
||||
summary,
|
||||
session_source,
|
||||
state: Arc::new(ModelClientState {
|
||||
config,
|
||||
auth_manager,
|
||||
model_info,
|
||||
otel_manager,
|
||||
provider,
|
||||
conversation_id,
|
||||
effort,
|
||||
summary,
|
||||
session_source,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_session(&self) -> ModelClientSession {
|
||||
ModelClientSession {
|
||||
state: Arc::clone(&self.state),
|
||||
connection: None,
|
||||
websocket_last_items: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelClient {
|
||||
pub fn get_model_context_window(&self) -> Option<i64> {
|
||||
let model_info = self.get_model_info();
|
||||
let model_info = &self.state.model_info;
|
||||
let effective_context_window_percent = model_info.effective_context_window_percent;
|
||||
model_info.context_window.map(|context_window| {
|
||||
context_window.saturating_mul(effective_context_window_percent) / 100
|
||||
@@ -105,39 +135,290 @@ impl ModelClient {
|
||||
}
|
||||
|
||||
pub fn config(&self) -> Arc<Config> {
|
||||
Arc::clone(&self.config)
|
||||
Arc::clone(&self.state.config)
|
||||
}
|
||||
|
||||
pub fn provider(&self) -> &ModelProviderInfo {
|
||||
&self.provider
|
||||
&self.state.provider
|
||||
}
|
||||
|
||||
pub fn get_provider(&self) -> ModelProviderInfo {
|
||||
self.state.provider.clone()
|
||||
}
|
||||
|
||||
pub fn get_otel_manager(&self) -> OtelManager {
|
||||
self.state.otel_manager.clone()
|
||||
}
|
||||
|
||||
pub fn get_session_source(&self) -> SessionSource {
|
||||
self.state.session_source.clone()
|
||||
}
|
||||
|
||||
/// Returns the currently configured model slug.
|
||||
pub fn get_model(&self) -> String {
|
||||
self.state.model_info.slug.clone()
|
||||
}
|
||||
|
||||
pub fn get_model_info(&self) -> ModelInfo {
|
||||
self.state.model_info.clone()
|
||||
}
|
||||
|
||||
/// Returns the current reasoning effort setting.
|
||||
pub fn get_reasoning_effort(&self) -> Option<ReasoningEffortConfig> {
|
||||
self.state.effort
|
||||
}
|
||||
|
||||
/// Returns the current reasoning summary setting.
|
||||
pub fn get_reasoning_summary(&self) -> ReasoningSummaryConfig {
|
||||
self.state.summary
|
||||
}
|
||||
|
||||
pub fn get_auth_manager(&self) -> Option<Arc<AuthManager>> {
|
||||
self.state.auth_manager.clone()
|
||||
}
|
||||
|
||||
/// Compacts the current conversation history using the Compact endpoint.
|
||||
///
|
||||
/// This is a unary call (no streaming) that returns a new list of
|
||||
/// `ResponseItem`s representing the compacted transcript.
|
||||
pub async fn compact_conversation_history(&self, prompt: &Prompt) -> Result<Vec<ResponseItem>> {
|
||||
if prompt.input.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let auth_manager = self.state.auth_manager.clone();
|
||||
let auth = match auth_manager.as_ref() {
|
||||
Some(manager) => manager.auth().await,
|
||||
None => None,
|
||||
};
|
||||
let api_provider = self
|
||||
.state
|
||||
.provider
|
||||
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
|
||||
let transport = ReqwestTransport::new(build_reqwest_client());
|
||||
let request_telemetry = self.build_request_telemetry();
|
||||
let client = ApiCompactClient::new(transport, api_provider, api_auth)
|
||||
.with_telemetry(Some(request_telemetry));
|
||||
|
||||
let instructions = prompt
|
||||
.get_full_instructions(&self.state.model_info)
|
||||
.into_owned();
|
||||
let payload = ApiCompactionInput {
|
||||
model: &self.state.model_info.slug,
|
||||
input: &prompt.input,
|
||||
instructions: &instructions,
|
||||
};
|
||||
|
||||
let mut extra_headers = ApiHeaderMap::new();
|
||||
if let SessionSource::SubAgent(sub) = &self.state.session_source {
|
||||
let subagent = if let crate::protocol::SubAgentSource::Other(label) = sub {
|
||||
label.clone()
|
||||
} else {
|
||||
serde_json::to_value(sub)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
|
||||
.unwrap_or_else(|| "other".to_string())
|
||||
};
|
||||
if let Ok(val) = HeaderValue::from_str(&subagent) {
|
||||
extra_headers.insert("x-openai-subagent", val);
|
||||
}
|
||||
}
|
||||
|
||||
client
|
||||
.compact_input(&payload, extra_headers)
|
||||
.await
|
||||
.map_err(map_api_error)
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelClientSession {
|
||||
/// Streams a single model turn using either the Responses or Chat
|
||||
/// Completions wire API, depending on the configured provider.
|
||||
///
|
||||
/// For Chat providers, the underlying stream is optionally aggregated
|
||||
/// based on the `show_raw_agent_reasoning` flag in the config.
|
||||
pub async fn stream(&self, prompt: &Prompt) -> Result<ResponseStream> {
|
||||
match self.provider.wire_api {
|
||||
pub async fn stream(&mut self, prompt: &Prompt) -> Result<ResponseStream> {
|
||||
match self.state.provider.wire_api {
|
||||
WireApi::Responses => self.stream_responses_api(prompt).await,
|
||||
WireApi::ResponsesWebsocket => self.stream_responses_websocket(prompt).await,
|
||||
WireApi::Chat => {
|
||||
let api_stream = self.stream_chat_completions(prompt).await?;
|
||||
|
||||
if self.config.show_raw_agent_reasoning {
|
||||
if self.state.config.show_raw_agent_reasoning {
|
||||
Ok(map_response_stream(
|
||||
api_stream.streaming_mode(),
|
||||
self.otel_manager.clone(),
|
||||
self.state.otel_manager.clone(),
|
||||
))
|
||||
} else {
|
||||
Ok(map_response_stream(
|
||||
api_stream.aggregate(),
|
||||
self.otel_manager.clone(),
|
||||
self.state.otel_manager.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_responses_request(&self, prompt: &Prompt) -> Result<ApiPrompt> {
|
||||
let model_info = self.state.model_info.clone();
|
||||
let instructions = prompt.get_full_instructions(&model_info).into_owned();
|
||||
let tools_json: Vec<Value> = create_tools_json_for_responses_api(&prompt.tools)?;
|
||||
Ok(build_api_prompt(prompt, instructions, tools_json))
|
||||
}
|
||||
|
||||
fn build_responses_options(
|
||||
&self,
|
||||
prompt: &Prompt,
|
||||
compression: Compression,
|
||||
) -> ApiResponsesOptions {
|
||||
let model_info = &self.state.model_info;
|
||||
|
||||
let default_reasoning_effort = model_info.default_reasoning_level;
|
||||
let reasoning = if model_info.supports_reasoning_summaries {
|
||||
Some(Reasoning {
|
||||
effort: self.state.effort.or(default_reasoning_effort),
|
||||
summary: if self.state.summary == ReasoningSummaryConfig::None {
|
||||
None
|
||||
} else {
|
||||
Some(self.state.summary)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let include = if reasoning.is_some() {
|
||||
vec!["reasoning.encrypted_content".to_string()]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let verbosity = if model_info.support_verbosity {
|
||||
self.state
|
||||
.config
|
||||
.model_verbosity
|
||||
.or(model_info.default_verbosity)
|
||||
} else {
|
||||
if self.state.config.model_verbosity.is_some() {
|
||||
warn!(
|
||||
"model_verbosity is set but ignored as the model does not support verbosity: {}",
|
||||
model_info.slug
|
||||
);
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let text = create_text_param_for_request(verbosity, &prompt.output_schema);
|
||||
let conversation_id = self.state.conversation_id.to_string();
|
||||
|
||||
ApiResponsesOptions {
|
||||
reasoning,
|
||||
include,
|
||||
prompt_cache_key: Some(conversation_id.clone()),
|
||||
text,
|
||||
store_override: None,
|
||||
conversation_id: Some(conversation_id),
|
||||
session_source: Some(self.state.session_source.clone()),
|
||||
extra_headers: beta_feature_headers(&self.state.config),
|
||||
compression,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_incremental_items(&self, input_items: &[ResponseItem]) -> Option<Vec<ResponseItem>> {
|
||||
// Checks whether the current request input is an incremental append to the previous request.
|
||||
// If items in the new request contain all the items from the previous request we build
|
||||
// a response.append request otherwise we start with a fresh response.create request.
|
||||
let previous_len = self.websocket_last_items.len();
|
||||
let can_append = previous_len > 0
|
||||
&& input_items.starts_with(&self.websocket_last_items)
|
||||
&& previous_len < input_items.len();
|
||||
if can_append {
|
||||
Some(input_items[previous_len..].to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_websocket_request(
|
||||
&self,
|
||||
api_prompt: &ApiPrompt,
|
||||
options: &ApiResponsesOptions,
|
||||
) -> ResponsesWsRequest {
|
||||
if let Some(append_items) = self.get_incremental_items(&api_prompt.input) {
|
||||
return ResponsesWsRequest::ResponseAppend(ResponseAppendWsRequest {
|
||||
input: append_items,
|
||||
});
|
||||
}
|
||||
|
||||
let ApiResponsesOptions {
|
||||
reasoning,
|
||||
include,
|
||||
prompt_cache_key,
|
||||
text,
|
||||
store_override,
|
||||
..
|
||||
} = options;
|
||||
|
||||
let store = store_override.unwrap_or(false);
|
||||
let payload = ResponseCreateWsRequest {
|
||||
model: self.state.model_info.slug.clone(),
|
||||
instructions: api_prompt.instructions.clone(),
|
||||
input: api_prompt.input.clone(),
|
||||
tools: api_prompt.tools.clone(),
|
||||
tool_choice: "auto".to_string(),
|
||||
parallel_tool_calls: api_prompt.parallel_tool_calls,
|
||||
reasoning: reasoning.clone(),
|
||||
store,
|
||||
stream: true,
|
||||
include: include.clone(),
|
||||
prompt_cache_key: prompt_cache_key.clone(),
|
||||
text: text.clone(),
|
||||
};
|
||||
|
||||
ResponsesWsRequest::ResponseCreate(payload)
|
||||
}
|
||||
|
||||
async fn websocket_connection(
|
||||
&mut self,
|
||||
api_provider: codex_api::Provider,
|
||||
api_auth: CoreAuthProvider,
|
||||
options: &ApiResponsesOptions,
|
||||
) -> std::result::Result<&ApiWebSocketConnection, ApiError> {
|
||||
let needs_new = match self.connection.as_ref() {
|
||||
Some(conn) => conn.is_closed().await,
|
||||
None => true,
|
||||
};
|
||||
|
||||
if needs_new {
|
||||
let mut headers = options.extra_headers.clone();
|
||||
headers.extend(build_conversation_headers(options.conversation_id.clone()));
|
||||
let new_conn: ApiWebSocketConnection =
|
||||
ApiWebSocketResponsesClient::new(api_provider, api_auth)
|
||||
.connect(headers)
|
||||
.await?;
|
||||
self.connection = Some(new_conn);
|
||||
}
|
||||
|
||||
self.connection.as_ref().ok_or(ApiError::Stream(
|
||||
"websocket connection is unavailable".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn responses_request_compression(&self, auth: Option<&crate::auth::CodexAuth>) -> Compression {
|
||||
if self
|
||||
.state
|
||||
.config
|
||||
.features
|
||||
.enabled(Feature::EnableRequestCompression)
|
||||
&& auth.is_some_and(|auth| auth.mode == AuthMode::ChatGPT)
|
||||
&& self.state.provider.is_openai()
|
||||
{
|
||||
Compression::Zstd
|
||||
} else {
|
||||
Compression::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Streams a turn via the OpenAI Chat Completions API.
|
||||
///
|
||||
/// This path is only used when the provider is configured with
|
||||
@@ -149,13 +430,13 @@ impl ModelClient {
|
||||
));
|
||||
}
|
||||
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let model_info = self.get_model_info();
|
||||
let auth_manager = self.state.auth_manager.clone();
|
||||
let model_info = self.state.model_info.clone();
|
||||
let instructions = prompt.get_full_instructions(&model_info).into_owned();
|
||||
let tools_json = create_tools_json_for_chat_completions_api(&prompt.tools)?;
|
||||
let api_prompt = build_api_prompt(prompt, instructions, tools_json);
|
||||
let conversation_id = self.conversation_id.to_string();
|
||||
let session_source = self.session_source.clone();
|
||||
let conversation_id = self.state.conversation_id.to_string();
|
||||
let session_source = self.state.session_source.clone();
|
||||
|
||||
let mut auth_recovery = auth_manager
|
||||
.as_ref()
|
||||
@@ -166,9 +447,10 @@ impl ModelClient {
|
||||
None => None,
|
||||
};
|
||||
let api_provider = self
|
||||
.state
|
||||
.provider
|
||||
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
|
||||
let transport = ReqwestTransport::new(build_reqwest_client());
|
||||
let (request_telemetry, sse_telemetry) = self.build_streaming_telemetry();
|
||||
let client = ApiChatClient::new(transport, api_provider, api_auth)
|
||||
@@ -176,7 +458,7 @@ impl ModelClient {
|
||||
|
||||
let stream_result = client
|
||||
.stream_prompt(
|
||||
&self.get_model(),
|
||||
&self.state.model_info.slug,
|
||||
&api_prompt,
|
||||
Some(conversation_id.clone()),
|
||||
Some(session_source.clone()),
|
||||
@@ -203,52 +485,14 @@ impl ModelClient {
|
||||
async fn stream_responses_api(&self, prompt: &Prompt) -> Result<ResponseStream> {
|
||||
if let Some(path) = &*CODEX_RS_SSE_FIXTURE {
|
||||
warn!(path, "Streaming from fixture");
|
||||
let stream = codex_api::stream_from_fixture(path, self.provider.stream_idle_timeout())
|
||||
.map_err(map_api_error)?;
|
||||
return Ok(map_response_stream(stream, self.otel_manager.clone()));
|
||||
let stream =
|
||||
codex_api::stream_from_fixture(path, self.state.provider.stream_idle_timeout())
|
||||
.map_err(map_api_error)?;
|
||||
return Ok(map_response_stream(stream, self.state.otel_manager.clone()));
|
||||
}
|
||||
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let model_info = self.get_model_info();
|
||||
let instructions = prompt.get_full_instructions(&model_info).into_owned();
|
||||
let tools_json: Vec<Value> = create_tools_json_for_responses_api(&prompt.tools)?;
|
||||
|
||||
let default_reasoning_effort = model_info.default_reasoning_level;
|
||||
let reasoning = if model_info.supports_reasoning_summaries {
|
||||
Some(Reasoning {
|
||||
effort: self.effort.or(default_reasoning_effort),
|
||||
summary: if self.summary == ReasoningSummaryConfig::None {
|
||||
None
|
||||
} else {
|
||||
Some(self.summary)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let include: Vec<String> = if reasoning.is_some() {
|
||||
vec!["reasoning.encrypted_content".to_string()]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let verbosity = if model_info.support_verbosity {
|
||||
self.config.model_verbosity.or(model_info.default_verbosity)
|
||||
} else {
|
||||
if self.config.model_verbosity.is_some() {
|
||||
warn!(
|
||||
"model_verbosity is set but ignored as the model does not support verbosity: {}",
|
||||
model_info.slug
|
||||
);
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let text = create_text_param_for_request(verbosity, &prompt.output_schema);
|
||||
let api_prompt = build_api_prompt(prompt, instructions.clone(), tools_json);
|
||||
let conversation_id = self.conversation_id.to_string();
|
||||
let session_source = self.session_source.clone();
|
||||
let auth_manager = self.state.auth_manager.clone();
|
||||
let api_prompt = self.build_responses_request(prompt)?;
|
||||
|
||||
let mut auth_recovery = auth_manager
|
||||
.as_ref()
|
||||
@@ -259,47 +503,26 @@ impl ModelClient {
|
||||
None => None,
|
||||
};
|
||||
let api_provider = self
|
||||
.state
|
||||
.provider
|
||||
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
|
||||
let transport = ReqwestTransport::new(build_reqwest_client());
|
||||
let (request_telemetry, sse_telemetry) = self.build_streaming_telemetry();
|
||||
let compression = if self
|
||||
.config
|
||||
.features
|
||||
.enabled(Feature::EnableRequestCompression)
|
||||
&& auth
|
||||
.as_ref()
|
||||
.is_some_and(|auth| auth.mode == AuthMode::ChatGPT)
|
||||
&& self.provider.is_openai()
|
||||
{
|
||||
Compression::Zstd
|
||||
} else {
|
||||
Compression::None
|
||||
};
|
||||
let compression = self.responses_request_compression(auth.as_ref());
|
||||
|
||||
let client = ApiResponsesClient::new(transport, api_provider, api_auth)
|
||||
.with_telemetry(Some(request_telemetry), Some(sse_telemetry));
|
||||
|
||||
let options = ApiResponsesOptions {
|
||||
reasoning: reasoning.clone(),
|
||||
include: include.clone(),
|
||||
prompt_cache_key: Some(conversation_id.clone()),
|
||||
text: text.clone(),
|
||||
store_override: None,
|
||||
conversation_id: Some(conversation_id.clone()),
|
||||
session_source: Some(session_source.clone()),
|
||||
extra_headers: beta_feature_headers(&self.config),
|
||||
compression,
|
||||
};
|
||||
let options = self.build_responses_options(prompt, compression);
|
||||
|
||||
let stream_result = client
|
||||
.stream_prompt(&self.get_model(), &api_prompt, options)
|
||||
.stream_prompt(&self.state.model_info.slug, &api_prompt, options)
|
||||
.await;
|
||||
|
||||
match stream_result {
|
||||
Ok(stream) => {
|
||||
return Ok(map_response_stream(stream, self.otel_manager.clone()));
|
||||
return Ok(map_response_stream(stream, self.state.otel_manager.clone()));
|
||||
}
|
||||
Err(ApiError::Transport(TransportError::Http { status, .. }))
|
||||
if status == StatusCode::UNAUTHORIZED =>
|
||||
@@ -312,106 +535,69 @@ impl ModelClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_provider(&self) -> ModelProviderInfo {
|
||||
self.provider.clone()
|
||||
}
|
||||
/// Streams a turn via the Responses API over WebSocket transport.
|
||||
async fn stream_responses_websocket(&mut self, prompt: &Prompt) -> Result<ResponseStream> {
|
||||
let auth_manager = self.state.auth_manager.clone();
|
||||
let api_prompt = self.build_responses_request(prompt)?;
|
||||
|
||||
pub fn get_otel_manager(&self) -> OtelManager {
|
||||
self.otel_manager.clone()
|
||||
}
|
||||
|
||||
pub fn get_session_source(&self) -> SessionSource {
|
||||
self.session_source.clone()
|
||||
}
|
||||
|
||||
/// Returns the currently configured model slug.
|
||||
pub fn get_model(&self) -> String {
|
||||
self.model_info.slug.clone()
|
||||
}
|
||||
|
||||
pub fn get_model_info(&self) -> ModelInfo {
|
||||
self.model_info.clone()
|
||||
}
|
||||
|
||||
/// Returns the current reasoning effort setting.
|
||||
pub fn get_reasoning_effort(&self) -> Option<ReasoningEffortConfig> {
|
||||
self.effort
|
||||
}
|
||||
|
||||
/// Returns the current reasoning summary setting.
|
||||
pub fn get_reasoning_summary(&self) -> ReasoningSummaryConfig {
|
||||
self.summary
|
||||
}
|
||||
|
||||
pub fn get_auth_manager(&self) -> Option<Arc<AuthManager>> {
|
||||
self.auth_manager.clone()
|
||||
}
|
||||
|
||||
/// Compacts the current conversation history using the Compact endpoint.
|
||||
///
|
||||
/// This is a unary call (no streaming) that returns a new list of
|
||||
/// `ResponseItem`s representing the compacted transcript.
|
||||
pub async fn compact_conversation_history(&self, prompt: &Prompt) -> Result<Vec<ResponseItem>> {
|
||||
if prompt.input.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let auth = match auth_manager.as_ref() {
|
||||
Some(manager) => manager.auth().await,
|
||||
None => None,
|
||||
};
|
||||
let api_provider = self
|
||||
.provider
|
||||
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?;
|
||||
let transport = ReqwestTransport::new(build_reqwest_client());
|
||||
let request_telemetry = self.build_request_telemetry();
|
||||
let client = ApiCompactClient::new(transport, api_provider, api_auth)
|
||||
.with_telemetry(Some(request_telemetry));
|
||||
|
||||
let instructions = prompt
|
||||
.get_full_instructions(&self.get_model_info())
|
||||
.into_owned();
|
||||
let payload = ApiCompactionInput {
|
||||
model: &self.get_model(),
|
||||
input: &prompt.input,
|
||||
instructions: &instructions,
|
||||
};
|
||||
|
||||
let mut extra_headers = ApiHeaderMap::new();
|
||||
if let SessionSource::SubAgent(sub) = &self.session_source {
|
||||
let subagent = if let crate::protocol::SubAgentSource::Other(label) = sub {
|
||||
label.clone()
|
||||
} else {
|
||||
serde_json::to_value(sub)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
|
||||
.unwrap_or_else(|| "other".to_string())
|
||||
let mut auth_recovery = auth_manager
|
||||
.as_ref()
|
||||
.map(super::auth::AuthManager::unauthorized_recovery);
|
||||
loop {
|
||||
let auth = match auth_manager.as_ref() {
|
||||
Some(manager) => manager.auth().await,
|
||||
None => None,
|
||||
};
|
||||
if let Ok(val) = HeaderValue::from_str(&subagent) {
|
||||
extra_headers.insert("x-openai-subagent", val);
|
||||
}
|
||||
let api_provider = self
|
||||
.state
|
||||
.provider
|
||||
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
|
||||
let compression = self.responses_request_compression(auth.as_ref());
|
||||
|
||||
let options = self.build_responses_options(prompt, compression);
|
||||
let request = self.prepare_websocket_request(&api_prompt, &options);
|
||||
|
||||
let connection = match self
|
||||
.websocket_connection(api_provider.clone(), api_auth.clone(), &options)
|
||||
.await
|
||||
{
|
||||
Ok(connection) => connection,
|
||||
Err(ApiError::Transport(TransportError::Http { status, .. }))
|
||||
if status == StatusCode::UNAUTHORIZED =>
|
||||
{
|
||||
handle_unauthorized(status, &mut auth_recovery).await?;
|
||||
continue;
|
||||
}
|
||||
Err(err) => return Err(map_api_error(err)),
|
||||
};
|
||||
|
||||
let stream_result = connection
|
||||
.stream_request(request)
|
||||
.await
|
||||
.map_err(map_api_error)?;
|
||||
self.websocket_last_items = api_prompt.input.clone();
|
||||
|
||||
return Ok(map_response_stream(
|
||||
stream_result,
|
||||
self.state.otel_manager.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
client
|
||||
.compact_input(&payload, extra_headers)
|
||||
.await
|
||||
.map_err(map_api_error)
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelClient {
|
||||
/// Builds request and SSE telemetry for streaming API calls (Chat/Responses).
|
||||
fn build_streaming_telemetry(&self) -> (Arc<dyn RequestTelemetry>, Arc<dyn SseTelemetry>) {
|
||||
let telemetry = Arc::new(ApiTelemetry::new(self.otel_manager.clone()));
|
||||
let telemetry = Arc::new(ApiTelemetry::new(self.state.otel_manager.clone()));
|
||||
let request_telemetry: Arc<dyn RequestTelemetry> = telemetry.clone();
|
||||
let sse_telemetry: Arc<dyn SseTelemetry> = telemetry;
|
||||
(request_telemetry, sse_telemetry)
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelClient {
|
||||
/// Builds request telemetry for unary API calls (e.g., Compact endpoint).
|
||||
fn build_request_telemetry(&self) -> Arc<dyn RequestTelemetry> {
|
||||
let telemetry = Arc::new(ApiTelemetry::new(self.otel_manager.clone()));
|
||||
let telemetry = Arc::new(ApiTelemetry::new(self.state.otel_manager.clone()));
|
||||
let request_telemetry: Arc<dyn RequestTelemetry> = telemetry;
|
||||
request_telemetry
|
||||
}
|
||||
|
||||
+227
-32
@@ -48,6 +48,7 @@ use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_rmcp_client::OAuthCredentialsStoreMode;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::prelude::*;
|
||||
use futures::stream::FuturesOrdered;
|
||||
@@ -77,6 +78,7 @@ use tracing::warn;
|
||||
use crate::ModelProviderInfo;
|
||||
use crate::WireApi;
|
||||
use crate::client::ModelClient;
|
||||
use crate::client::ModelClientSession;
|
||||
use crate::client_common::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
use crate::compact::collect_user_messages;
|
||||
@@ -84,6 +86,7 @@ use crate::config::Config;
|
||||
use crate::config::Constrained;
|
||||
use crate::config::ConstraintResult;
|
||||
use crate::config::GhostSnapshotConfig;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::context_manager::ContextManager;
|
||||
use crate::environment_context::EnvironmentContext;
|
||||
@@ -107,6 +110,7 @@ use crate::protocol::ErrorEvent;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::ExecApprovalRequestEvent;
|
||||
use crate::protocol::McpServerRefreshConfig;
|
||||
use crate::protocol::Op;
|
||||
use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::ReasoningContentDeltaEvent;
|
||||
@@ -148,7 +152,6 @@ use crate::tools::spec::ToolsConfig;
|
||||
use crate::tools::spec::ToolsConfigParams;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use crate::unified_exec::UnifiedExecProcessManager;
|
||||
use crate::user_instructions::DeveloperInstructions;
|
||||
use crate::user_instructions::UserInstructions;
|
||||
use crate::user_notification::UserNotification;
|
||||
use crate::util::backoff;
|
||||
@@ -156,6 +159,7 @@ use codex_async_utils::OrCancelExt;
|
||||
use codex_otel::OtelManager;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
@@ -164,6 +168,7 @@ use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_readiness::Readiness;
|
||||
use codex_utils_readiness::ReadinessFlag;
|
||||
use tokio::sync::watch;
|
||||
|
||||
/// The high-level interface to the Codex system.
|
||||
/// It operates as a queue pair where you send submissions and receive events.
|
||||
@@ -172,7 +177,7 @@ pub struct Codex {
|
||||
pub(crate) tx_sub: Sender<Submission>,
|
||||
pub(crate) rx_event: Receiver<Event>,
|
||||
// Last known status of the agent.
|
||||
pub(crate) agent_status: Arc<RwLock<AgentStatus>>,
|
||||
pub(crate) agent_status: watch::Receiver<AgentStatus>,
|
||||
}
|
||||
|
||||
/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`],
|
||||
@@ -275,7 +280,7 @@ impl Codex {
|
||||
|
||||
// Generate a unique ID for the lifetime of this Codex session.
|
||||
let session_source_clone = session_configuration.session_source.clone();
|
||||
let agent_status = Arc::new(RwLock::new(AgentStatus::PendingInit));
|
||||
let (agent_status_tx, agent_status_rx) = watch::channel(AgentStatus::PendingInit);
|
||||
|
||||
let session = Session::new(
|
||||
session_configuration,
|
||||
@@ -284,7 +289,7 @@ impl Codex {
|
||||
models_manager.clone(),
|
||||
exec_policy,
|
||||
tx_event.clone(),
|
||||
Arc::clone(&agent_status),
|
||||
agent_status_tx.clone(),
|
||||
conversation_history,
|
||||
session_source_clone,
|
||||
skills_manager,
|
||||
@@ -303,7 +308,7 @@ impl Codex {
|
||||
next_id: AtomicU64::new(0),
|
||||
tx_sub,
|
||||
rx_event,
|
||||
agent_status,
|
||||
agent_status: agent_status_rx,
|
||||
};
|
||||
|
||||
#[allow(deprecated)]
|
||||
@@ -345,8 +350,7 @@ impl Codex {
|
||||
}
|
||||
|
||||
pub(crate) async fn agent_status(&self) -> AgentStatus {
|
||||
let status = self.agent_status.read().await;
|
||||
status.clone()
|
||||
self.agent_status.borrow().clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,11 +360,12 @@ impl Codex {
|
||||
pub(crate) struct Session {
|
||||
conversation_id: ThreadId,
|
||||
tx_event: Sender<Event>,
|
||||
agent_status: Arc<RwLock<AgentStatus>>,
|
||||
agent_status: watch::Sender<AgentStatus>,
|
||||
state: Mutex<SessionState>,
|
||||
/// The set of enabled features should be invariant for the lifetime of the
|
||||
/// session.
|
||||
features: Features,
|
||||
pending_mcp_server_refresh_config: Mutex<Option<McpServerRefreshConfig>>,
|
||||
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
|
||||
pub(crate) services: SessionServices,
|
||||
next_internal_sub_id: AtomicU64,
|
||||
@@ -557,7 +562,7 @@ impl Session {
|
||||
models_manager: Arc<ModelsManager>,
|
||||
exec_policy: ExecPolicyManager,
|
||||
tx_event: Sender<Event>,
|
||||
agent_status: Arc<RwLock<AgentStatus>>,
|
||||
agent_status: watch::Sender<AgentStatus>,
|
||||
initial_history: InitialHistory,
|
||||
session_source: SessionSource,
|
||||
skills_manager: Arc<SkillsManager>,
|
||||
@@ -685,7 +690,7 @@ impl Session {
|
||||
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
|
||||
mcp_startup_cancellation_token: CancellationToken::new(),
|
||||
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
|
||||
unified_exec_manager: UnifiedExecProcessManager::default(),
|
||||
notifier: UserNotifier::new(config.notify.clone()),
|
||||
rollout: Mutex::new(Some(rollout_recorder)),
|
||||
@@ -703,9 +708,10 @@ impl Session {
|
||||
let sess = Arc::new(Session {
|
||||
conversation_id,
|
||||
tx_event: tx_event.clone(),
|
||||
agent_status: Arc::clone(&agent_status),
|
||||
agent_status,
|
||||
state: Mutex::new(state),
|
||||
features: config.features.clone(),
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
active_turn: Mutex::new(None),
|
||||
services,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
@@ -742,6 +748,8 @@ impl Session {
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: session_configuration.cwd.clone(),
|
||||
};
|
||||
let cancel_token = sess.mcp_startup_cancellation_token().await;
|
||||
|
||||
sess.services
|
||||
.mcp_connection_manager
|
||||
.write()
|
||||
@@ -751,7 +759,7 @@ impl Session {
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
auth_statuses.clone(),
|
||||
tx_event.clone(),
|
||||
sess.services.mcp_startup_cancellation_token.clone(),
|
||||
cancel_token,
|
||||
sandbox_state,
|
||||
)
|
||||
.await;
|
||||
@@ -852,6 +860,11 @@ impl Session {
|
||||
if persist && !rollout_items.is_empty() {
|
||||
self.persist_rollout_items(&rollout_items).await;
|
||||
}
|
||||
|
||||
// Append the current session's initial context after the reconstructed history.
|
||||
let initial_context = self.build_initial_context(&turn_context);
|
||||
self.record_conversation_items(&turn_context, &initial_context)
|
||||
.await;
|
||||
// Flush after seeding history and any persisted rollout copy.
|
||||
self.flush_rollout().await;
|
||||
}
|
||||
@@ -1004,6 +1017,28 @@ impl Session {
|
||||
)))
|
||||
}
|
||||
|
||||
fn build_permissions_update_item(
|
||||
&self,
|
||||
previous: Option<&Arc<TurnContext>>,
|
||||
next: &TurnContext,
|
||||
) -> Option<ResponseItem> {
|
||||
let prev = previous?;
|
||||
if prev.sandbox_policy == next.sandbox_policy
|
||||
&& prev.approval_policy == next.approval_policy
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
DeveloperInstructions::from_policy(
|
||||
&next.sandbox_policy,
|
||||
next.approval_policy,
|
||||
&next.cwd,
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Persist the event to rollout and send it to clients.
|
||||
pub(crate) async fn send_event(&self, turn_context: &TurnContext, msg: EventMsg) {
|
||||
let legacy_source = msg.clone();
|
||||
@@ -1026,8 +1061,7 @@ impl Session {
|
||||
pub(crate) async fn send_event_raw(&self, event: Event) {
|
||||
// Record the last known agent status.
|
||||
if let Some(status) = agent_status_from_event(&event.msg) {
|
||||
let mut guard = self.agent_status.write().await;
|
||||
*guard = status;
|
||||
self.agent_status.send_replace(status);
|
||||
}
|
||||
// Persist the event into rollout (recorder filters as needed)
|
||||
let rollout_items = vec![RolloutItem::EventMsg(event.msg.clone())];
|
||||
@@ -1045,8 +1079,7 @@ impl Session {
|
||||
pub(crate) async fn send_event_raw_flushed(&self, event: Event) {
|
||||
// Record the last known agent status.
|
||||
if let Some(status) = agent_status_from_event(&event.msg) {
|
||||
let mut guard = self.agent_status.write().await;
|
||||
*guard = status;
|
||||
self.agent_status.send_replace(status);
|
||||
}
|
||||
self.persist_rollout_items(&[RolloutItem::EventMsg(event.msg.clone())])
|
||||
.await;
|
||||
@@ -1335,8 +1368,16 @@ impl Session {
|
||||
}
|
||||
|
||||
pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec<ResponseItem> {
|
||||
let mut items = Vec::<ResponseItem>::with_capacity(3);
|
||||
let mut items = Vec::<ResponseItem>::with_capacity(4);
|
||||
let shell = self.user_shell();
|
||||
items.push(
|
||||
DeveloperInstructions::from_policy(
|
||||
&turn_context.sandbox_policy,
|
||||
turn_context.approval_policy,
|
||||
&turn_context.cwd,
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() {
|
||||
items.push(DeveloperInstructions::new(developer_instructions.to_string()).into());
|
||||
}
|
||||
@@ -1351,8 +1392,6 @@ impl Session {
|
||||
}
|
||||
items.push(ResponseItem::from(EnvironmentContext::new(
|
||||
Some(turn_context.cwd.clone()),
|
||||
Some(turn_context.approval_policy),
|
||||
Some(turn_context.sandbox_policy.clone()),
|
||||
shell.as_ref().clone(),
|
||||
)));
|
||||
items
|
||||
@@ -1551,6 +1590,17 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn has_pending_input(&self) -> bool {
|
||||
let active = self.active_turn.lock().await;
|
||||
match active.as_ref() {
|
||||
Some(at) => {
|
||||
let ts = at.turn_state.lock().await;
|
||||
ts.has_pending_input()
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_resources(
|
||||
&self,
|
||||
server: &str,
|
||||
@@ -1631,12 +1681,85 @@ impl Session {
|
||||
Arc::clone(&self.services.user_shell)
|
||||
}
|
||||
|
||||
async fn refresh_mcp_servers_if_requested(&self, turn_context: &TurnContext) {
|
||||
let refresh_config = { self.pending_mcp_server_refresh_config.lock().await.take() };
|
||||
let Some(refresh_config) = refresh_config else {
|
||||
return;
|
||||
};
|
||||
|
||||
let McpServerRefreshConfig {
|
||||
mcp_servers,
|
||||
mcp_oauth_credentials_store_mode,
|
||||
} = refresh_config;
|
||||
|
||||
let mcp_servers =
|
||||
match serde_json::from_value::<HashMap<String, McpServerConfig>>(mcp_servers) {
|
||||
Ok(servers) => servers,
|
||||
Err(err) => {
|
||||
warn!("failed to parse MCP server refresh config: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let store_mode = match serde_json::from_value::<OAuthCredentialsStoreMode>(
|
||||
mcp_oauth_credentials_store_mode,
|
||||
) {
|
||||
Ok(mode) => mode,
|
||||
Err(err) => {
|
||||
warn!("failed to parse MCP OAuth refresh config: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let auth_statuses = compute_auth_statuses(mcp_servers.iter(), store_mode).await;
|
||||
let sandbox_state = SandboxState {
|
||||
sandbox_policy: turn_context.sandbox_policy.clone(),
|
||||
codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: turn_context.cwd.clone(),
|
||||
};
|
||||
let cancel_token = self.reset_mcp_startup_cancellation_token().await;
|
||||
|
||||
let mut refreshed_manager = McpConnectionManager::default();
|
||||
refreshed_manager
|
||||
.initialize(
|
||||
mcp_servers,
|
||||
store_mode,
|
||||
auth_statuses,
|
||||
self.get_tx_event(),
|
||||
cancel_token,
|
||||
sandbox_state,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut manager = self.services.mcp_connection_manager.write().await;
|
||||
*manager = refreshed_manager;
|
||||
}
|
||||
|
||||
async fn mcp_startup_cancellation_token(&self) -> CancellationToken {
|
||||
self.services
|
||||
.mcp_startup_cancellation_token
|
||||
.lock()
|
||||
.await
|
||||
.clone()
|
||||
}
|
||||
|
||||
async fn reset_mcp_startup_cancellation_token(&self) -> CancellationToken {
|
||||
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
|
||||
guard.cancel();
|
||||
let cancel_token = CancellationToken::new();
|
||||
*guard = cancel_token.clone();
|
||||
cancel_token
|
||||
}
|
||||
|
||||
fn show_raw_agent_reasoning(&self) -> bool {
|
||||
self.services.show_raw_agent_reasoning
|
||||
}
|
||||
|
||||
async fn cancel_mcp_startup(&self) {
|
||||
self.services.mcp_startup_cancellation_token.cancel();
|
||||
self.services
|
||||
.mcp_startup_cancellation_token
|
||||
.lock()
|
||||
.await
|
||||
.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1694,6 +1817,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
Op::ListMcpTools => {
|
||||
handlers::list_mcp_tools(&sess, &config, sub.id.clone()).await;
|
||||
}
|
||||
Op::RefreshMcpServers { config } => {
|
||||
handlers::refresh_mcp_servers(&sess, config).await;
|
||||
}
|
||||
Op::ListCustomPrompts => {
|
||||
handlers::list_custom_prompts(&sess, sub.id.clone()).await;
|
||||
}
|
||||
@@ -1762,6 +1888,7 @@ mod handlers {
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ListCustomPromptsResponseEvent;
|
||||
use codex_protocol::protocol::ListSkillsResponseEvent;
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::ReviewRequest;
|
||||
@@ -1853,13 +1980,24 @@ mod handlers {
|
||||
|
||||
// Attempt to inject input into current task
|
||||
if let Err(items) = sess.inject_input(items).await {
|
||||
let mut update_items = Vec::new();
|
||||
if let Some(env_item) =
|
||||
sess.build_environment_update_item(previous_context.as_ref(), ¤t_context)
|
||||
{
|
||||
sess.record_conversation_items(¤t_context, std::slice::from_ref(&env_item))
|
||||
update_items.push(env_item);
|
||||
}
|
||||
if let Some(permissions_item) =
|
||||
sess.build_permissions_update_item(previous_context.as_ref(), ¤t_context)
|
||||
{
|
||||
update_items.push(permissions_item);
|
||||
}
|
||||
if !update_items.is_empty() {
|
||||
sess.record_conversation_items(¤t_context, &update_items)
|
||||
.await;
|
||||
}
|
||||
|
||||
sess.refresh_mcp_servers_if_requested(¤t_context)
|
||||
.await;
|
||||
sess.spawn_task(Arc::clone(¤t_context), items, RegularTask)
|
||||
.await;
|
||||
*previous_context = Some(current_context);
|
||||
@@ -1991,6 +2129,11 @@ mod handlers {
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn refresh_mcp_servers(sess: &Arc<Session>, refresh_config: McpServerRefreshConfig) {
|
||||
let mut guard = sess.pending_mcp_server_refresh_config.lock().await;
|
||||
*guard = Some(refresh_config);
|
||||
}
|
||||
|
||||
pub async fn list_mcp_tools(sess: &Session, config: &Arc<Config>, sub_id: String) {
|
||||
let mcp_connection_manager = sess.services.mcp_connection_manager.read().await;
|
||||
let snapshot = collect_mcp_snapshot_from_manager(
|
||||
@@ -2175,6 +2318,7 @@ mod handlers {
|
||||
review_request: ReviewRequest,
|
||||
) {
|
||||
let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await;
|
||||
sess.refresh_mcp_servers_if_requested(&turn_context).await;
|
||||
match resolve_review_request(review_request, turn_context.cwd.as_path()) {
|
||||
Ok(resolved) => {
|
||||
spawn_review_thread(
|
||||
@@ -2536,12 +2680,15 @@ async fn run_model_turn(
|
||||
output_schema: turn_context.final_output_json_schema.clone(),
|
||||
};
|
||||
|
||||
let mut client_session = turn_context.client.new_session();
|
||||
|
||||
let mut retries = 0;
|
||||
loop {
|
||||
let err = match try_run_turn(
|
||||
Arc::clone(&router),
|
||||
Arc::clone(&sess),
|
||||
Arc::clone(&turn_context),
|
||||
&mut client_session,
|
||||
Arc::clone(&turn_diff_tracker),
|
||||
&prompt,
|
||||
cancellation_token.child_token(),
|
||||
@@ -2633,6 +2780,7 @@ async fn try_run_turn(
|
||||
router: Arc<ToolRouter>,
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
client_session: &mut ModelClientSession,
|
||||
turn_diff_tracker: SharedTurnDiffTracker,
|
||||
prompt: &Prompt,
|
||||
cancellation_token: CancellationToken,
|
||||
@@ -2661,9 +2809,7 @@ async fn try_run_turn(
|
||||
);
|
||||
|
||||
sess.persist_rollout_items(&[rollout_item]).await;
|
||||
let mut stream = turn_context
|
||||
.client
|
||||
.clone()
|
||||
let mut stream = client_session
|
||||
.stream(prompt)
|
||||
.instrument(trace_span!("stream_request"))
|
||||
.or_cancel(&cancellation_token)
|
||||
@@ -2765,6 +2911,9 @@ async fn try_run_turn(
|
||||
.await;
|
||||
should_emit_turn_diff = true;
|
||||
|
||||
needs_follow_up |= sess.has_pending_input().await;
|
||||
error!("needs_follow_up: {needs_follow_up}");
|
||||
|
||||
break Ok(TurnRunResult {
|
||||
needs_follow_up,
|
||||
last_agent_message,
|
||||
@@ -2941,7 +3090,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn record_initial_history_reconstructs_resumed_transcript() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let (rollout_items, expected) = sample_rollout(&session, &turn_context);
|
||||
let (rollout_items, mut expected) = sample_rollout(&session, &turn_context);
|
||||
|
||||
session
|
||||
.record_initial_history(InitialHistory::Resumed(ResumedHistory {
|
||||
@@ -2951,6 +3100,7 @@ mod tests {
|
||||
}))
|
||||
.await;
|
||||
|
||||
expected.extend(session.build_initial_context(&turn_context));
|
||||
let history = session.state.lock().await.clone_history();
|
||||
assert_eq!(expected, history.raw_items());
|
||||
}
|
||||
@@ -3035,12 +3185,13 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn record_initial_history_reconstructs_forked_transcript() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let (rollout_items, expected) = sample_rollout(&session, &turn_context);
|
||||
let (rollout_items, mut expected) = sample_rollout(&session, &turn_context);
|
||||
|
||||
session
|
||||
.record_initial_history(InitialHistory::Forked(rollout_items))
|
||||
.await;
|
||||
|
||||
expected.extend(session.build_initial_context(&turn_context));
|
||||
let history = session.state.lock().await.clone_history();
|
||||
assert_eq!(expected, history.raw_items());
|
||||
}
|
||||
@@ -3490,7 +3641,7 @@ mod tests {
|
||||
));
|
||||
let agent_control = AgentControl::default();
|
||||
let exec_policy = ExecPolicyManager::default();
|
||||
let agent_status = Arc::new(RwLock::new(AgentStatus::PendingInit));
|
||||
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
|
||||
let model = ModelsManager::get_model_offline(config.model.as_deref());
|
||||
let session_configuration = SessionConfiguration {
|
||||
provider: config.model_provider.clone(),
|
||||
@@ -3524,7 +3675,7 @@ mod tests {
|
||||
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
|
||||
mcp_startup_cancellation_token: CancellationToken::new(),
|
||||
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
|
||||
unified_exec_manager: UnifiedExecProcessManager::default(),
|
||||
notifier: UserNotifier::new(None),
|
||||
rollout: Mutex::new(None),
|
||||
@@ -3553,9 +3704,10 @@ mod tests {
|
||||
let session = Session {
|
||||
conversation_id,
|
||||
tx_event,
|
||||
agent_status: Arc::clone(&agent_status),
|
||||
agent_status: agent_status_tx,
|
||||
state: Mutex::new(state),
|
||||
features: config.features.clone(),
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
active_turn: Mutex::new(None),
|
||||
services,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
@@ -3584,7 +3736,7 @@ mod tests {
|
||||
));
|
||||
let agent_control = AgentControl::default();
|
||||
let exec_policy = ExecPolicyManager::default();
|
||||
let agent_status = Arc::new(RwLock::new(AgentStatus::PendingInit));
|
||||
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
|
||||
let model = ModelsManager::get_model_offline(config.model.as_deref());
|
||||
let session_configuration = SessionConfiguration {
|
||||
provider: config.model_provider.clone(),
|
||||
@@ -3618,7 +3770,7 @@ mod tests {
|
||||
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
|
||||
mcp_startup_cancellation_token: CancellationToken::new(),
|
||||
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
|
||||
unified_exec_manager: UnifiedExecProcessManager::default(),
|
||||
notifier: UserNotifier::new(None),
|
||||
rollout: Mutex::new(None),
|
||||
@@ -3647,9 +3799,10 @@ mod tests {
|
||||
let session = Arc::new(Session {
|
||||
conversation_id,
|
||||
tx_event,
|
||||
agent_status: Arc::clone(&agent_status),
|
||||
agent_status: agent_status_tx,
|
||||
state: Mutex::new(state),
|
||||
features: config.features.clone(),
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
active_turn: Mutex::new(None),
|
||||
services,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
@@ -3658,6 +3811,48 @@ mod tests {
|
||||
(session, turn_context, rx_event)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_mcp_servers_is_deferred_until_next_turn() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let old_token = session.mcp_startup_cancellation_token().await;
|
||||
assert!(!old_token.is_cancelled());
|
||||
|
||||
let mcp_oauth_credentials_store_mode =
|
||||
serde_json::to_value(OAuthCredentialsStoreMode::Auto).expect("serialize store mode");
|
||||
let refresh_config = McpServerRefreshConfig {
|
||||
mcp_servers: json!({}),
|
||||
mcp_oauth_credentials_store_mode,
|
||||
};
|
||||
{
|
||||
let mut guard = session.pending_mcp_server_refresh_config.lock().await;
|
||||
*guard = Some(refresh_config);
|
||||
}
|
||||
|
||||
assert!(!old_token.is_cancelled());
|
||||
assert!(
|
||||
session
|
||||
.pending_mcp_server_refresh_config
|
||||
.lock()
|
||||
.await
|
||||
.is_some()
|
||||
);
|
||||
|
||||
session
|
||||
.refresh_mcp_servers_if_requested(&turn_context)
|
||||
.await;
|
||||
|
||||
assert!(old_token.is_cancelled());
|
||||
assert!(
|
||||
session
|
||||
.pending_mcp_server_refresh_config
|
||||
.lock()
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
let new_token = session.mcp_startup_cancellation_token().await;
|
||||
assert!(!new_token.is_cancelled());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_model_warning_appends_user_message() {
|
||||
let (mut session, turn_context) = make_session_and_context().await;
|
||||
|
||||
@@ -87,7 +87,7 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
next_id: AtomicU64::new(0),
|
||||
tx_sub: tx_ops,
|
||||
rx_event: rx_sub,
|
||||
agent_status: Arc::clone(&codex.agent_status),
|
||||
agent_status: codex.agent_status.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ pub(crate) async fn run_codex_thread_one_shot(
|
||||
// Bridge events so we can observe completion and shut down automatically.
|
||||
let (tx_bridge, rx_bridge) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let ops_tx = io.tx_sub.clone();
|
||||
let agent_status = Arc::clone(&io.agent_status);
|
||||
let agent_status = io.agent_status.clone();
|
||||
let io_for_bridge = io;
|
||||
tokio::spawn(async move {
|
||||
while let Ok(event) = io_for_bridge.next_event().await {
|
||||
@@ -363,20 +363,23 @@ mod tests {
|
||||
use super::*;
|
||||
use async_channel::bounded;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AgentStatus;
|
||||
use codex_protocol::protocol::RawResponseItemEvent;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnAbortedEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::sync::watch;
|
||||
|
||||
#[tokio::test]
|
||||
async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() {
|
||||
let (tx_events, rx_events) = bounded(1);
|
||||
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
|
||||
let codex = Arc::new(Codex {
|
||||
next_id: AtomicU64::new(0),
|
||||
tx_sub,
|
||||
rx_event: rx_events,
|
||||
agent_status: Default::default(),
|
||||
agent_status,
|
||||
});
|
||||
|
||||
let (session, ctx, _rx_evt) = crate::codex::make_session_and_context_with_rx().await;
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::protocol::Event;
|
||||
use crate::protocol::Op;
|
||||
use crate::protocol::Submission;
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::watch;
|
||||
|
||||
pub struct CodexThread {
|
||||
codex: Codex,
|
||||
@@ -38,6 +39,10 @@ impl CodexThread {
|
||||
self.codex.agent_status().await
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe_status(&self) -> watch::Receiver<AgentStatus> {
|
||||
self.codex.agent_status.clone()
|
||||
}
|
||||
|
||||
pub fn rollout_path(&self) -> PathBuf {
|
||||
self.rollout_path.clone()
|
||||
}
|
||||
|
||||
@@ -1,46 +1,8 @@
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
|
||||
use crate::bash::parse_shell_lc_plain_commands;
|
||||
use crate::is_safe_command::is_known_safe_command;
|
||||
#[cfg(windows)]
|
||||
#[path = "windows_dangerous_commands.rs"]
|
||||
mod windows_dangerous_commands;
|
||||
|
||||
pub fn requires_initial_appoval(
|
||||
policy: AskForApproval,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
command: &[String],
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
) -> bool {
|
||||
if is_known_safe_command(command) {
|
||||
return false;
|
||||
}
|
||||
match policy {
|
||||
AskForApproval::Never | AskForApproval::OnFailure => false,
|
||||
AskForApproval::OnRequest => {
|
||||
// In DangerFullAccess or ExternalSandbox, only prompt if the command looks dangerous.
|
||||
if matches!(
|
||||
sandbox_policy,
|
||||
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
|
||||
) {
|
||||
return command_might_be_dangerous(command);
|
||||
}
|
||||
|
||||
// In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for
|
||||
// non‑escalated, non‑dangerous commands — let the sandbox enforce
|
||||
// restrictions (e.g., block network/write) without a user prompt.
|
||||
if sandbox_permissions.requires_escalated_permissions() {
|
||||
return true;
|
||||
}
|
||||
command_might_be_dangerous(command)
|
||||
}
|
||||
AskForApproval::UnlessTrusted => !is_known_safe_command(command),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command_might_be_dangerous(command: &[String]) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
@@ -86,7 +48,6 @@ fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::protocol::NetworkAccess;
|
||||
|
||||
fn vec_str(items: &[&str]) -> Vec<String> {
|
||||
items.iter().map(std::string::ToString::to_string).collect()
|
||||
@@ -154,23 +115,4 @@ mod tests {
|
||||
fn rm_f_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&["rm", "-f", "/"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_sandbox_only_prompts_for_dangerous_commands() {
|
||||
let external_policy = SandboxPolicy::ExternalSandbox {
|
||||
network_access: NetworkAccess::Restricted,
|
||||
};
|
||||
assert!(!requires_initial_appoval(
|
||||
AskForApproval::OnRequest,
|
||||
&external_policy,
|
||||
&vec_str(&["ls"]),
|
||||
SandboxPermissions::UseDefault,
|
||||
));
|
||||
assert!(requires_initial_appoval(
|
||||
AskForApproval::OnRequest,
|
||||
&external_policy,
|
||||
&vec_str(&["rm", "-rf", "/"]),
|
||||
SandboxPermissions::UseDefault,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,7 +297,8 @@ async fn drain_to_completed(
|
||||
turn_context: &TurnContext,
|
||||
prompt: &Prompt,
|
||||
) -> CodexResult<()> {
|
||||
let mut stream = turn_context.client.clone().stream(prompt).await?;
|
||||
let mut client_session = turn_context.client.new_session();
|
||||
let mut stream = client_session.stream(prompt).await?;
|
||||
loop {
|
||||
let maybe_event = stream.next().await;
|
||||
let Some(event) = maybe_event else {
|
||||
|
||||
@@ -268,6 +268,11 @@ pub struct Config {
|
||||
/// auto (default): keyring if available, otherwise file.
|
||||
pub mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode,
|
||||
|
||||
/// Optional fixed port to use for the local HTTP callback server used during MCP OAuth login.
|
||||
///
|
||||
/// When unset, Codex will bind to an ephemeral port chosen by the OS.
|
||||
pub mcp_oauth_callback_port: Option<u16>,
|
||||
|
||||
/// Combined provider map (defaults merged with user-defined overrides).
|
||||
pub model_providers: HashMap<String, ModelProviderInfo>,
|
||||
|
||||
@@ -751,6 +756,10 @@ pub struct ConfigToml {
|
||||
#[serde(default)]
|
||||
pub mcp_oauth_credentials_store: Option<OAuthCredentialsStoreMode>,
|
||||
|
||||
/// Optional fixed port for the local HTTP callback server used during MCP OAuth login.
|
||||
/// When unset, Codex will bind to an ephemeral port chosen by the OS.
|
||||
pub mcp_oauth_callback_port: Option<u16>,
|
||||
|
||||
/// User-defined provider entries that extend/override the built-in list.
|
||||
#[serde(default)]
|
||||
pub model_providers: HashMap<String, ModelProviderInfo>,
|
||||
@@ -1361,6 +1370,7 @@ impl Config {
|
||||
// The config.toml omits "_mode" because it's a config file. However, "_mode"
|
||||
// is important in code to differentiate the mode from the store implementation.
|
||||
mcp_oauth_credentials_store_mode: cfg.mcp_oauth_credentials_store.unwrap_or_default(),
|
||||
mcp_oauth_callback_port: cfg.mcp_oauth_callback_port,
|
||||
model_providers,
|
||||
project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES),
|
||||
project_doc_fallback_filenames: cfg
|
||||
@@ -3245,6 +3255,7 @@ model_verbosity = "high"
|
||||
cli_auth_credentials_store_mode: Default::default(),
|
||||
mcp_servers: HashMap::new(),
|
||||
mcp_oauth_credentials_store_mode: Default::default(),
|
||||
mcp_oauth_callback_port: None,
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
project_doc_fallback_filenames: Vec::new(),
|
||||
@@ -3331,6 +3342,7 @@ model_verbosity = "high"
|
||||
cli_auth_credentials_store_mode: Default::default(),
|
||||
mcp_servers: HashMap::new(),
|
||||
mcp_oauth_credentials_store_mode: Default::default(),
|
||||
mcp_oauth_callback_port: None,
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
project_doc_fallback_filenames: Vec::new(),
|
||||
@@ -3432,6 +3444,7 @@ model_verbosity = "high"
|
||||
cli_auth_credentials_store_mode: Default::default(),
|
||||
mcp_servers: HashMap::new(),
|
||||
mcp_oauth_credentials_store_mode: Default::default(),
|
||||
mcp_oauth_callback_port: None,
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
project_doc_fallback_filenames: Vec::new(),
|
||||
@@ -3519,6 +3532,7 @@ model_verbosity = "high"
|
||||
cli_auth_credentials_store_mode: Default::default(),
|
||||
mcp_servers: HashMap::new(),
|
||||
mcp_oauth_credentials_store_mode: Default::default(),
|
||||
mcp_oauth_callback_port: None,
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
project_doc_fallback_filenames: Vec::new(),
|
||||
@@ -3832,6 +3846,34 @@ trust_level = "untrusted"
|
||||
assert_eq!(result, Some("explicit-provider".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_deserializes_mcp_oauth_callback_port() {
|
||||
let toml = r#"mcp_oauth_callback_port = 4321"#;
|
||||
let cfg: ConfigToml =
|
||||
toml::from_str(toml).expect("TOML deserialization should succeed for callback port");
|
||||
assert_eq!(cfg.mcp_oauth_callback_port, Some(4321));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loads_mcp_oauth_callback_port_from_toml() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let toml = r#"
|
||||
model = "gpt-5.1"
|
||||
mcp_oauth_callback_port = 5678
|
||||
"#;
|
||||
let cfg: ConfigToml =
|
||||
toml::from_str(toml).expect("TOML deserialization should succeed for callback port");
|
||||
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigOverrides::default(),
|
||||
codex_home.path().to_path_buf(),
|
||||
)?;
|
||||
|
||||
assert_eq!(config.mcp_oauth_callback_port, Some(5678));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_untrusted_project_gets_unless_trusted_approval_policy() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
use crate::codex::TurnContext;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::NetworkAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::shell::Shell;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
@@ -17,55 +12,12 @@ use std::path::PathBuf;
|
||||
#[serde(rename = "environment_context", rename_all = "snake_case")]
|
||||
pub(crate) struct EnvironmentContext {
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub approval_policy: Option<AskForApproval>,
|
||||
pub sandbox_mode: Option<SandboxMode>,
|
||||
pub network_access: Option<NetworkAccess>,
|
||||
pub writable_roots: Option<Vec<AbsolutePathBuf>>,
|
||||
pub shell: Shell,
|
||||
}
|
||||
|
||||
impl EnvironmentContext {
|
||||
pub fn new(
|
||||
cwd: Option<PathBuf>,
|
||||
approval_policy: Option<AskForApproval>,
|
||||
sandbox_policy: Option<SandboxPolicy>,
|
||||
shell: Shell,
|
||||
) -> Self {
|
||||
Self {
|
||||
cwd,
|
||||
approval_policy,
|
||||
sandbox_mode: match sandbox_policy {
|
||||
Some(SandboxPolicy::DangerFullAccess) => Some(SandboxMode::DangerFullAccess),
|
||||
Some(SandboxPolicy::ReadOnly) => Some(SandboxMode::ReadOnly),
|
||||
Some(SandboxPolicy::ExternalSandbox { .. }) => Some(SandboxMode::DangerFullAccess),
|
||||
Some(SandboxPolicy::WorkspaceWrite { .. }) => Some(SandboxMode::WorkspaceWrite),
|
||||
None => None,
|
||||
},
|
||||
network_access: match sandbox_policy {
|
||||
Some(SandboxPolicy::DangerFullAccess) => Some(NetworkAccess::Enabled),
|
||||
Some(SandboxPolicy::ReadOnly) => Some(NetworkAccess::Restricted),
|
||||
Some(SandboxPolicy::ExternalSandbox { network_access }) => Some(network_access),
|
||||
Some(SandboxPolicy::WorkspaceWrite { network_access, .. }) => {
|
||||
if network_access {
|
||||
Some(NetworkAccess::Enabled)
|
||||
} else {
|
||||
Some(NetworkAccess::Restricted)
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
},
|
||||
writable_roots: match sandbox_policy {
|
||||
Some(SandboxPolicy::WorkspaceWrite { writable_roots, .. }) => {
|
||||
if writable_roots.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(writable_roots)
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
shell,
|
||||
}
|
||||
pub fn new(cwd: Option<PathBuf>, shell: Shell) -> Self {
|
||||
Self { cwd, shell }
|
||||
}
|
||||
|
||||
/// Compares two environment contexts, ignoring the shell. Useful when
|
||||
@@ -74,19 +26,11 @@ impl EnvironmentContext {
|
||||
pub fn equals_except_shell(&self, other: &EnvironmentContext) -> bool {
|
||||
let EnvironmentContext {
|
||||
cwd,
|
||||
approval_policy,
|
||||
sandbox_mode,
|
||||
network_access,
|
||||
writable_roots,
|
||||
// should compare all fields except shell
|
||||
shell: _,
|
||||
} = other;
|
||||
|
||||
self.cwd == *cwd
|
||||
&& self.approval_policy == *approval_policy
|
||||
&& self.sandbox_mode == *sandbox_mode
|
||||
&& self.network_access == *network_access
|
||||
&& self.writable_roots == *writable_roots
|
||||
}
|
||||
|
||||
pub fn diff(before: &TurnContext, after: &TurnContext, shell: &Shell) -> Self {
|
||||
@@ -95,26 +39,11 @@ impl EnvironmentContext {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let approval_policy = if before.approval_policy != after.approval_policy {
|
||||
Some(after.approval_policy)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sandbox_policy = if before.sandbox_policy != after.sandbox_policy {
|
||||
Some(after.sandbox_policy.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
EnvironmentContext::new(cwd, approval_policy, sandbox_policy, shell.clone())
|
||||
EnvironmentContext::new(cwd, shell.clone())
|
||||
}
|
||||
|
||||
pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self {
|
||||
Self::new(
|
||||
Some(turn_context.cwd.clone()),
|
||||
Some(turn_context.approval_policy),
|
||||
Some(turn_context.sandbox_policy.clone()),
|
||||
shell.clone(),
|
||||
)
|
||||
Self::new(Some(turn_context.cwd.clone()), shell.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,10 +55,6 @@ impl EnvironmentContext {
|
||||
/// ```xml
|
||||
/// <environment_context>
|
||||
/// <cwd>...</cwd>
|
||||
/// <approval_policy>...</approval_policy>
|
||||
/// <sandbox_mode>...</sandbox_mode>
|
||||
/// <writable_roots>...</writable_roots>
|
||||
/// <network_access>...</network_access>
|
||||
/// <shell>...</shell>
|
||||
/// </environment_context>
|
||||
/// ```
|
||||
@@ -138,29 +63,6 @@ impl EnvironmentContext {
|
||||
if let Some(cwd) = self.cwd {
|
||||
lines.push(format!(" <cwd>{}</cwd>", cwd.to_string_lossy()));
|
||||
}
|
||||
if let Some(approval_policy) = self.approval_policy {
|
||||
lines.push(format!(
|
||||
" <approval_policy>{approval_policy}</approval_policy>"
|
||||
));
|
||||
}
|
||||
if let Some(sandbox_mode) = self.sandbox_mode {
|
||||
lines.push(format!(" <sandbox_mode>{sandbox_mode}</sandbox_mode>"));
|
||||
}
|
||||
if let Some(network_access) = self.network_access {
|
||||
lines.push(format!(
|
||||
" <network_access>{network_access}</network_access>"
|
||||
));
|
||||
}
|
||||
if let Some(writable_roots) = self.writable_roots {
|
||||
lines.push(" <writable_roots>".to_string());
|
||||
for writable_root in writable_roots {
|
||||
lines.push(format!(
|
||||
" <root>{}</root>",
|
||||
writable_root.to_string_lossy()
|
||||
));
|
||||
}
|
||||
lines.push(" </writable_roots>".to_string());
|
||||
}
|
||||
|
||||
let shell_name = self.shell.name();
|
||||
lines.push(format!(" <shell>{shell_name}</shell>"));
|
||||
@@ -187,7 +89,6 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
use core_test_support::test_path_buf;
|
||||
use core_test_support::test_tmp_path_buf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn fake_shell() -> Shell {
|
||||
@@ -198,50 +99,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_write_policy(writable_roots: Vec<&str>, network_access: bool) -> SandboxPolicy {
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: writable_roots
|
||||
.into_iter()
|
||||
.map(|s| AbsolutePathBuf::try_from(s).unwrap())
|
||||
.collect(),
|
||||
network_access,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_workspace_write_environment_context() {
|
||||
let cwd = test_path_buf("/repo");
|
||||
let writable_root = test_tmp_path_buf();
|
||||
let cwd_str = cwd.to_str().expect("cwd is valid utf-8");
|
||||
let writable_root_str = writable_root
|
||||
.to_str()
|
||||
.expect("writable root is valid utf-8");
|
||||
let context = EnvironmentContext::new(
|
||||
Some(cwd.clone()),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(
|
||||
vec![cwd_str, writable_root_str],
|
||||
false,
|
||||
)),
|
||||
fake_shell(),
|
||||
);
|
||||
let context = EnvironmentContext::new(Some(cwd.clone()), fake_shell());
|
||||
|
||||
let expected = format!(
|
||||
r#"<environment_context>
|
||||
<cwd>{cwd}</cwd>
|
||||
<approval_policy>on-request</approval_policy>
|
||||
<sandbox_mode>workspace-write</sandbox_mode>
|
||||
<network_access>restricted</network_access>
|
||||
<writable_roots>
|
||||
<root>{cwd}</root>
|
||||
<root>{writable_root}</root>
|
||||
</writable_roots>
|
||||
<shell>bash</shell>
|
||||
</environment_context>"#,
|
||||
cwd = cwd.display(),
|
||||
writable_root = writable_root.display(),
|
||||
);
|
||||
|
||||
assert_eq!(context.serialize_to_xml(), expected);
|
||||
@@ -249,17 +117,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn serialize_read_only_environment_context() {
|
||||
let context = EnvironmentContext::new(
|
||||
None,
|
||||
Some(AskForApproval::Never),
|
||||
Some(SandboxPolicy::ReadOnly),
|
||||
fake_shell(),
|
||||
);
|
||||
let context = EnvironmentContext::new(None, fake_shell());
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
<approval_policy>never</approval_policy>
|
||||
<sandbox_mode>read-only</sandbox_mode>
|
||||
<network_access>restricted</network_access>
|
||||
<shell>bash</shell>
|
||||
</environment_context>"#;
|
||||
|
||||
@@ -268,19 +128,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn serialize_external_sandbox_environment_context() {
|
||||
let context = EnvironmentContext::new(
|
||||
None,
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(SandboxPolicy::ExternalSandbox {
|
||||
network_access: NetworkAccess::Enabled,
|
||||
}),
|
||||
fake_shell(),
|
||||
);
|
||||
let context = EnvironmentContext::new(None, fake_shell());
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
<approval_policy>on-request</approval_policy>
|
||||
<sandbox_mode>danger-full-access</sandbox_mode>
|
||||
<network_access>enabled</network_access>
|
||||
<shell>bash</shell>
|
||||
</environment_context>"#;
|
||||
|
||||
@@ -289,19 +139,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn serialize_external_sandbox_with_restricted_network_environment_context() {
|
||||
let context = EnvironmentContext::new(
|
||||
None,
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(SandboxPolicy::ExternalSandbox {
|
||||
network_access: NetworkAccess::Restricted,
|
||||
}),
|
||||
fake_shell(),
|
||||
);
|
||||
let context = EnvironmentContext::new(None, fake_shell());
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
<approval_policy>on-request</approval_policy>
|
||||
<sandbox_mode>danger-full-access</sandbox_mode>
|
||||
<network_access>restricted</network_access>
|
||||
<shell>bash</shell>
|
||||
</environment_context>"#;
|
||||
|
||||
@@ -310,17 +150,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn serialize_full_access_environment_context() {
|
||||
let context = EnvironmentContext::new(
|
||||
None,
|
||||
Some(AskForApproval::OnFailure),
|
||||
Some(SandboxPolicy::DangerFullAccess),
|
||||
fake_shell(),
|
||||
);
|
||||
let context = EnvironmentContext::new(None, fake_shell());
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
<approval_policy>on-failure</approval_policy>
|
||||
<sandbox_mode>danger-full-access</sandbox_mode>
|
||||
<network_access>enabled</network_access>
|
||||
<shell>bash</shell>
|
||||
</environment_context>"#;
|
||||
|
||||
@@ -328,55 +160,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_except_shell_compares_approval_policy() {
|
||||
// Approval policy
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo"], false)),
|
||||
fake_shell(),
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::Never),
|
||||
Some(workspace_write_policy(vec!["/repo"], true)),
|
||||
fake_shell(),
|
||||
);
|
||||
assert!(!context1.equals_except_shell(&context2));
|
||||
fn equals_except_shell_compares_cwd() {
|
||||
let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell());
|
||||
let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell());
|
||||
assert!(context1.equals_except_shell(&context2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_except_shell_compares_sandbox_policy() {
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(SandboxPolicy::new_read_only_policy()),
|
||||
fake_shell(),
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(SandboxPolicy::new_workspace_write_policy()),
|
||||
fake_shell(),
|
||||
);
|
||||
fn equals_except_shell_ignores_sandbox_policy() {
|
||||
let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell());
|
||||
let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell());
|
||||
|
||||
assert!(!context1.equals_except_shell(&context2));
|
||||
assert!(context1.equals_except_shell(&context2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_except_shell_compares_workspace_write_policy() {
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo", "/tmp", "/var"], false)),
|
||||
fake_shell(),
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo", "/tmp"], true)),
|
||||
fake_shell(),
|
||||
);
|
||||
fn equals_except_shell_compares_cwd_differences() {
|
||||
let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo1")), fake_shell());
|
||||
let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo2")), fake_shell());
|
||||
|
||||
assert!(!context1.equals_except_shell(&context2));
|
||||
}
|
||||
@@ -385,8 +186,6 @@ mod tests {
|
||||
fn equals_except_shell_ignores_shell() {
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo"], false)),
|
||||
Shell {
|
||||
shell_type: ShellType::Bash,
|
||||
shell_path: "/bin/bash".into(),
|
||||
@@ -395,8 +194,6 @@ mod tests {
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo"], false)),
|
||||
Shell {
|
||||
shell_type: ShellType::Zsh,
|
||||
shell_path: "/bin/zsh".into(),
|
||||
|
||||
@@ -5,9 +5,10 @@ use std::sync::Arc;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
|
||||
use crate::command_safety::is_dangerous_command::requires_initial_appoval;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::is_dangerous_command::command_might_be_dangerous;
|
||||
use crate::is_safe_command::is_known_safe_command;
|
||||
use codex_execpolicy::AmendError;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Error as ExecPolicyRuleError;
|
||||
@@ -116,14 +117,15 @@ impl ExecPolicyManager {
|
||||
let exec_policy = self.current();
|
||||
let commands =
|
||||
parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]);
|
||||
let heuristics_fallback = |cmd: &[String]| {
|
||||
if requires_initial_appoval(approval_policy, sandbox_policy, cmd, sandbox_permissions) {
|
||||
Decision::Prompt
|
||||
} else {
|
||||
Decision::Allow
|
||||
}
|
||||
let exec_policy_fallback = |cmd: &[String]| {
|
||||
render_decision_for_unmatched_command(
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
cmd,
|
||||
sandbox_permissions,
|
||||
)
|
||||
};
|
||||
let evaluation = exec_policy.check_multiple(commands.iter(), &heuristics_fallback);
|
||||
let evaluation = exec_policy.check_multiple(commands.iter(), &exec_policy_fallback);
|
||||
|
||||
match evaluation.decision {
|
||||
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
|
||||
@@ -242,6 +244,70 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
/// If a command is not matched by any execpolicy rule, derive a [`Decision`].
|
||||
pub fn render_decision_for_unmatched_command(
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
command: &[String],
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
) -> Decision {
|
||||
if is_known_safe_command(command) {
|
||||
return Decision::Allow;
|
||||
}
|
||||
|
||||
// On Windows, ReadOnly sandbox is not a real sandbox, so special-case it
|
||||
// here.
|
||||
let runtime_sandbox_provides_safety =
|
||||
cfg!(windows) && matches!(sandbox_policy, SandboxPolicy::ReadOnly);
|
||||
|
||||
// If the command is flagged as dangerous or we have no sandbox protection,
|
||||
// we should never allow it to run without user approval.
|
||||
//
|
||||
// We prefer to prompt the user rather than outright forbid the command,
|
||||
// but if the user has explicitly disabled prompts, we must
|
||||
// forbid the command.
|
||||
if command_might_be_dangerous(command) || runtime_sandbox_provides_safety {
|
||||
return if matches!(approval_policy, AskForApproval::Never) {
|
||||
Decision::Forbidden
|
||||
} else {
|
||||
Decision::Prompt
|
||||
};
|
||||
}
|
||||
|
||||
match approval_policy {
|
||||
AskForApproval::Never | AskForApproval::OnFailure => {
|
||||
// We allow the command to run, relying on the sandbox for
|
||||
// protection.
|
||||
Decision::Allow
|
||||
}
|
||||
AskForApproval::UnlessTrusted => {
|
||||
// We already checked `is_known_safe_command(command)` and it
|
||||
// returned false, so we must prompt.
|
||||
Decision::Prompt
|
||||
}
|
||||
AskForApproval::OnRequest => {
|
||||
match sandbox_policy {
|
||||
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
|
||||
// The user has indicated we should "just run" commands
|
||||
// in their unrestricted environment, so we do so since the
|
||||
// command has not been flagged as dangerous.
|
||||
Decision::Allow
|
||||
}
|
||||
SandboxPolicy::ReadOnly | SandboxPolicy::WorkspaceWrite { .. } => {
|
||||
// In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for
|
||||
// non‑escalated, non‑dangerous commands — let the sandbox enforce
|
||||
// restrictions (e.g., block network/write) without a user prompt.
|
||||
if sandbox_permissions.requires_escalated_permissions() {
|
||||
Decision::Prompt
|
||||
} else {
|
||||
Decision::Allow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_policy_path(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(RULES_DIR_NAME).join(DEFAULT_POLICY_FILE)
|
||||
}
|
||||
@@ -1051,4 +1117,108 @@ prefix_rule(
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn vec_str(items: &[&str]) -> Vec<String> {
|
||||
items.iter().map(std::string::ToString::to_string).collect()
|
||||
}
|
||||
|
||||
/// Note this test behaves differently on Windows because it exercises an
|
||||
/// `if cfg!(windows)` code path in render_decision_for_unmatched_command().
|
||||
#[tokio::test]
|
||||
async fn verify_approval_requirement_for_unsafe_powershell_command() {
|
||||
// `brew install powershell` to run this test on a Mac!
|
||||
// Note `pwsh` is required to parse a PowerShell command to see if it
|
||||
// is safe.
|
||||
if which::which("pwsh").is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let policy = ExecPolicyManager::new(Arc::new(Policy::empty()));
|
||||
let features = Features::with_defaults();
|
||||
let permissions = SandboxPermissions::UseDefault;
|
||||
|
||||
// This command should not be run without user approval unless there is
|
||||
// a proper sandbox in place to ensure safety.
|
||||
let sneaky_command = vec_str(&["pwsh", "-Command", "echo hi @(calc)"]);
|
||||
let expected_amendment = Some(ExecPolicyAmendment::new(vec_str(&[
|
||||
"pwsh",
|
||||
"-Command",
|
||||
"echo hi @(calc)",
|
||||
])));
|
||||
let (pwsh_approval_reason, expected_req) = if cfg!(windows) {
|
||||
(
|
||||
r#"On Windows, SandboxPolicy::ReadOnly should be assumed to mean
|
||||
that no sandbox is present, so anything that is not "provably
|
||||
safe" should require approval."#,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: expected_amendment.clone(),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"On non-Windows, rely on the read-only sandbox to prevent harm.",
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: expected_amendment.clone(),
|
||||
},
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
expected_req,
|
||||
policy
|
||||
.create_exec_approval_requirement_for_command(
|
||||
&features,
|
||||
&sneaky_command,
|
||||
AskForApproval::OnRequest,
|
||||
&SandboxPolicy::ReadOnly,
|
||||
permissions,
|
||||
)
|
||||
.await,
|
||||
"{pwsh_approval_reason}"
|
||||
);
|
||||
|
||||
// This is flagged as a dangerous command on all platforms.
|
||||
let dangerous_command = vec_str(&["rm", "-rf", "/important/data"]);
|
||||
assert_eq!(
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec_str(&[
|
||||
"rm",
|
||||
"-rf",
|
||||
"/important/data",
|
||||
]))),
|
||||
},
|
||||
policy
|
||||
.create_exec_approval_requirement_for_command(
|
||||
&features,
|
||||
&dangerous_command,
|
||||
AskForApproval::OnRequest,
|
||||
&SandboxPolicy::ReadOnly,
|
||||
permissions,
|
||||
)
|
||||
.await,
|
||||
r#"On all platforms, a forbidden command should require approval
|
||||
(unless AskForApproval::Never is specified)."#
|
||||
);
|
||||
|
||||
// A dangerous command should be forbidden if the user has specified
|
||||
// AskForApproval::Never.
|
||||
assert_eq!(
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: "`rm -rf /important/data` rejected: blocked by policy".to_string(),
|
||||
},
|
||||
policy
|
||||
.create_exec_approval_requirement_for_command(
|
||||
&features,
|
||||
&dangerous_command,
|
||||
AskForApproval::Never,
|
||||
&SandboxPolicy::ReadOnly,
|
||||
permissions,
|
||||
)
|
||||
.await,
|
||||
r#"On all platforms, a forbidden command should require approval
|
||||
(unless AskForApproval::Never is specified)."#
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ pub enum Feature {
|
||||
EnableRequestCompression,
|
||||
/// Enable collab tools.
|
||||
Collab,
|
||||
/// Steer feature flag - when enabled, Enter submits immediately instead of queuing.
|
||||
Steer,
|
||||
}
|
||||
|
||||
impl Feature {
|
||||
@@ -420,4 +422,14 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Steer,
|
||||
key: "steer",
|
||||
stage: Stage::Beta {
|
||||
name: "Steer conversation",
|
||||
menu_description: "Enter submits immediately; Tab queues messages when a task is running.",
|
||||
announcement: "NEW! Try Steer mode: Enter submits immediately, Tab queues. Enable in /experimental!",
|
||||
},
|
||||
default_enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -126,6 +126,7 @@ pub use codex_protocol::protocol;
|
||||
pub use codex_protocol::config_types as protocol_config_types;
|
||||
|
||||
pub use client::ModelClient;
|
||||
pub use client::ModelClientSession;
|
||||
pub use client_common::Prompt;
|
||||
pub use client_common::REVIEW_PROMPT;
|
||||
pub use client_common::ResponseEvent;
|
||||
|
||||
@@ -42,6 +42,10 @@ pub enum WireApi {
|
||||
/// The Responses API exposed by OpenAI at `/v1/responses`.
|
||||
Responses,
|
||||
|
||||
/// Experimental: Responses API over WebSocket transport.
|
||||
#[serde(rename = "responses_websocket")]
|
||||
ResponsesWebsocket,
|
||||
|
||||
/// Regular Chat Completions compatible with `/v1/chat/completions`.
|
||||
#[default]
|
||||
Chat,
|
||||
@@ -156,6 +160,7 @@ impl ModelProviderInfo {
|
||||
query_params: self.query_params.clone(),
|
||||
wire: match self.wire_api {
|
||||
WireApi::Responses => ApiWireApi::Responses,
|
||||
WireApi::ResponsesWebsocket => ApiWireApi::Responses,
|
||||
WireApi::Chat => ApiWireApi::Chat,
|
||||
},
|
||||
headers,
|
||||
|
||||
@@ -3,6 +3,7 @@ use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ModelUpgrade;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use indoc::indoc;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
pub const HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt5_1_migration_prompt";
|
||||
@@ -318,6 +319,16 @@ fn gpt_52_codex_upgrade() -> ModelUpgrade {
|
||||
"Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work."
|
||||
.to_string(),
|
||||
),
|
||||
migration_markdown: Some(
|
||||
indoc! {r#"
|
||||
**Codex just got an upgrade. Introducing {model_to}.**
|
||||
|
||||
Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work. Learn more about {model_to} at https://openai.com/index/introducing-gpt-5-2-codex
|
||||
|
||||
You can continue using {model_from} if you prefer.
|
||||
"#}
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_mutating_xargs_pipeline() {
|
||||
let inner = r#"rg -l QkBindingController presentation/src/main/java | xargs perl -pi -e 's/QkBindingController/QkController/g'"#;
|
||||
assert_parsed(
|
||||
&vec_str(&["bash", "-lc", inner]),
|
||||
vec![
|
||||
ParsedCommand::Search {
|
||||
cmd: "rg -l QkBindingController presentation/src/main/java".to_string(),
|
||||
query: Some("QkBindingController".to_string()),
|
||||
path: Some("java".to_string()),
|
||||
},
|
||||
ParsedCommand::Unknown {
|
||||
cmd: "xargs perl -pi -e s/QkBindingController/QkController/g".to_string(),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_cat() {
|
||||
let inner = "cat webview/README.md";
|
||||
@@ -1404,8 +1422,9 @@ fn is_small_formatting_command(tokens: &[String]) -> bool {
|
||||
match cmd {
|
||||
// Always formatting; typically used in pipes.
|
||||
// `nl` is special-cased below to allow `nl <file>` to be treated as a read command.
|
||||
"wc" | "tr" | "cut" | "sort" | "uniq" | "xargs" | "tee" | "column" | "awk" | "yes"
|
||||
| "printf" => true,
|
||||
"wc" | "tr" | "cut" | "sort" | "uniq" | "tee" | "column" | "awk" | "yes" => true,
|
||||
"xargs" => !is_mutating_xargs_command(tokens),
|
||||
"printf" => true,
|
||||
"head" => {
|
||||
// Treat as formatting when no explicit file operand is present.
|
||||
// Common forms: `head -n 40`, `head -c 100`.
|
||||
@@ -1465,6 +1484,54 @@ fn is_small_formatting_command(tokens: &[String]) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_mutating_xargs_command(tokens: &[String]) -> bool {
|
||||
xargs_subcommand(tokens).is_some_and(xargs_is_mutating_subcommand)
|
||||
}
|
||||
|
||||
fn xargs_subcommand(tokens: &[String]) -> Option<&[String]> {
|
||||
if tokens.first().map(String::as_str) != Some("xargs") {
|
||||
return None;
|
||||
}
|
||||
let mut i = 1;
|
||||
while i < tokens.len() {
|
||||
let token = &tokens[i];
|
||||
if token == "--" {
|
||||
return tokens.get(i + 1..).filter(|rest| !rest.is_empty());
|
||||
}
|
||||
if !token.starts_with('-') {
|
||||
return tokens.get(i..).filter(|rest| !rest.is_empty());
|
||||
}
|
||||
let takes_value = matches!(
|
||||
token.as_str(),
|
||||
"-E" | "-e" | "-I" | "-L" | "-n" | "-P" | "-s"
|
||||
);
|
||||
if takes_value && token.len() == 2 {
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn xargs_is_mutating_subcommand(tokens: &[String]) -> bool {
|
||||
let Some((head, tail)) = tokens.split_first() else {
|
||||
return false;
|
||||
};
|
||||
match head.as_str() {
|
||||
"perl" | "ruby" => xargs_has_in_place_flag(tail),
|
||||
"sed" => xargs_has_in_place_flag(tail) || tail.iter().any(|token| token == "--in-place"),
|
||||
"rg" => tail.iter().any(|token| token == "--replace"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn xargs_has_in_place_flag(tokens: &[String]) -> bool {
|
||||
tokens.iter().any(|token| {
|
||||
token == "-i" || token.starts_with("-i") || token == "-pi" || token.starts_with("-pi")
|
||||
})
|
||||
}
|
||||
|
||||
fn drop_small_formatting_commands(mut commands: Vec<Vec<String>>) -> Vec<Vec<String>> {
|
||||
commands.retain(|tokens| !is_small_formatting_command(tokens));
|
||||
commands
|
||||
|
||||
@@ -206,6 +206,7 @@ mod tests {
|
||||
RolloutItem::ResponseItem(items[0].clone()),
|
||||
RolloutItem::ResponseItem(items[1].clone()),
|
||||
RolloutItem::ResponseItem(items[2].clone()),
|
||||
RolloutItem::ResponseItem(items[3].clone()),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -17,7 +17,7 @@ use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub(crate) struct SessionServices {
|
||||
pub(crate) mcp_connection_manager: Arc<RwLock<McpConnectionManager>>,
|
||||
pub(crate) mcp_startup_cancellation_token: CancellationToken,
|
||||
pub(crate) mcp_startup_cancellation_token: Mutex<CancellationToken>,
|
||||
pub(crate) unified_exec_manager: UnifiedExecProcessManager,
|
||||
pub(crate) notifier: UserNotifier,
|
||||
pub(crate) rollout: Mutex<Option<RolloutRecorder>>,
|
||||
|
||||
@@ -104,6 +104,10 @@ impl TurnState {
|
||||
ret
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn has_pending_input(&self) -> bool {
|
||||
!self.pending_input.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveTurn {
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::skills::SkillsManager;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
@@ -30,6 +31,7 @@ use std::sync::Arc;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
use tempfile::TempDir;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::warn;
|
||||
|
||||
/// Represents a newly created Codex thread (formerly called a conversation), including the first event
|
||||
/// (which is [`EventMsg::SessionConfigured`]).
|
||||
@@ -56,6 +58,10 @@ pub(crate) struct ThreadManagerState {
|
||||
models_manager: Arc<ModelsManager>,
|
||||
skills_manager: Arc<SkillsManager>,
|
||||
session_source: SessionSource,
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
#[allow(dead_code)]
|
||||
// Captures submitted ops for testing purpose.
|
||||
ops_log: Arc<std::sync::Mutex<Vec<(ThreadId, Op)>>>,
|
||||
}
|
||||
|
||||
impl ThreadManager {
|
||||
@@ -74,6 +80,8 @@ impl ThreadManager {
|
||||
skills_manager: Arc::new(SkillsManager::new(codex_home)),
|
||||
auth_manager,
|
||||
session_source,
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
ops_log: Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
}),
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
_test_codex_home_guard: None,
|
||||
@@ -111,6 +119,8 @@ impl ThreadManager {
|
||||
skills_manager: Arc::new(SkillsManager::new(codex_home)),
|
||||
auth_manager,
|
||||
session_source: SessionSource::Exec,
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
ops_log: Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
}),
|
||||
_test_codex_home_guard: None,
|
||||
}
|
||||
@@ -136,6 +146,27 @@ impl ThreadManager {
|
||||
self.state.threads.read().await.keys().copied().collect()
|
||||
}
|
||||
|
||||
pub async fn refresh_mcp_servers(&self, refresh_config: McpServerRefreshConfig) {
|
||||
let threads = self
|
||||
.state
|
||||
.threads
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
for thread in threads {
|
||||
if let Err(err) = thread
|
||||
.submit(Op::RefreshMcpServers {
|
||||
config: refresh_config.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!("failed to request MCP server refresh: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_thread(&self, thread_id: ThreadId) -> CodexResult<Arc<CodexThread>> {
|
||||
self.state.get_thread(thread_id).await
|
||||
}
|
||||
@@ -202,9 +233,19 @@ impl ThreadManager {
|
||||
.await
|
||||
}
|
||||
|
||||
fn agent_control(&self) -> AgentControl {
|
||||
pub(crate) fn agent_control(&self) -> AgentControl {
|
||||
AgentControl::new(Arc::downgrade(&self.state))
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn captured_ops(&self) -> Vec<(ThreadId, Op)> {
|
||||
self.state
|
||||
.ops_log
|
||||
.lock()
|
||||
.map(|log| log.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl ThreadManagerState {
|
||||
@@ -217,7 +258,14 @@ impl ThreadManagerState {
|
||||
}
|
||||
|
||||
pub(crate) async fn send_op(&self, thread_id: ThreadId, op: Op) -> CodexResult<String> {
|
||||
self.get_thread(thread_id).await?.submit(op).await
|
||||
let thread = self.get_thread(thread_id).await?;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
{
|
||||
if let Ok(mut log) = self.ops_log.lock() {
|
||||
log.push((thread_id, op.clone()));
|
||||
}
|
||||
}
|
||||
thread.submit(op).await
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Used by upcoming multi-agent tooling.
|
||||
@@ -402,6 +450,7 @@ mod tests {
|
||||
RolloutItem::ResponseItem(items[0].clone()),
|
||||
RolloutItem::ResponseItem(items[1].clone()),
|
||||
RolloutItem::ResponseItem(items[2].clone()),
|
||||
RolloutItem::ResponseItem(items[3].clone()),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::config::Config;
|
||||
use crate::error::CodexErr;
|
||||
@@ -11,29 +12,13 @@ use crate::tools::registry::ToolKind;
|
||||
use async_trait::async_trait;
|
||||
use codex_protocol::ThreadId;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
pub struct CollabHandler;
|
||||
|
||||
pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000;
|
||||
pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 300_000;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentArgs {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SendInputArgs {
|
||||
id: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WaitArgs {
|
||||
id: String,
|
||||
timeout_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CloseAgentArgs {
|
||||
id: String,
|
||||
@@ -68,10 +53,10 @@ impl ToolHandler for CollabHandler {
|
||||
};
|
||||
|
||||
match tool_name.as_str() {
|
||||
"spawn_agent" => handle_spawn_agent(session, turn, arguments).await,
|
||||
"send_input" => handle_send_input(session, arguments).await,
|
||||
"wait" => handle_wait(arguments).await,
|
||||
"close_agent" => handle_close_agent(arguments).await,
|
||||
"spawn_agent" => spawn::handle(session, turn, arguments).await,
|
||||
"send_input" => send_input::handle(session, arguments).await,
|
||||
"wait" => wait::handle(session, arguments).await,
|
||||
"close_agent" => close_agent::handle(session, arguments).await,
|
||||
other => Err(FunctionCallError::RespondToModel(format!(
|
||||
"unsupported collab tool {other}"
|
||||
))),
|
||||
@@ -79,84 +64,233 @@ impl ToolHandler for CollabHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_spawn_agent(
|
||||
session: std::sync::Arc<crate::codex::Session>,
|
||||
turn: std::sync::Arc<TurnContext>,
|
||||
arguments: String,
|
||||
) -> Result<ToolOutput, FunctionCallError> {
|
||||
let args: SpawnAgentArgs = parse_arguments(&arguments)?;
|
||||
if args.message.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"Empty message can't be send to an agent".to_string(),
|
||||
));
|
||||
}
|
||||
let config = build_agent_spawn_config(turn.as_ref())?;
|
||||
let result = session
|
||||
.services
|
||||
.agent_control
|
||||
.spawn_agent(config, args.message, true)
|
||||
.await
|
||||
.map_err(|err| FunctionCallError::Fatal(err.to_string()))?;
|
||||
mod spawn {
|
||||
use super::*;
|
||||
use crate::codex::Session;
|
||||
use std::sync::Arc;
|
||||
|
||||
Ok(ToolOutput::Function {
|
||||
content: format!("agent_id: {result}"),
|
||||
success: Some(true),
|
||||
content_items: None,
|
||||
})
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentArgs {
|
||||
message: String,
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
session: Arc<Session>,
|
||||
turn: Arc<TurnContext>,
|
||||
arguments: String,
|
||||
) -> Result<ToolOutput, FunctionCallError> {
|
||||
let args: SpawnAgentArgs = parse_arguments(&arguments)?;
|
||||
if args.message.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"Empty message can't be send to an agent".to_string(),
|
||||
));
|
||||
}
|
||||
let config = build_agent_spawn_config(turn.as_ref())?;
|
||||
let result = session
|
||||
.services
|
||||
.agent_control
|
||||
.spawn_agent(config, args.message, true)
|
||||
.await
|
||||
.map_err(|err| FunctionCallError::Fatal(err.to_string()))?;
|
||||
|
||||
Ok(ToolOutput::Function {
|
||||
content: format!("agent_id: {result}"),
|
||||
success: Some(true),
|
||||
content_items: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_send_input(
|
||||
session: std::sync::Arc<crate::codex::Session>,
|
||||
arguments: String,
|
||||
) -> Result<ToolOutput, FunctionCallError> {
|
||||
let args: SendInputArgs = parse_arguments(&arguments)?;
|
||||
let agent_id = agent_id(&args.id)?;
|
||||
if args.message.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"Empty message can't be send to an agent".to_string(),
|
||||
));
|
||||
mod send_input {
|
||||
use super::*;
|
||||
use crate::codex::Session;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SendInputArgs {
|
||||
id: String,
|
||||
message: String,
|
||||
}
|
||||
let content = session
|
||||
.services
|
||||
.agent_control
|
||||
.send_prompt(agent_id, args.message)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
CodexErr::ThreadNotFound(id) => {
|
||||
FunctionCallError::RespondToModel(format!("agent with id {id} not found"))
|
||||
|
||||
pub async fn handle(
|
||||
session: Arc<Session>,
|
||||
arguments: String,
|
||||
) -> Result<ToolOutput, FunctionCallError> {
|
||||
let args: SendInputArgs = parse_arguments(&arguments)?;
|
||||
let agent_id = agent_id(&args.id)?;
|
||||
if args.message.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"Empty message can't be send to an agent".to_string(),
|
||||
));
|
||||
}
|
||||
let content = session
|
||||
.services
|
||||
.agent_control
|
||||
.send_prompt(agent_id, args.message)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
CodexErr::ThreadNotFound(id) => {
|
||||
FunctionCallError::RespondToModel(format!("agent with id {id} not found"))
|
||||
}
|
||||
err => FunctionCallError::Fatal(err.to_string()),
|
||||
})?;
|
||||
|
||||
Ok(ToolOutput::Function {
|
||||
content,
|
||||
success: Some(true),
|
||||
content_items: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
mod wait {
|
||||
use super::*;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::codex::Session;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
use tokio::time::timeout_at;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WaitArgs {
|
||||
id: String,
|
||||
timeout_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct WaitResult {
|
||||
status: AgentStatus,
|
||||
timed_out: bool,
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
session: Arc<Session>,
|
||||
arguments: String,
|
||||
) -> Result<ToolOutput, FunctionCallError> {
|
||||
let args: WaitArgs = parse_arguments(&arguments)?;
|
||||
let agent_id = agent_id(&args.id)?;
|
||||
|
||||
// Validate timeout.
|
||||
let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS);
|
||||
let timeout_ms = match timeout_ms {
|
||||
ms if ms <= 0 => {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"timeout_ms must be greater than zero".to_owned(),
|
||||
));
|
||||
}
|
||||
err => FunctionCallError::Fatal(err.to_string()),
|
||||
ms => ms.min(MAX_WAIT_TIMEOUT_MS),
|
||||
};
|
||||
|
||||
let mut status_rx = session
|
||||
.services
|
||||
.agent_control
|
||||
.subscribe_status(agent_id)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
CodexErr::ThreadNotFound(id) => {
|
||||
FunctionCallError::RespondToModel(format!("agent with id {id} not found"))
|
||||
}
|
||||
err => FunctionCallError::Fatal(err.to_string()),
|
||||
})?;
|
||||
|
||||
// Get last known status.
|
||||
let mut status = status_rx.borrow_and_update().clone();
|
||||
let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64);
|
||||
|
||||
let timed_out = loop {
|
||||
if is_final(&status) {
|
||||
break false;
|
||||
}
|
||||
|
||||
match timeout_at(deadline, status_rx.changed()).await {
|
||||
Ok(Ok(())) => status = status_rx.borrow().clone(),
|
||||
Ok(Err(_)) => {
|
||||
let last_status = session.services.agent_control.get_status(agent_id).await;
|
||||
if last_status != AgentStatus::NotFound {
|
||||
// On-purpose we keep the last known status if the agent gets dropped. This
|
||||
// event is not supposed to happen.
|
||||
status = last_status;
|
||||
}
|
||||
break false;
|
||||
}
|
||||
Err(_) => break true,
|
||||
}
|
||||
};
|
||||
|
||||
if matches!(status, AgentStatus::NotFound) {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"agent with id {agent_id} not found"
|
||||
)));
|
||||
}
|
||||
|
||||
let result = WaitResult { status, timed_out };
|
||||
|
||||
let content = serde_json::to_string(&result).map_err(|err| {
|
||||
FunctionCallError::Fatal(format!("failed to serialize wait result: {err}"))
|
||||
})?;
|
||||
|
||||
Ok(ToolOutput::Function {
|
||||
content,
|
||||
success: Some(true),
|
||||
content_items: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_wait(arguments: String) -> Result<ToolOutput, FunctionCallError> {
|
||||
let args: WaitArgs = parse_arguments(&arguments)?;
|
||||
let _agent_id = agent_id(&args.id)?;
|
||||
|
||||
let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS);
|
||||
if timeout_ms <= 0 {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"timeout_ms must be greater than zero".to_string(),
|
||||
));
|
||||
Ok(ToolOutput::Function {
|
||||
content,
|
||||
success: Some(!result.timed_out),
|
||||
content_items: None,
|
||||
})
|
||||
}
|
||||
let _timeout_ms = timeout_ms.min(MAX_WAIT_TIMEOUT_MS);
|
||||
// TODO(jif): implement agent wait once lifecycle tracking is wired up.
|
||||
Err(FunctionCallError::Fatal("wait not implemented".to_string()))
|
||||
}
|
||||
|
||||
async fn handle_close_agent(arguments: String) -> Result<ToolOutput, FunctionCallError> {
|
||||
let args: CloseAgentArgs = parse_arguments(&arguments)?;
|
||||
let _agent_id = agent_id(&args.id)?;
|
||||
// TODO(jif): implement agent shutdown and return the final status.
|
||||
Err(FunctionCallError::Fatal(
|
||||
"close_agent not implemented".to_string(),
|
||||
))
|
||||
pub mod close_agent {
|
||||
use super::*;
|
||||
use crate::codex::Session;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub(super) struct CloseAgentResult {
|
||||
pub(super) status: AgentStatus,
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
session: Arc<Session>,
|
||||
arguments: String,
|
||||
) -> Result<ToolOutput, FunctionCallError> {
|
||||
let args: CloseAgentArgs = parse_arguments(&arguments)?;
|
||||
let agent_id = agent_id(&args.id)?;
|
||||
let mut status_rx = session
|
||||
.services
|
||||
.agent_control
|
||||
.subscribe_status(agent_id)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
CodexErr::ThreadNotFound(id) => {
|
||||
FunctionCallError::RespondToModel(format!("agent with id {id} not found"))
|
||||
}
|
||||
err => FunctionCallError::Fatal(err.to_string()),
|
||||
})?;
|
||||
let status = status_rx.borrow_and_update().clone();
|
||||
|
||||
if !matches!(status, AgentStatus::Shutdown) {
|
||||
let _ = session
|
||||
.services
|
||||
.agent_control
|
||||
.shutdown_agent(agent_id)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
CodexErr::ThreadNotFound(id) => {
|
||||
FunctionCallError::RespondToModel(format!("agent with id {id} not found"))
|
||||
}
|
||||
err => FunctionCallError::Fatal(err.to_string()),
|
||||
})?;
|
||||
}
|
||||
|
||||
let content = serde_json::to_string(&CloseAgentResult { status }).map_err(|err| {
|
||||
FunctionCallError::Fatal(format!("failed to serialize close_agent result: {err}"))
|
||||
})?;
|
||||
|
||||
Ok(ToolOutput::Function {
|
||||
content,
|
||||
success: Some(true),
|
||||
content_items: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_id(id: &str) -> Result<ThreadId, FunctionCallError> {
|
||||
@@ -192,3 +326,384 @@ fn build_agent_spawn_config(turn: &TurnContext) -> Result<Config, FunctionCallEr
|
||||
})?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::CodexAuth;
|
||||
use crate::ThreadManager;
|
||||
use crate::built_in_model_providers;
|
||||
use crate::codex::make_session_and_context;
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::Op;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use codex_protocol::ThreadId;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::timeout;
|
||||
|
||||
fn invocation(
|
||||
session: Arc<crate::codex::Session>,
|
||||
turn: Arc<TurnContext>,
|
||||
tool_name: &str,
|
||||
payload: ToolPayload,
|
||||
) -> ToolInvocation {
|
||||
ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
tracker: Arc::new(Mutex::new(TurnDiffTracker::default())),
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
fn function_payload(args: serde_json::Value) -> ToolPayload {
|
||||
ToolPayload::Function {
|
||||
arguments: args.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_manager() -> ThreadManager {
|
||||
ThreadManager::with_models_provider(
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
built_in_model_providers()["openai"].clone(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_rejects_non_function_payloads() {
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
ToolPayload::Custom {
|
||||
input: "hello".to_string(),
|
||||
},
|
||||
);
|
||||
let Err(err) = CollabHandler.handle(invocation).await else {
|
||||
panic!("payload should be rejected");
|
||||
};
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel(
|
||||
"collab handler received unsupported payload".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_rejects_unknown_tool() {
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"unknown_tool",
|
||||
function_payload(json!({})),
|
||||
);
|
||||
let Err(err) = CollabHandler.handle(invocation).await else {
|
||||
panic!("tool should be rejected");
|
||||
};
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel("unsupported collab tool unknown_tool".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_rejects_empty_message() {
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({"message": " "})),
|
||||
);
|
||||
let Err(err) = CollabHandler.handle(invocation).await else {
|
||||
panic!("empty message should be rejected");
|
||||
};
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel(
|
||||
"Empty message can't be send to an agent".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_errors_when_manager_dropped() {
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({"message": "hello"})),
|
||||
);
|
||||
let Err(err) = CollabHandler.handle(invocation).await else {
|
||||
panic!("spawn should fail without a manager");
|
||||
};
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::Fatal("unsupported operation: thread manager dropped".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_input_rejects_empty_message() {
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"send_input",
|
||||
function_payload(json!({"id": ThreadId::new().to_string(), "message": ""})),
|
||||
);
|
||||
let Err(err) = CollabHandler.handle(invocation).await else {
|
||||
panic!("empty message should be rejected");
|
||||
};
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel(
|
||||
"Empty message can't be send to an agent".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_input_rejects_invalid_id() {
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"send_input",
|
||||
function_payload(json!({"id": "not-a-uuid", "message": "hi"})),
|
||||
);
|
||||
let Err(err) = CollabHandler.handle(invocation).await else {
|
||||
panic!("invalid id should be rejected");
|
||||
};
|
||||
let FunctionCallError::RespondToModel(msg) = err else {
|
||||
panic!("expected respond-to-model error");
|
||||
};
|
||||
assert!(msg.starts_with("invalid agent id not-a-uuid:"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_input_reports_missing_agent() {
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
session.services.agent_control = manager.agent_control();
|
||||
let agent_id = ThreadId::new();
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"send_input",
|
||||
function_payload(json!({"id": agent_id.to_string(), "message": "hi"})),
|
||||
);
|
||||
let Err(err) = CollabHandler.handle(invocation).await else {
|
||||
panic!("missing agent should be reported");
|
||||
};
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel(format!("agent with id {agent_id} not found"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_rejects_non_positive_timeout() {
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"wait",
|
||||
function_payload(json!({"id": ThreadId::new().to_string(), "timeout_ms": 0})),
|
||||
);
|
||||
let Err(err) = CollabHandler.handle(invocation).await else {
|
||||
panic!("non-positive timeout should be rejected");
|
||||
};
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel("timeout_ms must be greater than zero".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_rejects_invalid_id() {
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"wait",
|
||||
function_payload(json!({"id": "invalid"})),
|
||||
);
|
||||
let Err(err) = CollabHandler.handle(invocation).await else {
|
||||
panic!("invalid id should be rejected");
|
||||
};
|
||||
let FunctionCallError::RespondToModel(msg) = err else {
|
||||
panic!("expected respond-to-model error");
|
||||
};
|
||||
assert!(msg.starts_with("invalid agent id invalid:"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_times_out_when_status_is_not_final() {
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
session.services.agent_control = manager.agent_control();
|
||||
let config = turn.client.config().as_ref().clone();
|
||||
let thread = manager.start_thread(config).await.expect("start thread");
|
||||
let agent_id = thread.thread_id;
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"wait",
|
||||
function_payload(json!({"id": agent_id.to_string(), "timeout_ms": 10})),
|
||||
);
|
||||
let output = CollabHandler
|
||||
.handle(invocation)
|
||||
.await
|
||||
.expect("wait should succeed");
|
||||
let ToolOutput::Function {
|
||||
content, success, ..
|
||||
} = output
|
||||
else {
|
||||
panic!("expected function output");
|
||||
};
|
||||
assert_eq!(content, r#"{"status":"pending_init","timed_out":true}"#);
|
||||
assert_eq!(success, Some(false));
|
||||
|
||||
let _ = thread
|
||||
.thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_returns_final_status_without_timeout() {
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
session.services.agent_control = manager.agent_control();
|
||||
let config = turn.client.config().as_ref().clone();
|
||||
let thread = manager.start_thread(config).await.expect("start thread");
|
||||
let agent_id = thread.thread_id;
|
||||
let mut status_rx = manager
|
||||
.agent_control()
|
||||
.subscribe_status(agent_id)
|
||||
.await
|
||||
.expect("subscribe should succeed");
|
||||
|
||||
let _ = thread
|
||||
.thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("shutdown should submit");
|
||||
let _ = timeout(Duration::from_secs(1), status_rx.changed())
|
||||
.await
|
||||
.expect("shutdown status should arrive");
|
||||
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"wait",
|
||||
function_payload(json!({"id": agent_id.to_string(), "timeout_ms": 1000})),
|
||||
);
|
||||
let output = CollabHandler
|
||||
.handle(invocation)
|
||||
.await
|
||||
.expect("wait should succeed");
|
||||
let ToolOutput::Function {
|
||||
content, success, ..
|
||||
} = output
|
||||
else {
|
||||
panic!("expected function output");
|
||||
};
|
||||
assert_eq!(content, r#"{"status":"shutdown","timed_out":false}"#);
|
||||
assert_eq!(success, Some(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_agent_submits_shutdown_and_returns_status() {
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
session.services.agent_control = manager.agent_control();
|
||||
let config = turn.client.config().as_ref().clone();
|
||||
let thread = manager.start_thread(config).await.expect("start thread");
|
||||
let agent_id = thread.thread_id;
|
||||
let status_before = manager.agent_control().get_status(agent_id).await;
|
||||
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"close_agent",
|
||||
function_payload(json!({"id": agent_id.to_string()})),
|
||||
);
|
||||
let output = CollabHandler
|
||||
.handle(invocation)
|
||||
.await
|
||||
.expect("close_agent should succeed");
|
||||
let ToolOutput::Function {
|
||||
content, success, ..
|
||||
} = output
|
||||
else {
|
||||
panic!("expected function output");
|
||||
};
|
||||
let result: close_agent::CloseAgentResult =
|
||||
serde_json::from_str(&content).expect("close_agent result should be json");
|
||||
assert_eq!(result.status, status_before);
|
||||
assert_eq!(success, Some(true));
|
||||
|
||||
let ops = manager.captured_ops();
|
||||
let submitted_shutdown = ops
|
||||
.iter()
|
||||
.any(|(id, op)| *id == agent_id && matches!(op, Op::Shutdown));
|
||||
assert_eq!(submitted_shutdown, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_agent_spawn_config_uses_turn_context_values() {
|
||||
let (_session, mut turn) = make_session_and_context().await;
|
||||
turn.developer_instructions = Some("dev".to_string());
|
||||
turn.base_instructions = Some("base".to_string());
|
||||
turn.compact_prompt = Some("compact".to_string());
|
||||
turn.user_instructions = Some("user".to_string());
|
||||
turn.shell_environment_policy = ShellEnvironmentPolicy {
|
||||
use_profile: true,
|
||||
..ShellEnvironmentPolicy::default()
|
||||
};
|
||||
let temp_dir = tempfile::tempdir().expect("temp dir");
|
||||
turn.cwd = temp_dir.path().to_path_buf();
|
||||
turn.codex_linux_sandbox_exe = Some(PathBuf::from("/bin/echo"));
|
||||
turn.approval_policy = AskForApproval::Never;
|
||||
turn.sandbox_policy = SandboxPolicy::DangerFullAccess;
|
||||
|
||||
let config = build_agent_spawn_config(&turn).expect("spawn config");
|
||||
let mut expected = (*turn.client.config()).clone();
|
||||
expected.model = Some(turn.client.get_model());
|
||||
expected.model_provider = turn.client.get_provider();
|
||||
expected.model_reasoning_effort = turn.client.get_reasoning_effort();
|
||||
expected.model_reasoning_summary = turn.client.get_reasoning_summary();
|
||||
expected.developer_instructions = turn.developer_instructions.clone();
|
||||
expected.base_instructions = turn.base_instructions.clone();
|
||||
expected.compact_prompt = turn.compact_prompt.clone();
|
||||
expected.user_instructions = turn.user_instructions.clone();
|
||||
expected.shell_environment_policy = turn.shell_environment_policy.clone();
|
||||
expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone();
|
||||
expected.cwd = turn.cwd.clone();
|
||||
expected
|
||||
.approval_policy
|
||||
.set(turn.approval_policy)
|
||||
.expect("approval policy set");
|
||||
expected
|
||||
.sandbox_policy
|
||||
.set(turn.sandbox_policy)
|
||||
.expect("sandbox policy set");
|
||||
assert_eq!(config, expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,10 +30,16 @@ pub trait ToolHandler: Send + Sync {
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns `true` if the [ToolInvocation] *might* mutate the environment of the
|
||||
/// user (through file system, OS operations, ...).
|
||||
/// This function must remains defensive and return `true` if a doubt exist on the
|
||||
/// exact effect of a ToolInvocation.
|
||||
async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Perform the actual [ToolInvocation] and returns a [ToolOutput] containing
|
||||
/// the final output to return to the model.
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError>;
|
||||
}
|
||||
|
||||
|
||||
@@ -75,34 +75,6 @@ impl From<SkillInstructions> for ResponseItem {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename = "developer_instructions", rename_all = "snake_case")]
|
||||
pub(crate) struct DeveloperInstructions {
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl DeveloperInstructions {
|
||||
pub fn new<T: Into<String>>(text: T) -> Self {
|
||||
Self { text: text.into() }
|
||||
}
|
||||
|
||||
pub fn into_text(self) -> String {
|
||||
self.text
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DeveloperInstructions> for ResponseItem {
|
||||
fn from(di: DeveloperInstructions) -> Self {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: di.into_text(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -88,7 +88,7 @@ async fn run_request(input: Vec<ResponseItem>) -> Value {
|
||||
SessionSource::Exec,
|
||||
);
|
||||
|
||||
let client = ModelClient::new(
|
||||
let mut client_session = ModelClient::new(
|
||||
Arc::clone(&config),
|
||||
None,
|
||||
model_info,
|
||||
@@ -98,12 +98,13 @@ async fn run_request(input: Vec<ResponseItem>) -> Value {
|
||||
summary,
|
||||
conversation_id,
|
||||
SessionSource::Exec,
|
||||
);
|
||||
)
|
||||
.new_session();
|
||||
|
||||
let mut prompt = Prompt::default();
|
||||
prompt.input = input;
|
||||
|
||||
let mut stream = match client.stream(&prompt).await {
|
||||
let mut stream = match client_session.stream(&prompt).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => panic!("stream chat failed: {e}"),
|
||||
};
|
||||
|
||||
@@ -89,7 +89,7 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec<ResponseEvent> {
|
||||
SessionSource::Exec,
|
||||
);
|
||||
|
||||
let client = ModelClient::new(
|
||||
let mut client = ModelClient::new(
|
||||
Arc::clone(&config),
|
||||
None,
|
||||
model_info,
|
||||
@@ -99,7 +99,8 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec<ResponseEvent> {
|
||||
summary,
|
||||
conversation_id,
|
||||
SessionSource::Exec,
|
||||
);
|
||||
)
|
||||
.new_session();
|
||||
|
||||
let mut prompt = Prompt::default();
|
||||
prompt.input = vec![ResponseItem::Message {
|
||||
|
||||
@@ -15,11 +15,13 @@ codex-core = { workspace = true, features = ["test-support"] }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
tokio = { workspace = true, features = ["net", "time"] }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
walkdir = { workspace = true }
|
||||
wiremock = { workspace = true }
|
||||
shlex = { workspace = true }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
@@ -5,7 +6,12 @@ use std::time::Duration;
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use futures::SinkExt;
|
||||
use futures::StreamExt;
|
||||
use serde_json::Value;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use wiremock::BodyPrintLimit;
|
||||
use wiremock::Match;
|
||||
use wiremock::Mock;
|
||||
@@ -199,6 +205,47 @@ impl ResponsesRequest {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WebSocketRequest {
|
||||
body: Value,
|
||||
}
|
||||
|
||||
impl WebSocketRequest {
|
||||
pub fn body_json(&self) -> Value {
|
||||
self.body.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WebSocketTestServer {
|
||||
uri: String,
|
||||
connections: Arc<Mutex<Vec<Vec<WebSocketRequest>>>>,
|
||||
shutdown: oneshot::Sender<()>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl WebSocketTestServer {
|
||||
pub fn uri(&self) -> &str {
|
||||
&self.uri
|
||||
}
|
||||
|
||||
pub fn connections(&self) -> Vec<Vec<WebSocketRequest>> {
|
||||
self.connections.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn single_connection(&self) -> Vec<WebSocketRequest> {
|
||||
let connections = self.connections.lock().unwrap();
|
||||
if connections.len() != 1 {
|
||||
panic!("expected 1 connection, got {}", connections.len());
|
||||
}
|
||||
connections.first().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn shutdown(self) {
|
||||
let _ = self.shutdown.send(());
|
||||
let _ = self.task.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelsMock {
|
||||
requests: Arc<Mutex<Vec<wiremock::Request>>>,
|
||||
@@ -724,6 +771,91 @@ pub async fn start_mock_server() -> MockServer {
|
||||
server
|
||||
}
|
||||
|
||||
/// Starts a lightweight WebSocket server for `/v1/responses` tests.
|
||||
///
|
||||
/// Each connection consumes a queue of request/event sequences. For each
|
||||
/// request message, the server records the payload and streams the matching
|
||||
/// events as WebSocket text frames before moving to the next request.
|
||||
pub async fn start_websocket_server(connections: Vec<Vec<Vec<Value>>>) -> WebSocketTestServer {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind websocket server");
|
||||
let addr = listener.local_addr().expect("websocket server address");
|
||||
let uri = format!("ws://{addr}");
|
||||
let connections_log = Arc::new(Mutex::new(Vec::new()));
|
||||
let requests = Arc::clone(&connections_log);
|
||||
let connections = Arc::new(Mutex::new(VecDeque::from(connections)));
|
||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
let accept_res = tokio::select! {
|
||||
_ = &mut shutdown_rx => return,
|
||||
accept_res = listener.accept() => accept_res,
|
||||
};
|
||||
let (stream, _) = match accept_res {
|
||||
Ok(value) => value,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut ws_stream = match tokio_tungstenite::accept_async(stream).await {
|
||||
Ok(ws) => ws,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let connection_requests = {
|
||||
let mut pending = connections.lock().unwrap();
|
||||
pending.pop_front()
|
||||
};
|
||||
|
||||
let Some(connection_requests) = connection_requests else {
|
||||
let _ = ws_stream.close(None).await;
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut connection_log = Vec::new();
|
||||
for request_events in connection_requests {
|
||||
let Some(Ok(message)) = ws_stream.next().await else {
|
||||
break;
|
||||
};
|
||||
if let Some(body) = parse_ws_request_body(message) {
|
||||
connection_log.push(WebSocketRequest { body });
|
||||
}
|
||||
|
||||
for event in &request_events {
|
||||
let Ok(payload) = serde_json::to_string(event) else {
|
||||
continue;
|
||||
};
|
||||
if ws_stream.send(Message::Text(payload)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requests.lock().unwrap().push(connection_log);
|
||||
let _ = ws_stream.close(None).await;
|
||||
|
||||
if connections.lock().unwrap().is_empty() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
WebSocketTestServer {
|
||||
uri,
|
||||
connections: connections_log,
|
||||
shutdown: shutdown_tx,
|
||||
task,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ws_request_body(message: Message) -> Option<Value> {
|
||||
match message {
|
||||
Message::Text(text) => serde_json::from_str(&text).ok(),
|
||||
Message::Binary(bytes) => serde_json::from_slice(&bytes).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FunctionCallResponseMocks {
|
||||
pub function_call: ResponseMock,
|
||||
|
||||
@@ -19,6 +19,7 @@ pub struct StreamingSseChunk {
|
||||
/// Minimal streaming SSE server for tests that need gated per-chunk delivery.
|
||||
pub struct StreamingSseServer {
|
||||
uri: String,
|
||||
requests: Arc<TokioMutex<Vec<Vec<u8>>>>,
|
||||
shutdown: oneshot::Sender<()>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
@@ -28,6 +29,10 @@ impl StreamingSseServer {
|
||||
&self.uri
|
||||
}
|
||||
|
||||
pub async fn requests(&self) -> Vec<Vec<u8>> {
|
||||
self.requests.lock().await.clone()
|
||||
}
|
||||
|
||||
pub async fn shutdown(self) {
|
||||
let _ = self.shutdown.send(());
|
||||
let _ = self.task.await;
|
||||
@@ -61,6 +66,8 @@ pub async fn start_streaming_sse_server(
|
||||
responses: VecDeque::from(responses),
|
||||
completions: VecDeque::from(completion_senders),
|
||||
}));
|
||||
let requests = Arc::new(TokioMutex::new(Vec::new()));
|
||||
let requests_for_task = Arc::clone(&requests);
|
||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
@@ -70,6 +77,7 @@ pub async fn start_streaming_sse_server(
|
||||
accept_res = listener.accept() => {
|
||||
let (mut stream, _) = accept_res.expect("accept streaming SSE connection");
|
||||
let state = Arc::clone(&state);
|
||||
let requests = Arc::clone(&requests_for_task);
|
||||
tokio::spawn(async move {
|
||||
let (request, body_prefix) = read_http_request(&mut stream).await;
|
||||
let Some((method, path)) = parse_request_line(&request) else {
|
||||
@@ -78,7 +86,7 @@ pub async fn start_streaming_sse_server(
|
||||
};
|
||||
|
||||
if method == "GET" && path == "/v1/models" {
|
||||
if drain_request_body(&mut stream, &request, body_prefix)
|
||||
if read_request_body(&mut stream, &request, body_prefix)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
@@ -95,13 +103,16 @@ pub async fn start_streaming_sse_server(
|
||||
}
|
||||
|
||||
if method == "POST" && path == "/v1/responses" {
|
||||
if drain_request_body(&mut stream, &request, body_prefix)
|
||||
let body = match read_request_body(&mut stream, &request, body_prefix)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = write_http_response(&mut stream, 400, "bad request", "text/plain").await;
|
||||
return;
|
||||
}
|
||||
Ok(body) => body,
|
||||
Err(_) => {
|
||||
let _ = write_http_response(&mut stream, 400, "bad request", "text/plain").await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
requests.lock().await.push(body);
|
||||
let Some((chunks, completion)) = take_next_stream(&state).await else {
|
||||
let _ = write_http_response(&mut stream, 500, "no responses queued", "text/plain").await;
|
||||
return;
|
||||
@@ -137,6 +148,7 @@ pub async fn start_streaming_sse_server(
|
||||
(
|
||||
StreamingSseServer {
|
||||
uri,
|
||||
requests,
|
||||
shutdown: shutdown_tx,
|
||||
task,
|
||||
},
|
||||
@@ -202,13 +214,13 @@ fn content_length(headers: &str) -> Option<usize> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn drain_request_body(
|
||||
async fn read_request_body(
|
||||
stream: &mut tokio::net::TcpStream,
|
||||
headers: &str,
|
||||
mut body_prefix: Vec<u8>,
|
||||
) -> std::io::Result<()> {
|
||||
) -> std::io::Result<Vec<u8>> {
|
||||
let Some(content_len) = content_length(headers) else {
|
||||
return Ok(());
|
||||
return Ok(body_prefix);
|
||||
};
|
||||
|
||||
if body_prefix.len() > content_len {
|
||||
@@ -217,12 +229,13 @@ async fn drain_request_body(
|
||||
|
||||
let remaining = content_len.saturating_sub(body_prefix.len());
|
||||
if remaining == 0 {
|
||||
return Ok(());
|
||||
return Ok(body_prefix);
|
||||
}
|
||||
|
||||
let mut rest = vec![0u8; remaining];
|
||||
stream.read_exact(&mut rest).await?;
|
||||
Ok(())
|
||||
body_prefix.extend_from_slice(&rest);
|
||||
Ok(body_prefix)
|
||||
}
|
||||
|
||||
async fn write_sse_headers(stream: &mut tokio::net::TcpStream) -> std::io::Result<()> {
|
||||
|
||||
@@ -81,7 +81,7 @@ async fn responses_stream_includes_subagent_header_on_review() {
|
||||
session_source.clone(),
|
||||
);
|
||||
|
||||
let client = ModelClient::new(
|
||||
let mut client_session = ModelClient::new(
|
||||
Arc::clone(&config),
|
||||
None,
|
||||
model_info,
|
||||
@@ -91,7 +91,8 @@ async fn responses_stream_includes_subagent_header_on_review() {
|
||||
summary,
|
||||
conversation_id,
|
||||
session_source,
|
||||
);
|
||||
)
|
||||
.new_session();
|
||||
|
||||
let mut prompt = Prompt::default();
|
||||
prompt.input = vec![ResponseItem::Message {
|
||||
@@ -102,7 +103,7 @@ async fn responses_stream_includes_subagent_header_on_review() {
|
||||
}],
|
||||
}];
|
||||
|
||||
let mut stream = client.stream(&prompt).await.expect("stream failed");
|
||||
let mut stream = client_session.stream(&prompt).await.expect("stream failed");
|
||||
while let Some(event) = stream.next().await {
|
||||
if matches!(event, Ok(ResponseEvent::Completed { .. })) {
|
||||
break;
|
||||
@@ -176,7 +177,7 @@ async fn responses_stream_includes_subagent_header_on_other() {
|
||||
session_source.clone(),
|
||||
);
|
||||
|
||||
let client = ModelClient::new(
|
||||
let mut client_session = ModelClient::new(
|
||||
Arc::clone(&config),
|
||||
None,
|
||||
model_info,
|
||||
@@ -186,7 +187,8 @@ async fn responses_stream_includes_subagent_header_on_other() {
|
||||
summary,
|
||||
conversation_id,
|
||||
session_source,
|
||||
);
|
||||
)
|
||||
.new_session();
|
||||
|
||||
let mut prompt = Prompt::default();
|
||||
prompt.input = vec![ResponseItem::Message {
|
||||
@@ -197,7 +199,7 @@ async fn responses_stream_includes_subagent_header_on_other() {
|
||||
}],
|
||||
}];
|
||||
|
||||
let mut stream = client.stream(&prompt).await.expect("stream failed");
|
||||
let mut stream = client_session.stream(&prompt).await.expect("stream failed");
|
||||
while let Some(event) = stream.next().await {
|
||||
if matches!(event, Ok(ResponseEvent::Completed { .. })) {
|
||||
break;
|
||||
@@ -269,7 +271,7 @@ async fn responses_respects_model_info_overrides_from_config() {
|
||||
session_source.clone(),
|
||||
);
|
||||
|
||||
let client = ModelClient::new(
|
||||
let mut client = ModelClient::new(
|
||||
Arc::clone(&config),
|
||||
None,
|
||||
model_info,
|
||||
@@ -279,7 +281,8 @@ async fn responses_respects_model_info_overrides_from_config() {
|
||||
summary,
|
||||
conversation_id,
|
||||
session_source,
|
||||
);
|
||||
)
|
||||
.new_session();
|
||||
|
||||
let mut prompt = Prompt::default();
|
||||
prompt.input = vec![ResponseItem::Message {
|
||||
|
||||
@@ -284,7 +284,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
|
||||
let expected_initial_json = json!([]);
|
||||
assert_eq!(initial_json, expected_initial_json);
|
||||
|
||||
// 2) Submit new input; the request body must include the prior item followed by the new user input.
|
||||
// 2) Submit new input; the request body must include the prior items, then initial context, then new user input.
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
@@ -298,24 +298,55 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
|
||||
|
||||
let request = resp_mock.single_request();
|
||||
let request_body = request.body_json();
|
||||
let expected_input = json!([
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{ "type": "input_text", "text": "resumed user message" }]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{ "type": "output_text", "text": "resumed assistant message" }]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{ "type": "input_text", "text": "hello" }]
|
||||
}
|
||||
]);
|
||||
assert_eq!(request_body["input"], expected_input);
|
||||
let input = request_body["input"].as_array().expect("input array");
|
||||
let messages: Vec<(String, String)> = input
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let role = item.get("role")?.as_str()?;
|
||||
let text = item
|
||||
.get("content")?
|
||||
.as_array()?
|
||||
.first()?
|
||||
.get("text")?
|
||||
.as_str()?;
|
||||
Some((role.to_string(), text.to_string()))
|
||||
})
|
||||
.collect();
|
||||
let pos_prior_user = messages
|
||||
.iter()
|
||||
.position(|(role, text)| role == "user" && text == "resumed user message")
|
||||
.expect("prior user message");
|
||||
let pos_prior_assistant = messages
|
||||
.iter()
|
||||
.position(|(role, text)| role == "assistant" && text == "resumed assistant message")
|
||||
.expect("prior assistant message");
|
||||
let pos_permissions = messages
|
||||
.iter()
|
||||
.position(|(role, text)| role == "developer" && text.contains("`approval_policy`"))
|
||||
.expect("permissions message");
|
||||
let pos_user_instructions = messages
|
||||
.iter()
|
||||
.position(|(role, text)| {
|
||||
role == "user"
|
||||
&& text.contains("be nice")
|
||||
&& (text.starts_with("# AGENTS.md instructions for ")
|
||||
|| text.starts_with("<user_instructions>"))
|
||||
})
|
||||
.expect("user instructions");
|
||||
let pos_environment = messages
|
||||
.iter()
|
||||
.position(|(role, text)| role == "user" && text.contains("<environment_context>"))
|
||||
.expect("environment context");
|
||||
let pos_new_user = messages
|
||||
.iter()
|
||||
.position(|(role, text)| role == "user" && text == "hello")
|
||||
.expect("new user message");
|
||||
|
||||
assert!(pos_prior_user < pos_prior_assistant);
|
||||
assert!(pos_prior_assistant < pos_permissions);
|
||||
assert!(pos_permissions < pos_user_instructions);
|
||||
assert!(pos_user_instructions < pos_environment);
|
||||
assert!(pos_environment < pos_new_user);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -344,7 +375,7 @@ async fn includes_conversation_id_and_model_headers_in_request() {
|
||||
);
|
||||
let NewThread {
|
||||
thread: codex,
|
||||
thread_id: conversation_id,
|
||||
thread_id: session_id,
|
||||
session_configured: _,
|
||||
..
|
||||
} = thread_manager
|
||||
@@ -366,15 +397,13 @@ async fn includes_conversation_id_and_model_headers_in_request() {
|
||||
|
||||
let request = resp_mock.single_request();
|
||||
assert_eq!(request.path(), "/v1/responses");
|
||||
let request_conversation_id = request
|
||||
.header("conversation_id")
|
||||
.expect("conversation_id header");
|
||||
let request_session_id = request.header("session_id").expect("session_id header");
|
||||
let request_authorization = request
|
||||
.header("authorization")
|
||||
.expect("authorization header");
|
||||
let request_originator = request.header("originator").expect("originator header");
|
||||
|
||||
assert_eq!(request_conversation_id, conversation_id.to_string());
|
||||
assert_eq!(request_session_id, session_id.to_string());
|
||||
assert_eq!(request_originator, "codex_cli_rs");
|
||||
assert_eq!(request_authorization, "Bearer Test API Key");
|
||||
}
|
||||
@@ -455,7 +484,7 @@ async fn chatgpt_auth_sends_correct_request() {
|
||||
);
|
||||
let NewThread {
|
||||
thread: codex,
|
||||
thread_id: conversation_id,
|
||||
thread_id,
|
||||
session_configured: _,
|
||||
..
|
||||
} = thread_manager
|
||||
@@ -477,9 +506,6 @@ async fn chatgpt_auth_sends_correct_request() {
|
||||
|
||||
let request = resp_mock.single_request();
|
||||
assert_eq!(request.path(), "/api/codex/responses");
|
||||
let request_conversation_id = request
|
||||
.header("conversation_id")
|
||||
.expect("conversation_id header");
|
||||
let request_authorization = request
|
||||
.header("authorization")
|
||||
.expect("authorization header");
|
||||
@@ -489,7 +515,9 @@ async fn chatgpt_auth_sends_correct_request() {
|
||||
.expect("chatgpt-account-id header");
|
||||
let request_body = request.body_json();
|
||||
|
||||
assert_eq!(request_conversation_id, conversation_id.to_string());
|
||||
let session_id = request.header("session_id").expect("session_id header");
|
||||
assert_eq!(session_id, thread_id.to_string());
|
||||
|
||||
assert_eq!(request_originator, "codex_cli_rs");
|
||||
assert_eq!(request_authorization, "Bearer Access Token");
|
||||
assert_eq!(request_chatgpt_account_id, "account_id");
|
||||
@@ -618,17 +646,26 @@ async fn includes_user_instructions_message_in_request() {
|
||||
.unwrap()
|
||||
.contains("be nice")
|
||||
);
|
||||
assert_message_role(&request_body["input"][0], "user");
|
||||
assert_message_starts_with(&request_body["input"][0], "# AGENTS.md instructions for ");
|
||||
assert_message_ends_with(&request_body["input"][0], "</INSTRUCTIONS>");
|
||||
let ui_text = request_body["input"][0]["content"][0]["text"]
|
||||
assert_message_role(&request_body["input"][0], "developer");
|
||||
let permissions_text = request_body["input"][0]["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("invalid permissions message content");
|
||||
assert!(
|
||||
permissions_text.contains("`sandbox_mode`"),
|
||||
"expected permissions message to mention sandbox_mode, got {permissions_text:?}"
|
||||
);
|
||||
|
||||
assert_message_role(&request_body["input"][1], "user");
|
||||
assert_message_starts_with(&request_body["input"][1], "# AGENTS.md instructions for ");
|
||||
assert_message_ends_with(&request_body["input"][1], "</INSTRUCTIONS>");
|
||||
let ui_text = request_body["input"][1]["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("invalid message content");
|
||||
assert!(ui_text.contains("<INSTRUCTIONS>"));
|
||||
assert!(ui_text.contains("be nice"));
|
||||
assert_message_role(&request_body["input"][1], "user");
|
||||
assert_message_starts_with(&request_body["input"][1], "<environment_context>");
|
||||
assert_message_ends_with(&request_body["input"][1], "</environment_context>");
|
||||
assert_message_role(&request_body["input"][2], "user");
|
||||
assert_message_starts_with(&request_body["input"][2], "<environment_context>");
|
||||
assert_message_ends_with(&request_body["input"][2], "</environment_context>");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -682,8 +719,10 @@ async fn skills_append_to_instructions() {
|
||||
let request = resp_mock.single_request();
|
||||
let request_body = request.body_json();
|
||||
|
||||
assert_message_role(&request_body["input"][0], "user");
|
||||
let instructions_text = request_body["input"][0]["content"][0]["text"]
|
||||
assert_message_role(&request_body["input"][0], "developer");
|
||||
|
||||
assert_message_role(&request_body["input"][1], "user");
|
||||
let instructions_text = request_body["input"][1]["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("instructions text");
|
||||
assert!(
|
||||
@@ -1049,6 +1088,10 @@ async fn includes_developer_instructions_message_in_request() {
|
||||
let request = resp_mock.single_request();
|
||||
let request_body = request.body_json();
|
||||
|
||||
let permissions_text = request_body["input"][0]["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("invalid permissions message content");
|
||||
|
||||
assert!(
|
||||
!request_body["instructions"]
|
||||
.as_str()
|
||||
@@ -1056,18 +1099,24 @@ async fn includes_developer_instructions_message_in_request() {
|
||||
.contains("be nice")
|
||||
);
|
||||
assert_message_role(&request_body["input"][0], "developer");
|
||||
assert_message_equals(&request_body["input"][0], "be useful");
|
||||
assert_message_role(&request_body["input"][1], "user");
|
||||
assert_message_starts_with(&request_body["input"][1], "# AGENTS.md instructions for ");
|
||||
assert_message_ends_with(&request_body["input"][1], "</INSTRUCTIONS>");
|
||||
let ui_text = request_body["input"][1]["content"][0]["text"]
|
||||
assert!(
|
||||
permissions_text.contains("`sandbox_mode`"),
|
||||
"expected permissions message to mention sandbox_mode, got {permissions_text:?}"
|
||||
);
|
||||
|
||||
assert_message_role(&request_body["input"][1], "developer");
|
||||
assert_message_equals(&request_body["input"][1], "be useful");
|
||||
assert_message_role(&request_body["input"][2], "user");
|
||||
assert_message_starts_with(&request_body["input"][2], "# AGENTS.md instructions for ");
|
||||
assert_message_ends_with(&request_body["input"][2], "</INSTRUCTIONS>");
|
||||
let ui_text = request_body["input"][2]["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("invalid message content");
|
||||
assert!(ui_text.contains("<INSTRUCTIONS>"));
|
||||
assert!(ui_text.contains("be nice"));
|
||||
assert_message_role(&request_body["input"][2], "user");
|
||||
assert_message_starts_with(&request_body["input"][2], "<environment_context>");
|
||||
assert_message_ends_with(&request_body["input"][2], "</environment_context>");
|
||||
assert_message_role(&request_body["input"][3], "user");
|
||||
assert_message_starts_with(&request_body["input"][3], "<environment_context>");
|
||||
assert_message_ends_with(&request_body["input"][3], "</environment_context>");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -1122,7 +1171,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
|
||||
SessionSource::Exec,
|
||||
);
|
||||
|
||||
let client = ModelClient::new(
|
||||
let mut client = ModelClient::new(
|
||||
Arc::clone(&config),
|
||||
None,
|
||||
model_info,
|
||||
@@ -1132,7 +1181,8 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
|
||||
summary,
|
||||
conversation_id,
|
||||
SessionSource::Exec,
|
||||
);
|
||||
)
|
||||
.new_session();
|
||||
|
||||
let mut prompt = Prompt::default();
|
||||
prompt.input.push(ResponseItem::Reasoning {
|
||||
|
||||
@@ -604,8 +604,14 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() {
|
||||
.and_then(|item| item.get("text"))
|
||||
.and_then(|text| text.as_str());
|
||||
|
||||
// Ignore the cached UI prefix (project docs + skills) since it is not relevant to
|
||||
// compaction behavior and can change as bundled skills evolve.
|
||||
// Ignore cached prefix messages (project docs + permissions) since they are not
|
||||
// relevant to compaction behavior and can change as bundled prompts evolve.
|
||||
let role = value.get("role").and_then(|role| role.as_str());
|
||||
if role == Some("developer")
|
||||
&& text.is_some_and(|text| text.contains("`sandbox_mode`"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
!text.is_some_and(|text| text.starts_with("# AGENTS.md instructions for "))
|
||||
})
|
||||
.cloned()
|
||||
@@ -1726,9 +1732,11 @@ async fn manual_compact_twice_preserves_latest_user_messages() {
|
||||
.into_iter()
|
||||
.collect::<VecDeque<_>>();
|
||||
|
||||
// System prompt
|
||||
// Permissions developer message
|
||||
final_output.pop_front();
|
||||
// Developer instructions
|
||||
// User instructions (project docs/skills)
|
||||
final_output.pop_front();
|
||||
// Environment context
|
||||
final_output.pop_front();
|
||||
|
||||
let _ = final_output
|
||||
|
||||
@@ -216,11 +216,12 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let user_instructions = requests[0]["input"][0]["content"][0]["text"]
|
||||
let permissions_message = requests[0]["input"][0].clone();
|
||||
let user_instructions = requests[0]["input"][1]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let environment_context = requests[0]["input"][1]["content"][0]["text"]
|
||||
let environment_context = requests[0]["input"][2]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
@@ -241,6 +242,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
|
||||
"model": expected_model,
|
||||
"instructions": prompt,
|
||||
"input": [
|
||||
permissions_message,
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
@@ -290,6 +292,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
|
||||
"model": expected_model,
|
||||
"instructions": prompt,
|
||||
"input": [
|
||||
permissions_message,
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
@@ -359,6 +362,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
|
||||
"model": expected_model,
|
||||
"instructions": prompt,
|
||||
"input": [
|
||||
permissions_message,
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
@@ -419,6 +423,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
|
||||
"model": expected_model,
|
||||
"instructions": prompt,
|
||||
"input": [
|
||||
permissions_message,
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
@@ -470,6 +475,27 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
|
||||
}
|
||||
]
|
||||
},
|
||||
permissions_message,
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": user_instructions
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": environment_context
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
@@ -499,6 +525,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
|
||||
"model": expected_model,
|
||||
"instructions": prompt,
|
||||
"input": [
|
||||
permissions_message,
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
@@ -550,6 +577,48 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
|
||||
}
|
||||
]
|
||||
},
|
||||
permissions_message,
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": user_instructions
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": environment_context
|
||||
}
|
||||
]
|
||||
},
|
||||
permissions_message,
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": user_instructions
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": environment_context
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
@@ -664,11 +733,12 @@ async fn compact_resume_after_second_compaction_preserves_history() {
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let user_instructions = requests[0]["input"][0]["content"][0]["text"]
|
||||
let permissions_message = requests[0]["input"][0].clone();
|
||||
let user_instructions = requests[0]["input"][1]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let environment_instructions = requests[0]["input"][1]["content"][0]["text"]
|
||||
let environment_instructions = requests[0]["input"][2]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
@@ -682,6 +752,7 @@ async fn compact_resume_after_second_compaction_preserves_history() {
|
||||
{
|
||||
"instructions": prompt,
|
||||
"input": [
|
||||
permissions_message,
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
@@ -723,6 +794,27 @@ async fn compact_resume_after_second_compaction_preserves_history() {
|
||||
}
|
||||
]
|
||||
},
|
||||
permissions_message,
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": user_instructions
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": environment_instructions
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
|
||||
@@ -138,8 +138,9 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
|
||||
// GetHistory on fork1 flushed; the file is ready.
|
||||
let fork1_items = read_items(&fork1_path);
|
||||
assert!(fork1_items.len() > expected_after_first.len());
|
||||
pretty_assertions::assert_eq!(
|
||||
serde_json::to_value(&fork1_items).unwrap(),
|
||||
serde_json::to_value(&fork1_items[..expected_after_first.len()]).unwrap(),
|
||||
serde_json::to_value(&expected_after_first).unwrap()
|
||||
);
|
||||
|
||||
@@ -162,8 +163,9 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
.unwrap_or(0);
|
||||
let expected_after_second: Vec<RolloutItem> = fork1_items[..cut_last_on_fork1].to_vec();
|
||||
let fork2_items = read_items(&fork2_path);
|
||||
assert!(fork2_items.len() > expected_after_second.len());
|
||||
pretty_assertions::assert_eq!(
|
||||
serde_json::to_value(&fork2_items).unwrap(),
|
||||
serde_json::to_value(&fork2_items[..expected_after_second.len()]).unwrap(),
|
||||
serde_json::to_value(&expected_after_second).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
use anyhow::Context;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::RolloutItem;
|
||||
use codex_core::protocol::RolloutLine;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use image::ImageBuffer;
|
||||
use image::Rgba;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
fn find_user_message_with_image(text: &str) -> Option<ResponseItem> {
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let rollout: RolloutLine = match serde_json::from_str(trimmed) {
|
||||
Ok(rollout) => rollout,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) =
|
||||
&rollout.item
|
||||
&& role == "user"
|
||||
&& content
|
||||
.iter()
|
||||
.any(|span| matches!(span, ContentItem::InputImage { .. }))
|
||||
&& let RolloutItem::ResponseItem(item) = rollout.item.clone()
|
||||
{
|
||||
return Some(item);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_image_url(item: &ResponseItem) -> Option<String> {
|
||||
match item {
|
||||
ResponseItem::Message { content, .. } => content.iter().find_map(|span| match span {
|
||||
ContentItem::InputImage { image_url } => Some(image_url.clone()),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_rollout_text(path: &Path) -> anyhow::Result<String> {
|
||||
for _ in 0..50 {
|
||||
if path.exists()
|
||||
&& let Ok(text) = std::fs::read_to_string(path)
|
||||
&& !text.trim().is_empty()
|
||||
{
|
||||
return Ok(text);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
std::fs::read_to_string(path)
|
||||
.with_context(|| format!("read rollout file at {}", path.display()))
|
||||
}
|
||||
|
||||
fn write_test_png(path: &Path, color: [u8; 4]) -> anyhow::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let image = ImageBuffer::from_pixel(2, 2, Rgba(color));
|
||||
image.save(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let TestCodex {
|
||||
codex,
|
||||
cwd,
|
||||
session_configured,
|
||||
home: _home,
|
||||
..
|
||||
} = test_codex().build(&server).await?;
|
||||
|
||||
let rel_path = "images/paste.png";
|
||||
let abs_path = cwd.path().join(rel_path);
|
||||
write_test_png(&abs_path, [12, 34, 56, 255])?;
|
||||
|
||||
let response = sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-1"),
|
||||
]);
|
||||
responses::mount_sse_once(&server, response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![
|
||||
UserInput::LocalImage {
|
||||
path: abs_path.clone(),
|
||||
},
|
||||
UserInput::Text {
|
||||
text: "pasted image".to_string(),
|
||||
},
|
||||
],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||
codex.submit(Op::Shutdown).await?;
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::ShutdownComplete)).await;
|
||||
|
||||
let rollout_path = codex.rollout_path();
|
||||
let rollout_text = read_rollout_text(&rollout_path).await?;
|
||||
let actual = find_user_message_with_image(&rollout_text)
|
||||
.expect("expected user message with input image in rollout");
|
||||
|
||||
let image_url = extract_image_url(&actual).expect("expected image url in rollout");
|
||||
let expected = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentItem::InputText {
|
||||
text: codex_protocol::models::local_image_open_tag_text(1),
|
||||
},
|
||||
ContentItem::InputImage { image_url },
|
||||
ContentItem::InputText {
|
||||
text: codex_protocol::models::image_close_tag_text(),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text: "pasted image".to_string(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(actual, expected);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let TestCodex {
|
||||
codex,
|
||||
cwd,
|
||||
session_configured,
|
||||
home: _home,
|
||||
..
|
||||
} = test_codex().build(&server).await?;
|
||||
|
||||
let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=".to_string();
|
||||
|
||||
let response = sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-1"),
|
||||
]);
|
||||
responses::mount_sse_once(&server, response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![
|
||||
UserInput::Image {
|
||||
image_url: image_url.clone(),
|
||||
},
|
||||
UserInput::Text {
|
||||
text: "dropped image".to_string(),
|
||||
},
|
||||
],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||
codex.submit(Op::Shutdown).await?;
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::ShutdownComplete)).await;
|
||||
|
||||
let rollout_path = codex.rollout_path();
|
||||
let rollout_text = read_rollout_text(&rollout_path).await?;
|
||||
let actual = find_user_message_with_image(&rollout_text)
|
||||
.expect("expected user message with input image in rollout");
|
||||
|
||||
let image_url = extract_image_url(&actual).expect("expected image url in rollout");
|
||||
let expected = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentItem::InputText {
|
||||
text: codex_protocol::models::image_open_tag_text(),
|
||||
},
|
||||
ContentItem::InputImage { image_url },
|
||||
ContentItem::InputText {
|
||||
text: codex_protocol::models::image_close_tag_text(),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text: "dropped image".to_string(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(actual, expected);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use indoc::indoc;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -410,6 +411,16 @@ fn gpt52_codex_upgrade() -> codex_protocol::openai_models::ModelUpgrade {
|
||||
"Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work."
|
||||
.to_string(),
|
||||
),
|
||||
migration_markdown: Some(
|
||||
indoc! {r#"
|
||||
**Codex just got an upgrade. Introducing {model_to}.**
|
||||
|
||||
Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work. Learn more about {model_to} at https://openai.com/index/introducing-gpt-5-2-codex
|
||||
|
||||
You can continue using {model_from} if you prefer.
|
||||
"#}
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ mod exec_policy;
|
||||
mod fork_thread;
|
||||
mod grep_files;
|
||||
mod hierarchical_agents;
|
||||
mod image_rollout;
|
||||
mod items;
|
||||
mod json_result;
|
||||
mod list_dir;
|
||||
@@ -41,6 +42,8 @@ mod model_overrides;
|
||||
mod model_tools;
|
||||
mod models_etag_responses;
|
||||
mod otel;
|
||||
mod pending_input;
|
||||
mod permissions_messages;
|
||||
mod prompt_caching;
|
||||
mod quota_exceeded;
|
||||
mod read_file;
|
||||
@@ -69,3 +72,4 @@ mod user_notification;
|
||||
mod user_shell_cmd;
|
||||
mod view_image;
|
||||
mod web_search_cached;
|
||||
mod websocket;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_message_item_added;
|
||||
use core_test_support::responses::ev_output_text_delta;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::streaming_sse::StreamingSseChunk;
|
||||
use core_test_support::streaming_sse::start_streaming_sse_server;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
fn ev_message_item_done(id: &str, text: &str) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"id": id,
|
||||
"content": [{"type": "output_text", "text": text}]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn sse_event(event: Value) -> String {
|
||||
responses::sse(vec![event])
|
||||
}
|
||||
|
||||
fn message_input_texts(body: &Value, role: &str) -> Vec<String> {
|
||||
body.get("input")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|item| item.get("type").and_then(Value::as_str) == Some("message"))
|
||||
.filter(|item| item.get("role").and_then(Value::as_str) == Some(role))
|
||||
.filter_map(|item| item.get("content").and_then(Value::as_array))
|
||||
.flatten()
|
||||
.filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text"))
|
||||
.filter_map(|span| span.get("text").and_then(Value::as_str).map(str::to_owned))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn injected_user_input_triggers_follow_up_request_with_deltas() {
|
||||
let (gate_completed_tx, gate_completed_rx) = oneshot::channel();
|
||||
|
||||
let first_chunks = vec![
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse_event(ev_response_created("resp-1")),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse_event(ev_message_item_added("msg-1", "")),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse_event(ev_output_text_delta("first ")),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse_event(ev_output_text_delta("turn")),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse_event(ev_message_item_done("msg-1", "first turn")),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: Some(gate_completed_rx),
|
||||
body: sse_event(ev_completed("resp-1")),
|
||||
},
|
||||
];
|
||||
|
||||
let second_chunks = vec![
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse_event(ev_response_created("resp-2")),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse_event(ev_completed("resp-2")),
|
||||
},
|
||||
];
|
||||
|
||||
let (server, _completions) =
|
||||
start_streaming_sse_server(vec![first_chunks, second_chunks]).await;
|
||||
|
||||
let codex = test_codex()
|
||||
.with_model("gpt-5.1")
|
||||
.build_with_streaming_server(&server)
|
||||
.await
|
||||
.unwrap()
|
||||
.codex;
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "first prompt".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_event(&codex, |event| {
|
||||
matches!(event, EventMsg::AgentMessageContentDelta(_))
|
||||
})
|
||||
.await;
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "second prompt".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _ = gate_completed_tx.send(());
|
||||
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let requests = server.requests().await;
|
||||
assert_eq!(requests.len(), 2);
|
||||
|
||||
let first_body: Value = serde_json::from_slice(&requests[0]).expect("parse first request");
|
||||
let second_body: Value = serde_json::from_slice(&requests[1]).expect("parse second request");
|
||||
|
||||
let first_texts = message_input_texts(&first_body, "user");
|
||||
assert!(first_texts.iter().any(|text| text == "first prompt"));
|
||||
assert!(!first_texts.iter().any(|text| text == "second prompt"));
|
||||
|
||||
let second_texts = message_input_texts(&second_body, "user");
|
||||
assert!(second_texts.iter().any(|text| text == "first prompt"));
|
||||
assert!(second_texts.iter().any(|text| text == "second prompt"));
|
||||
|
||||
server.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
use anyhow::Result;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashSet;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn permissions_texts(input: &[serde_json::Value]) -> Vec<String> {
|
||||
input
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let role = item.get("role")?.as_str()?;
|
||||
if role != "developer" {
|
||||
return None;
|
||||
}
|
||||
let text = item
|
||||
.get("content")?
|
||||
.as_array()?
|
||||
.first()?
|
||||
.get("text")?
|
||||
.as_str()?;
|
||||
if text.contains("`approval_policy`") {
|
||||
Some(text.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sse_completed(id: &str) -> String {
|
||||
sse(vec![ev_response_created(id), ev_completed(id)])
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn permissions_message_sent_once_on_start() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let req = mount_sse_once(&server, sse_completed("resp-1")).await;
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let request = req.single_request();
|
||||
let body = request.body_json();
|
||||
let input = body["input"].as_array().expect("input array");
|
||||
let permissions = permissions_texts(input);
|
||||
assert_eq!(permissions.len(), 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn permissions_message_added_on_override_change() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let req1 = mount_sse_once(&server, sse_completed("resp-1")).await;
|
||||
let req2 = mount_sse_once(&server, sse_completed("resp-2")).await;
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
test.codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
sandbox_policy: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let body1 = req1.single_request().body_json();
|
||||
let body2 = req2.single_request().body_json();
|
||||
let input1 = body1["input"].as_array().expect("input array");
|
||||
let input2 = body2["input"].as_array().expect("input array");
|
||||
let permissions_1 = permissions_texts(input1);
|
||||
let permissions_2 = permissions_texts(input2);
|
||||
|
||||
assert_eq!(permissions_1.len(), 1);
|
||||
assert_eq!(permissions_2.len(), 2);
|
||||
let unique = permissions_2.into_iter().collect::<HashSet<String>>();
|
||||
assert_eq!(unique.len(), 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn permissions_message_not_added_when_no_change() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let req1 = mount_sse_once(&server, sse_completed("resp-1")).await;
|
||||
let req2 = mount_sse_once(&server, sse_completed("resp-2")).await;
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let body1 = req1.single_request().body_json();
|
||||
let body2 = req2.single_request().body_json();
|
||||
let input1 = body1["input"].as_array().expect("input array");
|
||||
let input2 = body2["input"].as_array().expect("input array");
|
||||
let permissions_1 = permissions_texts(input1);
|
||||
let permissions_2 = permissions_texts(input2);
|
||||
|
||||
assert_eq!(permissions_1.len(), 1);
|
||||
assert_eq!(permissions_2.len(), 1);
|
||||
assert_eq!(permissions_1, permissions_2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn resume_replays_permissions_messages() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let _req1 = mount_sse_once(&server, sse_completed("resp-1")).await;
|
||||
let _req2 = mount_sse_once(&server, sse_completed("resp-2")).await;
|
||||
let req3 = mount_sse_once(&server, sse_completed("resp-3")).await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
});
|
||||
let initial = builder.build(&server).await?;
|
||||
let rollout_path = initial.session_configured.rollout_path.clone();
|
||||
let home = initial.home.clone();
|
||||
|
||||
initial
|
||||
.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
initial
|
||||
.codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
sandbox_policy: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
initial
|
||||
.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let resumed = builder.resume(&server, home, rollout_path).await?;
|
||||
resumed
|
||||
.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "after resume".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let body3 = req3.single_request().body_json();
|
||||
let input = body3["input"].as_array().expect("input array");
|
||||
let permissions = permissions_texts(input);
|
||||
assert_eq!(permissions.len(), 3);
|
||||
let unique = permissions.into_iter().collect::<HashSet<String>>();
|
||||
assert_eq!(unique.len(), 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn resume_and_fork_append_permissions_messages() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let _req1 = mount_sse_once(&server, sse_completed("resp-1")).await;
|
||||
let req2 = mount_sse_once(&server, sse_completed("resp-2")).await;
|
||||
let req3 = mount_sse_once(&server, sse_completed("resp-3")).await;
|
||||
let req4 = mount_sse_once(&server, sse_completed("resp-4")).await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
});
|
||||
let initial = builder.build(&server).await?;
|
||||
let rollout_path = initial.session_configured.rollout_path.clone();
|
||||
let home = initial.home.clone();
|
||||
|
||||
initial
|
||||
.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
initial
|
||||
.codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
sandbox_policy: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
initial
|
||||
.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let body2 = req2.single_request().body_json();
|
||||
let input2 = body2["input"].as_array().expect("input array");
|
||||
let permissions_base = permissions_texts(input2);
|
||||
assert_eq!(permissions_base.len(), 2);
|
||||
|
||||
builder = builder.with_config(|config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
});
|
||||
let resumed = builder.resume(&server, home, rollout_path.clone()).await?;
|
||||
resumed
|
||||
.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "after resume".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let body3 = req3.single_request().body_json();
|
||||
let input3 = body3["input"].as_array().expect("input array");
|
||||
let permissions_resume = permissions_texts(input3);
|
||||
assert_eq!(permissions_resume.len(), permissions_base.len() + 1);
|
||||
assert_eq!(
|
||||
&permissions_resume[..permissions_base.len()],
|
||||
permissions_base.as_slice()
|
||||
);
|
||||
assert!(!permissions_base.contains(permissions_resume.last().expect("new permissions")));
|
||||
|
||||
let mut fork_config = initial.config.clone();
|
||||
fork_config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
let forked = initial
|
||||
.thread_manager
|
||||
.fork_thread(usize::MAX, fork_config, rollout_path)
|
||||
.await?;
|
||||
forked
|
||||
.thread
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "after fork".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let body4 = req4.single_request().body_json();
|
||||
let input4 = body4["input"].as_array().expect("input array");
|
||||
let permissions_fork = permissions_texts(input4);
|
||||
assert_eq!(permissions_fork.len(), permissions_base.len() + 2);
|
||||
assert_eq!(
|
||||
&permissions_fork[..permissions_base.len()],
|
||||
permissions_base.as_slice()
|
||||
);
|
||||
let new_permissions = &permissions_fork[permissions_base.len()..];
|
||||
assert_eq!(new_permissions.len(), 2);
|
||||
assert_eq!(new_permissions[0], new_permissions[1]);
|
||||
assert!(!permissions_base.contains(&new_permissions[0]));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn permissions_message_includes_writable_roots() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let req = mount_sse_once(&server, sse_completed("resp-1")).await;
|
||||
let writable = TempDir::new()?;
|
||||
let writable_root = AbsolutePathBuf::try_from(writable.path())?;
|
||||
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![writable_root],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
};
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.sandbox_policy = Constrained::allow_any(sandbox_policy);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let body = req.single_request().body_json();
|
||||
let input = body["input"].as_array().expect("input array");
|
||||
let permissions = permissions_texts(input);
|
||||
let sandbox_text = "Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. Network access is restricted.";
|
||||
let approval_text = " Approvals are your mechanism to get user consent to run shell commands without the sandbox. `approval_policy` is `on-request`: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task.\n\nHere are scenarios where you'll need to request approval:\n- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)\n- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.\n- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)\n- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.\n- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for.\n\nWhen requesting approval to execute a command that will require escalated privileges:\n - Provide the `sandbox_permissions` parameter with the value `\"require_escalated\"`\n - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter";
|
||||
// Normalize paths by removing trailing slashes to match AbsolutePathBuf behavior
|
||||
let normalize_path =
|
||||
|p: &std::path::Path| -> String { p.to_string_lossy().trim_end_matches('/').to_string() };
|
||||
let mut roots = vec![
|
||||
normalize_path(writable.path()),
|
||||
normalize_path(test.config.cwd.as_path()),
|
||||
];
|
||||
if cfg!(unix) && std::path::Path::new("/tmp").is_dir() {
|
||||
roots.push("/tmp".to_string());
|
||||
}
|
||||
if let Some(tmpdir) = std::env::var_os("TMPDIR") {
|
||||
let tmpdir_path = std::path::PathBuf::from(&tmpdir);
|
||||
if tmpdir_path.is_absolute() && !tmpdir.is_empty() {
|
||||
roots.push(normalize_path(&tmpdir_path));
|
||||
}
|
||||
}
|
||||
let roots_text = if roots.len() == 1 {
|
||||
format!(" The writable root is `{}`.", roots[0])
|
||||
} else {
|
||||
format!(
|
||||
" The writable roots are {}.",
|
||||
roots
|
||||
.iter()
|
||||
.map(|root| format!("`{root}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
};
|
||||
let expected = format!(
|
||||
"<permissions instructions>{sandbox_text}{approval_text}{roots_text}</permissions instructions>"
|
||||
);
|
||||
// Normalize line endings to handle Windows vs Unix differences
|
||||
let normalize_line_endings = |s: &str| s.replace("\r\n", "\n");
|
||||
let expected_normalized = normalize_line_endings(&expected);
|
||||
let actual_normalized: Vec<String> = permissions
|
||||
.iter()
|
||||
.map(|s| normalize_line_endings(s))
|
||||
.collect();
|
||||
assert_eq!(actual_normalized, vec![expected_normalized]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -36,9 +36,6 @@ fn default_env_context_str(cwd: &str, shell: &Shell) -> String {
|
||||
format!(
|
||||
r#"<environment_context>
|
||||
<cwd>{cwd}</cwd>
|
||||
<approval_policy>on-request</approval_policy>
|
||||
<sandbox_mode>read-only</sandbox_mode>
|
||||
<network_access>restricted</network_access>
|
||||
<shell>{shell_name}</shell>
|
||||
</environment_context>"#
|
||||
)
|
||||
@@ -252,9 +249,13 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
|
||||
|
||||
let body1 = req1.single_request().body_json();
|
||||
let input1 = body1["input"].as_array().expect("input array");
|
||||
assert_eq!(input1.len(), 3, "expected cached prefix + env + user msg");
|
||||
assert_eq!(
|
||||
input1.len(),
|
||||
4,
|
||||
"expected permissions + cached prefix + env + user msg"
|
||||
);
|
||||
|
||||
let ui_text = input1[0]["content"][0]["text"]
|
||||
let ui_text = input1[1]["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("ui message text");
|
||||
assert!(
|
||||
@@ -266,11 +267,11 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
|
||||
let cwd_str = config.cwd.to_string_lossy();
|
||||
let expected_env_text = default_env_context_str(&cwd_str, &shell);
|
||||
assert_eq!(
|
||||
input1[1],
|
||||
input1[2],
|
||||
text_user_input(expected_env_text),
|
||||
"expected environment context after UI message"
|
||||
);
|
||||
assert_eq!(input1[2], text_user_input("hello 1".to_string()));
|
||||
assert_eq!(input1[3], text_user_input("hello 1".to_string()));
|
||||
|
||||
let body2 = req2.single_request().body_json();
|
||||
let input2 = body2["input"].as_array().expect("input array");
|
||||
@@ -312,16 +313,17 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let writable = TempDir::new().unwrap();
|
||||
let new_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![writable.path().try_into().unwrap()],
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
sandbox_policy: Some(SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![writable.path().try_into().unwrap()],
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
}),
|
||||
sandbox_policy: Some(new_policy.clone()),
|
||||
model: Some("o3".to_string()),
|
||||
effort: Some(Some(ReasoningEffort::High)),
|
||||
summary: Some(ReasoningSummary::Detailed),
|
||||
@@ -354,36 +356,18 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": "hello 2" } ]
|
||||
});
|
||||
// After overriding the turn context, the environment context should be emitted again
|
||||
// reflecting the new approval policy and sandbox settings. Omit cwd because it did
|
||||
// not change.
|
||||
let shell = default_user_shell();
|
||||
let expected_env_text_2 = format!(
|
||||
r#"<environment_context>
|
||||
<approval_policy>never</approval_policy>
|
||||
<sandbox_mode>workspace-write</sandbox_mode>
|
||||
<network_access>enabled</network_access>
|
||||
<writable_roots>
|
||||
<root>{}</root>
|
||||
</writable_roots>
|
||||
<shell>{}</shell>
|
||||
</environment_context>"#,
|
||||
writable.path().display(),
|
||||
shell.name()
|
||||
let expected_permissions_msg = body1["input"][0].clone();
|
||||
// After overriding the turn context, emit a new permissions message.
|
||||
let body1_input = body1["input"].as_array().expect("input array");
|
||||
let expected_permissions_msg_2 = body2["input"][body1_input.len()].clone();
|
||||
assert_ne!(
|
||||
expected_permissions_msg_2, expected_permissions_msg,
|
||||
"expected updated permissions message after override"
|
||||
);
|
||||
let expected_env_msg_2 = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": expected_env_text_2 } ]
|
||||
});
|
||||
let expected_body2 = serde_json::json!(
|
||||
[
|
||||
body1["input"].as_array().unwrap().as_slice(),
|
||||
[expected_env_msg_2, expected_user_message_2].as_slice(),
|
||||
]
|
||||
.concat()
|
||||
);
|
||||
assert_eq!(body2["input"], expected_body2);
|
||||
let mut expected_body2 = body1["input"].as_array().expect("input array").to_vec();
|
||||
expected_body2.push(expected_permissions_msg_2);
|
||||
expected_body2.push(expected_user_message_2);
|
||||
assert_eq!(body2["input"], serde_json::Value::Array(expected_body2));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -439,10 +423,8 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul
|
||||
.filter(|text| text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG))
|
||||
.collect();
|
||||
assert!(
|
||||
env_texts
|
||||
.iter()
|
||||
.any(|text| text.contains("<approval_policy>never</approval_policy>")),
|
||||
"environment context should reflect overridden approval policy: {env_texts:?}"
|
||||
!env_texts.is_empty(),
|
||||
"expected environment context to be emitted: {env_texts:?}"
|
||||
);
|
||||
|
||||
let env_count = input
|
||||
@@ -462,9 +444,29 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul
|
||||
.is_some()
|
||||
})
|
||||
.count();
|
||||
assert_eq!(
|
||||
env_count, 2,
|
||||
"environment context should appear exactly twice, found {env_count}"
|
||||
assert!(
|
||||
env_count >= 1,
|
||||
"environment context should appear at least once, found {env_count}"
|
||||
);
|
||||
|
||||
let permissions_texts: Vec<&str> = input
|
||||
.iter()
|
||||
.filter_map(|msg| {
|
||||
let role = msg["role"].as_str()?;
|
||||
if role != "developer" {
|
||||
return None;
|
||||
}
|
||||
msg["content"]
|
||||
.as_array()
|
||||
.and_then(|content| content.first())
|
||||
.and_then(|item| item["text"].as_str())
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
permissions_texts
|
||||
.iter()
|
||||
.any(|text| text.contains("`approval_policy` is `never`")),
|
||||
"permissions message should reflect overridden approval policy: {permissions_texts:?}"
|
||||
);
|
||||
|
||||
let user_texts: Vec<&str> = input
|
||||
@@ -514,6 +516,12 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
|
||||
// Second turn using per-turn overrides via UserTurn
|
||||
let new_cwd = TempDir::new().unwrap();
|
||||
let writable = TempDir::new().unwrap();
|
||||
let new_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![AbsolutePathBuf::try_from(writable.path()).unwrap()],
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
@@ -521,12 +529,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
|
||||
}],
|
||||
cwd: new_cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![AbsolutePathBuf::try_from(writable.path()).unwrap()],
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
},
|
||||
sandbox_policy: new_policy.clone(),
|
||||
model: "o3".to_string(),
|
||||
effort: Some(ReasoningEffort::High),
|
||||
summary: ReasoningSummary::Detailed,
|
||||
@@ -556,31 +559,28 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
|
||||
let expected_env_text_2 = format!(
|
||||
r#"<environment_context>
|
||||
<cwd>{}</cwd>
|
||||
<approval_policy>never</approval_policy>
|
||||
<sandbox_mode>workspace-write</sandbox_mode>
|
||||
<network_access>enabled</network_access>
|
||||
<writable_roots>
|
||||
<root>{}</root>
|
||||
</writable_roots>
|
||||
<shell>{}</shell>
|
||||
</environment_context>"#,
|
||||
new_cwd.path().display(),
|
||||
writable.path().display(),
|
||||
shell.name(),
|
||||
shell.name()
|
||||
);
|
||||
let expected_env_msg_2 = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": expected_env_text_2 } ]
|
||||
});
|
||||
let expected_body2 = serde_json::json!(
|
||||
[
|
||||
body1["input"].as_array().unwrap().as_slice(),
|
||||
[expected_env_msg_2, expected_user_message_2].as_slice(),
|
||||
]
|
||||
.concat()
|
||||
let expected_permissions_msg = body1["input"][0].clone();
|
||||
let body1_input = body1["input"].as_array().expect("input array");
|
||||
let expected_permissions_msg_2 = body2["input"][body1_input.len() + 1].clone();
|
||||
assert_ne!(
|
||||
expected_permissions_msg_2, expected_permissions_msg,
|
||||
"expected updated permissions message after per-turn override"
|
||||
);
|
||||
assert_eq!(body2["input"], expected_body2);
|
||||
let mut expected_body2 = body1_input.to_vec();
|
||||
expected_body2.push(expected_env_msg_2);
|
||||
expected_body2.push(expected_permissions_msg_2);
|
||||
expected_body2.push(expected_user_message_2);
|
||||
assert_eq!(body2["input"], serde_json::Value::Array(expected_body2));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -648,7 +648,8 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
|
||||
let body1 = req1.single_request().body_json();
|
||||
let body2 = req2.single_request().body_json();
|
||||
|
||||
let expected_ui_msg = body1["input"][0].clone();
|
||||
let expected_permissions_msg = body1["input"][0].clone();
|
||||
let expected_ui_msg = body1["input"][1].clone();
|
||||
|
||||
let shell = default_user_shell();
|
||||
let default_cwd_lossy = default_cwd.to_string_lossy();
|
||||
@@ -657,6 +658,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
|
||||
let expected_user_message_1 = text_user_input("hello 1".to_string());
|
||||
|
||||
let expected_input_1 = serde_json::Value::Array(vec![
|
||||
expected_permissions_msg.clone(),
|
||||
expected_ui_msg.clone(),
|
||||
expected_env_msg_1.clone(),
|
||||
expected_user_message_1.clone(),
|
||||
@@ -665,6 +667,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
|
||||
|
||||
let expected_user_message_2 = text_user_input("hello 2".to_string());
|
||||
let expected_input_2 = serde_json::Value::Array(vec![
|
||||
expected_permissions_msg,
|
||||
expected_ui_msg,
|
||||
expected_env_msg_1,
|
||||
expected_user_message_1,
|
||||
@@ -738,34 +741,34 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
|
||||
let body1 = req1.single_request().body_json();
|
||||
let body2 = req2.single_request().body_json();
|
||||
|
||||
let expected_ui_msg = body1["input"][0].clone();
|
||||
let expected_permissions_msg = body1["input"][0].clone();
|
||||
let expected_ui_msg = body1["input"][1].clone();
|
||||
|
||||
let shell = default_user_shell();
|
||||
let expected_env_text_1 = default_env_context_str(&default_cwd.to_string_lossy(), &shell);
|
||||
let expected_env_msg_1 = text_user_input(expected_env_text_1);
|
||||
let expected_user_message_1 = text_user_input("hello 1".to_string());
|
||||
let expected_input_1 = serde_json::Value::Array(vec![
|
||||
expected_permissions_msg.clone(),
|
||||
expected_ui_msg.clone(),
|
||||
expected_env_msg_1.clone(),
|
||||
expected_user_message_1.clone(),
|
||||
]);
|
||||
assert_eq!(body1["input"], expected_input_1);
|
||||
|
||||
let shell_name = shell.name();
|
||||
let expected_env_msg_2 = text_user_input(format!(
|
||||
r#"<environment_context>
|
||||
<approval_policy>never</approval_policy>
|
||||
<sandbox_mode>danger-full-access</sandbox_mode>
|
||||
<network_access>enabled</network_access>
|
||||
<shell>{shell_name}</shell>
|
||||
</environment_context>"#
|
||||
));
|
||||
let body1_input = body1["input"].as_array().expect("input array");
|
||||
let expected_permissions_msg_2 = body2["input"][body1_input.len()].clone();
|
||||
assert_ne!(
|
||||
expected_permissions_msg_2, expected_permissions_msg,
|
||||
"expected updated permissions message after policy change"
|
||||
);
|
||||
let expected_user_message_2 = text_user_input("hello 2".to_string());
|
||||
let expected_input_2 = serde_json::Value::Array(vec![
|
||||
expected_permissions_msg,
|
||||
expected_ui_msg,
|
||||
expected_env_msg_1,
|
||||
expected_user_message_1,
|
||||
expected_env_msg_2,
|
||||
expected_permissions_msg_2,
|
||||
expected_user_message_2,
|
||||
]);
|
||||
assert_eq!(body2["input"], expected_input_2);
|
||||
|
||||
@@ -67,7 +67,7 @@ async fn retries_on_early_close() {
|
||||
name: "openai".into(),
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
// Environment variable that should exist in the test environment.
|
||||
// ModelClient will return an error if the environment variable for the
|
||||
// ModelClientSession will return an error if the environment variable for the
|
||||
// provider is not set.
|
||||
env_key: Some("PATH".into()),
|
||||
env_key_instructions: None,
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::ContentItem;
|
||||
use codex_core::ModelClient;
|
||||
use codex_core::ModelClientSession;
|
||||
use codex_core::ModelProviderInfo;
|
||||
use codex_core::Prompt;
|
||||
use codex_core::ResponseEvent;
|
||||
use codex_core::ResponseItem;
|
||||
use codex_core::WireApi;
|
||||
use codex_core::models_manager::manager::ModelsManager;
|
||||
use codex_core::protocol::SessionSource;
|
||||
use codex_otel::OtelManager;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use core_test_support::responses::WebSocketTestServer;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::start_websocket_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use futures::StreamExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
const MODEL: &str = "gpt-5.2-codex";
|
||||
|
||||
struct WebsocketTestHarness {
|
||||
_codex_home: TempDir,
|
||||
client: ModelClient,
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn responses_websocket_streams_request() {
|
||||
skip_if_no_network!();
|
||||
|
||||
let server = start_websocket_server(vec![vec![vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_completed("resp-1"),
|
||||
]]])
|
||||
.await;
|
||||
|
||||
let harness = websocket_harness(&server).await;
|
||||
let mut session = harness.client.new_session();
|
||||
let prompt = prompt_with_input(vec![message_item("hello")]);
|
||||
|
||||
stream_until_complete(&mut session, &prompt).await;
|
||||
|
||||
let connection = server.single_connection();
|
||||
assert_eq!(connection.len(), 1);
|
||||
let body = connection.first().expect("missing request").body_json();
|
||||
|
||||
assert_eq!(body["type"].as_str(), Some("response.create"));
|
||||
assert_eq!(body["model"].as_str(), Some(MODEL));
|
||||
assert_eq!(body["stream"], serde_json::Value::Bool(true));
|
||||
assert_eq!(body["input"].as_array().map(Vec::len), Some(1));
|
||||
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn responses_websocket_appends_on_prefix() {
|
||||
skip_if_no_network!();
|
||||
|
||||
let server = start_websocket_server(vec![vec![
|
||||
vec![ev_response_created("resp-1"), ev_completed("resp-1")],
|
||||
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
|
||||
]])
|
||||
.await;
|
||||
|
||||
let harness = websocket_harness(&server).await;
|
||||
let mut session = harness.client.new_session();
|
||||
let prompt_one = prompt_with_input(vec![message_item("hello")]);
|
||||
let prompt_two = prompt_with_input(vec![message_item("hello"), message_item("second")]);
|
||||
|
||||
stream_until_complete(&mut session, &prompt_one).await;
|
||||
stream_until_complete(&mut session, &prompt_two).await;
|
||||
|
||||
let connection = server.single_connection();
|
||||
assert_eq!(connection.len(), 2);
|
||||
let first = connection.first().expect("missing request").body_json();
|
||||
let second = connection.get(1).expect("missing request").body_json();
|
||||
|
||||
assert_eq!(first["type"].as_str(), Some("response.create"));
|
||||
assert_eq!(first["model"].as_str(), Some(MODEL));
|
||||
assert_eq!(first["stream"], serde_json::Value::Bool(true));
|
||||
assert_eq!(first["input"].as_array().map(Vec::len), Some(1));
|
||||
let expected_append = serde_json::json!({
|
||||
"type": "response.append",
|
||||
"input": serde_json::to_value(&prompt_two.input[1..]).expect("serialize append items"),
|
||||
});
|
||||
assert_eq!(second, expected_append);
|
||||
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn responses_websocket_creates_on_non_prefix() {
|
||||
skip_if_no_network!();
|
||||
|
||||
let server = start_websocket_server(vec![vec![
|
||||
vec![ev_response_created("resp-1"), ev_completed("resp-1")],
|
||||
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
|
||||
]])
|
||||
.await;
|
||||
|
||||
let harness = websocket_harness(&server).await;
|
||||
let mut session = harness.client.new_session();
|
||||
let prompt_one = prompt_with_input(vec![message_item("hello")]);
|
||||
let prompt_two = prompt_with_input(vec![message_item("different")]);
|
||||
|
||||
stream_until_complete(&mut session, &prompt_one).await;
|
||||
stream_until_complete(&mut session, &prompt_two).await;
|
||||
|
||||
let connection = server.single_connection();
|
||||
assert_eq!(connection.len(), 2);
|
||||
let second = connection.get(1).expect("missing request").body_json();
|
||||
|
||||
assert_eq!(second["type"].as_str(), Some("response.create"));
|
||||
assert_eq!(second["model"].as_str(), Some(MODEL));
|
||||
assert_eq!(second["stream"], serde_json::Value::Bool(true));
|
||||
assert_eq!(
|
||||
second["input"],
|
||||
serde_json::to_value(&prompt_two.input).unwrap()
|
||||
);
|
||||
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
||||
fn message_item(text: &str) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".into(),
|
||||
content: vec![ContentItem::InputText { text: text.into() }],
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_with_input(input: Vec<ResponseItem>) -> Prompt {
|
||||
let mut prompt = Prompt::default();
|
||||
prompt.input = input;
|
||||
prompt
|
||||
}
|
||||
|
||||
fn websocket_provider(server: &WebSocketTestServer) -> ModelProviderInfo {
|
||||
ModelProviderInfo {
|
||||
name: "mock-ws".into(),
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
env_key: None,
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
wire_api: WireApi::ResponsesWebsocket,
|
||||
query_params: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
request_max_retries: Some(0),
|
||||
stream_max_retries: Some(0),
|
||||
stream_idle_timeout_ms: Some(5_000),
|
||||
requires_openai_auth: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn websocket_harness(server: &WebSocketTestServer) -> WebsocketTestHarness {
|
||||
let provider = websocket_provider(server);
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let mut config = load_default_config_for_test(&codex_home).await;
|
||||
config.model = Some(MODEL.to_string());
|
||||
let config = Arc::new(config);
|
||||
let model_info = ModelsManager::construct_model_info_offline(MODEL, &config);
|
||||
let conversation_id = ThreadId::new();
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
|
||||
let otel_manager = OtelManager::new(
|
||||
conversation_id,
|
||||
MODEL,
|
||||
model_info.slug.as_str(),
|
||||
None,
|
||||
Some("test@test.com".to_string()),
|
||||
auth_manager.get_auth_mode(),
|
||||
false,
|
||||
"test".to_string(),
|
||||
SessionSource::Exec,
|
||||
);
|
||||
let client = ModelClient::new(
|
||||
Arc::clone(&config),
|
||||
None,
|
||||
model_info,
|
||||
otel_manager,
|
||||
provider.clone(),
|
||||
None,
|
||||
ReasoningSummary::Auto,
|
||||
conversation_id,
|
||||
SessionSource::Exec,
|
||||
);
|
||||
|
||||
WebsocketTestHarness {
|
||||
_codex_home: codex_home,
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_until_complete(session: &mut ModelClientSession, prompt: &Prompt) {
|
||||
let mut stream = session
|
||||
.stream(prompt)
|
||||
.await
|
||||
.expect("websocket stream failed");
|
||||
|
||||
while let Some(event) = stream.next().await {
|
||||
if matches!(event, Ok(ResponseEvent::Completed { .. })) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ impl Policy {
|
||||
Evaluation::from_matches(matched_rules)
|
||||
}
|
||||
|
||||
/// Checks multiple commands and aggregates the results.
|
||||
pub fn check_multiple<Commands, F>(
|
||||
&self,
|
||||
commands: Commands,
|
||||
@@ -81,12 +82,19 @@ impl Policy {
|
||||
Evaluation::from_matches(matched_rules)
|
||||
}
|
||||
|
||||
/// Returns matching rules for the given command. If no rules match and
|
||||
/// `heuristics_fallback` is provided, returns a single
|
||||
/// `HeuristicsRuleMatch` with the decision rendered by
|
||||
/// `heuristics_fallback`.
|
||||
///
|
||||
/// If `heuristics_fallback.is_some()`, then the returned vector is
|
||||
/// guaranteed to be non-empty.
|
||||
pub fn matches_for_command(
|
||||
&self,
|
||||
cmd: &[String],
|
||||
heuristics_fallback: HeuristicsFallback<'_>,
|
||||
) -> Vec<RuleMatch> {
|
||||
let mut matched_rules: Vec<RuleMatch> = match cmd.first() {
|
||||
let matched_rules: Vec<RuleMatch> = match cmd.first() {
|
||||
Some(first) => self
|
||||
.rules_by_program
|
||||
.get_vec(first)
|
||||
@@ -95,14 +103,16 @@ impl Policy {
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
if let (true, Some(heuristics_fallback)) = (matched_rules.is_empty(), heuristics_fallback) {
|
||||
matched_rules.push(RuleMatch::HeuristicsRuleMatch {
|
||||
if matched_rules.is_empty()
|
||||
&& let Some(heuristics_fallback) = heuristics_fallback
|
||||
{
|
||||
vec![RuleMatch::HeuristicsRuleMatch {
|
||||
command: cmd.to_vec(),
|
||||
decision: heuristics_fallback(cmd),
|
||||
});
|
||||
}]
|
||||
} else {
|
||||
matched_rules
|
||||
}
|
||||
|
||||
matched_rules
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,12 +131,11 @@ impl Evaluation {
|
||||
.any(|rule_match| !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }))
|
||||
}
|
||||
|
||||
/// Caller is responsible for ensuring that `matched_rules` is non-empty.
|
||||
fn from_matches(matched_rules: Vec<RuleMatch>) -> Self {
|
||||
let decision = matched_rules
|
||||
.iter()
|
||||
.map(RuleMatch::decision)
|
||||
.max()
|
||||
.unwrap_or(Decision::Allow);
|
||||
let decision = matched_rules.iter().map(RuleMatch::decision).max();
|
||||
#[expect(clippy::expect_used)]
|
||||
let decision = decision.expect("invariant failed: matched_rules must be non-empty");
|
||||
|
||||
Self {
|
||||
decision,
|
||||
|
||||
@@ -15,7 +15,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt"] }
|
||||
tracing = { version = "0.1.43", features = ["log"] }
|
||||
which = "6.0"
|
||||
which = "8.0"
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = "0.6"
|
||||
|
||||
@@ -381,23 +381,26 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> {
|
||||
let instructions = request["messages"][0]["content"].as_str().unwrap();
|
||||
assert!(instructions.starts_with("You are a helpful assistant."));
|
||||
|
||||
let developer_msg = request["messages"]
|
||||
let developer_messages: Vec<&serde_json::Value> = request["messages"]
|
||||
.as_array()
|
||||
.and_then(|messages| {
|
||||
messages
|
||||
.iter()
|
||||
.find(|msg| msg.get("role").and_then(|role| role.as_str()) == Some("developer"))
|
||||
})
|
||||
.unwrap();
|
||||
let developer_content = developer_msg
|
||||
.get("content")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|msg| msg.get("role").and_then(|role| role.as_str()) == Some("developer"))
|
||||
.collect();
|
||||
let developer_contents: Vec<&str> = developer_messages
|
||||
.iter()
|
||||
.filter_map(|msg| msg.get("content").and_then(|value| value.as_str()))
|
||||
.collect();
|
||||
assert!(
|
||||
!developer_content.contains('<'),
|
||||
"expected developer instructions without XML tags, got `{developer_content}`"
|
||||
developer_contents
|
||||
.iter()
|
||||
.any(|content| content.contains("`sandbox_mode`")),
|
||||
"expected permissions developer message, got {developer_contents:?}"
|
||||
);
|
||||
assert!(
|
||||
developer_contents.contains(&"Foreshadow upcoming tool calls."),
|
||||
"expected developer instructions in developer messages, got {developer_contents:?}"
|
||||
);
|
||||
assert_eq!(developer_content, "Foreshadow upcoming tool calls.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3,4 +3,5 @@ load("//:defs.bzl", "codex_rust_crate")
|
||||
codex_rust_crate(
|
||||
name = "protocol",
|
||||
crate_name = "codex_protocol",
|
||||
compile_data = glob(["src/prompts/permissions/**/*.md"]),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use codex_utils_image::load_and_resize_to_fit;
|
||||
use mcp_types::CallToolResult;
|
||||
@@ -9,6 +10,11 @@ use serde::Serialize;
|
||||
use serde::ser::Serializer;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::config_types::SandboxMode;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::NetworkAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::WritableRoot;
|
||||
use crate::user_input::UserInput;
|
||||
use codex_git::GhostCommit;
|
||||
use codex_utils_image::error::ImageProcessingError;
|
||||
@@ -158,6 +164,159 @@ pub enum ResponseItem {
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Developer-provided guidance that is injected into a turn as a developer role
|
||||
/// message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename = "developer_instructions", rename_all = "snake_case")]
|
||||
pub struct DeveloperInstructions {
|
||||
text: String,
|
||||
}
|
||||
|
||||
const APPROVAL_POLICY_NEVER: &str = include_str!("prompts/permissions/approval_policy/never.md");
|
||||
const APPROVAL_POLICY_UNLESS_TRUSTED: &str =
|
||||
include_str!("prompts/permissions/approval_policy/unless_trusted.md");
|
||||
const APPROVAL_POLICY_ON_FAILURE: &str =
|
||||
include_str!("prompts/permissions/approval_policy/on_failure.md");
|
||||
const APPROVAL_POLICY_ON_REQUEST: &str =
|
||||
include_str!("prompts/permissions/approval_policy/on_request.md");
|
||||
|
||||
const SANDBOX_MODE_DANGER_FULL_ACCESS: &str =
|
||||
include_str!("prompts/permissions/sandbox_mode/danger_full_access.md");
|
||||
const SANDBOX_MODE_WORKSPACE_WRITE: &str =
|
||||
include_str!("prompts/permissions/sandbox_mode/workspace_write.md");
|
||||
const SANDBOX_MODE_READ_ONLY: &str = include_str!("prompts/permissions/sandbox_mode/read_only.md");
|
||||
|
||||
impl DeveloperInstructions {
|
||||
pub fn new<T: Into<String>>(text: T) -> Self {
|
||||
Self { text: text.into() }
|
||||
}
|
||||
|
||||
pub fn into_text(self) -> String {
|
||||
self.text
|
||||
}
|
||||
|
||||
pub fn concat(self, other: impl Into<DeveloperInstructions>) -> Self {
|
||||
let mut text = self.text;
|
||||
text.push_str(&other.into().text);
|
||||
Self { text }
|
||||
}
|
||||
|
||||
pub fn from_policy(
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
approval_policy: AskForApproval,
|
||||
cwd: &Path,
|
||||
) -> Self {
|
||||
let network_access = if sandbox_policy.has_full_network_access() {
|
||||
NetworkAccess::Enabled
|
||||
} else {
|
||||
NetworkAccess::Restricted
|
||||
};
|
||||
|
||||
let (sandbox_mode, writable_roots) = match sandbox_policy {
|
||||
SandboxPolicy::DangerFullAccess => (SandboxMode::DangerFullAccess, None),
|
||||
SandboxPolicy::ReadOnly => (SandboxMode::ReadOnly, None),
|
||||
SandboxPolicy::ExternalSandbox { .. } => (SandboxMode::DangerFullAccess, None),
|
||||
SandboxPolicy::WorkspaceWrite { .. } => {
|
||||
let roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
|
||||
(SandboxMode::WorkspaceWrite, Some(roots))
|
||||
}
|
||||
};
|
||||
|
||||
DeveloperInstructions::from_permissions_with_network(
|
||||
sandbox_mode,
|
||||
network_access,
|
||||
approval_policy,
|
||||
writable_roots,
|
||||
)
|
||||
}
|
||||
|
||||
fn from_permissions_with_network(
|
||||
sandbox_mode: SandboxMode,
|
||||
network_access: NetworkAccess,
|
||||
approval_policy: AskForApproval,
|
||||
writable_roots: Option<Vec<WritableRoot>>,
|
||||
) -> Self {
|
||||
let start_tag = DeveloperInstructions::new("<permissions instructions>");
|
||||
let end_tag = DeveloperInstructions::new("</permissions instructions>");
|
||||
start_tag
|
||||
.concat(DeveloperInstructions::sandbox_text(
|
||||
sandbox_mode,
|
||||
network_access,
|
||||
))
|
||||
.concat(DeveloperInstructions::from(approval_policy))
|
||||
.concat(DeveloperInstructions::from_writable_roots(writable_roots))
|
||||
.concat(end_tag)
|
||||
}
|
||||
|
||||
fn from_writable_roots(writable_roots: Option<Vec<WritableRoot>>) -> Self {
|
||||
let Some(roots) = writable_roots else {
|
||||
return DeveloperInstructions::new("");
|
||||
};
|
||||
|
||||
if roots.is_empty() {
|
||||
return DeveloperInstructions::new("");
|
||||
}
|
||||
|
||||
let roots_list: Vec<String> = roots
|
||||
.iter()
|
||||
.map(|r| format!("`{}`", r.root.to_string_lossy()))
|
||||
.collect();
|
||||
let text = if roots_list.len() == 1 {
|
||||
format!(" The writable root is {}.", roots_list[0])
|
||||
} else {
|
||||
format!(" The writable roots are {}.", roots_list.join(", "))
|
||||
};
|
||||
DeveloperInstructions::new(text)
|
||||
}
|
||||
|
||||
fn sandbox_text(mode: SandboxMode, network_access: NetworkAccess) -> DeveloperInstructions {
|
||||
let template = match mode {
|
||||
SandboxMode::DangerFullAccess => SANDBOX_MODE_DANGER_FULL_ACCESS.trim_end(),
|
||||
SandboxMode::WorkspaceWrite => SANDBOX_MODE_WORKSPACE_WRITE.trim_end(),
|
||||
SandboxMode::ReadOnly => SANDBOX_MODE_READ_ONLY.trim_end(),
|
||||
};
|
||||
let text = template.replace("{network_access}", &network_access.to_string());
|
||||
|
||||
DeveloperInstructions::new(text)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DeveloperInstructions> for ResponseItem {
|
||||
fn from(di: DeveloperInstructions) -> Self {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: di.into_text(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SandboxMode> for DeveloperInstructions {
|
||||
fn from(mode: SandboxMode) -> Self {
|
||||
let network_access = match mode {
|
||||
SandboxMode::DangerFullAccess => NetworkAccess::Enabled,
|
||||
SandboxMode::WorkspaceWrite | SandboxMode::ReadOnly => NetworkAccess::Restricted,
|
||||
};
|
||||
|
||||
DeveloperInstructions::sandbox_text(mode, network_access)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AskForApproval> for DeveloperInstructions {
|
||||
fn from(mode: AskForApproval) -> Self {
|
||||
let text = match mode {
|
||||
AskForApproval::Never => APPROVAL_POLICY_NEVER.trim_end(),
|
||||
AskForApproval::UnlessTrusted => APPROVAL_POLICY_UNLESS_TRUSTED.trim_end(),
|
||||
AskForApproval::OnFailure => APPROVAL_POLICY_ON_FAILURE.trim_end(),
|
||||
AskForApproval::OnRequest => APPROVAL_POLICY_ON_REQUEST.trim_end(),
|
||||
};
|
||||
|
||||
DeveloperInstructions::new(text)
|
||||
}
|
||||
}
|
||||
|
||||
fn should_serialize_reasoning_content(content: &Option<Vec<ReasoningItemContent>>) -> bool {
|
||||
match content {
|
||||
Some(content) => !content
|
||||
@@ -625,12 +784,71 @@ impl std::ops::Deref for FunctionCallOutputPayload {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config_types::SandboxMode;
|
||||
use crate::protocol::AskForApproval;
|
||||
use anyhow::Result;
|
||||
use mcp_types::ImageContent;
|
||||
use mcp_types::TextContent;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn converts_sandbox_mode_into_developer_instructions() {
|
||||
assert_eq!(
|
||||
DeveloperInstructions::from(SandboxMode::WorkspaceWrite),
|
||||
DeveloperInstructions::new(
|
||||
"Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. Network access is restricted."
|
||||
)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
DeveloperInstructions::from(SandboxMode::ReadOnly),
|
||||
DeveloperInstructions::new(
|
||||
"Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The sandbox only permits reading files. Network access is restricted."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_permissions_with_network_access_override() {
|
||||
let instructions = DeveloperInstructions::from_permissions_with_network(
|
||||
SandboxMode::WorkspaceWrite,
|
||||
NetworkAccess::Enabled,
|
||||
AskForApproval::OnRequest,
|
||||
None,
|
||||
);
|
||||
|
||||
let text = instructions.into_text();
|
||||
assert!(
|
||||
text.contains("Network access is enabled."),
|
||||
"expected network access to be enabled in message"
|
||||
);
|
||||
assert!(
|
||||
text.contains("`approval_policy` is `on-request`"),
|
||||
"expected approval guidance to be included"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_permissions_from_policy() {
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
};
|
||||
|
||||
let instructions = DeveloperInstructions::from_policy(
|
||||
&policy,
|
||||
AskForApproval::UnlessTrusted,
|
||||
&PathBuf::from("/tmp"),
|
||||
);
|
||||
let text = instructions.into_text();
|
||||
assert!(text.contains("Network access is enabled."));
|
||||
assert!(text.contains("`approval_policy` is `unless-trusted`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_success_as_plain_string() -> Result<()> {
|
||||
let item = ResponseInputItem::FunctionCallOutput {
|
||||
|
||||
@@ -54,6 +54,7 @@ pub struct ModelUpgrade {
|
||||
pub migration_config_key: String,
|
||||
pub model_link: Option<String>,
|
||||
pub upgrade_copy: Option<String>,
|
||||
pub migration_markdown: Option<String>,
|
||||
}
|
||||
|
||||
/// Metadata describing a Codex-supported model.
|
||||
@@ -234,6 +235,7 @@ impl From<ModelInfo> for ModelPreset {
|
||||
// todo(aibrahim): add the model link here.
|
||||
model_link: None,
|
||||
upgrade_copy: None,
|
||||
migration_markdown: None,
|
||||
}),
|
||||
show_in_picker: info.visibility == ModelVisibility::List,
|
||||
supported_in_api: info.supported_in_api,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Approvals are your mechanism to get user consent to run shell commands without the sandbox. `approval_policy` is `never`: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
|
||||
@@ -0,0 +1 @@
|
||||
Approvals are your mechanism to get user consent to run shell commands without the sandbox. `approval_policy` is `on-failure`: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
|
||||
@@ -0,0 +1,12 @@
|
||||
Approvals are your mechanism to get user consent to run shell commands without the sandbox. `approval_policy` is `on-request`: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task.
|
||||
|
||||
Here are scenarios where you'll need to request approval:
|
||||
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
|
||||
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
|
||||
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
|
||||
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.
|
||||
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for.
|
||||
|
||||
When requesting approval to execute a command that will require escalated privileges:
|
||||
- Provide the `sandbox_permissions` parameter with the value `"require_escalated"`
|
||||
- Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
|
||||
@@ -0,0 +1 @@
|
||||
Approvals are your mechanism to get user consent to run shell commands without the sandbox. `approval_policy` is `unless-trusted`: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
|
||||
@@ -0,0 +1 @@
|
||||
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`: No filesystem sandboxing - all commands are permitted. Network access is {network_access}.
|
||||
@@ -0,0 +1 @@
|
||||
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The sandbox only permits reading files. Network access is {network_access}.
|
||||
@@ -0,0 +1 @@
|
||||
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. Network access is {network_access}.
|
||||
@@ -60,6 +60,13 @@ pub struct Submission {
|
||||
pub op: Op,
|
||||
}
|
||||
|
||||
/// Config payload for refreshing MCP servers.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
|
||||
pub struct McpServerRefreshConfig {
|
||||
pub mcp_servers: Value,
|
||||
pub mcp_oauth_credentials_store_mode: Value,
|
||||
}
|
||||
|
||||
/// Submission operation
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
@@ -95,7 +102,7 @@ pub enum Op {
|
||||
/// Policy to use for tool calls such as `local_shell`.
|
||||
sandbox_policy: SandboxPolicy,
|
||||
|
||||
/// Must be a valid model slug for the [`crate::client::ModelClient`]
|
||||
/// Must be a valid model slug for the configured client session
|
||||
/// associated with this conversation.
|
||||
model: String,
|
||||
|
||||
@@ -186,6 +193,9 @@ pub enum Op {
|
||||
/// Reply is delivered via `EventMsg::McpListToolsResponse`.
|
||||
ListMcpTools,
|
||||
|
||||
/// Request MCP servers to reinitialize and refresh cached tool lists.
|
||||
RefreshMcpServers { config: McpServerRefreshConfig },
|
||||
|
||||
/// Request the list of available custom prompts.
|
||||
ListCustomPrompts,
|
||||
|
||||
@@ -699,7 +709,7 @@ pub enum AgentStatus {
|
||||
Completed(Option<String>),
|
||||
/// Agent encountered an error.
|
||||
Errored(String),
|
||||
/// Agent has been shutdowned.
|
||||
/// Agent has been shutdown.
|
||||
Shutdown,
|
||||
/// Agent is not found.
|
||||
NotFound,
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::time::Duration;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use anyhow::bail;
|
||||
use reqwest::ClientBuilder;
|
||||
use rmcp::transport::auth::OAuthState;
|
||||
use tiny_http::Response;
|
||||
@@ -44,6 +45,7 @@ pub async fn perform_oauth_login(
|
||||
http_headers: Option<HashMap<String, String>>,
|
||||
env_http_headers: Option<HashMap<String, String>>,
|
||||
scopes: &[String],
|
||||
callback_port: Option<u16>,
|
||||
) -> Result<()> {
|
||||
let headers = OauthHeaders {
|
||||
http_headers,
|
||||
@@ -56,6 +58,7 @@ pub async fn perform_oauth_login(
|
||||
headers,
|
||||
scopes,
|
||||
true,
|
||||
callback_port,
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
@@ -63,6 +66,7 @@ pub async fn perform_oauth_login(
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn perform_oauth_login_return_url(
|
||||
server_name: &str,
|
||||
server_url: &str,
|
||||
@@ -71,6 +75,7 @@ pub async fn perform_oauth_login_return_url(
|
||||
env_http_headers: Option<HashMap<String, String>>,
|
||||
scopes: &[String],
|
||||
timeout_secs: Option<i64>,
|
||||
callback_port: Option<u16>,
|
||||
) -> Result<OauthLoginHandle> {
|
||||
let headers = OauthHeaders {
|
||||
http_headers,
|
||||
@@ -83,6 +88,7 @@ pub async fn perform_oauth_login_return_url(
|
||||
headers,
|
||||
scopes,
|
||||
false,
|
||||
callback_port,
|
||||
timeout_secs,
|
||||
)
|
||||
.await?;
|
||||
@@ -188,7 +194,21 @@ struct OauthLoginFlow {
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
fn resolve_callback_port(callback_port: Option<u16>) -> Result<Option<u16>> {
|
||||
if let Some(config_port) = callback_port {
|
||||
if config_port == 0 {
|
||||
bail!(
|
||||
"invalid MCP OAuth callback port `{config_port}`: port must be between 1 and 65535"
|
||||
);
|
||||
}
|
||||
return Ok(Some(config_port));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
impl OauthLoginFlow {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn new(
|
||||
server_name: &str,
|
||||
server_url: &str,
|
||||
@@ -196,11 +216,18 @@ impl OauthLoginFlow {
|
||||
headers: OauthHeaders,
|
||||
scopes: &[String],
|
||||
launch_browser: bool,
|
||||
callback_port: Option<u16>,
|
||||
timeout_secs: Option<i64>,
|
||||
) -> Result<Self> {
|
||||
const DEFAULT_OAUTH_TIMEOUT_SECS: i64 = 300;
|
||||
|
||||
let server = Arc::new(Server::http("127.0.0.1:0").map_err(|err| anyhow!(err))?);
|
||||
let callback_port = resolve_callback_port(callback_port)?;
|
||||
let bind_addr = match callback_port {
|
||||
Some(port) => format!("127.0.0.1:{port}"),
|
||||
None => "127.0.0.1:0".to_string(),
|
||||
};
|
||||
|
||||
let server = Arc::new(Server::http(&bind_addr).map_err(|err| anyhow!(err))?);
|
||||
let guard = CallbackServerGuard {
|
||||
server: Arc::clone(&server),
|
||||
};
|
||||
|
||||
+154
-25
@@ -20,7 +20,7 @@ use crate::model_migration::run_model_migration_prompt;
|
||||
use crate::pager_overlay::Overlay;
|
||||
use crate::render::highlight::highlight_bash_to_lines;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::resume_picker::ResumeSelection;
|
||||
use crate::resume_picker::SessionSelection;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crate::update_action::UpdateAction;
|
||||
@@ -201,6 +201,7 @@ async fn handle_model_migration_prompt_if_needed(
|
||||
migration_config_key,
|
||||
model_link,
|
||||
upgrade_copy,
|
||||
migration_markdown,
|
||||
}) = upgrade
|
||||
{
|
||||
if migration_prompt_hidden(config, migration_config_key.as_str()) {
|
||||
@@ -234,6 +235,7 @@ async fn handle_model_migration_prompt_if_needed(
|
||||
&target_model,
|
||||
model_link.clone(),
|
||||
upgrade_copy.clone(),
|
||||
migration_markdown.clone(),
|
||||
heading_label,
|
||||
target_description,
|
||||
can_opt_out,
|
||||
@@ -340,7 +342,7 @@ impl App {
|
||||
active_profile: Option<String>,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<PathBuf>,
|
||||
resume_selection: ResumeSelection,
|
||||
session_selection: SessionSelection,
|
||||
feedback: codex_feedback::CodexFeedback,
|
||||
is_first_run: bool,
|
||||
) -> Result<AppExitInfo> {
|
||||
@@ -373,8 +375,8 @@ impl App {
|
||||
}
|
||||
|
||||
let enhanced_keys_supported = tui.enhanced_keys_supported();
|
||||
let mut chat_widget = match resume_selection {
|
||||
ResumeSelection::StartFresh | ResumeSelection::Exit => {
|
||||
let mut chat_widget = match session_selection {
|
||||
SessionSelection::StartFresh | SessionSelection::Exit => {
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: config.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
@@ -390,12 +392,13 @@ impl App {
|
||||
};
|
||||
ChatWidget::new(init, thread_manager.clone())
|
||||
}
|
||||
ResumeSelection::Resume(path) => {
|
||||
SessionSelection::Resume(path) => {
|
||||
let resumed = thread_manager
|
||||
.resume_thread_from_rollout(config.clone(), path.clone(), auth_manager.clone())
|
||||
.await
|
||||
.wrap_err_with(|| {
|
||||
format!("Failed to resume session from {}", path.display())
|
||||
let path_display = path.display();
|
||||
format!("Failed to resume session from {path_display}")
|
||||
})?;
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: config.clone(),
|
||||
@@ -412,6 +415,29 @@ impl App {
|
||||
};
|
||||
ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured)
|
||||
}
|
||||
SessionSelection::Fork(path) => {
|
||||
let forked = thread_manager
|
||||
.fork_thread(usize::MAX, config.clone(), path.clone())
|
||||
.await
|
||||
.wrap_err_with(|| {
|
||||
let path_display = path.display();
|
||||
format!("Failed to fork session from {path_display}")
|
||||
})?;
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: config.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
initial_prompt: initial_prompt.clone(),
|
||||
initial_images: initial_images.clone(),
|
||||
enhanced_keys_supported,
|
||||
auth_manager: auth_manager.clone(),
|
||||
models_manager: thread_manager.get_models_manager(),
|
||||
feedback: feedback.clone(),
|
||||
is_first_run,
|
||||
model: model.clone(),
|
||||
};
|
||||
ChatWidget::new_from_existing(init, forked.thread, forked.session_configured)
|
||||
}
|
||||
};
|
||||
|
||||
chat_widget.maybe_prompt_windows_sandbox_enable();
|
||||
@@ -517,7 +543,7 @@ impl App {
|
||||
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
|
||||
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
|
||||
let pasted = pasted.replace("\r", "\n");
|
||||
self.chat_widget.handle_paste(pasted);
|
||||
self.chat_widget.handle_paste_event(pasted);
|
||||
}
|
||||
TuiEvent::Draw => {
|
||||
self.chat_widget.maybe_post_pending_notification(tui);
|
||||
@@ -592,7 +618,7 @@ impl App {
|
||||
)
|
||||
.await?
|
||||
{
|
||||
ResumeSelection::Resume(path) => {
|
||||
SessionSelection::Resume(path) => {
|
||||
let summary = session_summary(
|
||||
self.chat_widget.token_usage(),
|
||||
self.chat_widget.thread_id(),
|
||||
@@ -641,14 +667,85 @@ impl App {
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let path_display = path.display();
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to resume session from {}: {err}",
|
||||
path.display()
|
||||
"Failed to resume session from {path_display}: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
ResumeSelection::Exit | ResumeSelection::StartFresh => {}
|
||||
SessionSelection::Exit
|
||||
| SessionSelection::StartFresh
|
||||
| SessionSelection::Fork(_) => {}
|
||||
}
|
||||
|
||||
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::OpenForkPicker => {
|
||||
match crate::resume_picker::run_fork_picker(
|
||||
tui,
|
||||
&self.config.codex_home,
|
||||
&self.config.model_provider_id,
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
SessionSelection::Fork(path) => {
|
||||
let summary = session_summary(
|
||||
self.chat_widget.token_usage(),
|
||||
self.chat_widget.thread_id(),
|
||||
);
|
||||
match self
|
||||
.server
|
||||
.fork_thread(usize::MAX, self.config.clone(), path.clone())
|
||||
.await
|
||||
{
|
||||
Ok(forked) => {
|
||||
self.shutdown_current_thread().await;
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: self.config.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: self.app_event_tx.clone(),
|
||||
initial_prompt: None,
|
||||
initial_images: Vec::new(),
|
||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
models_manager: self.server.get_models_manager(),
|
||||
feedback: self.feedback.clone(),
|
||||
is_first_run: false,
|
||||
model: self.current_model.clone(),
|
||||
};
|
||||
self.chat_widget = ChatWidget::new_from_existing(
|
||||
init,
|
||||
forked.thread,
|
||||
forked.session_configured,
|
||||
);
|
||||
self.current_model = model_info.slug.clone();
|
||||
if let Some(summary) = summary {
|
||||
let mut lines: Vec<Line<'static>> =
|
||||
vec![summary.usage_line.clone().into()];
|
||||
if let Some(command) = summary.resume_command {
|
||||
let spans = vec![
|
||||
"To continue this session, run ".into(),
|
||||
command.cyan(),
|
||||
];
|
||||
lines.push(spans.into());
|
||||
}
|
||||
self.chat_widget.add_plain_history_lines(lines);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let path_display = path.display();
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to fork session from {path_display}: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
SessionSelection::Exit
|
||||
| SessionSelection::StartFresh
|
||||
| SessionSelection::Resume(_) => {}
|
||||
}
|
||||
|
||||
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
|
||||
@@ -716,9 +813,6 @@ impl App {
|
||||
}
|
||||
self.chat_widget.handle_codex_event(event);
|
||||
}
|
||||
AppEvent::ConversationHistory(ev) => {
|
||||
self.on_conversation_history_for_backtrack(tui, ev).await?;
|
||||
}
|
||||
AppEvent::ExitRequest => {
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -1340,8 +1434,9 @@ impl App {
|
||||
&& self.backtrack.nth_user_message != usize::MAX
|
||||
&& self.chat_widget.composer_is_empty() =>
|
||||
{
|
||||
// Delegate to helper for clarity; preserves behavior.
|
||||
self.confirm_backtrack_from_main();
|
||||
if let Some(selection) = self.confirm_backtrack_from_main() {
|
||||
self.apply_backtrack_selection(tui, selection);
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
@@ -1412,6 +1507,7 @@ mod tests {
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_protocol::ThreadId;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::prelude::Line;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -1503,6 +1599,9 @@ mod tests {
|
||||
fn model_migration_copy_to_plain_text(
|
||||
copy: &crate::model_migration::ModelMigrationCopy,
|
||||
) -> String {
|
||||
if let Some(markdown) = copy.markdown.as_ref() {
|
||||
return markdown.clone();
|
||||
}
|
||||
let mut s = String::new();
|
||||
for span in ©.heading {
|
||||
s.push_str(&span.content);
|
||||
@@ -1585,6 +1684,7 @@ mod tests {
|
||||
migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG.to_string(),
|
||||
model_link: None,
|
||||
upgrade_copy: None,
|
||||
migration_markdown: None,
|
||||
});
|
||||
available.retain(|preset| preset.model != "gpt-5-codex");
|
||||
available.push(current.clone());
|
||||
@@ -1640,6 +1740,7 @@ mod tests {
|
||||
&upgrade.id,
|
||||
upgrade.model_link.clone(),
|
||||
upgrade.upgrade_copy.clone(),
|
||||
upgrade.migration_markdown.clone(),
|
||||
target.display_name.clone(),
|
||||
target_description,
|
||||
can_opt_out,
|
||||
@@ -1673,7 +1774,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
|
||||
let mut app = make_test_app().await;
|
||||
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||
|
||||
let user_cell = |text: &str| -> Arc<dyn HistoryCell> {
|
||||
Arc::new(UserHistoryCell {
|
||||
@@ -1709,9 +1810,8 @@ mod tests {
|
||||
)) as Arc<dyn HistoryCell>
|
||||
};
|
||||
|
||||
// Simulate the transcript after trimming for a fork, replaying history, and
|
||||
// appending the edited turn. The session header separates the retained history
|
||||
// from the forked thread's replayed turns.
|
||||
// Simulate a transcript with duplicated history (e.g., from prior backtracks)
|
||||
// and an edited turn appended after a session header boundary.
|
||||
app.transcript_cells = vec![
|
||||
make_header(true),
|
||||
user_cell("first question"),
|
||||
@@ -1727,15 +1827,44 @@ mod tests {
|
||||
|
||||
assert_eq!(user_count(&app.transcript_cells), 2);
|
||||
|
||||
app.backtrack.base_id = Some(ThreadId::new());
|
||||
let base_id = ThreadId::new();
|
||||
app.chat_widget.handle_codex_event(Event {
|
||||
id: String::new(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: base_id,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
rollout_path: PathBuf::new(),
|
||||
}),
|
||||
});
|
||||
|
||||
app.backtrack.base_id = Some(base_id);
|
||||
app.backtrack.primed = true;
|
||||
app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1);
|
||||
|
||||
app.confirm_backtrack_from_main();
|
||||
let selection = app
|
||||
.confirm_backtrack_from_main()
|
||||
.expect("backtrack selection");
|
||||
assert_eq!(selection.nth_user_message, 1);
|
||||
assert_eq!(selection.prefill, "follow-up (edited)");
|
||||
|
||||
let (_, nth, prefill) = app.backtrack.pending.clone().expect("pending backtrack");
|
||||
assert_eq!(nth, 1);
|
||||
assert_eq!(prefill, "follow-up (edited)");
|
||||
app.apply_backtrack_rollback(selection);
|
||||
|
||||
let mut rollback_turns = None;
|
||||
while let Ok(op) = op_rx.try_recv() {
|
||||
if let Op::ThreadRollback { num_turns } = op {
|
||||
rollback_turns = Some(num_turns);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(rollback_turns, Some(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+111
-121
@@ -1,5 +1,17 @@
|
||||
//! Backtracking and transcript overlay event routing.
|
||||
//!
|
||||
//! This file owns backtrack mode (Esc/Enter navigation in the transcript overlay) and also
|
||||
//! mediates a key rendering boundary for the transcript overlay.
|
||||
//!
|
||||
//! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live
|
||||
//! tail derived from the current in-flight `ChatWidget.active_cell`.
|
||||
//!
|
||||
//! That live tail is kept in sync during `TuiEvent::Draw` handling for `Overlay::Transcript` by
|
||||
//! asking `ChatWidget` for an active-cell cache key and transcript lines and by passing them into
|
||||
//! `TranscriptOverlay::sync_live_tail`. This preserves the invariant that the overlay reflects
|
||||
//! both committed history and in-flight activity without changing flush or coalescing behavior.
|
||||
|
||||
use std::any::TypeId;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::app::App;
|
||||
@@ -8,7 +20,7 @@ use crate::history_cell::UserHistoryCell;
|
||||
use crate::pager_overlay::Overlay;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use codex_core::protocol::ConversationPathResponseEvent;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_protocol::ThreadId;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -20,14 +32,18 @@ use crossterm::event::KeyEventKind;
|
||||
pub(crate) struct BacktrackState {
|
||||
/// True when Esc has primed backtrack mode in the main view.
|
||||
pub(crate) primed: bool,
|
||||
/// Session id of the base thread to fork from.
|
||||
/// Session id of the base thread to rollback.
|
||||
pub(crate) base_id: Option<ThreadId>,
|
||||
/// Index in the transcript of the last user message.
|
||||
pub(crate) nth_user_message: usize,
|
||||
/// True when the transcript overlay is showing a backtrack preview.
|
||||
pub(crate) overlay_preview_active: bool,
|
||||
/// Pending fork request: (base_id, nth_user_message, prefill).
|
||||
pub(crate) pending: Option<(ThreadId, usize, String)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct BacktrackSelection {
|
||||
pub(crate) nth_user_message: usize,
|
||||
pub(crate) prefill: String,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -96,22 +112,22 @@ impl App {
|
||||
}
|
||||
|
||||
/// Stage a backtrack and request thread history from the agent.
|
||||
pub(crate) fn request_backtrack(
|
||||
&mut self,
|
||||
prefill: String,
|
||||
base_id: ThreadId,
|
||||
nth_user_message: usize,
|
||||
) {
|
||||
self.backtrack.pending = Some((base_id, nth_user_message, prefill));
|
||||
if let Some(path) = self.chat_widget.rollout_path() {
|
||||
let ev = ConversationPathResponseEvent {
|
||||
conversation_id: base_id,
|
||||
path,
|
||||
};
|
||||
self.app_event_tx
|
||||
.send(crate::app_event::AppEvent::ConversationHistory(ev));
|
||||
} else {
|
||||
tracing::error!("rollout path unavailable; cannot backtrack");
|
||||
pub(crate) fn apply_backtrack_rollback(&mut self, selection: BacktrackSelection) {
|
||||
let user_total = user_count(&self.transcript_cells);
|
||||
if user_total == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let num_turns = user_total.saturating_sub(selection.nth_user_message);
|
||||
let num_turns = u32::try_from(num_turns).unwrap_or(u32::MAX);
|
||||
if num_turns == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
self.chat_widget.submit_op(Op::ThreadRollback { num_turns });
|
||||
self.trim_transcript_for_backtrack(selection.nth_user_message);
|
||||
if !selection.prefill.is_empty() {
|
||||
self.chat_widget.set_composer_text(selection.prefill);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +189,7 @@ impl App {
|
||||
self.backtrack.overlay_preview_active = true;
|
||||
let count = user_count(&self.transcript_cells);
|
||||
if let Some(last) = count.checked_sub(1) {
|
||||
self.apply_backtrack_selection(last);
|
||||
self.apply_backtrack_selection_internal(last);
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
@@ -197,12 +213,12 @@ impl App {
|
||||
.min(last_index)
|
||||
};
|
||||
|
||||
self.apply_backtrack_selection(next_selection);
|
||||
self.apply_backtrack_selection_internal(next_selection);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
/// Apply a computed backtrack selection to the overlay and internal counter.
|
||||
fn apply_backtrack_selection(&mut self, nth_user_message: usize) {
|
||||
fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) {
|
||||
if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) {
|
||||
self.backtrack.nth_user_message = nth_user_message;
|
||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||
@@ -216,8 +232,47 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward any event to the overlay and close it if done.
|
||||
/// Forwards an event to the overlay and closes it if done.
|
||||
///
|
||||
/// The transcript overlay draw path is special because the overlay should match the main
|
||||
/// viewport while the active cell is still streaming or mutating.
|
||||
///
|
||||
/// `TranscriptOverlay` owns committed transcript cells, while `ChatWidget` owns the current
|
||||
/// in-flight active cell (often a coalesced exec/tool group). During draws we append that
|
||||
/// in-flight cell as a cached, render-only live tail so `Ctrl+T` does not appear to "lose" tool
|
||||
/// calls until a later flush boundary.
|
||||
///
|
||||
/// This logic lives here (instead of inside the overlay widget) because `ChatWidget` is the
|
||||
/// source of truth for the active cell and its cache invalidation key, and because `App` owns
|
||||
/// overlay lifecycle and frame scheduling for animations.
|
||||
fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
||||
if let TuiEvent::Draw = &event
|
||||
&& let Some(Overlay::Transcript(t)) = &mut self.overlay
|
||||
{
|
||||
let active_key = self.chat_widget.active_cell_transcript_key();
|
||||
let chat_widget = &self.chat_widget;
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
let width = frame.area().width.max(1);
|
||||
t.sync_live_tail(width, active_key, |w| {
|
||||
chat_widget.active_cell_transcript_lines(w)
|
||||
});
|
||||
t.render(frame.area(), frame.buffer);
|
||||
})?;
|
||||
let close_overlay = t.is_done();
|
||||
if !close_overlay
|
||||
&& active_key.is_some_and(|key| key.animation_tick.is_some())
|
||||
&& t.is_scrolled_to_bottom()
|
||||
{
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(std::time::Duration::from_millis(50));
|
||||
}
|
||||
if close_overlay {
|
||||
self.close_transcript_overlay(tui);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(overlay) = &mut self.overlay {
|
||||
overlay.handle_event(tui, event)?;
|
||||
if overlay.is_done() {
|
||||
@@ -231,16 +286,13 @@ impl App {
|
||||
/// Handle Enter in overlay backtrack preview: confirm selection and reset state.
|
||||
fn overlay_confirm_backtrack(&mut self, tui: &mut tui::Tui) {
|
||||
let nth_user_message = self.backtrack.nth_user_message;
|
||||
if let Some(base_id) = self.backtrack.base_id {
|
||||
let prefill = nth_user_position(&self.transcript_cells, nth_user_message)
|
||||
.and_then(|idx| self.transcript_cells.get(idx))
|
||||
.and_then(|cell| cell.as_any().downcast_ref::<UserHistoryCell>())
|
||||
.map(|c| c.message.clone())
|
||||
.unwrap_or_default();
|
||||
self.close_transcript_overlay(tui);
|
||||
self.request_backtrack(prefill, base_id, nth_user_message);
|
||||
let selection = self.backtrack_selection(nth_user_message);
|
||||
self.close_transcript_overlay(tui);
|
||||
if let Some(selection) = selection {
|
||||
self.apply_backtrack_rollback(selection);
|
||||
self.render_transcript_once(tui);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
self.reset_backtrack_state();
|
||||
}
|
||||
|
||||
/// Handle Esc in overlay backtrack preview: step selection if armed, else forward.
|
||||
@@ -254,18 +306,11 @@ impl App {
|
||||
}
|
||||
|
||||
/// Confirm a primed backtrack from the main view (no overlay visible).
|
||||
/// Computes the prefill from the selected user message and requests history.
|
||||
pub(crate) fn confirm_backtrack_from_main(&mut self) {
|
||||
if let Some(base_id) = self.backtrack.base_id {
|
||||
let prefill =
|
||||
nth_user_position(&self.transcript_cells, self.backtrack.nth_user_message)
|
||||
.and_then(|idx| self.transcript_cells.get(idx))
|
||||
.and_then(|cell| cell.as_any().downcast_ref::<UserHistoryCell>())
|
||||
.map(|c| c.message.clone())
|
||||
.unwrap_or_default();
|
||||
self.request_backtrack(prefill, base_id, self.backtrack.nth_user_message);
|
||||
}
|
||||
/// Computes the prefill from the selected user message for rollback.
|
||||
pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option<BacktrackSelection> {
|
||||
let selection = self.backtrack_selection(self.backtrack.nth_user_message);
|
||||
self.reset_backtrack_state();
|
||||
selection
|
||||
}
|
||||
|
||||
/// Clear all backtrack-related state and composer hints.
|
||||
@@ -277,89 +322,34 @@ impl App {
|
||||
self.chat_widget.clear_esc_backtrack_hint();
|
||||
}
|
||||
|
||||
/// Handle a ConversationHistory response while a backtrack is pending.
|
||||
/// If it matches the primed base session, fork and switch to the new conversation.
|
||||
pub(crate) async fn on_conversation_history_for_backtrack(
|
||||
pub(crate) fn apply_backtrack_selection(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
ev: ConversationPathResponseEvent,
|
||||
) -> Result<()> {
|
||||
if let Some((base_id, _, _)) = self.backtrack.pending.as_ref()
|
||||
&& ev.conversation_id == *base_id
|
||||
&& let Some((_, nth_user_message, prefill)) = self.backtrack.pending.take()
|
||||
{
|
||||
self.fork_and_switch_to_new_conversation(tui, ev, nth_user_message, prefill)
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fork the conversation using provided history and switch UI/state accordingly.
|
||||
async fn fork_and_switch_to_new_conversation(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
ev: ConversationPathResponseEvent,
|
||||
nth_user_message: usize,
|
||||
prefill: String,
|
||||
selection: BacktrackSelection,
|
||||
) {
|
||||
let cfg = self.chat_widget.config_ref().clone();
|
||||
// Perform the fork via a thin wrapper for clarity/testability.
|
||||
let result = self
|
||||
.perform_fork(ev.path.clone(), nth_user_message, cfg.clone())
|
||||
.await;
|
||||
match result {
|
||||
Ok(new_conv) => {
|
||||
self.install_forked_conversation(tui, cfg, new_conv, nth_user_message, &prefill)
|
||||
}
|
||||
Err(e) => tracing::error!("error forking conversation: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin wrapper around ThreadManager::fork_thread.
|
||||
async fn perform_fork(
|
||||
&self,
|
||||
path: PathBuf,
|
||||
nth_user_message: usize,
|
||||
cfg: codex_core::config::Config,
|
||||
) -> codex_core::error::Result<codex_core::NewThread> {
|
||||
self.server.fork_thread(nth_user_message, cfg, path).await
|
||||
}
|
||||
|
||||
/// Install a forked thread into the ChatWidget and update UI to reflect selection.
|
||||
fn install_forked_conversation(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
cfg: codex_core::config::Config,
|
||||
new_conv: codex_core::NewThread,
|
||||
nth_user_message: usize,
|
||||
prefill: &str,
|
||||
) {
|
||||
let thread = new_conv.thread;
|
||||
let session_configured = new_conv.session_configured;
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: cfg,
|
||||
model: self.current_model.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: self.app_event_tx.clone(),
|
||||
initial_prompt: None,
|
||||
initial_images: Vec::new(),
|
||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
models_manager: self.server.get_models_manager(),
|
||||
feedback: self.feedback.clone(),
|
||||
is_first_run: false,
|
||||
};
|
||||
self.chat_widget =
|
||||
crate::chatwidget::ChatWidget::new_from_existing(init, thread, session_configured);
|
||||
// Trim transcript up to the selected user message and re-render it.
|
||||
self.trim_transcript_for_backtrack(nth_user_message);
|
||||
self.apply_backtrack_rollback(selection);
|
||||
self.render_transcript_once(tui);
|
||||
if !prefill.is_empty() {
|
||||
self.chat_widget.set_composer_text(prefill.to_string());
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
fn backtrack_selection(&self, nth_user_message: usize) -> Option<BacktrackSelection> {
|
||||
let base_id = self.backtrack.base_id?;
|
||||
if self.chat_widget.thread_id() != Some(base_id) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prefill = nth_user_position(&self.transcript_cells, nth_user_message)
|
||||
.and_then(|idx| self.transcript_cells.get(idx))
|
||||
.and_then(|cell| cell.as_any().downcast_ref::<UserHistoryCell>())
|
||||
.map(|c| c.message.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(BacktrackSelection {
|
||||
nth_user_message,
|
||||
prefill,
|
||||
})
|
||||
}
|
||||
|
||||
/// Trim transcript_cells to preserve only content up to the selected user message.
|
||||
fn trim_transcript_for_backtrack(&mut self, nth_user_message: usize) {
|
||||
trim_transcript_cells_to_nth_user(&mut self.transcript_cells, nth_user_message);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_common::approval_presets::ApprovalPreset;
|
||||
use codex_core::protocol::ConversationPathResponseEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::RateLimitSnapshot;
|
||||
use codex_file_search::FileMatch;
|
||||
@@ -39,6 +38,9 @@ pub(crate) enum AppEvent {
|
||||
/// Open the resume picker inside the running TUI session.
|
||||
OpenResumePicker,
|
||||
|
||||
/// Open the fork picker inside the running TUI session.
|
||||
OpenForkPicker,
|
||||
|
||||
/// Request to exit the application gracefully.
|
||||
ExitRequest,
|
||||
|
||||
@@ -183,9 +185,6 @@ pub(crate) enum AppEvent {
|
||||
/// Re-open the approval presets popup.
|
||||
OpenApprovalsPopup,
|
||||
|
||||
/// Forwarded conversation history snapshot from the current conversation.
|
||||
ConversationHistory(ConversationPathResponseEvent),
|
||||
|
||||
/// Open the branch picker option from the review popup.
|
||||
OpenReviewBranchPicker(PathBuf),
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum InputResult {
|
||||
Submitted(String),
|
||||
Queued(String),
|
||||
Command(SlashCommand),
|
||||
CommandWithArgs(SlashCommand, String),
|
||||
None,
|
||||
@@ -132,6 +133,7 @@ pub(crate) struct ChatComposer {
|
||||
context_window_used_tokens: Option<i64>,
|
||||
skills: Option<Vec<SkillMetadata>>,
|
||||
dismissed_skill_popup_token: Option<String>,
|
||||
steer_enabled: bool,
|
||||
}
|
||||
|
||||
/// Popup state – at most one can be visible at any time.
|
||||
@@ -182,6 +184,7 @@ impl ChatComposer {
|
||||
context_window_used_tokens: None,
|
||||
skills: None,
|
||||
dismissed_skill_popup_token: None,
|
||||
steer_enabled: false,
|
||||
};
|
||||
// Apply configuration via the setter to keep side-effects centralized.
|
||||
this.set_disable_paste_burst(disable_paste_burst);
|
||||
@@ -192,6 +195,10 @@ impl ChatComposer {
|
||||
self.skills = skills;
|
||||
}
|
||||
|
||||
pub fn set_steer_enabled(&mut self, enabled: bool) {
|
||||
self.steer_enabled = enabled;
|
||||
}
|
||||
|
||||
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
|
||||
let footer_props = self.footer_props();
|
||||
let footer_hint_height = self
|
||||
@@ -1140,6 +1147,199 @@ impl ChatComposer {
|
||||
self.textarea.set_cursor(new_cursor);
|
||||
}
|
||||
|
||||
/// Prepare text for submission/queuing. Returns None if submission should be suppressed.
|
||||
fn prepare_submission_text(&mut self) -> Option<String> {
|
||||
// If we have pending placeholder pastes, replace them in the textarea text
|
||||
// and continue to the normal submission flow to handle slash commands.
|
||||
if !self.pending_pastes.is_empty() {
|
||||
let mut text = self.textarea.text().to_string();
|
||||
for (placeholder, actual) in &self.pending_pastes {
|
||||
if text.contains(placeholder) {
|
||||
text = text.replace(placeholder, actual);
|
||||
}
|
||||
}
|
||||
self.textarea.set_text(&text);
|
||||
self.pending_pastes.clear();
|
||||
}
|
||||
|
||||
let mut text = self.textarea.text().to_string();
|
||||
let original_input = text.clone();
|
||||
let input_starts_with_space = original_input.starts_with(' ');
|
||||
self.textarea.set_text("");
|
||||
|
||||
// Replace all pending pastes in the text
|
||||
for (placeholder, actual) in &self.pending_pastes {
|
||||
if text.contains(placeholder) {
|
||||
text = text.replace(placeholder, actual);
|
||||
}
|
||||
}
|
||||
self.pending_pastes.clear();
|
||||
|
||||
// If there is neither text nor attachments, suppress submission entirely.
|
||||
let has_attachments = !self.attached_images.is_empty();
|
||||
text = text.trim().to_string();
|
||||
|
||||
if let Some((name, _rest)) = parse_slash_name(&text) {
|
||||
let treat_as_plain_text = input_starts_with_space || name.contains('/');
|
||||
if !treat_as_plain_text {
|
||||
let is_builtin = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| {
|
||||
windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox
|
||||
})
|
||||
.any(|(command_name, _)| command_name == name);
|
||||
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
|
||||
let is_known_prompt = name
|
||||
.strip_prefix(&prompt_prefix)
|
||||
.map(|prompt_name| {
|
||||
self.custom_prompts
|
||||
.iter()
|
||||
.any(|prompt| prompt.name == prompt_name)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !is_builtin && !is_known_prompt {
|
||||
let message = format!(
|
||||
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
|
||||
);
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event(message, None),
|
||||
)));
|
||||
self.textarea.set_text(&original_input);
|
||||
self.textarea.set_cursor(original_input.len());
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) {
|
||||
Ok(expanded) => expanded,
|
||||
Err(err) => {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_error_event(err.user_message()),
|
||||
)));
|
||||
self.textarea.set_text(&original_input);
|
||||
self.textarea.set_cursor(original_input.len());
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if let Some(expanded) = expanded_prompt {
|
||||
text = expanded;
|
||||
}
|
||||
if text.is_empty() && !has_attachments {
|
||||
return None;
|
||||
}
|
||||
if !text.is_empty() {
|
||||
self.history.record_local_submission(&text);
|
||||
}
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// Common logic for handling message submission/queuing.
|
||||
/// Returns the appropriate InputResult based on `should_queue`.
|
||||
fn handle_submission(&mut self, should_queue: bool) -> (InputResult, bool) {
|
||||
// If the first line is a bare built-in slash command (no args),
|
||||
// dispatch it even when the slash popup isn't visible. This preserves
|
||||
// the workflow: type a prefix ("/di"), press Tab to complete to
|
||||
// "/diff ", then press Enter/Ctrl+Shift+Q to run it. Tab moves the cursor beyond
|
||||
// the '/name' token and our caret-based heuristic hides the popup,
|
||||
// but Enter/Ctrl+Shift+Q should still dispatch the command rather than submit
|
||||
// literal text.
|
||||
if let Some(result) = self.try_dispatch_bare_slash_command() {
|
||||
return (result, true);
|
||||
}
|
||||
|
||||
// If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst
|
||||
// and accumulate it rather than submitting or inserting immediately.
|
||||
// Do not treat as paste inside a slash-command context.
|
||||
let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_))
|
||||
|| self
|
||||
.textarea
|
||||
.text()
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.starts_with('/');
|
||||
if self.paste_burst.is_active() && !in_slash_context {
|
||||
let now = Instant::now();
|
||||
if self.paste_burst.append_newline_if_active(now) {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
|
||||
// During a paste-like burst, treat Enter/Ctrl+Shift+Q as a newline instead of submit.
|
||||
let now = Instant::now();
|
||||
if self
|
||||
.paste_burst
|
||||
.newline_should_insert_instead_of_submit(now)
|
||||
&& !in_slash_context
|
||||
{
|
||||
self.textarea.insert_str("\n");
|
||||
self.paste_burst.extend_window(now);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
|
||||
let original_input = self.textarea.text().to_string();
|
||||
if let Some(result) = self.try_dispatch_slash_command_with_args() {
|
||||
return (result, true);
|
||||
}
|
||||
|
||||
if let Some(text) = self.prepare_submission_text() {
|
||||
if should_queue {
|
||||
(InputResult::Queued(text), true)
|
||||
} else {
|
||||
// Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images().
|
||||
(InputResult::Submitted(text), true)
|
||||
}
|
||||
} else {
|
||||
// Restore text if submission was suppressed
|
||||
self.textarea.set_text(&original_input);
|
||||
(InputResult::None, true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the first line is a bare slash command (no args) and dispatch it.
|
||||
/// Returns Some(InputResult) if a command was dispatched, None otherwise.
|
||||
fn try_dispatch_bare_slash_command(&mut self) -> Option<InputResult> {
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
if let Some((name, rest)) = parse_slash_name(first_line)
|
||||
&& rest.is_empty()
|
||||
&& let Some((_n, cmd)) = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| {
|
||||
windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox
|
||||
})
|
||||
.find(|(n, _)| *n == name)
|
||||
{
|
||||
self.textarea.set_text("");
|
||||
Some(InputResult::Command(cmd))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the input is a slash command with args (e.g., /review args) and dispatch it.
|
||||
/// Returns Some(InputResult) if a command was dispatched, None otherwise.
|
||||
fn try_dispatch_slash_command_with_args(&mut self) -> Option<InputResult> {
|
||||
let original_input = self.textarea.text().to_string();
|
||||
let input_starts_with_space = original_input.starts_with(' ');
|
||||
|
||||
if !input_starts_with_space {
|
||||
let text = self.textarea.text().to_string();
|
||||
if let Some((name, rest)) = parse_slash_name(&text)
|
||||
&& !rest.is_empty()
|
||||
&& !name.contains('/')
|
||||
&& let Some((_n, cmd)) = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.find(|(command_name, _)| *command_name == name)
|
||||
&& cmd == SlashCommand::Review
|
||||
{
|
||||
self.textarea.set_text("");
|
||||
return Some(InputResult::CommandWithArgs(cmd, rest.to_string()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Handle key event when no popup is visible.
|
||||
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
if self.handle_shortcut_overlay_key(&key_event) {
|
||||
@@ -1198,156 +1398,19 @@ impl ChatComposer {
|
||||
}
|
||||
self.handle_input_basic(key_event)
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Tab,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => self.handle_submission(true),
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
// If the first line is a bare built-in slash command (no args),
|
||||
// dispatch it even when the slash popup isn't visible. This preserves
|
||||
// the workflow: type a prefix ("/di"), press Tab to complete to
|
||||
// "/diff ", then press Enter to run it. Tab moves the cursor beyond
|
||||
// the '/name' token and our caret-based heuristic hides the popup,
|
||||
// but Enter should still dispatch the command rather than submit
|
||||
// literal text.
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
if let Some((name, rest)) = parse_slash_name(first_line)
|
||||
&& rest.is_empty()
|
||||
&& let Some((_n, cmd)) = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| {
|
||||
windows_degraded_sandbox_active()
|
||||
|| *cmd != SlashCommand::ElevateSandbox
|
||||
})
|
||||
.find(|(n, _)| *n == name)
|
||||
{
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Command(cmd), true);
|
||||
}
|
||||
// If we're in a paste-like burst capture, treat Enter as part of the burst
|
||||
// and accumulate it rather than submitting or inserting immediately.
|
||||
// Do not treat Enter as paste inside a slash-command context.
|
||||
let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_))
|
||||
|| self
|
||||
.textarea
|
||||
.text()
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.starts_with('/');
|
||||
if self.paste_burst.is_active() && !in_slash_context {
|
||||
let now = Instant::now();
|
||||
if self.paste_burst.append_newline_if_active(now) {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
// If we have pending placeholder pastes, replace them in the textarea text
|
||||
// and continue to the normal submission flow to handle slash commands.
|
||||
if !self.pending_pastes.is_empty() {
|
||||
let mut text = self.textarea.text().to_string();
|
||||
for (placeholder, actual) in &self.pending_pastes {
|
||||
if text.contains(placeholder) {
|
||||
text = text.replace(placeholder, actual);
|
||||
}
|
||||
}
|
||||
self.textarea.set_text(&text);
|
||||
self.pending_pastes.clear();
|
||||
}
|
||||
|
||||
// During a paste-like burst, treat Enter as a newline instead of submit.
|
||||
let now = Instant::now();
|
||||
if self
|
||||
.paste_burst
|
||||
.newline_should_insert_instead_of_submit(now)
|
||||
&& !in_slash_context
|
||||
{
|
||||
self.textarea.insert_str("\n");
|
||||
self.paste_burst.extend_window(now);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
let mut text = self.textarea.text().to_string();
|
||||
let original_input = text.clone();
|
||||
let input_starts_with_space = original_input.starts_with(' ');
|
||||
self.textarea.set_text("");
|
||||
|
||||
// Replace all pending pastes in the text
|
||||
for (placeholder, actual) in &self.pending_pastes {
|
||||
if text.contains(placeholder) {
|
||||
text = text.replace(placeholder, actual);
|
||||
}
|
||||
}
|
||||
self.pending_pastes.clear();
|
||||
|
||||
// If there is neither text nor attachments, suppress submission entirely.
|
||||
let has_attachments = !self.attached_images.is_empty();
|
||||
text = text.trim().to_string();
|
||||
if let Some((name, _rest)) = parse_slash_name(&text) {
|
||||
let treat_as_plain_text = input_starts_with_space || name.contains('/');
|
||||
if !treat_as_plain_text {
|
||||
let is_builtin = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| {
|
||||
windows_degraded_sandbox_active()
|
||||
|| *cmd != SlashCommand::ElevateSandbox
|
||||
})
|
||||
.any(|(command_name, _)| command_name == name);
|
||||
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
|
||||
let is_known_prompt = name
|
||||
.strip_prefix(&prompt_prefix)
|
||||
.map(|prompt_name| {
|
||||
self.custom_prompts
|
||||
.iter()
|
||||
.any(|prompt| prompt.name == prompt_name)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !is_builtin && !is_known_prompt {
|
||||
let message = format!(
|
||||
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
|
||||
);
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event(message, None),
|
||||
)));
|
||||
self.textarea.set_text(&original_input);
|
||||
self.textarea.set_cursor(original_input.len());
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !input_starts_with_space
|
||||
&& let Some((name, rest)) = parse_slash_name(&text)
|
||||
&& !rest.is_empty()
|
||||
&& !name.contains('/')
|
||||
&& let Some((_n, cmd)) = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.find(|(command_name, _)| *command_name == name)
|
||||
&& cmd == SlashCommand::Review
|
||||
{
|
||||
return (InputResult::CommandWithArgs(cmd, rest.to_string()), true);
|
||||
}
|
||||
|
||||
let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) {
|
||||
Ok(expanded) => expanded,
|
||||
Err(err) => {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_error_event(err.user_message()),
|
||||
)));
|
||||
self.textarea.set_text(&original_input);
|
||||
self.textarea.set_cursor(original_input.len());
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
};
|
||||
if let Some(expanded) = expanded_prompt {
|
||||
text = expanded;
|
||||
}
|
||||
if text.is_empty() && !has_attachments {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
if !text.is_empty() {
|
||||
self.history.record_local_submission(&text);
|
||||
}
|
||||
// Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images().
|
||||
(InputResult::Submitted(text), true)
|
||||
let should_queue = !self.steer_enabled;
|
||||
self.handle_submission(should_queue)
|
||||
}
|
||||
input => self.handle_input_basic(input),
|
||||
}
|
||||
@@ -1578,6 +1641,7 @@ impl ChatComposer {
|
||||
esc_backtrack_hint: self.esc_backtrack_hint,
|
||||
use_shift_enter_hint: self.use_shift_enter_hint,
|
||||
is_task_running: self.is_task_running,
|
||||
steer_enabled: self.steer_enabled,
|
||||
context_window_percent: self.context_window_percent,
|
||||
context_window_used_tokens: self.context_window_used_tokens,
|
||||
}
|
||||
@@ -2208,6 +2272,14 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
composer.set_text_content("draft text".to_string());
|
||||
assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string()));
|
||||
@@ -2234,6 +2306,13 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
let (result, needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
|
||||
@@ -2274,6 +2353,10 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
// Force an active paste burst so this test doesn't depend on tight timing.
|
||||
composer
|
||||
@@ -2307,6 +2390,10 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
|
||||
assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay);
|
||||
@@ -2481,6 +2568,10 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
||||
assert!(composer.is_in_paste_burst());
|
||||
@@ -2510,6 +2601,10 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE));
|
||||
|
||||
@@ -2581,6 +2676,9 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
// Force an active burst so this test doesn't depend on tight timing.
|
||||
composer
|
||||
@@ -2620,6 +2718,9 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
let needs_redraw = composer.handle_paste("hello".to_string());
|
||||
assert!(needs_redraw);
|
||||
@@ -2649,6 +2750,9 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
// Ensure composer is empty and press Enter.
|
||||
assert!(composer.textarea.text().is_empty());
|
||||
@@ -2676,6 +2780,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10);
|
||||
let needs_redraw = composer.handle_paste(large.clone());
|
||||
@@ -2711,6 +2816,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
composer.handle_paste(large);
|
||||
assert_eq!(composer.pending_pastes.len(), 1);
|
||||
@@ -2780,6 +2886,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_placeholder_snapshots() {
|
||||
snapshot_composer_state("image_placeholder_single", false, |composer| {
|
||||
composer.attach_image(PathBuf::from("/tmp/image1.png"));
|
||||
});
|
||||
|
||||
snapshot_composer_state("image_placeholder_multiple", false, |composer| {
|
||||
composer.attach_image(PathBuf::from("/tmp/image1.png"));
|
||||
composer.attach_image(PathBuf::from("/tmp/image2.png"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_popup_model_first_for_mo_ui() {
|
||||
use ratatui::Terminal;
|
||||
@@ -2795,6 +2913,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
// Type "/mo" humanlike so paste-burst doesn’t interfere.
|
||||
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
|
||||
@@ -2823,6 +2942,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
|
||||
|
||||
match &composer.active_popup {
|
||||
@@ -2854,6 +2974,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
// Type "/res" humanlike so paste-burst doesn’t interfere.
|
||||
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']);
|
||||
@@ -2947,6 +3068,9 @@ mod tests {
|
||||
InputResult::Submitted(text) => {
|
||||
panic!("expected command dispatch, but composer submitted literal text: {text}")
|
||||
}
|
||||
InputResult::Queued(_) => {
|
||||
panic!("expected command dispatch, but composer queued literal text")
|
||||
}
|
||||
InputResult::None => panic!("expected Command result for '/init'"),
|
||||
}
|
||||
assert!(composer.textarea.is_empty(), "composer should be cleared");
|
||||
@@ -3023,6 +3147,9 @@ mod tests {
|
||||
InputResult::Submitted(text) => {
|
||||
panic!("expected command dispatch after Tab completion, got literal submit: {text}")
|
||||
}
|
||||
InputResult::Queued(_) => {
|
||||
panic!("expected command dispatch after Tab completion, got literal queue")
|
||||
}
|
||||
InputResult::None => panic!("expected Command result for '/diff'"),
|
||||
}
|
||||
assert!(composer.textarea.is_empty());
|
||||
@@ -3059,6 +3186,9 @@ mod tests {
|
||||
InputResult::Submitted(text) => {
|
||||
panic!("expected command dispatch, but composer submitted literal text: {text}")
|
||||
}
|
||||
InputResult::Queued(_) => {
|
||||
panic!("expected command dispatch, but composer queued literal text")
|
||||
}
|
||||
InputResult::None => panic!("expected Command result for '/mention'"),
|
||||
}
|
||||
assert!(composer.textarea.is_empty(), "composer should be cleared");
|
||||
@@ -3081,6 +3211,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
// Define test cases: (paste content, is_large)
|
||||
let test_cases = [
|
||||
@@ -3357,6 +3488,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
let path = PathBuf::from("/tmp/image1.png");
|
||||
composer.attach_image(path.clone());
|
||||
composer.handle_paste(" hi".into());
|
||||
@@ -3381,6 +3513,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
let path = PathBuf::from("/tmp/image2.png");
|
||||
composer.attach_image(path.clone());
|
||||
let (result, _) =
|
||||
@@ -3616,6 +3749,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
// Inject prompts as if received via event.
|
||||
composer.set_custom_prompts(vec![CustomPrompt {
|
||||
@@ -3652,6 +3786,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
composer.set_custom_prompts(vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
@@ -3686,6 +3821,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
composer.set_custom_prompts(vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
@@ -3724,6 +3860,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
// Create a custom prompt with positional args (no named args like $USER)
|
||||
composer.set_custom_prompts(vec![CustomPrompt {
|
||||
@@ -3788,6 +3925,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
composer
|
||||
.textarea
|
||||
@@ -3824,6 +3962,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
composer.textarea.set_text(" /this-looks-like-a-command");
|
||||
|
||||
@@ -3957,6 +4096,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
composer.set_custom_prompts(vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
@@ -3994,6 +4134,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
composer.set_custom_prompts(vec![CustomPrompt {
|
||||
name: "elegant".to_string(),
|
||||
@@ -4061,6 +4202,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
composer.set_custom_prompts(vec![CustomPrompt {
|
||||
name: "price".to_string(),
|
||||
@@ -4098,6 +4240,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
composer.set_custom_prompts(vec![CustomPrompt {
|
||||
name: "repeat".to_string(),
|
||||
|
||||
@@ -20,6 +20,7 @@ pub(crate) struct FooterProps {
|
||||
pub(crate) esc_backtrack_hint: bool,
|
||||
pub(crate) use_shift_enter_hint: bool,
|
||||
pub(crate) is_task_running: bool,
|
||||
pub(crate) steer_enabled: bool,
|
||||
pub(crate) context_window_percent: Option<i64>,
|
||||
pub(crate) context_window_used_tokens: Option<i64>,
|
||||
}
|
||||
@@ -110,10 +111,18 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
|
||||
shortcut_overlay_lines(state)
|
||||
}
|
||||
FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)],
|
||||
FooterMode::ContextOnly => vec![context_window_line(
|
||||
props.context_window_percent,
|
||||
props.context_window_used_tokens,
|
||||
)],
|
||||
FooterMode::ContextOnly => {
|
||||
let mut line = context_window_line(
|
||||
props.context_window_percent,
|
||||
props.context_window_used_tokens,
|
||||
);
|
||||
if props.is_task_running && props.steer_enabled {
|
||||
line.push_span(" · ".dim());
|
||||
line.push_span(key_hint::plain(KeyCode::Tab));
|
||||
line.push_span(" to queue message".dim());
|
||||
}
|
||||
vec![line]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +170,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
|
||||
let mut commands = Line::from("");
|
||||
let mut shell_commands = Line::from("");
|
||||
let mut newline = Line::from("");
|
||||
let mut queue_message_tab = Line::from("");
|
||||
let mut file_paths = Line::from("");
|
||||
let mut paste_image = Line::from("");
|
||||
let mut external_editor = Line::from("");
|
||||
@@ -174,6 +184,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
|
||||
ShortcutId::Commands => commands = text,
|
||||
ShortcutId::ShellCommands => shell_commands = text,
|
||||
ShortcutId::InsertNewline => newline = text,
|
||||
ShortcutId::QueueMessageTab => queue_message_tab = text,
|
||||
ShortcutId::FilePaths => file_paths = text,
|
||||
ShortcutId::PasteImage => paste_image = text,
|
||||
ShortcutId::ExternalEditor => external_editor = text,
|
||||
@@ -188,6 +199,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
|
||||
commands,
|
||||
shell_commands,
|
||||
newline,
|
||||
queue_message_tab,
|
||||
file_paths,
|
||||
paste_image,
|
||||
external_editor,
|
||||
@@ -266,6 +278,7 @@ enum ShortcutId {
|
||||
Commands,
|
||||
ShellCommands,
|
||||
InsertNewline,
|
||||
QueueMessageTab,
|
||||
FilePaths,
|
||||
PasteImage,
|
||||
ExternalEditor,
|
||||
@@ -372,6 +385,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
|
||||
prefix: "",
|
||||
label: " for newline",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::QueueMessageTab,
|
||||
bindings: &[ShortcutBinding {
|
||||
key: key_hint::plain(KeyCode::Tab),
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
prefix: "",
|
||||
label: " to queue message",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::FilePaths,
|
||||
bindings: &[ShortcutBinding {
|
||||
@@ -464,6 +486,7 @@ mod tests {
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
@@ -476,6 +499,7 @@ mod tests {
|
||||
esc_backtrack_hint: true,
|
||||
use_shift_enter_hint: true,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
@@ -488,6 +512,7 @@ mod tests {
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
@@ -500,6 +525,7 @@ mod tests {
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
steer_enabled: false,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
@@ -512,6 +538,7 @@ mod tests {
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
@@ -524,6 +551,7 @@ mod tests {
|
||||
esc_backtrack_hint: true,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
@@ -536,6 +564,7 @@ mod tests {
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
steer_enabled: false,
|
||||
context_window_percent: Some(72),
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
@@ -548,9 +577,36 @@ mod tests {
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: Some(123_456),
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_context_only_queue_hint_disabled",
|
||||
FooterProps {
|
||||
mode: FooterMode::ContextOnly,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
steer_enabled: false,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_context_only_queue_hint_enabled",
|
||||
FooterProps {
|
||||
mode: FooterMode::ContextOnly,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
steer_enabled: true,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,10 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn set_steer_enabled(&mut self, enabled: bool) {
|
||||
self.composer.set_steer_enabled(enabled);
|
||||
}
|
||||
|
||||
pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> {
|
||||
self.status.as_ref()
|
||||
}
|
||||
|
||||
+6
-6
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
assertion_line: 2151
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
@@ -11,8 +10,9 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" / for commands ! for shell commands "
|
||||
" shift + enter for newline @ for file paths "
|
||||
" ctrl + v to paste images ctrl + g to edit in external editor "
|
||||
" esc again to edit previous message ctrl + c to exit "
|
||||
" ctrl + t to view transcript "
|
||||
" / for commands ! for shell commands "
|
||||
" shift + enter for newline tab to queue message "
|
||||
" @ for file paths ctrl + v to paste images "
|
||||
" ctrl + g to edit in external editor esc again to edit previous message "
|
||||
" ctrl + c to exit "
|
||||
" ctrl + t to view transcript "
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
assertion_line: 2116
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› [Image #1][Image #2] "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left "
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
assertion_line: 2116
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› [Image #1] "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left "
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 100% context left "
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 100% context left · tab to queue message "
|
||||
+6
-6
@@ -1,10 +1,10 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
assertion_line: 455
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" / for commands ! for shell commands "
|
||||
" shift + enter for newline @ for file paths "
|
||||
" ctrl + v to paste images ctrl + g to edit in external editor "
|
||||
" esc again to edit previous message ctrl + c to exit "
|
||||
" ctrl + t to view transcript "
|
||||
" / for commands ! for shell commands "
|
||||
" shift + enter for newline tab to queue message "
|
||||
" @ for file paths ctrl + v to paste images "
|
||||
" ctrl + g to edit in external editor esc again to edit previous message "
|
||||
" ctrl + c to exit "
|
||||
" ctrl + t to view transcript "
|
||||
|
||||
+169
-21
@@ -1,3 +1,20 @@
|
||||
//! The main Codex TUI chat surface.
|
||||
//!
|
||||
//! `ChatWidget` consumes protocol events, builds and updates history cells, and drives rendering
|
||||
//! for both the main viewport and overlay UIs.
|
||||
//!
|
||||
//! The UI has both committed transcript cells (finalized `HistoryCell`s) and an in-flight active
|
||||
//! cell (`ChatWidget.active_cell`) that can mutate in place while streaming (often representing a
|
||||
//! coalesced exec/tool group). The transcript overlay (`Ctrl+T`) renders committed cells plus a
|
||||
//! cached, render-only live tail derived from the current active cell so in-flight tool calls are
|
||||
//! visible immediately.
|
||||
//!
|
||||
//! The transcript overlay is kept in sync by `App::overlay_forward_event`, which syncs a live tail
|
||||
//! during draws using `active_cell_transcript_key()` and `active_cell_transcript_lines()`. The
|
||||
//! cache key is designed to change when the active cell mutates in place or when its transcript
|
||||
//! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on
|
||||
//! every draw.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
@@ -318,6 +335,16 @@ pub(crate) struct ChatWidget {
|
||||
codex_op_tx: UnboundedSender<Op>,
|
||||
bottom_pane: BottomPane,
|
||||
active_cell: Option<Box<dyn HistoryCell>>,
|
||||
/// Monotonic-ish counter used to invalidate transcript overlay caching.
|
||||
///
|
||||
/// The transcript overlay appends a cached "live tail" for the current active cell. Most
|
||||
/// active-cell updates are mutations of the *existing* cell (not a replacement), so pointer
|
||||
/// identity alone is not a good cache key.
|
||||
///
|
||||
/// Callers bump this whenever the active cell's transcript output could change without
|
||||
/// flushing. It is intentionally allowed to wrap, which implies a rare one-time cache collision
|
||||
/// where the overlay may briefly treat new tail content as already cached.
|
||||
active_cell_revision: u64,
|
||||
config: Config,
|
||||
model: String,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
@@ -374,6 +401,30 @@ pub(crate) struct ChatWidget {
|
||||
external_editor_state: ExternalEditorState,
|
||||
}
|
||||
|
||||
/// Snapshot of active-cell state that affects transcript overlay rendering.
|
||||
///
|
||||
/// The overlay keeps a cached "live tail" for the in-flight cell; this key lets
|
||||
/// it cheaply decide when to recompute that tail as the active cell evolves.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ActiveCellTranscriptKey {
|
||||
/// Cache-busting revision for in-place updates.
|
||||
///
|
||||
/// Many active cells are updated incrementally while streaming (for example when exec groups
|
||||
/// add output or change status), and the transcript overlay caches its live tail, so this
|
||||
/// revision gives a cheap way to say "same active cell, but its transcript output is different
|
||||
/// now". Callers bump it on any mutation that can affect `HistoryCell::transcript_lines`.
|
||||
pub(crate) revision: u64,
|
||||
/// Whether the active cell continues the prior stream, which affects
|
||||
/// spacing between transcript blocks.
|
||||
pub(crate) is_stream_continuation: bool,
|
||||
/// Optional animation tick for time-dependent transcript output.
|
||||
///
|
||||
/// When this changes, the overlay recomputes the cached tail even if the revision and width
|
||||
/// are unchanged, which is how shimmer/spinner visuals can animate in the overlay without any
|
||||
/// underlying data change.
|
||||
pub(crate) animation_tick: Option<u64>,
|
||||
}
|
||||
|
||||
struct UserMessage {
|
||||
text: String,
|
||||
image_paths: Vec<PathBuf>,
|
||||
@@ -903,7 +954,9 @@ impl ChatWidget {
|
||||
}) && wait_cell.matches(command_display.as_deref())
|
||||
{
|
||||
// Same process still waiting; update command display if it shows up late.
|
||||
wait_cell.update_command_display(command_display);
|
||||
if wait_cell.update_command_display(command_display) {
|
||||
self.bump_active_cell_revision();
|
||||
}
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
@@ -924,6 +977,7 @@ impl ChatWidget {
|
||||
command_display,
|
||||
self.config.animations,
|
||||
)));
|
||||
self.bump_active_cell_revision();
|
||||
self.request_redraw();
|
||||
} else {
|
||||
if let Some(wait_cell) = self.active_cell.as_ref().and_then(|cell| {
|
||||
@@ -1228,6 +1282,9 @@ impl ChatWidget {
|
||||
cell.complete_call(&ev.call_id, output, ev.duration);
|
||||
if cell.should_flush() {
|
||||
self.flush_active_cell();
|
||||
} else {
|
||||
self.bump_active_cell_revision();
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1344,6 +1401,7 @@ impl ChatWidget {
|
||||
)
|
||||
{
|
||||
*cell = new_exec;
|
||||
self.bump_active_cell_revision();
|
||||
} else {
|
||||
self.flush_active_cell();
|
||||
|
||||
@@ -1355,6 +1413,7 @@ impl ChatWidget {
|
||||
interaction_input,
|
||||
self.config.animations,
|
||||
)));
|
||||
self.bump_active_cell_revision();
|
||||
}
|
||||
|
||||
self.request_redraw();
|
||||
@@ -1368,6 +1427,7 @@ impl ChatWidget {
|
||||
ev.invocation,
|
||||
self.config.animations,
|
||||
)));
|
||||
self.bump_active_cell_revision();
|
||||
self.request_redraw();
|
||||
}
|
||||
pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) {
|
||||
@@ -1440,6 +1500,7 @@ impl ChatWidget {
|
||||
skills: None,
|
||||
}),
|
||||
active_cell: None,
|
||||
active_cell_revision: 0,
|
||||
config,
|
||||
model: model.clone(),
|
||||
auth_manager,
|
||||
@@ -1482,6 +1543,9 @@ impl ChatWidget {
|
||||
};
|
||||
|
||||
widget.prefetch_rate_limits();
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
|
||||
|
||||
widget
|
||||
}
|
||||
@@ -1526,6 +1590,7 @@ impl ChatWidget {
|
||||
skills: None,
|
||||
}),
|
||||
active_cell: None,
|
||||
active_cell_revision: 0,
|
||||
config,
|
||||
model: model.clone(),
|
||||
auth_manager,
|
||||
@@ -1568,6 +1633,9 @@ impl ChatWidget {
|
||||
};
|
||||
|
||||
widget.prefetch_rate_limits();
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
|
||||
|
||||
widget
|
||||
}
|
||||
@@ -1588,26 +1656,10 @@ impl ChatWidget {
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||||
&& c.eq_ignore_ascii_case(&'v') =>
|
||||
} if c.eq_ignore_ascii_case(&'v')
|
||||
&& modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
|
||||
{
|
||||
match paste_image_to_temp_png() {
|
||||
Ok((path, info)) => {
|
||||
tracing::debug!(
|
||||
"pasted image size={}x{} format={}",
|
||||
info.width,
|
||||
info.height,
|
||||
info.encoded_format.label()
|
||||
);
|
||||
self.attach_image(path);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to paste image: {err}");
|
||||
self.add_to_history(history_cell::new_error_event(format!(
|
||||
"Failed to paste image: {err}",
|
||||
)));
|
||||
}
|
||||
}
|
||||
self.paste_image_from_clipboard();
|
||||
return;
|
||||
}
|
||||
other if other.kind == KeyEventKind::Press => {
|
||||
@@ -1633,7 +1685,19 @@ impl ChatWidget {
|
||||
_ => {
|
||||
match self.bottom_pane.handle_key_event(key_event) {
|
||||
InputResult::Submitted(text) => {
|
||||
// If a task is running, queue the user input to be sent after the turn completes.
|
||||
// Enter always sends messages immediately (bypasses queue check)
|
||||
// Clear any reasoning status header when submitting a new message
|
||||
self.reasoning_buffer.clear();
|
||||
self.full_reasoning_buffer.clear();
|
||||
self.set_status_header(String::from("Working"));
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
image_paths: self.bottom_pane.take_recent_submission_images(),
|
||||
};
|
||||
self.submit_user_message(user_message);
|
||||
}
|
||||
InputResult::Queued(text) => {
|
||||
// Tab queues the message if a task is running, otherwise submits immediately
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
image_paths: self.bottom_pane.take_recent_submission_images(),
|
||||
@@ -1658,6 +1722,32 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Attempt to attach an image from the system clipboard.
|
||||
///
|
||||
/// This is a best-effort path used when we receive an empty paste event,
|
||||
/// which some terminals emit when the clipboard contains non-text data
|
||||
/// (like images). When the clipboard can't be read or no image exists,
|
||||
/// surface a helpful follow-up so the user can retry with a file path.
|
||||
fn paste_image_from_clipboard(&mut self) {
|
||||
match paste_image_to_temp_png() {
|
||||
Ok((path, info)) => {
|
||||
tracing::debug!(
|
||||
"pasted image size={}x{} format={}",
|
||||
info.width,
|
||||
info.height,
|
||||
info.encoded_format.label()
|
||||
);
|
||||
self.attach_image(path);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to paste image: {err}");
|
||||
self.add_to_history(history_cell::new_error_event(format!(
|
||||
"Failed to paste image: {err}. Try saving the image to a file and pasting the file path instead.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn composer_text_with_pending(&self) -> String {
|
||||
self.bottom_pane.composer_text_with_pending()
|
||||
}
|
||||
@@ -1713,6 +1803,9 @@ impl ChatWidget {
|
||||
SlashCommand::Resume => {
|
||||
self.app_event_tx.send(AppEvent::OpenResumePicker);
|
||||
}
|
||||
SlashCommand::Fork => {
|
||||
self.app_event_tx.send(AppEvent::OpenForkPicker);
|
||||
}
|
||||
SlashCommand::Init => {
|
||||
let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME);
|
||||
if init_target.exists() {
|
||||
@@ -1909,6 +2002,20 @@ impl ChatWidget {
|
||||
self.bottom_pane.handle_paste(text);
|
||||
}
|
||||
|
||||
/// Route paste events through image detection.
|
||||
///
|
||||
/// Terminals vary in how they represent paste: some emit an empty paste
|
||||
/// payload when the clipboard isn't text (common for image-only clipboard
|
||||
/// contents). Treat the empty payload as a hint to attempt a clipboard
|
||||
/// image read; otherwise, fall back to text handling.
|
||||
pub(crate) fn handle_paste_event(&mut self, text: String) {
|
||||
if text.is_empty() {
|
||||
self.paste_image_from_clipboard();
|
||||
} else {
|
||||
self.handle_paste(text);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns true if caller should skip rendering this frame (a future frame is scheduled).
|
||||
pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool {
|
||||
if self.bottom_pane.flush_paste_burst_if_due() {
|
||||
@@ -2044,6 +2151,7 @@ impl ChatWidget {
|
||||
if !text.is_empty() {
|
||||
self.add_to_history(history_cell::new_user_prompt(text));
|
||||
}
|
||||
|
||||
self.needs_final_message_separator = false;
|
||||
}
|
||||
|
||||
@@ -2256,6 +2364,12 @@ impl ChatWidget {
|
||||
self.frame_requester.schedule_frame();
|
||||
}
|
||||
|
||||
fn bump_active_cell_revision(&mut self) {
|
||||
// Wrapping avoids overflow; wraparound would require 2^64 bumps and at
|
||||
// worst causes a one-time cache-key collision.
|
||||
self.active_cell_revision = self.active_cell_revision.wrapping_add(1);
|
||||
}
|
||||
|
||||
fn notify(&mut self, notification: Notification) {
|
||||
if !notification.allowed_for(&self.config.tui_notifications) {
|
||||
return;
|
||||
@@ -3563,6 +3677,9 @@ impl ChatWidget {
|
||||
} else {
|
||||
self.config.features.disable(feature);
|
||||
}
|
||||
if feature == Feature::Steer {
|
||||
self.bottom_pane.set_steer_enabled(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) {
|
||||
@@ -3873,6 +3990,37 @@ impl ChatWidget {
|
||||
self.current_rollout_path.clone()
|
||||
}
|
||||
|
||||
/// Returns a cache key describing the current in-flight active cell for the transcript overlay.
|
||||
///
|
||||
/// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the
|
||||
/// current active cell, and the overlay caches that tail; this key is what it uses to decide
|
||||
/// whether it must recompute. When there is no active cell, this returns `None` so the overlay
|
||||
/// can drop the tail entirely.
|
||||
///
|
||||
/// If callers mutate the active cell's transcript output without bumping the revision (or
|
||||
/// providing an appropriate animation tick), the overlay will keep showing a stale tail while
|
||||
/// the main viewport updates.
|
||||
pub(crate) fn active_cell_transcript_key(&self) -> Option<ActiveCellTranscriptKey> {
|
||||
let cell = self.active_cell.as_ref()?;
|
||||
Some(ActiveCellTranscriptKey {
|
||||
revision: self.active_cell_revision,
|
||||
is_stream_continuation: cell.is_stream_continuation(),
|
||||
animation_tick: cell.transcript_animation_tick(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the active cell's transcript lines for a given terminal width.
|
||||
///
|
||||
/// This is a convenience for the transcript overlay live-tail path, and it intentionally
|
||||
/// filters out empty results so the overlay can treat "nothing to render" as "no tail". Callers
|
||||
/// should pass the same width the overlay uses; using a different width will cause wrapping
|
||||
/// mismatches between the main viewport and the transcript overlay.
|
||||
pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option<Vec<Line<'static>>> {
|
||||
let cell = self.active_cell.as_ref()?;
|
||||
let lines = cell.transcript_lines(width);
|
||||
(!lines.is_empty()).then_some(lines)
|
||||
}
|
||||
|
||||
/// Return a reference to the widget's current config (includes any
|
||||
/// runtime overrides applied via TUI, e.g., model or approval policy).
|
||||
pub(crate) fn config_ref(&self) -> &Config {
|
||||
|
||||
+28
-1
@@ -2,6 +2,33 @@
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: term.backend().vt100().screen().contents()
|
||||
---
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
• I’m going to search the repo for where “Change Approved” is rendered to update
|
||||
that view.
|
||||
|
||||
@@ -14,4 +41,4 @@ expression: term.backend().vt100().screen().contents()
|
||||
|
||||
› Summarize recent commits
|
||||
|
||||
100% context left
|
||||
100% context left · tab to queue message
|
||||
|
||||
@@ -372,7 +372,7 @@ async fn make_chatwidget_manual(
|
||||
if let Some(model) = model_override {
|
||||
cfg.model = Some(model.to_string());
|
||||
}
|
||||
let bottom = BottomPane::new(BottomPaneParams {
|
||||
let mut bottom = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
@@ -382,6 +382,7 @@ async fn make_chatwidget_manual(
|
||||
animations_enabled: cfg.animations,
|
||||
skills: None,
|
||||
});
|
||||
bottom.set_steer_enabled(true);
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
|
||||
let codex_home = cfg.codex_home.clone();
|
||||
let widget = ChatWidget {
|
||||
@@ -389,6 +390,7 @@ async fn make_chatwidget_manual(
|
||||
codex_op_tx: op_tx,
|
||||
bottom_pane: bottom,
|
||||
active_cell: None,
|
||||
active_cell_revision: 0,
|
||||
config: cfg,
|
||||
model: resolved_model.clone(),
|
||||
auth_manager: auth_manager.clone(),
|
||||
@@ -1057,7 +1059,7 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() {
|
||||
assert_eq!(chat.bottom_pane.composer_text(), "repeat me");
|
||||
|
||||
// Queue the prompt while the task is running.
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
assert_eq!(chat.queued_user_messages.len(), 3);
|
||||
@@ -1079,7 +1081,7 @@ async fn streaming_final_answer_keeps_task_running_state() {
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("queued submission".to_string());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
assert_eq!(
|
||||
@@ -1306,6 +1308,66 @@ async fn unified_exec_end_after_task_complete_is_suppressed() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unified_exec_wait_cell_revision_updates_on_late_command_display() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.active_cell = Some(Box::new(crate::history_cell::new_unified_exec_wait_live(
|
||||
None,
|
||||
chat.config.animations,
|
||||
)));
|
||||
chat.unified_exec_processes.push(UnifiedExecProcessSummary {
|
||||
key: "proc-1".to_string(),
|
||||
command_display: "sleep 5".to_string(),
|
||||
});
|
||||
|
||||
let before = chat.active_cell_revision;
|
||||
chat.on_terminal_interaction(TerminalInteractionEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
process_id: "proc-1".to_string(),
|
||||
stdin: String::new(),
|
||||
});
|
||||
|
||||
assert_eq!(chat.active_cell_revision, before.wrapping_add(1));
|
||||
let lines = chat
|
||||
.active_cell_transcript_lines(80)
|
||||
.expect("active cell lines");
|
||||
let blob = lines_to_single_string(&lines);
|
||||
assert!(
|
||||
blob.contains("sleep 5"),
|
||||
"expected command display to render: {blob:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unified_exec_wait_cell_revision_updates_on_replacement() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.active_cell = Some(Box::new(crate::history_cell::new_unified_exec_wait_live(
|
||||
Some("old command".to_string()),
|
||||
chat.config.animations,
|
||||
)));
|
||||
chat.unified_exec_processes.push(UnifiedExecProcessSummary {
|
||||
key: "proc-2".to_string(),
|
||||
command_display: "new command".to_string(),
|
||||
});
|
||||
|
||||
let before = chat.active_cell_revision;
|
||||
chat.on_terminal_interaction(TerminalInteractionEvent {
|
||||
call_id: "call-2".to_string(),
|
||||
process_id: "proc-2".to_string(),
|
||||
stdin: String::new(),
|
||||
});
|
||||
|
||||
assert_eq!(chat.active_cell_revision, before.wrapping_add(1));
|
||||
let lines = chat
|
||||
.active_cell_transcript_lines(80)
|
||||
.expect("active cell lines");
|
||||
let blob = lines_to_single_string(&lines);
|
||||
assert!(
|
||||
blob.contains("new command"),
|
||||
"expected replacement wait cell to render: {blob:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unified_exec_waiting_multiple_empty_snapshots() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
@@ -1476,6 +1538,15 @@ async fn slash_resume_opens_picker() {
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_fork_opens_picker() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.dispatch_command(SlashCommand::Fork);
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenForkPicker));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_rollout_displays_current_path() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
@@ -32,6 +32,23 @@ pub struct Cli {
|
||||
#[clap(skip)]
|
||||
pub resume_show_all: bool,
|
||||
|
||||
// Internal controls set by the top-level `codex fork` subcommand.
|
||||
// These are not exposed as user flags on the base `codex` command.
|
||||
#[clap(skip)]
|
||||
pub fork_picker: bool,
|
||||
|
||||
#[clap(skip)]
|
||||
pub fork_last: bool,
|
||||
|
||||
/// Internal: fork a specific recorded session by id (UUID). Set by the
|
||||
/// top-level `codex fork <SESSION_ID>` wrapper; not exposed as a public flag.
|
||||
#[clap(skip)]
|
||||
pub fork_session_id: Option<String>,
|
||||
|
||||
/// Internal: show all sessions (disables cwd filtering and shows CWD column).
|
||||
#[clap(skip)]
|
||||
pub fork_show_all: bool,
|
||||
|
||||
/// Model the agent should use.
|
||||
#[arg(long, short = 'm')]
|
||||
pub model: Option<String>,
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
//! Transcript/history cells for the Codex TUI.
|
||||
//!
|
||||
//! A `HistoryCell` is the unit of display in the conversation UI, representing both committed
|
||||
//! transcript entries and, transiently, an in-flight active cell that can mutate in place while
|
||||
//! streaming.
|
||||
//!
|
||||
//! The transcript overlay (`Ctrl+T`) appends a cached live tail derived from the active cell, and
|
||||
//! that cached tail is refreshed based on an active-cell cache key. Cells that change based on
|
||||
//! elapsed time expose `transcript_animation_tick()`, and code that mutates the active cell in place
|
||||
//! bumps the active-cell revision tracked by `ChatWidget`, so the cache key changes whenever the
|
||||
//! rendered transcript output can change.
|
||||
|
||||
use crate::diff_render::create_diff_summary;
|
||||
use crate::diff_render::display_path_for;
|
||||
use crate::exec_cell::CommandOutput;
|
||||
@@ -100,6 +112,20 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
||||
fn is_stream_continuation(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns a coarse "animation tick" when transcript output is time-dependent.
|
||||
///
|
||||
/// The transcript overlay caches the rendered output of the in-flight active cell, so cells
|
||||
/// that include time-based UI (spinner, shimmer, etc.) should return a tick that changes over
|
||||
/// time to signal that the cached tail should be recomputed. Returning `None` means the
|
||||
/// transcript lines are stable, while returning `Some(tick)` during an in-flight animation
|
||||
/// allows the overlay to keep up with the main viewport.
|
||||
///
|
||||
/// If a cell uses time-based visuals but always returns `None`, `Ctrl+T` can appear "frozen" on
|
||||
/// the first rendered frame even though the main viewport is animating.
|
||||
fn transcript_animation_tick(&self) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for Box<dyn HistoryCell> {
|
||||
@@ -448,6 +474,7 @@ pub(crate) fn new_unified_exec_interaction(
|
||||
pub(crate) struct UnifiedExecWaitCell {
|
||||
command_display: Option<String>,
|
||||
animations_enabled: bool,
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
impl UnifiedExecWaitCell {
|
||||
@@ -455,6 +482,7 @@ impl UnifiedExecWaitCell {
|
||||
Self {
|
||||
command_display: command_display.filter(|display| !display.is_empty()),
|
||||
animations_enabled,
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,10 +494,19 @@ impl UnifiedExecWaitCell {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update_command_display(&mut self, command_display: Option<String>) {
|
||||
if self.command_display.is_none() {
|
||||
self.command_display = command_display.filter(|display| !display.is_empty());
|
||||
/// Update the command display once.
|
||||
///
|
||||
/// Unified exec can start without a stable command string, and later correlate a process id to
|
||||
/// a user-facing `command_display`. This method records that first non-empty command display and
|
||||
/// returns whether it changed the cell; callers use the `true` case to invalidate any cached
|
||||
/// transcript rendering (for example, the transcript overlay live tail).
|
||||
pub(crate) fn update_command_display(&mut self, command_display: Option<String>) -> bool {
|
||||
let command_display = command_display.filter(|display| !display.is_empty());
|
||||
if self.command_display.is_some() || command_display.is_none() {
|
||||
return false;
|
||||
}
|
||||
self.command_display = command_display;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn command_display(&self) -> Option<String> {
|
||||
@@ -507,6 +544,14 @@ impl HistoryCell for UnifiedExecWaitCell {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.display_lines(width).len() as u16
|
||||
}
|
||||
|
||||
fn transcript_animation_tick(&self) -> Option<u64> {
|
||||
if !self.animations_enabled {
|
||||
return None;
|
||||
}
|
||||
// Match `App`'s frame scheduling cadence for transcript overlay live-tail animation.
|
||||
Some((self.start_time.elapsed().as_millis() / 50) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_unified_exec_wait_live(
|
||||
@@ -1252,6 +1297,13 @@ impl HistoryCell for McpToolCallCell {
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn transcript_animation_tick(&self) -> Option<u64> {
|
||||
if !self.animations_enabled || self.result.is_some() {
|
||||
return None;
|
||||
}
|
||||
Some((self.start_time.elapsed().as_millis() / 50) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_active_mcp_tool_call(
|
||||
|
||||
+76
-26
@@ -431,27 +431,77 @@ async fn run_ratatui_app(
|
||||
initial_config
|
||||
};
|
||||
|
||||
// Determine resume behavior: explicit id, then resume last, then picker.
|
||||
let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() {
|
||||
match find_thread_path_by_id_str(&config.codex_home, id_str).await? {
|
||||
Some(path) => resume_picker::ResumeSelection::Resume(path),
|
||||
None => {
|
||||
error!("Error finding conversation path: {id_str}");
|
||||
restore();
|
||||
session_log::log_session_end();
|
||||
let _ = tui.terminal.clear();
|
||||
if let Err(err) = writeln!(
|
||||
std::io::stdout(),
|
||||
"No saved session found with ID {id_str}. Run `codex resume` without an ID to choose from existing sessions."
|
||||
) {
|
||||
error!("Failed to write resume error message: {err}");
|
||||
}
|
||||
return Ok(AppExitInfo {
|
||||
token_usage: codex_core::protocol::TokenUsage::default(),
|
||||
thread_id: None,
|
||||
update_action: None,
|
||||
});
|
||||
let mut missing_session_exit = |id_str: &str, action: &str| {
|
||||
error!("Error finding conversation path: {id_str}");
|
||||
restore();
|
||||
session_log::log_session_end();
|
||||
let _ = tui.terminal.clear();
|
||||
if let Err(err) = writeln!(
|
||||
std::io::stdout(),
|
||||
"No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions."
|
||||
) {
|
||||
error!("Failed to write session error message: {err}");
|
||||
}
|
||||
Ok(AppExitInfo {
|
||||
token_usage: codex_core::protocol::TokenUsage::default(),
|
||||
thread_id: None,
|
||||
update_action: None,
|
||||
})
|
||||
};
|
||||
|
||||
let use_fork = cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some();
|
||||
let session_selection = if use_fork {
|
||||
if let Some(id_str) = cli.fork_session_id.as_deref() {
|
||||
match find_thread_path_by_id_str(&config.codex_home, id_str).await? {
|
||||
Some(path) => resume_picker::SessionSelection::Fork(path),
|
||||
None => return missing_session_exit(id_str, "fork"),
|
||||
}
|
||||
} else if cli.fork_last {
|
||||
let provider_filter = vec![config.model_provider_id.clone()];
|
||||
match RolloutRecorder::list_threads(
|
||||
&config.codex_home,
|
||||
1,
|
||||
None,
|
||||
INTERACTIVE_SESSION_SOURCES,
|
||||
Some(provider_filter.as_slice()),
|
||||
&config.model_provider_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(page) => page
|
||||
.items
|
||||
.first()
|
||||
.map(|it| resume_picker::SessionSelection::Fork(it.path.clone()))
|
||||
.unwrap_or(resume_picker::SessionSelection::StartFresh),
|
||||
Err(_) => resume_picker::SessionSelection::StartFresh,
|
||||
}
|
||||
} else if cli.fork_picker {
|
||||
match resume_picker::run_fork_picker(
|
||||
&mut tui,
|
||||
&config.codex_home,
|
||||
&config.model_provider_id,
|
||||
cli.fork_show_all,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
resume_picker::SessionSelection::Exit => {
|
||||
restore();
|
||||
session_log::log_session_end();
|
||||
return Ok(AppExitInfo {
|
||||
token_usage: codex_core::protocol::TokenUsage::default(),
|
||||
thread_id: None,
|
||||
update_action: None,
|
||||
});
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
} else {
|
||||
resume_picker::SessionSelection::StartFresh
|
||||
}
|
||||
} else if let Some(id_str) = cli.resume_session_id.as_deref() {
|
||||
match find_thread_path_by_id_str(&config.codex_home, id_str).await? {
|
||||
Some(path) => resume_picker::SessionSelection::Resume(path),
|
||||
None => return missing_session_exit(id_str, "resume"),
|
||||
}
|
||||
} else if cli.resume_last {
|
||||
let provider_filter = vec![config.model_provider_id.clone()];
|
||||
@@ -468,9 +518,9 @@ async fn run_ratatui_app(
|
||||
Ok(page) => page
|
||||
.items
|
||||
.first()
|
||||
.map(|it| resume_picker::ResumeSelection::Resume(it.path.clone()))
|
||||
.unwrap_or(resume_picker::ResumeSelection::StartFresh),
|
||||
Err(_) => resume_picker::ResumeSelection::StartFresh,
|
||||
.map(|it| resume_picker::SessionSelection::Resume(it.path.clone()))
|
||||
.unwrap_or(resume_picker::SessionSelection::StartFresh),
|
||||
Err(_) => resume_picker::SessionSelection::StartFresh,
|
||||
}
|
||||
} else if cli.resume_picker {
|
||||
match resume_picker::run_resume_picker(
|
||||
@@ -481,7 +531,7 @@ async fn run_ratatui_app(
|
||||
)
|
||||
.await?
|
||||
{
|
||||
resume_picker::ResumeSelection::Exit => {
|
||||
resume_picker::SessionSelection::Exit => {
|
||||
restore();
|
||||
session_log::log_session_end();
|
||||
return Ok(AppExitInfo {
|
||||
@@ -493,7 +543,7 @@ async fn run_ratatui_app(
|
||||
other => other,
|
||||
}
|
||||
} else {
|
||||
resume_picker::ResumeSelection::StartFresh
|
||||
resume_picker::SessionSelection::StartFresh
|
||||
};
|
||||
|
||||
let Cli {
|
||||
@@ -513,7 +563,7 @@ async fn run_ratatui_app(
|
||||
active_profile,
|
||||
prompt,
|
||||
images,
|
||||
resume_selection,
|
||||
session_selection,
|
||||
feedback,
|
||||
should_show_trust_screen, // Proxy to: is it a first run in this directory?
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user