mirror of
https://github.com/openai/codex.git
synced 2026-03-24 23:53:53 +00:00
Compare commits
6 Commits
codex-exp-
...
crate/auth
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b44337ee5e | ||
|
|
50c8c745eb | ||
|
|
e5de13644d | ||
|
|
5cada46ddf | ||
|
|
88e5382fc4 | ||
|
|
392347d436 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Use a rust-release version that includes all native binaries.
|
||||
CODEX_VERSION=0.74.0
|
||||
CODEX_VERSION=0.115.0
|
||||
OUTPUT_DIR="${RUNNER_TEMP}"
|
||||
python3 ./scripts/stage_npm_packages.py \
|
||||
--release-version "$CODEX_VERSION" \
|
||||
|
||||
9
codex-rs/Cargo.lock
generated
9
codex-rs/Cargo.lock
generated
@@ -1935,6 +1935,13 @@ dependencies = [
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-core-auth"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-debug-client"
|
||||
version = "0.0.0"
|
||||
@@ -2327,7 +2334,6 @@ dependencies = [
|
||||
"icu_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_provider",
|
||||
"mime_guess",
|
||||
"pretty_assertions",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
@@ -2758,6 +2764,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"codex-utils-cache",
|
||||
"image",
|
||||
"mime_guess",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -22,6 +22,7 @@ members = [
|
||||
"shell-escalation",
|
||||
"skills",
|
||||
"core",
|
||||
"core/auth",
|
||||
"environment",
|
||||
"hooks",
|
||||
"secrets",
|
||||
@@ -104,6 +105,7 @@ codex-cloud-requirements = { path = "cloud-requirements" }
|
||||
codex-connectors = { path = "connectors" }
|
||||
codex-config = { path = "config" }
|
||||
codex-core = { path = "core" }
|
||||
codex-core-auth = { path = "core/auth" }
|
||||
codex-environment = { path = "environment" }
|
||||
codex-exec = { path = "exec" }
|
||||
codex-execpolicy = { path = "execpolicy" }
|
||||
|
||||
6
codex-rs/core/auth/BUILD.bazel
Normal file
6
codex-rs/core/auth/BUILD.bazel
Normal file
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "auth",
|
||||
crate_name = "codex_core_auth",
|
||||
)
|
||||
15
codex-rs/core/auth/Cargo.toml
Normal file
15
codex-rs/core/auth/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "codex-core-auth"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "codex_core_auth"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-core = { workspace = true }
|
||||
1
codex-rs/core/auth/src/lib.rs
Normal file
1
codex-rs/core/auth/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub use codex_core::auth::*;
|
||||
@@ -27,7 +27,6 @@ use crate::config::Config;
|
||||
use crate::error::RefreshTokenFailedError;
|
||||
use crate::error::RefreshTokenFailedReason;
|
||||
use crate::token_data::KnownPlan as InternalKnownPlan;
|
||||
use crate::token_data::PlanType as InternalPlanType;
|
||||
use crate::token_data::TokenData;
|
||||
use crate::token_data::parse_chatgpt_jwt_claims;
|
||||
use crate::util::try_parse_error_message;
|
||||
@@ -752,67 +751,6 @@ fn refresh_token_endpoint() -> String {
|
||||
.unwrap_or_else(|_| REFRESH_TOKEN_URL.to_string())
|
||||
}
|
||||
|
||||
impl AuthDotJson {
|
||||
fn from_external_tokens(external: &ExternalAuthTokens) -> std::io::Result<Self> {
|
||||
let mut token_info =
|
||||
parse_chatgpt_jwt_claims(&external.access_token).map_err(std::io::Error::other)?;
|
||||
token_info.chatgpt_account_id = Some(external.chatgpt_account_id.clone());
|
||||
token_info.chatgpt_plan_type = external
|
||||
.chatgpt_plan_type
|
||||
.as_deref()
|
||||
.map(InternalPlanType::from_raw_value)
|
||||
.or(token_info.chatgpt_plan_type)
|
||||
.or(Some(InternalPlanType::Unknown("unknown".to_string())));
|
||||
let tokens = TokenData {
|
||||
id_token: token_info,
|
||||
access_token: external.access_token.clone(),
|
||||
refresh_token: String::new(),
|
||||
account_id: Some(external.chatgpt_account_id.clone()),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
auth_mode: Some(ApiAuthMode::ChatgptAuthTokens),
|
||||
openai_api_key: None,
|
||||
tokens: Some(tokens),
|
||||
last_refresh: Some(Utc::now()),
|
||||
})
|
||||
}
|
||||
|
||||
fn from_external_access_token(
|
||||
access_token: &str,
|
||||
chatgpt_account_id: &str,
|
||||
chatgpt_plan_type: Option<&str>,
|
||||
) -> std::io::Result<Self> {
|
||||
let external = ExternalAuthTokens {
|
||||
access_token: access_token.to_string(),
|
||||
chatgpt_account_id: chatgpt_account_id.to_string(),
|
||||
chatgpt_plan_type: chatgpt_plan_type.map(str::to_string),
|
||||
};
|
||||
Self::from_external_tokens(&external)
|
||||
}
|
||||
|
||||
fn resolved_mode(&self) -> ApiAuthMode {
|
||||
if let Some(mode) = self.auth_mode {
|
||||
return mode;
|
||||
}
|
||||
if self.openai_api_key.is_some() {
|
||||
return ApiAuthMode::ApiKey;
|
||||
}
|
||||
ApiAuthMode::Chatgpt
|
||||
}
|
||||
|
||||
fn storage_mode(
|
||||
&self,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
) -> AuthCredentialsStoreMode {
|
||||
if self.resolved_mode() == ApiAuthMode::ChatgptAuthTokens {
|
||||
AuthCredentialsStoreMode::Ephemeral
|
||||
} else {
|
||||
auth_credentials_store_mode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal cached auth state.
|
||||
#[derive(Clone)]
|
||||
struct CachedAuth {
|
||||
@@ -1412,8 +1350,12 @@ impl AuthManager {
|
||||
),
|
||||
)));
|
||||
}
|
||||
let auth_dot_json =
|
||||
AuthDotJson::from_external_tokens(&refreshed).map_err(RefreshTokenError::Transient)?;
|
||||
let auth_dot_json = AuthDotJson::from_external_access_token(
|
||||
&refreshed.access_token,
|
||||
&refreshed.chatgpt_account_id,
|
||||
refreshed.chatgpt_plan_type.as_deref(),
|
||||
)
|
||||
.map_err(RefreshTokenError::Transient)?;
|
||||
save_auth(
|
||||
&self.codex_home,
|
||||
&auth_dot_json,
|
||||
|
||||
@@ -19,7 +19,9 @@ use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::token_data::PlanType;
|
||||
use crate::token_data::TokenData;
|
||||
use crate::token_data::parse_chatgpt_jwt_claims;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_keyring_store::DefaultKeyringStore;
|
||||
use codex_keyring_store::KeyringStore;
|
||||
@@ -56,6 +58,56 @@ pub struct AuthDotJson {
|
||||
pub last_refresh: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl AuthDotJson {
|
||||
pub(super) fn from_external_access_token(
|
||||
access_token: &str,
|
||||
chatgpt_account_id: &str,
|
||||
chatgpt_plan_type: Option<&str>,
|
||||
) -> std::io::Result<Self> {
|
||||
let mut token_info =
|
||||
parse_chatgpt_jwt_claims(access_token).map_err(std::io::Error::other)?;
|
||||
token_info.chatgpt_account_id = Some(chatgpt_account_id.to_string());
|
||||
token_info.chatgpt_plan_type = chatgpt_plan_type
|
||||
.map(PlanType::from_raw_value)
|
||||
.or(token_info.chatgpt_plan_type)
|
||||
.or(Some(PlanType::Unknown("unknown".to_string())));
|
||||
let tokens = TokenData {
|
||||
id_token: token_info,
|
||||
access_token: access_token.to_string(),
|
||||
refresh_token: String::new(),
|
||||
account_id: Some(chatgpt_account_id.to_string()),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
auth_mode: Some(AuthMode::ChatgptAuthTokens),
|
||||
openai_api_key: None,
|
||||
tokens: Some(tokens),
|
||||
last_refresh: Some(Utc::now()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolved_mode(&self) -> AuthMode {
|
||||
if let Some(mode) = self.auth_mode {
|
||||
return mode;
|
||||
}
|
||||
if self.openai_api_key.is_some() {
|
||||
return AuthMode::ApiKey;
|
||||
}
|
||||
AuthMode::Chatgpt
|
||||
}
|
||||
|
||||
pub(super) fn storage_mode(
|
||||
&self,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
) -> AuthCredentialsStoreMode {
|
||||
if self.resolved_mode() == AuthMode::ChatgptAuthTokens {
|
||||
AuthCredentialsStoreMode::Ephemeral
|
||||
} else {
|
||||
auth_credentials_store_mode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join("auth.json")
|
||||
}
|
||||
|
||||
@@ -4557,7 +4557,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() {
|
||||
.expect("tool call present");
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
let err = router
|
||||
.dispatch_tool_call(
|
||||
.dispatch_tool_call_with_code_mode_result(
|
||||
Arc::clone(&session),
|
||||
Arc::clone(&turn_context),
|
||||
tracker,
|
||||
@@ -4565,7 +4565,8 @@ async fn fatal_tool_error_stops_turn_and_reports_error() {
|
||||
ToolCallSource::Direct,
|
||||
)
|
||||
.await
|
||||
.expect_err("expected fatal error");
|
||||
.err()
|
||||
.expect("expected fatal error");
|
||||
|
||||
match err {
|
||||
FunctionCallError::Fatal(message) => {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- Global helpers:
|
||||
- `exit()`: Immediately ends the current script successfully (like an early return from the top level).
|
||||
- `text(value: string | number | boolean | undefined | null)`: Appends a text item and returns it. Non-string values are stringified with `JSON.stringify(...)` when possible.
|
||||
- `image(imageUrl: string)`: Appends an image item and returns it. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL.
|
||||
- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null })`: Appends an image item and returns it. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL.
|
||||
- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session.
|
||||
- `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing.
|
||||
- `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`.
|
||||
|
||||
@@ -14,6 +14,7 @@ use serde_json::Value as JsonValue;
|
||||
use crate::client_common::tools::ToolSpec;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::ToolRouter;
|
||||
use crate::tools::code_mode_description::augment_tool_spec_for_code_mode;
|
||||
use crate::tools::code_mode_description::code_mode_tool_reference;
|
||||
@@ -303,9 +304,11 @@ async fn call_nested_tool(
|
||||
tool_name: String,
|
||||
input: Option<JsonValue>,
|
||||
cancellation_token: tokio_util::sync::CancellationToken,
|
||||
) -> JsonValue {
|
||||
) -> Result<JsonValue, FunctionCallError> {
|
||||
if tool_name == PUBLIC_TOOL_NAME {
|
||||
return JsonValue::String(format!("{PUBLIC_TOOL_NAME} cannot invoke itself"));
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"{PUBLIC_TOOL_NAME} cannot invoke itself"
|
||||
)));
|
||||
}
|
||||
|
||||
let payload =
|
||||
@@ -316,12 +319,12 @@ async fn call_nested_tool(
|
||||
tool,
|
||||
raw_arguments,
|
||||
},
|
||||
Err(error) => return JsonValue::String(error),
|
||||
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
|
||||
}
|
||||
} else {
|
||||
match build_nested_tool_payload(tool_runtime.find_spec(&tool_name), &tool_name, input) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => return JsonValue::String(error),
|
||||
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -333,12 +336,8 @@ async fn call_nested_tool(
|
||||
};
|
||||
let result = tool_runtime
|
||||
.handle_tool_call_with_source(call, ToolCallSource::CodeMode, cancellation_token)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(result) => result.code_mode_result(),
|
||||
Err(error) => JsonValue::String(error.to_string()),
|
||||
}
|
||||
.await?;
|
||||
Ok(result.code_mode_result())
|
||||
}
|
||||
|
||||
fn tool_kind_for_spec(spec: &ToolSpec) -> protocol::CodeModeToolKind {
|
||||
|
||||
@@ -70,6 +70,8 @@ pub(super) enum HostToNodeMessage {
|
||||
request_id: String,
|
||||
id: String,
|
||||
code_mode_result: JsonValue,
|
||||
#[serde(default)]
|
||||
error_text: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -223,14 +223,48 @@ function codeModeWorkerMain() {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function normalizeOutputImageUrl(value) {
|
||||
if (typeof value !== 'string' || !value) {
|
||||
throw new TypeError('image expects a non-empty image URL string');
|
||||
function normalizeOutputImage(value) {
|
||||
let imageUrl;
|
||||
let detail;
|
||||
if (typeof value === 'string') {
|
||||
imageUrl = value;
|
||||
} else if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
if (typeof value.image_url === 'string') {
|
||||
imageUrl = value.image_url;
|
||||
}
|
||||
if (typeof value.detail === 'string') {
|
||||
detail = value.detail;
|
||||
} else if (
|
||||
Object.prototype.hasOwnProperty.call(value, 'detail') &&
|
||||
value.detail !== null &&
|
||||
typeof value.detail !== 'undefined'
|
||||
) {
|
||||
throw new TypeError('image detail must be a string when provided');
|
||||
}
|
||||
}
|
||||
if (/^(?:https?:\/\/|data:)/i.test(value)) {
|
||||
return value;
|
||||
|
||||
if (typeof imageUrl !== 'string' || !imageUrl) {
|
||||
throw new TypeError(
|
||||
'image expects a non-empty image URL string or an object with image_url and optional detail'
|
||||
);
|
||||
}
|
||||
throw new TypeError('image expects an http(s) or data URL');
|
||||
if (!/^(?:https?:\/\/|data:)/i.test(imageUrl)) {
|
||||
throw new TypeError('image expects an http(s) or data URL');
|
||||
}
|
||||
|
||||
if (typeof detail !== 'undefined' && !/^(?:auto|low|high|original)$/i.test(detail)) {
|
||||
throw new TypeError('image detail must be one of: auto, low, high, original');
|
||||
}
|
||||
|
||||
const normalized = { image_url: imageUrl };
|
||||
if (typeof detail === 'string') {
|
||||
normalized.detail = detail.toLowerCase();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function createCodeModeHelpers(context, state, toolCallId) {
|
||||
@@ -258,10 +292,7 @@ function codeModeWorkerMain() {
|
||||
return item;
|
||||
};
|
||||
const image = (value) => {
|
||||
const item = {
|
||||
type: 'input_image',
|
||||
image_url: normalizeOutputImageUrl(value),
|
||||
};
|
||||
const item = Object.assign({ type: 'input_image' }, normalizeOutputImage(value));
|
||||
ensureContentItems(context).push(item);
|
||||
return item;
|
||||
};
|
||||
@@ -595,6 +626,10 @@ function createProtocol() {
|
||||
return;
|
||||
}
|
||||
pending.delete(message.request_id + ':' + message.id);
|
||||
if (typeof message.error_text === 'string') {
|
||||
entry.reject(new Error(message.error_text));
|
||||
return;
|
||||
}
|
||||
entry.resolve(message.code_mode_result ?? '');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use super::process::write_message;
|
||||
use super::protocol::HostToNodeMessage;
|
||||
use super::protocol::NodeToHostMessage;
|
||||
use crate::tools::parallel::ToolCallRuntime;
|
||||
|
||||
pub(crate) struct CodeModeWorker {
|
||||
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
@@ -53,17 +54,23 @@ impl CodeModeProcess {
|
||||
let tool_runtime = tool_runtime.clone();
|
||||
let stdin = stdin.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = call_nested_tool(
|
||||
exec,
|
||||
tool_runtime,
|
||||
tool_call.name,
|
||||
tool_call.input,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await;
|
||||
let (code_mode_result, error_text) = match result {
|
||||
Ok(code_mode_result) => (code_mode_result, None),
|
||||
Err(error) => (serde_json::Value::Null, Some(error.to_string())),
|
||||
};
|
||||
let response = HostToNodeMessage::Response {
|
||||
request_id: tool_call.request_id,
|
||||
id: tool_call.id,
|
||||
code_mode_result: call_nested_tool(
|
||||
exec,
|
||||
tool_runtime,
|
||||
tool_call.name,
|
||||
tool_call.input,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await,
|
||||
code_mode_result,
|
||||
error_text,
|
||||
};
|
||||
if let Err(err) = write_message(&stdin, &response).await {
|
||||
warn!("failed to write {PUBLIC_TOOL_NAME} tool response: {err}");
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
use async_trait::async_trait;
|
||||
use codex_environment::ExecutorFileSystem;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ImageDetail;
|
||||
use codex_protocol::models::local_image_content_items_with_label_number;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_image::PromptImageMode;
|
||||
use codex_utils_image::load_for_prompt_bytes;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::original_image_detail::can_request_original_image_detail;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::ViewImageToolCallEvent;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
@@ -38,7 +40,7 @@ enum ViewImageDetail {
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for ViewImageHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
type Output = ViewImageOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
@@ -135,22 +137,14 @@ impl ToolHandler for ViewImageHandler {
|
||||
};
|
||||
let image_detail = use_original_detail.then_some(ImageDetail::Original);
|
||||
|
||||
let content = local_image_content_items_with_label_number(
|
||||
abs_path.as_path(),
|
||||
file_bytes,
|
||||
/*label_number*/ None,
|
||||
image_mode,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|item| match item {
|
||||
ContentItem::InputText { text } => FunctionCallOutputContentItem::InputText { text },
|
||||
ContentItem::InputImage { image_url } => FunctionCallOutputContentItem::InputImage {
|
||||
image_url,
|
||||
detail: image_detail,
|
||||
},
|
||||
ContentItem::OutputText { text } => FunctionCallOutputContentItem::InputText { text },
|
||||
})
|
||||
.collect();
|
||||
let image =
|
||||
load_for_prompt_bytes(abs_path.as_path(), file_bytes, image_mode).map_err(|error| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"unable to process image at `{}`: {error}",
|
||||
abs_path.display()
|
||||
))
|
||||
})?;
|
||||
let image_url = image.into_data_url();
|
||||
|
||||
session
|
||||
.send_event(
|
||||
@@ -162,6 +156,75 @@ impl ToolHandler for ViewImageHandler {
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(FunctionToolOutput::from_content(content, Some(true)))
|
||||
Ok(ViewImageOutput {
|
||||
image_url,
|
||||
image_detail,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ViewImageOutput {
|
||||
image_url: String,
|
||||
image_detail: Option<ImageDetail>,
|
||||
}
|
||||
|
||||
impl ToolOutput for ViewImageOutput {
|
||||
fn log_preview(&self) -> String {
|
||||
self.image_url.clone()
|
||||
}
|
||||
|
||||
fn success_for_logging(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
|
||||
let body =
|
||||
FunctionCallOutputBody::ContentItems(vec![FunctionCallOutputContentItem::InputImage {
|
||||
image_url: self.image_url.clone(),
|
||||
detail: self.image_detail,
|
||||
}]);
|
||||
let output = FunctionCallOutputPayload {
|
||||
body,
|
||||
success: Some(true),
|
||||
};
|
||||
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
call_id: call_id.to_string(),
|
||||
output,
|
||||
}
|
||||
}
|
||||
|
||||
fn code_mode_result(&self, _payload: &ToolPayload) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"image_url": self.image_url,
|
||||
"detail": self.image_detail
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn code_mode_result_returns_image_url_object() {
|
||||
let output = ViewImageOutput {
|
||||
image_url: "data:image/png;base64,AAA".to_string(),
|
||||
image_detail: None,
|
||||
};
|
||||
|
||||
let result = output.code_mode_result(&ToolPayload::Function {
|
||||
arguments: "{}".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
json!({
|
||||
"image_url": "data:image/png;base64,AAA",
|
||||
"detail": null,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1607,8 +1607,8 @@ impl JsReplManager {
|
||||
let tracker = Arc::clone(&exec.tracker);
|
||||
|
||||
match router
|
||||
.dispatch_tool_call(
|
||||
session.clone(),
|
||||
.dispatch_tool_call_with_code_mode_result(
|
||||
session,
|
||||
turn,
|
||||
tracker,
|
||||
call,
|
||||
@@ -1616,7 +1616,8 @@ impl JsReplManager {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
Ok(result) => {
|
||||
let response = result.into_response();
|
||||
let summary = Self::summarize_tool_call_response(&response);
|
||||
match serde_json::to_value(response) {
|
||||
Ok(value) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::error::CodexErr;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::context::AbortedToolOutput;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::registry::AnyToolResult;
|
||||
use crate::tools::router::ToolCall;
|
||||
use crate::tools::router::ToolCallSource;
|
||||
@@ -57,9 +58,17 @@ impl ToolCallRuntime {
|
||||
call: ToolCall,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> impl std::future::Future<Output = Result<ResponseInputItem, CodexErr>> {
|
||||
let error_call = call.clone();
|
||||
let future =
|
||||
self.handle_tool_call_with_source(call, ToolCallSource::Direct, cancellation_token);
|
||||
async move { future.await.map(AnyToolResult::into_response) }.in_current_span()
|
||||
async move {
|
||||
match future.await {
|
||||
Ok(response) => Ok(response.into_response()),
|
||||
Err(FunctionCallError::Fatal(message)) => Err(CodexErr::Fatal(message)),
|
||||
Err(other) => Ok(Self::failure_response(error_call, other)),
|
||||
}
|
||||
}
|
||||
.in_current_span()
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
@@ -68,7 +77,7 @@ impl ToolCallRuntime {
|
||||
call: ToolCall,
|
||||
source: ToolCallSource,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> impl std::future::Future<Output = Result<AnyToolResult, CodexErr>> {
|
||||
) -> impl std::future::Future<Output = Result<AnyToolResult, FunctionCallError>> {
|
||||
let supports_parallel = self.router.tool_supports_parallel(&call.tool_name);
|
||||
let router = Arc::clone(&self.router);
|
||||
let session = Arc::clone(&self.session);
|
||||
@@ -78,7 +87,7 @@ impl ToolCallRuntime {
|
||||
let started = Instant::now();
|
||||
|
||||
let dispatch_span = trace_span!(
|
||||
"dispatch_tool_call",
|
||||
"dispatch_tool_call_with_code_mode_result",
|
||||
otel.name = call.tool_name.as_str(),
|
||||
tool_name = call.tool_name.as_str(),
|
||||
call_id = call.call_id.as_str(),
|
||||
@@ -115,20 +124,42 @@ impl ToolCallRuntime {
|
||||
}));
|
||||
|
||||
async move {
|
||||
match handle.await {
|
||||
Ok(Ok(response)) => Ok(response),
|
||||
Ok(Err(FunctionCallError::Fatal(message))) => Err(CodexErr::Fatal(message)),
|
||||
Ok(Err(other)) => Err(CodexErr::Fatal(other.to_string())),
|
||||
Err(err) => Err(CodexErr::Fatal(format!(
|
||||
"tool task failed to receive: {err:?}"
|
||||
))),
|
||||
}
|
||||
handle.await.map_err(|err| {
|
||||
FunctionCallError::Fatal(format!("tool task failed to receive: {err:?}"))
|
||||
})?
|
||||
}
|
||||
.in_current_span()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolCallRuntime {
|
||||
fn failure_response(call: ToolCall, err: FunctionCallError) -> ResponseInputItem {
|
||||
let message = err.to_string();
|
||||
match call.payload {
|
||||
ToolPayload::ToolSearch { .. } => ResponseInputItem::ToolSearchOutput {
|
||||
call_id: call.call_id,
|
||||
status: "completed".to_string(),
|
||||
execution: "client".to_string(),
|
||||
tools: Vec::new(),
|
||||
},
|
||||
ToolPayload::Custom { .. } => ResponseInputItem::CustomToolCallOutput {
|
||||
call_id: call.call_id,
|
||||
name: None,
|
||||
output: codex_protocol::models::FunctionCallOutputPayload {
|
||||
body: codex_protocol::models::FunctionCallOutputBody::Text(message),
|
||||
success: Some(false),
|
||||
},
|
||||
},
|
||||
_ => ResponseInputItem::FunctionCallOutput {
|
||||
call_id: call.call_id,
|
||||
output: codex_protocol::models::FunctionCallOutputPayload {
|
||||
body: codex_protocol::models::FunctionCallOutputBody::Text(message),
|
||||
success: Some(false),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn aborted_response(call: &ToolCall, secs: f32) -> AnyToolResult {
|
||||
AnyToolResult {
|
||||
call_id: call.call_id.clone(),
|
||||
|
||||
@@ -5,11 +5,9 @@ use crate::function_tool::FunctionCallError;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::tools::code_mode::is_code_mode_nested_tool;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::context::ToolSearchOutput;
|
||||
use crate::tools::discoverable::DiscoverableTool;
|
||||
use crate::tools::registry::AnyToolResult;
|
||||
use crate::tools::registry::ConfiguredToolSpec;
|
||||
@@ -18,7 +16,6 @@ use crate::tools::spec::ToolsConfig;
|
||||
use crate::tools::spec::build_specs_with_discoverable_tools;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::LocalShellAction;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::models::SearchToolCallParams;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
@@ -214,21 +211,6 @@ impl ToolRouter {
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all, err)]
|
||||
pub async fn dispatch_tool_call(
|
||||
&self,
|
||||
session: Arc<Session>,
|
||||
turn: Arc<TurnContext>,
|
||||
tracker: SharedTurnDiffTracker,
|
||||
call: ToolCall,
|
||||
source: ToolCallSource,
|
||||
) -> Result<ResponseInputItem, FunctionCallError> {
|
||||
Ok(self
|
||||
.dispatch_tool_call_with_code_mode_result(session, turn, tracker, call, source)
|
||||
.await?
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all, err)]
|
||||
pub async fn dispatch_tool_call_with_code_mode_result(
|
||||
&self,
|
||||
@@ -244,23 +226,14 @@ impl ToolRouter {
|
||||
call_id,
|
||||
payload,
|
||||
} = call;
|
||||
let payload_outputs_custom = matches!(payload, ToolPayload::Custom { .. });
|
||||
let payload_outputs_tool_search = matches!(payload, ToolPayload::ToolSearch { .. });
|
||||
let failure_call_id = call_id.clone();
|
||||
|
||||
if source == ToolCallSource::Direct
|
||||
&& turn.tools_config.js_repl_tools_only
|
||||
&& !matches!(tool_name.as_str(), "js_repl" | "js_repl_reset")
|
||||
{
|
||||
let err = FunctionCallError::RespondToModel(
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"direct tool calls are disabled; use js_repl and codex.tool(...) instead"
|
||||
.to_string(),
|
||||
);
|
||||
return Ok(Self::failure_result(
|
||||
failure_call_id,
|
||||
payload_outputs_custom,
|
||||
payload_outputs_tool_search,
|
||||
err,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -274,53 +247,7 @@ impl ToolRouter {
|
||||
payload,
|
||||
};
|
||||
|
||||
match self.registry.dispatch_any(invocation).await {
|
||||
Ok(response) => Ok(response),
|
||||
Err(FunctionCallError::Fatal(message)) => Err(FunctionCallError::Fatal(message)),
|
||||
Err(err) => Ok(Self::failure_result(
|
||||
failure_call_id,
|
||||
payload_outputs_custom,
|
||||
payload_outputs_tool_search,
|
||||
err,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn failure_result(
|
||||
call_id: String,
|
||||
payload_outputs_custom: bool,
|
||||
payload_outputs_tool_search: bool,
|
||||
err: FunctionCallError,
|
||||
) -> AnyToolResult {
|
||||
let message = err.to_string();
|
||||
if payload_outputs_tool_search {
|
||||
AnyToolResult {
|
||||
call_id,
|
||||
payload: ToolPayload::ToolSearch {
|
||||
arguments: SearchToolCallParams {
|
||||
query: String::new(),
|
||||
limit: None,
|
||||
},
|
||||
},
|
||||
result: Box::new(ToolSearchOutput { tools: Vec::new() }),
|
||||
}
|
||||
} else if payload_outputs_custom {
|
||||
AnyToolResult {
|
||||
call_id,
|
||||
payload: ToolPayload::Custom {
|
||||
input: String::new(),
|
||||
},
|
||||
result: Box::new(FunctionToolOutput::from_text(message, Some(false))),
|
||||
}
|
||||
} else {
|
||||
AnyToolResult {
|
||||
call_id,
|
||||
payload: ToolPayload::Function {
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
result: Box::new(FunctionToolOutput::from_text(message, Some(false))),
|
||||
}
|
||||
}
|
||||
self.registry.dispatch_any(invocation).await
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::codex::make_session_and_context;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
use super::ToolCall;
|
||||
@@ -50,20 +50,21 @@ async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> {
|
||||
},
|
||||
};
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
let response = router
|
||||
.dispatch_tool_call(session, turn, tracker, call, ToolCallSource::Direct)
|
||||
.await?;
|
||||
|
||||
match response {
|
||||
ResponseInputItem::FunctionCallOutput { output, .. } => {
|
||||
let content = output.text_content().unwrap_or_default();
|
||||
assert!(
|
||||
content.contains("direct tool calls are disabled"),
|
||||
"unexpected tool call message: {content}",
|
||||
);
|
||||
}
|
||||
other => panic!("expected function call output, got {other:?}"),
|
||||
}
|
||||
let err = router
|
||||
.dispatch_tool_call_with_code_mode_result(
|
||||
session,
|
||||
turn,
|
||||
tracker,
|
||||
call,
|
||||
ToolCallSource::Direct,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.expect("direct tool calls should be blocked");
|
||||
let FunctionCallError::RespondToModel(message) = err else {
|
||||
panic!("expected RespondToModel, got {err:?}");
|
||||
};
|
||||
assert!(message.contains("direct tool calls are disabled"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -107,20 +108,22 @@ async fn js_repl_tools_only_allows_js_repl_source_calls() -> anyhow::Result<()>
|
||||
},
|
||||
};
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
let response = router
|
||||
.dispatch_tool_call(session, turn, tracker, call, ToolCallSource::JsRepl)
|
||||
.await?;
|
||||
|
||||
match response {
|
||||
ResponseInputItem::FunctionCallOutput { output, .. } => {
|
||||
let content = output.text_content().unwrap_or_default();
|
||||
assert!(
|
||||
!content.contains("direct tool calls are disabled"),
|
||||
"js_repl source should bypass direct-call policy gate"
|
||||
);
|
||||
}
|
||||
other => panic!("expected function call output, got {other:?}"),
|
||||
}
|
||||
let err = router
|
||||
.dispatch_tool_call_with_code_mode_result(
|
||||
session,
|
||||
turn,
|
||||
tracker,
|
||||
call,
|
||||
ToolCallSource::JsRepl,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.expect("shell call with empty args should fail");
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
!message.contains("direct tool calls are disabled"),
|
||||
"js_repl source should bypass direct-call policy gate"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -980,7 +980,21 @@ fn create_view_image_tool(can_request_original_image_detail: bool) -> ToolSpec {
|
||||
required: Some(vec!["path".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
output_schema: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image_url": {
|
||||
"type": "string",
|
||||
"description": "Data URL for the loaded image."
|
||||
},
|
||||
"detail": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Image detail hint returned by view_image. Returns `original` when original resolution is preserved, otherwise `null`."
|
||||
}
|
||||
},
|
||||
"required": ["image_url", "detail"],
|
||||
"additionalProperties": false
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2627,7 +2627,7 @@ fn code_mode_augments_builtin_tool_descriptions_with_typed_sample() {
|
||||
|
||||
assert_eq!(
|
||||
description,
|
||||
"View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: { path: string; }): Promise<unknown>; };\n```"
|
||||
"View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: { path: string; }): Promise<{ detail: string | null; image_url: string; }>; };\n```"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_core::config::types::McpServerConfig;
|
||||
use codex_core::config::types::McpServerTransportConfig;
|
||||
use codex_core::features::Feature;
|
||||
@@ -537,6 +539,46 @@ Error:\ boom\n
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore = "no exec_command on Windows")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_exec_surfaces_handler_errors_as_exceptions() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let (_test, second_mock) = run_code_mode_turn(
|
||||
&server,
|
||||
"surface nested tool handler failures as script exceptions",
|
||||
r#"
|
||||
try {
|
||||
await tools.exec_command({});
|
||||
text("no-exception");
|
||||
} catch (error) {
|
||||
text(`caught:${error?.message ?? String(error)}`);
|
||||
}
|
||||
"#,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let request = second_mock.single_request();
|
||||
let (output, success) = custom_tool_output_body_and_success(&request, "call-1");
|
||||
assert_ne!(
|
||||
success,
|
||||
Some(false),
|
||||
"script should catch the nested tool error: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("caught:"),
|
||||
"expected caught exception text in output: {output}"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("no-exception"),
|
||||
"nested tool error should not allow success path: {output}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore = "no exec_command on Windows")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_can_yield_and_resume_with_wait() -> Result<()> {
|
||||
@@ -1721,6 +1763,90 @@ image("data:image/png;base64,AAA");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_can_use_view_image_result_with_image_helper() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mut builder = test_codex()
|
||||
.with_model("gpt-5.3-codex")
|
||||
.with_config(move |config| {
|
||||
let _ = config.features.enable(Feature::CodeMode);
|
||||
let _ = config.features.enable(Feature::ImageDetailOriginal);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
let image_bytes = BASE64_STANDARD.decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
|
||||
)?;
|
||||
let image_path = test.cwd_path().join("code_mode_view_image.png");
|
||||
fs::write(&image_path, image_bytes)?;
|
||||
|
||||
let image_path_json = serde_json::to_string(&image_path.to_string_lossy().to_string())?;
|
||||
let code = format!(
|
||||
r#"
|
||||
const out = await tools.view_image({{ path: {image_path_json}, detail: "original" }});
|
||||
image(out);
|
||||
"#
|
||||
);
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call("call-1", "exec", &code),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let second_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("use exec to call view_image and emit its image output")
|
||||
.await?;
|
||||
|
||||
let req = second_mock.single_request();
|
||||
let items = custom_tool_output_items(&req, "call-1");
|
||||
let (_, success) = custom_tool_output_body_and_success(&req, "call-1");
|
||||
assert_ne!(
|
||||
success,
|
||||
Some(false),
|
||||
"code_mode view_image call failed unexpectedly"
|
||||
);
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_regex_match(
|
||||
concat!(
|
||||
r"(?s)\A",
|
||||
r"Script completed\nWall time \d+\.\d seconds\nOutput:\n\z"
|
||||
),
|
||||
text_item(&items, 0),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
items[1].get("type").and_then(Value::as_str),
|
||||
Some("input_image")
|
||||
);
|
||||
|
||||
let emitted_image_url = items[1]
|
||||
.get("image_url")
|
||||
.and_then(Value::as_str)
|
||||
.expect("image helper should emit an input_image item with image_url");
|
||||
assert!(emitted_image_url.starts_with("data:image/png;base64,"));
|
||||
assert_eq!(
|
||||
items[1].get("detail").and_then(Value::as_str),
|
||||
Some("original")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_can_apply_patch_via_nested_tool() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -2044,7 +2170,7 @@ text(JSON.stringify(tool));
|
||||
parsed,
|
||||
serde_json::json!({
|
||||
"name": "view_image",
|
||||
"description": "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: { path: string; }): Promise<unknown>; };\n```",
|
||||
"description": "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: { path: string; }): Promise<{ detail: string | null; image_url: string; }>; };\n```",
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1087,7 +1087,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn view_image_tool_placeholder_for_non_image_files() -> anyhow::Result<()> {
|
||||
async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
@@ -1150,20 +1150,19 @@ async fn view_image_tool_placeholder_for_non_image_files() -> anyhow::Result<()>
|
||||
request.inputs_of_type("input_image").is_empty(),
|
||||
"non-image file should not produce an input_image message"
|
||||
);
|
||||
let (placeholder, success) = request
|
||||
let (error_text, success) = request
|
||||
.function_call_output_content_and_success(call_id)
|
||||
.expect("function_call_output should be present");
|
||||
assert_eq!(success, None);
|
||||
let placeholder = placeholder.expect("placeholder text present");
|
||||
let error_text = error_text.expect("error text present");
|
||||
|
||||
assert!(
|
||||
placeholder.contains("Codex could not read the local image at")
|
||||
&& placeholder.contains("unsupported MIME type `application/json`"),
|
||||
"placeholder should describe the unsupported file type: {placeholder}"
|
||||
let expected_error = format!(
|
||||
"unable to process image at `{}`: unsupported image `application/json`",
|
||||
abs_path.display()
|
||||
);
|
||||
assert!(
|
||||
placeholder.contains(&abs_path.display().to_string()),
|
||||
"placeholder should mention path: {placeholder}"
|
||||
error_text.contains(&expected_error),
|
||||
"error should describe unsupported file type: {error_text}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -19,7 +19,6 @@ codex-utils-image = { workspace = true }
|
||||
icu_decimal = { workspace = true }
|
||||
icu_locale_core = { workspace = true }
|
||||
icu_provider = { workspace = true, features = ["sync"] }
|
||||
mime_guess = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -941,7 +941,7 @@ fn invalid_image_error_placeholder(
|
||||
fn unsupported_image_error_placeholder(path: &std::path::Path, mime: &str) -> ContentItem {
|
||||
ContentItem::InputText {
|
||||
text: format!(
|
||||
"Codex cannot attach image at `{}`: unsupported image format `{}`.",
|
||||
"Codex cannot attach image at `{}`: unsupported image `{}`.",
|
||||
path.display(),
|
||||
mime
|
||||
),
|
||||
@@ -972,28 +972,20 @@ pub fn local_image_content_items_with_label_number(
|
||||
}
|
||||
items
|
||||
}
|
||||
Err(err) => {
|
||||
if matches!(&err, ImageProcessingError::Read { .. }) {
|
||||
Err(err) => match &err {
|
||||
ImageProcessingError::Read { .. } | ImageProcessingError::Encode { .. } => {
|
||||
vec![local_image_error_placeholder(path, &err)]
|
||||
} else if err.is_invalid_image() {
|
||||
vec![invalid_image_error_placeholder(path, &err)]
|
||||
} else {
|
||||
let Some(mime_guess) = mime_guess::from_path(path).first() else {
|
||||
return vec![local_image_error_placeholder(
|
||||
path,
|
||||
"unsupported MIME type (unknown)",
|
||||
)];
|
||||
};
|
||||
let mime = mime_guess.essence_str().to_owned();
|
||||
if !mime.starts_with("image/") {
|
||||
return vec![local_image_error_placeholder(
|
||||
path,
|
||||
format!("unsupported MIME type `{mime}`"),
|
||||
)];
|
||||
}
|
||||
vec![unsupported_image_error_placeholder(path, &mime)]
|
||||
}
|
||||
}
|
||||
ImageProcessingError::Decode { .. } if err.is_invalid_image() => {
|
||||
vec![invalid_image_error_placeholder(path, &err)]
|
||||
}
|
||||
ImageProcessingError::Decode { .. } => {
|
||||
vec![local_image_error_placeholder(path, &err)]
|
||||
}
|
||||
ImageProcessingError::UnsupportedImageFormat { mime } => {
|
||||
vec![unsupported_image_error_placeholder(path, mime)]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2908,8 +2900,8 @@ mod tests {
|
||||
match &content[0] {
|
||||
ContentItem::InputText { text } => {
|
||||
assert!(
|
||||
text.contains("unsupported MIME type `application/json`"),
|
||||
"placeholder should mention unsupported MIME: {text}"
|
||||
text.contains("unsupported image `application/json`"),
|
||||
"placeholder should mention unsupported image MIME: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(&json_path.display().to_string()),
|
||||
@@ -2943,7 +2935,7 @@ mod tests {
|
||||
ResponseInputItem::Message { content, .. } => {
|
||||
assert_eq!(content.len(), 1);
|
||||
let expected = format!(
|
||||
"Codex cannot attach image at `{}`: unsupported image format `image/svg+xml`.",
|
||||
"Codex cannot attach image at `{}`: unsupported image `image/svg+xml`.",
|
||||
svg_path.display()
|
||||
);
|
||||
match &content[0] {
|
||||
|
||||
@@ -285,6 +285,32 @@ fn emit_missing_system_bwrap_warning(app_event_tx: &AppEventSender) {
|
||||
)));
|
||||
}
|
||||
|
||||
async fn emit_custom_prompt_deprecation_notice(app_event_tx: &AppEventSender, codex_home: &Path) {
|
||||
let prompts_dir = codex_home.join("prompts");
|
||||
let prompt_count = codex_core::custom_prompts::discover_prompts_in(&prompts_dir)
|
||||
.await
|
||||
.len();
|
||||
if prompt_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let prompt_label = if prompt_count == 1 {
|
||||
"prompt"
|
||||
} else {
|
||||
"prompts"
|
||||
};
|
||||
let details = format!(
|
||||
"Detected {prompt_count} custom {prompt_label} in `$CODEX_HOME/prompts`. Use the `$skill-creator` skill to convert each custom prompt into a skill."
|
||||
);
|
||||
|
||||
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_deprecation_notice(
|
||||
"Custom prompts are deprecated and will soon be removed.".to_string(),
|
||||
Some(details),
|
||||
),
|
||||
)));
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct SessionSummary {
|
||||
usage_line: String,
|
||||
@@ -1974,6 +2000,7 @@ impl App {
|
||||
let app_event_tx = AppEventSender::new(app_event_tx);
|
||||
emit_project_config_warnings(&app_event_tx, &config);
|
||||
emit_missing_system_bwrap_warning(&app_event_tx);
|
||||
emit_custom_prompt_deprecation_notice(&app_event_tx, &config.codex_home).await;
|
||||
tui.set_notification_method(config.tui_notification_method);
|
||||
|
||||
let harness_overrides =
|
||||
@@ -4324,6 +4351,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn render_history_cell(cell: &dyn HistoryCell, width: u16) -> String {
|
||||
cell.display_lines(width)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_custom_prompt_deprecation_notice_emits_when_prompts_exist() -> Result<()> {
|
||||
let codex_home = tempdir()?;
|
||||
let prompts_dir = codex_home.path().join("prompts");
|
||||
std::fs::create_dir_all(&prompts_dir)?;
|
||||
std::fs::write(prompts_dir.join("review.md"), "# Review\n")?;
|
||||
|
||||
let (tx_raw, mut rx) = unbounded_channel();
|
||||
let app_event_tx = AppEventSender::new(tx_raw);
|
||||
|
||||
emit_custom_prompt_deprecation_notice(&app_event_tx, codex_home.path()).await;
|
||||
|
||||
let cell = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||||
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
||||
};
|
||||
let rendered = render_history_cell(cell.as_ref(), 120);
|
||||
|
||||
assert_snapshot!("startup_custom_prompt_deprecation_notice", rendered);
|
||||
assert!(rx.try_recv().is_err(), "expected only one startup notice");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_custom_prompt_deprecation_notice_skips_missing_prompts_dir() -> Result<()> {
|
||||
let codex_home = tempdir()?;
|
||||
let (tx_raw, mut rx) = unbounded_channel();
|
||||
let app_event_tx = AppEventSender::new(tx_raw);
|
||||
|
||||
emit_custom_prompt_deprecation_notice(&app_event_tx, codex_home.path()).await;
|
||||
|
||||
assert!(rx.try_recv().is_err(), "expected no startup notice");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_custom_prompt_deprecation_notice_skips_empty_prompts_dir() -> Result<()> {
|
||||
let codex_home = tempdir()?;
|
||||
std::fs::create_dir_all(codex_home.path().join("prompts"))?;
|
||||
let (tx_raw, mut rx) = unbounded_channel();
|
||||
let app_event_tx = AppEventSender::new(tx_raw);
|
||||
|
||||
emit_custom_prompt_deprecation_notice(&app_event_tx, codex_home.path()).await;
|
||||
|
||||
assert!(rx.try_recv().is_err(), "expected no startup notice");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() {
|
||||
let wait_for_resume = App::should_wait_for_initial_session(&SessionSelection::Resume(
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/app.rs
|
||||
expression: rendered
|
||||
---
|
||||
⚠ Custom prompts are deprecated and will soon be removed.
|
||||
Detected 1 custom prompt in `$CODEX_HOME/prompts`. Use the `$skill-creator` skill to convert each custom prompt into
|
||||
a skill.
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
base64 = { workspace = true }
|
||||
image = { workspace = true, features = ["jpeg", "png", "gif", "webp"] }
|
||||
codex-utils-cache = { workspace = true }
|
||||
mime_guess = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "rt", "rt-multi-thread", "macros"] }
|
||||
|
||||
|
||||
@@ -23,9 +23,26 @@ pub enum ImageProcessingError {
|
||||
#[source]
|
||||
source: image::ImageError,
|
||||
},
|
||||
#[error("unsupported image `{mime}`")]
|
||||
UnsupportedImageFormat { mime: String },
|
||||
}
|
||||
|
||||
impl ImageProcessingError {
|
||||
pub fn decode_error(path: &std::path::Path, source: image::ImageError) -> Self {
|
||||
if matches!(source, ImageError::Decoding(_)) {
|
||||
return ImageProcessingError::Decode {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
let mime = mime_guess::from_path(path)
|
||||
.first()
|
||||
.map(|mime_guess| mime_guess.essence_str().to_owned())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
ImageProcessingError::UnsupportedImageFormat { mime }
|
||||
}
|
||||
|
||||
pub fn is_invalid_image(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
|
||||
@@ -74,12 +74,8 @@ pub fn load_for_prompt_bytes(
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let dynamic = image::load_from_memory(&file_bytes).map_err(|source| {
|
||||
ImageProcessingError::Decode {
|
||||
path: path_buf.clone(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
let dynamic = image::load_from_memory(&file_bytes)
|
||||
.map_err(|source| ImageProcessingError::decode_error(&path_buf, source))?;
|
||||
|
||||
let (width, height) = dynamic.dimensions();
|
||||
|
||||
@@ -294,10 +290,11 @@ mod tests {
|
||||
PromptImageMode::ResizeToFit,
|
||||
)
|
||||
.expect_err("invalid image should fail");
|
||||
match err {
|
||||
ImageProcessingError::Decode { .. } => {}
|
||||
_ => panic!("unexpected error variant"),
|
||||
}
|
||||
assert!(matches!(
|
||||
err,
|
||||
ImageProcessingError::Decode { .. }
|
||||
| ImageProcessingError::UnsupportedImageFormat { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
|
||||
Reference in New Issue
Block a user