chore: merge origin/main into symlink hardening

Co-authored-by: Codex noreply@openai.com
This commit is contained in:
viyatb-oai
2026-06-01 16:23:20 -07:00
co-authored by Codex noreply@openai.com
2047 changed files with 208407 additions and 75082 deletions
+1 -3
View File
@@ -21,7 +21,6 @@ codex-network-proxy = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-image = { workspace = true }
codex-utils-string = { workspace = true }
codex-utils-template = { workspace = true }
encoding_rs = { workspace = true }
globset = { workspace = true }
icu_decimal = { workspace = true }
@@ -60,5 +59,4 @@ tempfile = { workspace = true }
[package.metadata.cargo-shear]
# Required because:
# `icu_provider`: contains a required `sync` feature for `icu_decimal`
# `strum`: as strum_macros in non-nightly builds
ignored = ["icu_provider", "strum"]
ignored = ["icu_provider"]
+106 -18
View File
@@ -9,7 +9,10 @@ use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use std::collections::HashMap;
use std::fmt;
use std::num::NonZeroU64;
use std::ops::Deref;
use std::str::FromStr;
use std::time::Duration;
use strum_macros::Display;
use strum_macros::EnumIter;
@@ -18,6 +21,21 @@ use wildmatch::WildMatchPattern;
use crate::openai_models::ReasoningEffort;
/// Selects which part of the active context is charged against
/// `model_auto_compact_token_limit`.
#[derive(
Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum AutoCompactTokenLimitScope {
/// Count the full active context against the limit.
#[default]
Total,
/// Count sampled output and later growth after the carried window prefix.
BodyAfterPrefix,
}
/// A summary of the reasoning performed by the model. This can be useful for
/// debugging and understanding the model's reasoning process.
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries
@@ -77,6 +95,65 @@ pub enum SandboxMode {
DangerFullAccess,
}
/// Validated plain profile-v2 name used to select `$CODEX_HOME/<name>.config.toml`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProfileV2Name(String);
impl ProfileV2Name {
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct ProfileV2NameParseError {
value: String,
}
impl fmt::Display for ProfileV2NameParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"invalid --profile value `{}`; pass a plain name such as `work`",
self.value
)
}
}
impl std::error::Error for ProfileV2NameParseError {}
impl FromStr for ProfileV2Name {
type Err = ProfileV2NameParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
if value.is_empty()
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
{
return Err(ProfileV2NameParseError {
value: value.to_string(),
});
}
Ok(Self(value.to_string()))
}
}
impl Deref for ProfileV2Name {
type Target = str;
fn deref(&self) -> &Self::Target {
self.as_str()
}
}
impl fmt::Display for ProfileV2Name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Display, TS)]
#[strum(serialize_all = "snake_case")]
#[ts(type = r#""user" | "auto_review" | "guardian_subagent""#)]
@@ -355,6 +432,12 @@ pub enum ServiceTier {
Flex,
}
/// Request/config sentinel for explicit standard routing.
///
/// This is not a catalog service tier id. It means the user intentionally
/// selected no service tier, so model catalog defaults should not apply.
pub const SERVICE_TIER_DEFAULT_REQUEST_VALUE: &str = "default";
impl ServiceTier {
pub const fn request_value(self) -> &'static str {
match self {
@@ -465,22 +548,9 @@ pub enum TrustLevel {
/// Controls whether the TUI uses the terminal's alternate screen buffer.
///
/// **Background:** The alternate screen buffer provides a cleaner fullscreen experience
/// without polluting the terminal's scrollback history. However, it conflicts with terminal
/// multiplexers like Zellij that strictly follow the xterm specification, which defines
/// that alternate screen buffers should not have scrollback.
///
/// **Zellij's behavior:** Zellij intentionally disables scrollback in alternate screen mode
/// (see https://github.com/zellij-org/zellij/pull/1032) to comply with the xterm spec. This
/// is by design and not configurable in Zellij—there is no option to enable scrollback in
/// alternate screen mode.
///
/// **Solution:** This setting provides a pragmatic workaround:
/// - `auto` (default): Automatically detect the terminal multiplexer. If running in Zellij,
/// disable alternate screen to preserve scrollback. Enable it everywhere else.
/// - `always`: Always use alternate screen mode (original behavior before this fix).
/// - `never`: Never use alternate screen mode. Runs in inline mode, preserving scrollback
/// in all multiplexers.
/// - `auto` (default): Use alternate screen mode.
/// - `always`: Always use alternate screen mode.
/// - `never`: Never use alternate screen mode. Runs in inline mode, preserving scrollback.
///
/// The CLI flag `--no-alt-screen` can override this setting at runtime.
#[derive(
@@ -489,10 +559,10 @@ pub enum TrustLevel {
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum AltScreenMode {
/// Auto-detect: disable alternate screen in Zellij, enable elsewhere.
/// Use alternate screen mode.
#[default]
Auto,
/// Always use alternate screen (original behavior).
/// Always use alternate screen mode.
Always,
/// Never use alternate screen (inline mode only).
Never,
@@ -703,6 +773,24 @@ mod tests {
}
}
#[test]
fn profile_v2_name_rejects_paths_and_empty_names() {
assert_eq!(
ProfileV2Name::from_str("../foo"),
Err(ProfileV2NameParseError {
value: "../foo".to_string(),
}),
"dots and slashes are disallowed to prevent reading arbitrary files"
);
assert_eq!(
ProfileV2Name::from_str(""),
Err(ProfileV2NameParseError {
value: String::new(),
}),
"profile name cannot be empty"
);
}
#[test]
fn tui_visible_collaboration_modes_match_mode_kind_visibility() {
let expected = [ModeKind::Default, ModeKind::Plan];
+37
View File
@@ -7,6 +7,7 @@ use crate::exec_output::ExecToolCallOutput;
use crate::network_policy::NetworkPolicyDecisionPayload;
use crate::protocol::CodexErrorInfo;
use crate::protocol::ErrorEvent;
use crate::protocol::RateLimitReachedType;
use crate::protocol::RateLimitSnapshot;
use crate::protocol::TruncationPolicy;
use chrono::DateTime;
@@ -89,6 +90,8 @@ pub enum CodexErr {
/// Returned by run_command_stream when the spawned child process timed out (10s).
#[error("timeout waiting for child process to exit")]
Timeout,
#[error("request timed out")]
RequestTimeout,
/// Returned by run_command_stream when the child could not be spawned (its stdout/stderr pipes
/// could not be captured). Analogous to the previous `CodexError::Spawn` variant.
#[error("spawn failed: child stdout/stderr not captured")]
@@ -192,6 +195,7 @@ impl CodexErr {
| CodexErr::CyberPolicy { .. } => false,
CodexErr::Stream(..)
| CodexErr::Timeout
| CodexErr::RequestTimeout
| CodexErr::UnexpectedStatus(_)
| CodexErr::ResponseStreamFailed(_)
| CodexErr::ConnectionFailed(_)
@@ -448,6 +452,7 @@ pub struct UsageLimitReachedError {
pub resets_at: Option<DateTime<Utc>>,
pub rate_limits: Option<Box<RateLimitSnapshot>>,
pub promo_message: Option<String>,
pub rate_limit_reached_type: Option<RateLimitReachedType>,
}
impl std::fmt::Display for UsageLimitReachedError {
@@ -467,6 +472,38 @@ impl std::fmt::Display for UsageLimitReachedError {
);
}
if let Some(rate_limit_reached_type) = self.rate_limit_reached_type {
match rate_limit_reached_type {
RateLimitReachedType::WorkspaceOwnerCreditsDepleted => {
return write!(
f,
"Your workspace is out of credits. Add credits to continue."
);
}
RateLimitReachedType::WorkspaceMemberCreditsDepleted => {
return write!(
f,
"Your workspace is out of credits. Ask your workspace owner to refill in order to continue."
);
}
RateLimitReachedType::WorkspaceOwnerUsageLimitReached => {
return write!(
f,
"You hit your spend cap set in your workspace. Increase your spend cap to continue."
);
}
RateLimitReachedType::WorkspaceMemberUsageLimitReached => {
return write!(
f,
"You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue."
);
}
RateLimitReachedType::RateLimitReached => {
// Generic limits intentionally use the existing promo or plan copy below.
}
}
}
if let Some(promo_message) = &self.promo_message {
return write!(
f,
+54
View File
@@ -56,6 +56,7 @@ fn usage_limit_reached_error_formats_plus_plan() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -63,6 +64,44 @@ fn usage_limit_reached_error_formats_plus_plan() {
);
}
#[test]
fn usage_limit_reached_error_formats_rate_limit_reached_types() {
let cases = [
(
RateLimitReachedType::RateLimitReached,
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again later.",
),
(
RateLimitReachedType::WorkspaceOwnerCreditsDepleted,
"Your workspace is out of credits. Add credits to continue.",
),
(
RateLimitReachedType::WorkspaceMemberCreditsDepleted,
"Your workspace is out of credits. Ask your workspace owner to refill in order to continue.",
),
(
RateLimitReachedType::WorkspaceOwnerUsageLimitReached,
"You hit your spend cap set in your workspace. Increase your spend cap to continue.",
),
(
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
"You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue.",
),
];
for (rate_limit_reached_type, expected) in cases {
let err = UsageLimitReachedError {
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: Some(rate_limit_reached_type),
};
assert_eq!(err.to_string(), expected);
}
}
#[test]
fn server_overloaded_maps_to_protocol() {
let err = CodexErr::ServerOverloaded;
@@ -177,6 +216,7 @@ fn usage_limit_reached_error_formats_free_plan() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -191,6 +231,7 @@ fn usage_limit_reached_error_formats_go_plan() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -205,6 +246,7 @@ fn usage_limit_reached_error_formats_default_when_none() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -223,6 +265,7 @@ fn usage_limit_reached_error_formats_team_plan() {
resets_at: Some(resets_at),
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
let expected = format!(
"You've hit your usage limit. To get more access now, send a request to your admin or try again at {expected_time}."
@@ -238,6 +281,7 @@ fn usage_limit_reached_error_formats_business_plan_without_reset() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -252,6 +296,7 @@ fn usage_limit_reached_error_formats_self_serve_business_usage_based_plan() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -266,6 +311,7 @@ fn usage_limit_reached_error_formats_enterprise_cbp_usage_based_plan() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -280,6 +326,7 @@ fn usage_limit_reached_error_formats_default_for_other_plans() {
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
@@ -298,6 +345,7 @@ fn usage_limit_reached_error_formats_pro_plan_with_reset() {
resets_at: Some(resets_at),
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
let expected = format!(
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
@@ -324,6 +372,7 @@ fn usage_limit_reached_error_hides_upsell_for_non_codex_limit_name() {
"Visit https://chatgpt.com/codex/settings/usage to purchase more credits"
.to_string(),
),
rate_limit_reached_type: None,
};
let expected = format!(
"You've hit your usage limit for codex_other. Switch to another model now, or try again at {expected_time}."
@@ -343,6 +392,7 @@ fn usage_limit_reached_includes_minutes_when_available() {
resets_at: Some(resets_at),
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
assert_eq!(err.to_string(), expected);
@@ -482,6 +532,7 @@ fn usage_limit_reached_includes_hours_and_minutes() {
resets_at: Some(resets_at),
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
let expected = format!(
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
@@ -502,6 +553,7 @@ fn usage_limit_reached_includes_days_hours_minutes() {
resets_at: Some(resets_at),
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
assert_eq!(err.to_string(), expected);
@@ -519,6 +571,7 @@ fn usage_limit_reached_less_than_minute() {
resets_at: Some(resets_at),
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
assert_eq!(err.to_string(), expected);
@@ -538,6 +591,7 @@ fn usage_limit_reached_with_promo_message() {
promo_message: Some(
"To continue using Codex, start a free trial of <PLAN> today".to_string(),
),
rate_limit_reached_type: None,
};
let expected = format!(
"You've hit your usage limit. To continue using Codex, start a free trial of <PLAN> today, or try again at {expected_time}."
+49 -2
View File
@@ -1,6 +1,7 @@
use crate::mcp::CallToolResult;
use crate::memory_citation::MemoryCitation;
use crate::models::ContentItem;
use crate::models::ImageDetail;
use crate::models::MessagePhase;
use crate::models::ResponseItem;
use crate::models::WebSearchAction;
@@ -55,6 +56,9 @@ pub enum TurnItem {
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
pub struct UserMessageItem {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub client_id: Option<String>,
pub content: Vec<UserInput>,
}
@@ -178,6 +182,9 @@ pub struct McpToolCallItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub mcp_app_resource_uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub plugin_id: Option<String>,
pub status: McpToolCallStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
@@ -233,6 +240,7 @@ impl UserMessageItem {
pub fn new(content: &[UserInput]) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
client_id: None,
content: content.to_vec(),
}
}
@@ -241,9 +249,12 @@ impl UserMessageItem {
// Legacy user-message events flatten only text inputs into `message` and
// rebase text element ranges onto that concatenated text.
EventMsg::UserMessage(UserMessageEvent {
client_id: self.client_id.clone(),
message: self.message(),
images: Some(self.image_urls()),
image_details: self.image_details(),
local_images: self.local_image_paths(),
local_image_details: self.local_image_details(),
text_elements: self.text_elements(),
})
}
@@ -266,6 +277,7 @@ impl UserMessageItem {
if let UserInput::Text {
text,
text_elements,
..
} = input
{
// Text element ranges are relative to each text chunk; offset them so they align
@@ -290,21 +302,54 @@ impl UserMessageItem {
self.content
.iter()
.filter_map(|c| match c {
UserInput::Image { image_url } => Some(image_url.clone()),
UserInput::Image { image_url, .. } => Some(image_url.clone()),
_ => None,
})
.collect()
}
pub fn image_details(&self) -> Vec<Option<ImageDetail>> {
trim_trailing_default_image_details(
self.content
.iter()
.filter_map(|c| match c {
UserInput::Image { detail, .. } => Some(*detail),
_ => None,
})
.collect(),
)
}
pub fn local_image_paths(&self) -> Vec<std::path::PathBuf> {
self.content
.iter()
.filter_map(|c| match c {
UserInput::LocalImage { path } => Some(path.clone()),
UserInput::LocalImage { path, .. } => Some(path.clone()),
_ => None,
})
.collect()
}
pub fn local_image_details(&self) -> Vec<Option<ImageDetail>> {
trim_trailing_default_image_details(
self.content
.iter()
.filter_map(|c| match c {
UserInput::LocalImage { detail, .. } => Some(*detail),
_ => None,
})
.collect(),
)
}
}
fn trim_trailing_default_image_details(
mut details: Vec<Option<ImageDetail>>,
) -> Vec<Option<ImageDetail>> {
while matches!(details.last(), Some(None)) {
details.pop();
}
details
}
impl HookPromptItem {
@@ -494,6 +539,7 @@ impl McpToolCallItem {
arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()),
},
mcp_app_resource_uri: self.mcp_app_resource_uri.clone(),
plugin_id: self.plugin_id.clone(),
})
}
@@ -512,6 +558,7 @@ impl McpToolCallItem {
arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()),
},
mcp_app_resource_uri: self.mcp_app_resource_uri.clone(),
plugin_id: self.plugin_id.clone(),
duration: self.duration?,
result,
}))
+12
View File
@@ -26,6 +26,18 @@ impl std::fmt::Display for RequestId {
}
}
/// Presentation metadata advertised by an initialized MCP server.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct McpServerInfo {
pub name: String,
pub title: Option<String>,
pub version: String,
pub description: Option<String>,
pub icons: Option<Vec<serde_json::Value>>,
pub website_url: Option<String>,
}
/// Definition for a tool the client can call.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
+249 -136
View File
@@ -126,7 +126,7 @@ impl FileSystemPermissions {
match entry.access {
FileSystemAccessMode::Read => read.push(path.clone()),
FileSystemAccessMode::Write => write.push(path.clone()),
FileSystemAccessMode::None => return None,
FileSystemAccessMode::Deny => return None,
}
}
@@ -297,6 +297,15 @@ impl ManagedFileSystemPermissions {
}
}
/// Reserved identifier for the built-in read-only permission profile.
pub const BUILT_IN_PERMISSION_PROFILE_READ_ONLY: &str = ":read-only";
/// Reserved identifier for the built-in workspace-write permission profile.
pub const BUILT_IN_PERMISSION_PROFILE_WORKSPACE: &str = ":workspace";
/// Reserved identifier for the built-in full-access permission profile.
pub const BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS: &str = ":danger-full-access";
/// Canonical active runtime permissions for a conversation, turn, or command.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -330,26 +339,11 @@ pub struct ActivePermissionProfile {
/// profile.
pub id: String,
/// Optional parent profile identifier once permissions profiles support
/// inheritance. This is always `None` until that config feature exists.
/// Optional parent profile identifier from the selected permissions
/// profile's `extends` setting.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub extends: Option<String>,
/// Bounded user-requested modifications applied on top of the named
/// profile, if any.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub modifications: Vec<ActivePermissionProfileModification>,
}
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
pub enum ActivePermissionProfileModification {
/// Additional concrete directory that should be writable.
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
AdditionalWritableRoot { path: AbsolutePathBuf },
}
impl ActivePermissionProfile {
@@ -357,16 +351,11 @@ impl ActivePermissionProfile {
Self {
id: id.into(),
extends: None,
modifications: Vec::new(),
}
}
pub fn with_modifications(
mut self,
modifications: Vec<ActivePermissionProfileModification>,
) -> Self {
self.modifications = modifications;
self
pub fn read_only() -> Self {
Self::new(BUILT_IN_PERMISSION_PROFILE_READ_ONLY)
}
}
@@ -402,7 +391,7 @@ impl PermissionProfile {
/// Managed workspace-write filesystem access with restricted network
/// access.
///
/// The returned profile contains symbolic `:project_roots` entries that
/// The returned profile contains symbolic `:workspace_roots` entries that
/// must be resolved against the active permission root before enforcement.
pub fn workspace_write() -> Self {
Self::workspace_write_with(
@@ -416,7 +405,7 @@ impl PermissionProfile {
/// Managed workspace-write filesystem access with the legacy
/// `sandbox_workspace_write` knobs applied directly to the profile.
///
/// The returned profile contains symbolic `:project_roots` entries that
/// The returned profile contains symbolic `:workspace_roots` entries that
/// must be resolved against the active permission root before enforcement.
pub fn workspace_write_with(
writable_roots: &[AbsolutePathBuf],
@@ -435,6 +424,28 @@ impl PermissionProfile {
}
}
pub fn materialize_project_roots_with_workspace_roots(
self,
workspace_roots: &[AbsolutePathBuf],
) -> Self {
match self {
Self::Managed {
file_system,
network,
} => {
let file_system = file_system
.to_sandbox_policy()
.materialize_project_roots_with_workspace_roots(workspace_roots);
Self::Managed {
file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system),
network,
}
}
Self::Disabled => Self::Disabled,
Self::External { network } => Self::External { network },
}
}
pub fn from_runtime_permissions(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
@@ -880,7 +891,10 @@ pub enum ResponseItem {
result: String,
},
#[serde(alias = "compaction_summary")]
Compaction { encrypted_content: String },
Compaction {
encrypted_content: String,
},
CompactionTrigger,
ContextCompaction {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
@@ -890,6 +904,13 @@ pub enum ResponseItem {
Other,
}
impl ResponseItem {
/// Returns whether this item is an ordinary user-role message.
pub fn is_user_message(&self) -> bool {
matches!(self, Self::Message { role, .. } if role == "user")
}
}
pub const BASE_INSTRUCTIONS_DEFAULT: &str = include_str!("prompts/base_instructions/default.md");
/// Base instructions for the model in a thread. Corresponds to the `instructions` field in the ResponsesAPI.
@@ -1054,8 +1075,13 @@ pub fn local_image_content_items_with_label_number(
path: &std::path::Path,
file_bytes: Vec<u8>,
label_number: Option<usize>,
mode: PromptImageMode,
detail: ImageDetail,
) -> Vec<ContentItem> {
let mode = match detail {
ImageDetail::Original => PromptImageMode::Original,
ImageDetail::Auto | ImageDetail::Low | ImageDetail::High => PromptImageMode::ResizeToFit,
};
match load_for_prompt_bytes(path, file_bytes, mode) {
Ok(image) => {
let mut items = Vec::with_capacity(3);
@@ -1066,7 +1092,7 @@ pub fn local_image_content_items_with_label_number(
}
items.push(ContentItem::InputImage {
image_url: image.into_data_url(),
detail: Some(DEFAULT_IMAGE_DETAIL),
detail: Some(detail),
});
if label_number.is_some() {
items.push(ContentItem::InputText {
@@ -1211,29 +1237,25 @@ impl From<Vec<UserInput>> for ResponseInputItem {
.into_iter()
.flat_map(|c| match c {
UserInput::Text { text, .. } => vec![ContentItem::InputText { text }],
UserInput::Image { image_url } => {
UserInput::Image {
image_url, detail, ..
} => {
image_index += 1;
vec![
ContentItem::InputText {
text: image_open_tag_text(),
},
ContentItem::InputImage {
image_url,
detail: Some(DEFAULT_IMAGE_DETAIL),
},
ContentItem::InputText {
text: image_close_tag_text(),
},
]
let detail = detail.unwrap_or(DEFAULT_IMAGE_DETAIL);
vec![ContentItem::InputImage {
image_url,
detail: Some(detail),
}]
}
UserInput::LocalImage { path } => {
UserInput::LocalImage { path, detail, .. } => {
image_index += 1;
let detail = detail.unwrap_or(DEFAULT_IMAGE_DETAIL);
match std::fs::read(&path) {
Ok(file_bytes) => local_image_content_items_with_label_number(
&path,
file_bytes,
Some(image_index),
PromptImageMode::ResizeToFit,
detail,
),
Err(err) => vec![local_image_error_placeholder(&path, err)],
}
@@ -1253,30 +1275,6 @@ pub struct SearchToolCallParams {
pub limit: Option<usize>,
}
/// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec`
/// or `shell`, the `arguments` field should deserialize to this struct.
#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
pub struct ShellToolCallParams {
pub command: Vec<String>,
pub workdir: Option<String>,
/// This is the maximum time in milliseconds that the command is allowed to run.
#[serde(alias = "timeout")]
pub timeout_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub sandbox_permissions: Option<SandboxPermissions>,
/// Suggests a command prefix to persist for future sessions
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub prefix_rule: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub additional_permissions: Option<AdditionalPermissionProfile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub justification: Option<String>,
}
/// If the `name` of a `ResponseItem::FunctionCall` is `shell_command`, the
/// `arguments` field should deserialize to this struct.
#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -1319,6 +1317,9 @@ pub enum FunctionCallOutputContentItem {
#[ts(optional)]
detail: Option<ImageDetail>,
},
EncryptedContent {
encrypted_content: String,
},
}
/// Converts structured function-call output content into plain text for
@@ -1342,7 +1343,8 @@ pub fn function_call_output_content_items_to_text(
Some(text.as_str())
}
FunctionCallOutputContentItem::InputText { .. }
| FunctionCallOutputContentItem::InputImage { .. } => None,
| FunctionCallOutputContentItem::InputImage { .. }
| FunctionCallOutputContentItem::EncryptedContent { .. } => None,
})
.collect::<Vec<_>>();
@@ -1647,6 +1649,14 @@ mod tests {
use std::path::PathBuf;
use tempfile::tempdir;
// A tiny valid PNG (1x1) so image conversion tests don't depend on cross-crate
// file paths, which break under Bazel sandboxing.
const TINY_PNG_BYTES: &[u8] = &[
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6,
0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0,
1, 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
];
#[test]
fn response_input_message_conversion_preserves_phase() {
let item = ResponseItem::from(ResponseInputItem::Message {
@@ -1670,6 +1680,36 @@ mod tests {
);
}
#[test]
fn image_detail_roundtrips_all_wire_values() -> Result<()> {
assert_eq!(
serde_json::from_str::<ImageDetail>("\"auto\"")?,
ImageDetail::Auto
);
assert_eq!(
serde_json::from_str::<ImageDetail>("\"low\"")?,
ImageDetail::Low
);
assert_eq!(serde_json::to_string(&ImageDetail::Auto)?, "\"auto\"");
assert_eq!(serde_json::to_string(&ImageDetail::Low)?, "\"low\"");
let content_item: ContentItem = serde_json::from_value(serde_json::json!({
"type": "input_image",
"image_url": "data:image/png;base64,abc",
"detail": "auto",
}))?;
assert_eq!(
content_item,
ContentItem::InputImage {
image_url: "data:image/png;base64,abc".to_string(),
detail: Some(ImageDetail::Auto),
}
);
Ok(())
}
#[test]
fn sandbox_permissions_helpers_match_documented_semantics() {
let cases = [
@@ -1787,7 +1827,7 @@ mod tests {
path: FileSystemPath::GlobPattern {
pattern: "**/*.env".to_string(),
},
access: FileSystemAccessMode::None,
access: FileSystemAccessMode::Deny,
}]);
file_system_sandbox_policy.glob_scan_max_depth = Some(2);
@@ -2062,6 +2102,9 @@ mod tests {
image_url: "data:image/png;base64,AAA".to_string(),
detail: Some(DEFAULT_IMAGE_DETAIL),
},
FunctionCallOutputContentItem::EncryptedContent {
encrypted_content: "enc_opaque".to_string(),
},
];
let text = function_call_output_content_items_to_text(&content_items);
@@ -2269,6 +2312,35 @@ mod tests {
Ok(())
}
#[test]
fn serializes_encrypted_function_output_content_as_array() -> Result<()> {
let item = ResponseInputItem::FunctionCallOutput {
call_id: "call1".into(),
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::EncryptedContent {
encrypted_content: "enc_opaque".into(),
},
]),
};
let json = serde_json::to_value(&item)?;
assert_eq!(
json,
serde_json::json!({
"type": "function_call_output",
"call_id": "call1",
"output": [
{
"type": "encrypted_content",
"encrypted_content": "enc_opaque",
}
],
})
);
Ok(())
}
#[test]
fn preserves_existing_image_data_urls() -> Result<()> {
let call_tool_result = CallToolResult {
@@ -2393,6 +2465,30 @@ mod tests {
Ok(())
}
#[test]
fn deserializes_encrypted_array_payload_into_items() -> Result<()> {
let json = r#"[
{"type": "encrypted_content", "encrypted_content": "enc_opaque"}
]"#;
let payload: FunctionCallOutputPayload = serde_json::from_str(json)?;
let expected_items = vec![FunctionCallOutputContentItem::EncryptedContent {
encrypted_content: "enc_opaque".into(),
}];
assert_eq!(payload.success, None);
assert_eq!(
payload.body,
FunctionCallOutputBody::ContentItems(expected_items.clone())
);
assert_eq!(
serde_json::to_string(&payload)?,
serde_json::to_string(&expected_items)?
);
Ok(())
}
#[test]
fn deserializes_compaction_alias() -> Result<()> {
let json = r#"{"type":"compaction_summary","encrypted_content":"abc"}"#;
@@ -2424,20 +2520,28 @@ mod tests {
}
#[test]
fn serializes_context_compaction_trigger_without_payload() -> Result<()> {
let item = ResponseItem::ContextCompaction {
encrypted_content: None,
};
fn serializes_compaction_trigger_without_payload() -> Result<()> {
let item = ResponseItem::CompactionTrigger;
assert_eq!(
serde_json::to_value(item)?,
serde_json::json!({
"type": "context_compaction",
"type": "compaction_trigger",
})
);
Ok(())
}
#[test]
fn deserializes_compaction_trigger_without_payload() -> Result<()> {
let json = r#"{"type":"compaction_trigger"}"#;
let item: ResponseItem = serde_json::from_str(json)?;
assert_eq!(item, ResponseItem::CompactionTrigger);
Ok(())
}
#[test]
fn deserializes_legacy_ghost_snapshot_as_other() -> Result<()> {
let json = r#"{
@@ -2546,51 +2650,20 @@ mod tests {
}
#[test]
fn deserialize_shell_tool_call_params() -> Result<()> {
let json = r#"{
"command": ["ls", "-l"],
"workdir": "/tmp",
"timeout": 1000
}"#;
let params: ShellToolCallParams = serde_json::from_str(json)?;
assert_eq!(
ShellToolCallParams {
command: vec!["ls".to_string(), "-l".to_string()],
workdir: Some("/tmp".to_string()),
timeout_ms: Some(1000),
sandbox_permissions: None,
prefix_rule: None,
additional_permissions: None,
justification: None,
},
params
);
Ok(())
}
#[test]
fn wraps_image_user_input_with_tags() -> Result<()> {
fn serializes_image_user_input_without_tags() -> Result<()> {
let image_url = "data:image/png;base64,abc".to_string();
let item = ResponseInputItem::from(vec![UserInput::Image {
image_url: image_url.clone(),
detail: None,
}]);
match item {
ResponseInputItem::Message { content, .. } => {
let expected = vec![
ContentItem::InputText {
text: image_open_tag_text(),
},
ContentItem::InputImage {
image_url,
detail: Some(DEFAULT_IMAGE_DETAIL),
},
ContentItem::InputText {
text: image_close_tag_text(),
},
];
let expected = vec![ContentItem::InputImage {
image_url,
detail: Some(DEFAULT_IMAGE_DETAIL),
}];
assert_eq!(content, expected);
}
other => panic!("expected message response but got {other:?}"),
@@ -2599,6 +2672,31 @@ mod tests {
Ok(())
}
#[test]
fn image_user_input_preserves_requested_detail() -> Result<()> {
let image_url = "data:image/png;base64,abc".to_string();
let item = ResponseInputItem::from(vec![UserInput::Image {
image_url: image_url.clone(),
detail: Some(ImageDetail::Original),
}]);
match item {
ResponseInputItem::Message { content, .. } => {
assert_eq!(
content.first(),
Some(&ContentItem::InputImage {
image_url,
detail: Some(ImageDetail::Original),
})
);
}
other => panic!("expected message response but got {other:?}"),
}
Ok(())
}
#[test]
fn tool_search_call_roundtrips() -> Result<()> {
let parsed: ResponseItem = serde_json::from_str(
@@ -2767,55 +2865,40 @@ mod tests {
let image_url = "data:image/png;base64,abc".to_string();
let dir = tempdir()?;
let local_path = dir.path().join("local.png");
// A tiny valid PNG (1x1) so this test doesn't depend on cross-crate file paths, which
// break under Bazel sandboxing.
const TINY_PNG_BYTES: &[u8] = &[
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1,
8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2,
0, 0, 5, 0, 1, 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
];
std::fs::write(&local_path, TINY_PNG_BYTES)?;
let item = ResponseInputItem::from(vec![
UserInput::Image {
image_url: image_url.clone(),
detail: None,
},
UserInput::LocalImage {
path: local_path,
detail: None,
},
UserInput::LocalImage { path: local_path },
]);
match item {
ResponseInputItem::Message { content, .. } => {
assert_eq!(
content.first(),
Some(&ContentItem::InputText {
text: image_open_tag_text(),
})
);
assert_eq!(
content.get(1),
Some(&ContentItem::InputImage {
image_url,
detail: Some(DEFAULT_IMAGE_DETAIL),
})
);
assert_eq!(
content.get(2),
Some(&ContentItem::InputText {
text: image_close_tag_text(),
})
);
assert_eq!(
content.get(3),
content.get(1),
Some(&ContentItem::InputText {
text: local_image_open_tag_text(/*label_number*/ 2),
})
);
assert!(matches!(
content.get(4),
content.get(2),
Some(ContentItem::InputImage { .. })
));
assert_eq!(
content.get(5),
content.get(3),
Some(&ContentItem::InputText {
text: image_close_tag_text(),
})
@@ -2827,6 +2910,33 @@ mod tests {
Ok(())
}
#[test]
fn local_image_user_input_preserves_requested_detail() -> Result<()> {
let dir = tempdir()?;
let local_path = dir.path().join("local.png");
std::fs::write(&local_path, TINY_PNG_BYTES)?;
let item = ResponseInputItem::from(vec![UserInput::LocalImage {
path: local_path,
detail: Some(ImageDetail::Original),
}]);
match item {
ResponseInputItem::Message { content, .. } => {
assert!(matches!(
content.get(1),
Some(ContentItem::InputImage {
detail: Some(ImageDetail::Original),
..
})
));
}
other => panic!("expected message response but got {other:?}"),
}
Ok(())
}
#[test]
fn local_image_read_error_adds_placeholder() -> Result<()> {
let dir = tempdir()?;
@@ -2834,6 +2944,7 @@ mod tests {
let item = ResponseInputItem::from(vec![UserInput::LocalImage {
path: missing_path.clone(),
detail: None,
}]);
match item {
@@ -2868,6 +2979,7 @@ mod tests {
let item = ResponseInputItem::from(vec![UserInput::LocalImage {
path: json_path.clone(),
detail: None,
}]);
match item {
@@ -2905,6 +3017,7 @@ mod tests {
let item = ResponseInputItem::from(vec![UserInput::LocalImage {
path: svg_path.clone(),
detail: None,
}]);
match item {
+159 -3
View File
@@ -8,7 +8,9 @@ use std::str::FromStr;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::de::DeserializeOwned;
use strum::IntoEnumIterator;
use strum_macros::Display;
use strum_macros::EnumIter;
@@ -17,6 +19,8 @@ use ts_rs::TS;
use crate::config_types::Personality;
use crate::config_types::ReasoningSummary;
use crate::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use crate::config_types::ServiceTier;
use crate::config_types::Verbosity;
const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}";
@@ -146,6 +150,9 @@ pub struct ModelPreset {
/// Service tiers this model can run with.
#[serde(default)]
pub service_tiers: Vec<ModelServiceTier>,
/// Catalog default service tier id for this model.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_service_tier: Option<String>,
/// Whether this is the default model for new users.
pub is_default: bool,
/// recommended upgrade model
@@ -202,7 +209,6 @@ pub enum ConfigShellToolType {
#[serde(rename_all = "snake_case")]
pub enum ApplyPatchToolType {
Freeform,
Function,
}
#[derive(
@@ -223,6 +229,25 @@ pub enum TruncationMode {
Tokens,
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ToolMode {
Direct,
CodeMode,
CodeModeOnly,
}
fn deserialize_optional_model_selector<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: Deserializer<'de>,
T: DeserializeOwned,
{
let Some(value) = Option::<String>::deserialize(deserializer)? else {
return Ok(None);
};
Ok(serde_json::from_value(serde_json::Value::String(value)).ok())
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)]
pub struct TruncationPolicyConfig {
pub mode: TruncationMode,
@@ -270,6 +295,8 @@ pub struct ModelInfo {
pub additional_speed_tiers: Vec<String>,
#[serde(default)]
pub service_tiers: Vec<ModelServiceTier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_service_tier: Option<String>,
pub availability_nux: Option<ModelAvailabilityNux>,
pub upgrade: Option<ModelInfoUpgrade>,
pub base_instructions: String,
@@ -312,6 +339,14 @@ pub struct ModelInfo {
pub used_fallback_model_metadata: bool,
#[serde(default)]
pub supports_search_tool: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_review_model_override: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_optional_model_selector"
)]
pub tool_mode: Option<ToolMode>,
}
impl ModelInfo {
@@ -455,6 +490,7 @@ impl From<ModelInfo> for ModelPreset {
supports_personality,
additional_speed_tiers: info.additional_speed_tiers,
service_tiers: info.service_tiers,
default_service_tier: info.default_service_tier,
is_default: false, // default is the highest priority available model
upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade {
id: upgrade.model.clone(),
@@ -479,13 +515,30 @@ impl ModelPreset {
pub fn supports_fast_mode(&self) -> bool {
self.service_tiers
.iter()
.any(|tier| tier.id == SPEED_TIER_FAST)
.any(|tier| tier.id == ServiceTier::Fast.request_value())
|| self
.additional_speed_tiers
.iter()
.any(|tier| tier == SPEED_TIER_FAST)
}
}
impl ModelInfo {
pub fn supports_service_tier(&self, service_tier: &str) -> bool {
self.service_tiers
.iter()
.any(|tier| tier.id == service_tier)
}
pub fn service_tier_for_request(&self, service_tier: Option<String>) -> Option<String> {
service_tier.filter(|service_tier| {
service_tier != SERVICE_TIER_DEFAULT_REQUEST_VALUE
&& self.supports_service_tier(service_tier)
})
}
}
impl ModelPreset {
/// Filter models based on authentication mode.
///
/// In ChatGPT mode, all models are visible. Otherwise, only API-supported models are shown.
@@ -566,6 +619,7 @@ mod tests {
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
availability_nux: None,
upgrade: None,
base_instructions: "base".to_string(),
@@ -587,6 +641,8 @@ mod tests {
input_modalities: default_input_modalities(),
used_fallback_model_metadata: false,
supports_search_tool: false,
auto_review_model_override: None,
tool_mode: None,
}
}
@@ -804,6 +860,45 @@ mod tests {
assert!(!model.supports_image_detail_original);
assert_eq!(model.web_search_tool_type, WebSearchToolType::Text);
assert!(!model.supports_search_tool);
assert_eq!(model.auto_review_model_override, None);
assert_eq!(model.tool_mode, None);
}
#[test]
fn model_info_deserializes_known_tool_mode() {
let mut value =
serde_json::to_value(test_model(/*spec*/ None)).expect("serialize test model");
let object = value
.as_object_mut()
.expect("model info should be an object");
object.insert(
"tool_mode".to_string(),
serde_json::Value::String("code_mode_only".to_string()),
);
let model = serde_json::from_value::<ModelInfo>(value).expect("deserialize model info");
assert_eq!(model.tool_mode, Some(ToolMode::CodeModeOnly));
}
#[test]
fn model_info_treats_unknown_tool_mode_as_omitted() {
let mut value =
serde_json::to_value(test_model(/*spec*/ None)).expect("serialize test model");
let object = value
.as_object_mut()
.expect("model info should be an object");
object.insert(
"tool_mode".to_string(),
serde_json::Value::String("future_tool_mode".to_string()),
);
let model = serde_json::from_value::<ModelInfo>(value).expect("deserialize model info");
assert_eq!(model.tool_mode, None);
let serialized = serde_json::to_value(model).expect("serialize model info");
let object = serialized
.as_object()
.expect("model info should be an object");
assert!(!object.contains_key("tool_mode"));
}
#[test]
@@ -836,6 +931,7 @@ mod tests {
message: "Try Spark.".to_string(),
}),
additional_speed_tiers: vec![SPEED_TIER_FAST.to_string()],
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
service_tiers: Vec::new(),
..test_model(/*spec*/ None)
});
@@ -847,13 +943,17 @@ mod tests {
})
);
assert!(preset.supports_fast_mode());
assert_eq!(
preset.default_service_tier,
Some(ServiceTier::Fast.request_value().to_string())
);
}
#[test]
fn model_preset_supports_fast_mode_from_service_tiers() {
let preset = ModelPreset::from(ModelInfo {
service_tiers: vec![ModelServiceTier {
id: SPEED_TIER_FAST.to_string(),
id: ServiceTier::Fast.request_value().to_string(),
name: "Fast".to_string(),
description: "Priority processing.".to_string(),
}],
@@ -862,4 +962,60 @@ mod tests {
assert!(preset.supports_fast_mode());
}
#[test]
fn service_tier_for_request_omits_explicit_default_tier() {
let model = ModelInfo {
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
service_tiers: vec![ModelServiceTier {
id: ServiceTier::Fast.request_value().to_string(),
name: "Fast".to_string(),
description: "Priority processing.".to_string(),
}],
..test_model(/*spec*/ None)
};
assert_eq!(
model.service_tier_for_request(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())),
None
);
}
#[test]
fn service_tier_for_request_filters_unsupported_tiers() {
let model = ModelInfo {
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
service_tiers: vec![ModelServiceTier {
id: ServiceTier::Fast.request_value().to_string(),
name: "Fast".to_string(),
description: "Priority processing.".to_string(),
}],
..test_model(/*spec*/ None)
};
assert_eq!(
model.service_tier_for_request(Some(ServiceTier::Fast.request_value().to_string())),
Some(ServiceTier::Fast.request_value().to_string())
);
assert_eq!(
model.service_tier_for_request(Some("unsupported".to_string())),
None
);
assert_eq!(model.service_tier_for_request(/*service_tier*/ None), None);
}
#[test]
fn service_tier_for_request_does_not_apply_catalog_default() {
let model = ModelInfo {
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
service_tiers: vec![ModelServiceTier {
id: ServiceTier::Fast.request_value().to_string(),
name: "Fast".to_string(),
description: "Priority processing.".to_string(),
}],
..test_model(/*spec*/ None)
};
assert_eq!(model.service_tier_for_request(/*service_tier*/ None), None);
}
}
+308 -56
View File
@@ -95,7 +95,7 @@ impl NetworkSandboxPolicy {
/// Access mode for a filesystem entry.
///
/// When two equally specific entries target the same path, we compare these by
/// conflict precedence rather than by capability breadth: `none` beats
/// conflict precedence rather than by capability breadth: `deny` beats
/// `write`, and `write` beats `read`.
#[derive(
Debug,
@@ -117,12 +117,14 @@ impl NetworkSandboxPolicy {
pub enum FileSystemAccessMode {
Read,
Write,
None,
/// `none` is a legacy input alias retained temporarily for compatibility.
#[serde(alias = "none")]
Deny,
}
impl FileSystemAccessMode {
pub fn can_read(self) -> bool {
!matches!(self, FileSystemAccessMode::None)
!matches!(self, FileSystemAccessMode::Deny)
}
pub fn can_write(self) -> bool {
@@ -232,8 +234,39 @@ impl ReadDenyMatcher {
/// can skip read-deny checks without allocating matcher state. The `cwd`
/// resolves cwd-relative policy paths and special paths before matching.
pub fn new(file_system_sandbox_policy: &FileSystemSandboxPolicy, cwd: &Path) -> Option<Self> {
match Self::build(
file_system_sandbox_policy,
cwd,
InvalidDenyReadGlobBehavior::FailClosed,
) {
Ok(matcher) => matcher,
Err(_) => unreachable!("fail-closed glob handling does not return errors"),
}
}
/// Builds a matcher for callers that must reject malformed glob patterns.
///
/// Runtime read checks intentionally fail closed on malformed deny patterns.
/// Host-side expansion work should use this constructor instead so a typo
/// cannot broaden the set of paths it mutates before execution starts.
pub fn try_new(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &Path,
) -> Result<Option<Self>, String> {
Self::build(
file_system_sandbox_policy,
cwd,
InvalidDenyReadGlobBehavior::ReturnError,
)
}
fn build(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &Path,
invalid_glob_behavior: InvalidDenyReadGlobBehavior,
) -> Result<Option<Self>, String> {
if !file_system_sandbox_policy.has_denied_read_restrictions() {
return None;
return Ok(None);
}
// Exact roots are stored as all meaningful path spellings we can derive
@@ -247,22 +280,23 @@ impl ReadDenyMatcher {
// Pattern entries stay as policy-level globs. They are matched at read
// time here instead of being snapshotted to startup filesystem state.
let mut invalid_pattern = false;
let deny_read_matchers = file_system_sandbox_policy
.get_unreadable_globs_with_cwd(cwd)
.into_iter()
.filter_map(|pattern| match build_glob_matcher(&pattern) {
Some(matcher) => Some(matcher),
None => {
invalid_pattern = true;
None
}
})
.collect();
Some(Self {
let mut deny_read_matchers = Vec::new();
for pattern in file_system_sandbox_policy.get_unreadable_globs_with_cwd(cwd) {
match build_glob_matcher(&pattern) {
Ok(matcher) => deny_read_matchers.push(matcher),
Err(err) => match invalid_glob_behavior {
InvalidDenyReadGlobBehavior::FailClosed => invalid_pattern = true,
InvalidDenyReadGlobBehavior::ReturnError => {
return Err(format!("invalid deny-read glob pattern `{pattern}`: {err}"));
}
},
}
}
Ok(Some(Self {
denied_candidates,
deny_read_matchers,
invalid_pattern,
})
}))
}
/// Returns whether `path` is denied by the policy used to build this matcher.
@@ -295,6 +329,12 @@ impl ReadDenyMatcher {
}
}
#[derive(Clone, Copy)]
enum InvalidDenyReadGlobBehavior {
FailClosed,
ReturnError,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
@@ -303,7 +343,7 @@ pub enum FileSystemPath {
path: AbsolutePathBuf,
},
/// A git-style glob pattern. Pattern entries currently support
/// FileSystemAccessMode::None only.
/// FileSystemAccessMode::Deny only.
GlobPattern {
pattern: String,
},
@@ -312,6 +352,12 @@ pub enum FileSystemPath {
},
}
const PROJECT_ROOTS_GLOB_PATTERN_PREFIX: &str = "codex-project-roots://";
pub fn project_roots_glob_pattern(subpath: &Path) -> String {
format!("{PROJECT_ROOTS_GLOB_PATTERN_PREFIX}{}", subpath.display())
}
impl Default for FileSystemSandboxPolicy {
fn default() -> Self {
Self {
@@ -368,7 +414,7 @@ impl FileSystemSandboxPolicy {
&& self
.entries
.iter()
.any(|entry| entry.access == FileSystemAccessMode::None)
.any(|entry| entry.access == FileSystemAccessMode::Deny)
}
pub fn from_legacy_sandbox_policy_preserving_deny_entries(
@@ -385,7 +431,7 @@ impl FileSystemSandboxPolicy {
for deny_entry in existing
.entries
.iter()
.filter(|entry| entry.access == FileSystemAccessMode::None)
.filter(|entry| entry.access == FileSystemAccessMode::Deny)
{
if !rebuilt.entries.iter().any(|entry| entry == deny_entry) {
rebuilt.entries.push(deny_entry.clone());
@@ -401,7 +447,7 @@ impl FileSystemSandboxPolicy {
let has_deny_read_entries = existing
.entries
.iter()
.any(|entry| entry.access == FileSystemAccessMode::None);
.any(|entry| entry.access == FileSystemAccessMode::Deny);
if matches!(self.kind, FileSystemSandboxKind::Unrestricted) && has_deny_read_entries {
*self = Self::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
@@ -422,7 +468,7 @@ impl FileSystemSandboxPolicy {
for deny_entry in existing
.entries
.iter()
.filter(|entry| entry.access == FileSystemAccessMode::None)
.filter(|entry| entry.access == FileSystemAccessMode::Deny)
{
if !self.entries.iter().any(|entry| entry == deny_entry) {
self.entries.push(deny_entry.clone());
@@ -448,7 +494,7 @@ impl FileSystemSandboxPolicy {
FileSystemPath::Path { .. } => !self.has_same_target_write_override(entry),
FileSystemPath::GlobPattern { .. } => true,
FileSystemPath::Special { value } => match value {
FileSystemSpecialPath::Root => entry.access == FileSystemAccessMode::None,
FileSystemSpecialPath::Root => entry.access == FileSystemAccessMode::Deny,
FileSystemSpecialPath::Minimal | FileSystemSpecialPath::Unknown { .. } => {
false
}
@@ -610,7 +656,7 @@ impl FileSystemSandboxPolicy {
}
let Some(path) = resolve_candidate_path(path, cwd) else {
return FileSystemAccessMode::None;
return FileSystemAccessMode::Deny;
};
self.resolved_entries_with_cwd(cwd)
@@ -618,7 +664,7 @@ impl FileSystemSandboxPolicy {
.filter(|entry| path.as_path().starts_with(entry.path.as_path()))
.max_by_key(resolved_entry_precedence)
.map(|entry| entry.access)
.unwrap_or(FileSystemAccessMode::None)
.unwrap_or(FileSystemAccessMode::Deny)
}
pub fn can_read_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool {
@@ -657,7 +703,7 @@ impl FileSystemSandboxPolicy {
)
}
/// Replaces symbolic `:project_roots` entries with absolute paths resolved
/// Replaces symbolic `:workspace_roots` entries with absolute paths resolved
/// against `cwd`.
///
/// Use this when a durable permission profile must survive a cwd-only
@@ -665,15 +711,100 @@ impl FileSystemSandboxPolicy {
pub fn materialize_project_roots_with_cwd(mut self, cwd: &Path) -> Self {
let cwd = AbsolutePathBuf::from_absolute_path(cwd).ok();
for entry in &mut self.entries {
let FileSystemPath::Special {
value: FileSystemSpecialPath::ProjectRoots { .. },
} = &entry.path
else {
continue;
};
match &entry.path {
FileSystemPath::Special {
value: FileSystemSpecialPath::ProjectRoots { .. },
} => {
if let Some(path) = resolve_file_system_path(&entry.path, cwd.as_ref()) {
entry.path = FileSystemPath::Path { path };
}
}
FileSystemPath::GlobPattern { pattern } => {
if let (Some(cwd), Some(subpath)) =
(cwd.as_ref(), parse_project_roots_glob_pattern(pattern))
{
entry.path = FileSystemPath::GlobPattern {
pattern: resolve_project_roots_glob_pattern(subpath, cwd),
};
}
}
FileSystemPath::Special { value: _ } => {}
FileSystemPath::Path { .. } => {}
}
}
self
}
if let Some(path) = resolve_file_system_path(&entry.path, cwd.as_ref()) {
entry.path = FileSystemPath::Path { path };
/// Replaces symbolic `:workspace_roots` entries with concrete entries for
/// each workspace root.
pub fn materialize_project_roots_with_workspace_roots(
mut self,
workspace_roots: &[AbsolutePathBuf],
) -> Self {
let mut entries = Vec::with_capacity(self.entries.len());
for entry in self.entries {
match entry.path {
FileSystemPath::Special {
value: FileSystemSpecialPath::ProjectRoots { subpath },
} => {
entries.extend(workspace_roots.iter().map(|root| FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: match subpath.as_ref() {
Some(subpath) => AbsolutePathBuf::resolve_path_against_base(
subpath,
root.as_path(),
),
None => root.clone(),
},
},
access: entry.access,
}));
}
FileSystemPath::GlobPattern { pattern } => {
if let Some(subpath) = parse_project_roots_glob_pattern(&pattern) {
entries.extend(workspace_roots.iter().map(|root| FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: resolve_project_roots_glob_pattern(subpath, root),
},
access: entry.access,
}));
} else {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern { pattern },
access: entry.access,
});
}
}
FileSystemPath::Path { path } => {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: entry.access,
});
}
FileSystemPath::Special { value } => {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special { value },
access: entry.access,
});
}
}
}
self.entries = entries;
self
}
/// Preserves symbolic `:workspace_roots` entries while also adding concrete
/// entries for each provided workspace root.
pub fn with_materialized_project_roots_for_workspace_roots(
mut self,
workspace_roots: &[AbsolutePathBuf],
) -> Self {
let materialized = self
.clone()
.materialize_project_roots_with_workspace_roots(workspace_roots);
for entry in materialized.entries {
if !self.entries.contains(&entry) {
self.entries.push(entry);
}
}
self
@@ -725,7 +856,7 @@ impl FileSystemSandboxPolicy {
///
/// Unlike [`Self::with_additional_writable_roots`], this mirrors legacy
/// writable-roots semantics by adding exact roots even when they are
/// already writable through `:project_roots`, and by adding the default
/// already writable through `:workspace_roots`, and by adding the default
/// read-only protected subpaths for each new root.
pub fn with_additional_legacy_workspace_writable_roots(
mut self,
@@ -933,7 +1064,7 @@ impl FileSystemSandboxPolicy {
dedup_absolute_paths(
self.resolved_entries_with_cwd(cwd)
.iter()
.filter(|entry| entry.access == FileSystemAccessMode::None)
.filter(|entry| entry.access == FileSystemAccessMode::Deny)
.filter(|entry| !self.can_read_path_with_cwd(entry.path.as_path(), cwd))
// Restricted policies already deny reads outside explicit allow roots,
// so materializing the filesystem root here would erase narrower
@@ -954,7 +1085,7 @@ impl FileSystemSandboxPolicy {
let mut patterns = self
.entries
.iter()
.filter(|entry| entry.access == FileSystemAccessMode::None)
.filter(|entry| entry.access == FileSystemAccessMode::Deny)
.filter_map(|entry| match &entry.path {
FileSystemPath::GlobPattern { pattern } => {
Some(AbsolutePathBuf::resolve_path_against_base(pattern, cwd))
@@ -1013,7 +1144,7 @@ impl FileSystemSandboxPolicy {
}
FileSystemPath::Special { value } => match value {
FileSystemSpecialPath::Root => match entry.access {
FileSystemAccessMode::None => {}
FileSystemAccessMode::Deny => {}
FileSystemAccessMode::Read => {}
FileSystemAccessMode::Write => {
unbridgeable_root_write = true;
@@ -1171,6 +1302,18 @@ fn resolve_entry_path(
}
}
fn parse_project_roots_glob_pattern(pattern: &str) -> Option<&Path> {
pattern
.strip_prefix(PROJECT_ROOTS_GLOB_PATTERN_PREFIX)
.map(Path::new)
}
fn resolve_project_roots_glob_pattern(subpath: &Path, root: &AbsolutePathBuf) -> String {
AbsolutePathBuf::resolve_path_against_base(subpath, root.as_path())
.to_string_lossy()
.into_owned()
}
fn resolve_candidate_path(path: &Path, cwd: &Path) -> Option<AbsolutePathBuf> {
if path.is_absolute() {
AbsolutePathBuf::from_absolute_path(path).ok()
@@ -1291,15 +1434,15 @@ fn push_unique(candidates: &mut Vec<PathBuf>, candidate: PathBuf) {
}
}
fn build_glob_matcher(pattern: &str) -> Option<GlobMatcher> {
fn build_glob_matcher(pattern: &str) -> Result<GlobMatcher, String> {
// Keep `*` and `?` within a single path component and preserve an unclosed
// `[` as a literal so matcher behavior stays aligned with config parsing.
GlobBuilder::new(pattern)
.literal_separator(true)
.allow_unclosed_class(true)
.build()
.ok()
.map(|glob| glob.compile_matcher())
.map_err(|err| err.to_string())
}
fn resolve_file_system_special_path(
@@ -2063,7 +2206,7 @@ mod tests {
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: link_blocked },
access: FileSystemAccessMode::None,
access: FileSystemAccessMode::Deny,
},
]);
@@ -2125,7 +2268,7 @@ mod tests {
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: link_blocked },
access: FileSystemAccessMode::None,
access: FileSystemAccessMode::Deny,
},
]);
@@ -2264,7 +2407,7 @@ mod tests {
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: link_private },
access: FileSystemAccessMode::None,
access: FileSystemAccessMode::Deny,
},
]);
@@ -2311,7 +2454,7 @@ mod tests {
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: link_private },
access: FileSystemAccessMode::None,
access: FileSystemAccessMode::Deny,
},
]);
@@ -2353,7 +2496,7 @@ mod tests {
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: alias },
access: FileSystemAccessMode::None,
access: FileSystemAccessMode::Deny,
},
]);
@@ -2413,7 +2556,7 @@ mod tests {
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: link_blocked },
access: FileSystemAccessMode::None,
access: FileSystemAccessMode::Deny,
},
]);
@@ -2459,7 +2602,7 @@ mod tests {
path: FileSystemPath::Path {
path: docs_private.clone(),
},
access: FileSystemAccessMode::None,
access: FileSystemAccessMode::Deny,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
@@ -2479,7 +2622,7 @@ mod tests {
);
assert_eq!(
policy.resolve_access_with_cwd(docs_private.as_path(), cwd.path()),
FileSystemAccessMode::None
FileSystemAccessMode::Deny
);
assert_eq!(
policy.resolve_access_with_cwd(docs_private_public.as_path(), cwd.path()),
@@ -2620,7 +2763,7 @@ mod tests {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::None,
access: FileSystemAccessMode::Deny,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: docs.clone() },
@@ -2656,14 +2799,14 @@ mod tests {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::None,
access: FileSystemAccessMode::Deny,
},
]);
assert!(!policy.has_full_disk_write_access());
assert_eq!(
policy.resolve_access_with_cwd(root.as_path(), cwd.path()),
FileSystemAccessMode::None
FileSystemAccessMode::Deny
);
}
@@ -2763,6 +2906,115 @@ mod tests {
);
}
#[test]
fn materialize_project_roots_with_workspace_roots_expands_exact_and_glob_entries() {
let temp_dir = TempDir::new().expect("tempdir");
let first = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("first"))
.expect("resolve first root");
let second = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("second"))
.expect("resolve second root");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(".git".into())),
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: project_roots_glob_pattern(Path::new("**/*.env")),
},
access: FileSystemAccessMode::Deny,
},
]);
let actual =
policy.materialize_project_roots_with_workspace_roots(&[first.clone(), second.clone()]);
assert_eq!(
actual,
FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: first.clone(),
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: second.clone(),
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: first.join(".git"),
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: second.join(".git"),
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: AbsolutePathBuf::resolve_path_against_base(
"**/*.env",
first.as_path(),
)
.to_string_lossy()
.into_owned(),
},
access: FileSystemAccessMode::Deny,
},
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: AbsolutePathBuf::resolve_path_against_base(
"**/*.env",
second.as_path(),
)
.to_string_lossy()
.into_owned(),
},
access: FileSystemAccessMode::Deny,
},
])
);
}
#[test]
fn materialize_project_roots_with_cwd_expands_symbolic_glob_entries() {
let cwd = TempDir::new().expect("tempdir");
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: project_roots_glob_pattern(Path::new("**/*.env")),
},
access: FileSystemAccessMode::Deny,
}]);
let actual = policy.materialize_project_roots_with_cwd(cwd.path());
assert_eq!(
actual,
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: AbsolutePathBuf::resolve_path_against_base("**/*.env", cwd.path())
.to_string_lossy()
.into_owned(),
},
access: FileSystemAccessMode::Deny,
}])
);
}
#[test]
fn with_additional_legacy_workspace_writable_roots_protects_metadata() {
let temp_dir = TempDir::new().expect("tempdir");
@@ -2807,7 +3059,7 @@ mod tests {
#[test]
fn file_system_access_mode_orders_by_conflict_precedence() {
assert!(FileSystemAccessMode::Write > FileSystemAccessMode::Read);
assert!(FileSystemAccessMode::None > FileSystemAccessMode::Write);
assert!(FileSystemAccessMode::Deny > FileSystemAccessMode::Write);
}
#[test]
@@ -2817,7 +3069,7 @@ mod tests {
path: FileSystemPath::Path {
path: denied.clone(),
},
access: FileSystemAccessMode::None,
access: FileSystemAccessMode::Deny,
}]);
let rebuilt = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries(
@@ -2832,7 +3084,7 @@ mod tests {
== FileSystemPath::Path {
path: denied.clone(),
}
&& entry.access == FileSystemAccessMode::None
&& entry.access == FileSystemAccessMode::Deny
}),
"expected explicit deny entry to be preserved"
);
@@ -2865,14 +3117,14 @@ mod tests {
path: FileSystemPath::Path {
path: AbsolutePathBuf::try_from(path).expect("absolute deny path"),
},
access: FileSystemAccessMode::None,
access: FileSystemAccessMode::Deny,
}])
}
fn unreadable_glob_entry(pattern: String) -> FileSystemSandboxEntry {
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern { pattern },
access: FileSystemAccessMode::None,
access: FileSystemAccessMode::Deny,
}
}
+333 -241
View File
@@ -3,6 +3,7 @@
//! Uses a SQ (Submission Queue) / EQ (Event Queue) pattern to asynchronously communicate
//! between user and agent.
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::fmt;
use std::ops::Mul;
@@ -34,6 +35,7 @@ use crate::memory_citation::MemoryCitation;
use crate::models::ActivePermissionProfile;
use crate::models::BaseInstructions;
use crate::models::ContentItem;
use crate::models::ImageDetail;
use crate::models::MessagePhase;
use crate::models::PermissionProfile;
use crate::models::ResponseInputItem;
@@ -127,6 +129,9 @@ pub struct Submission {
pub id: String,
/// Payload
pub op: Op,
/// Client-provided id for the user message represented by `Op::UserInput`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_user_message_id: Option<String>,
/// Optional W3C trace carrier propagated across async submission handoffs.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trace: Option<W3cTraceContext>,
@@ -395,6 +400,96 @@ pub struct ConversationTextParams {
pub text: String,
}
/// Persistent thread-settings overrides that can be applied before user input or
/// on their own.
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, JsonSchema)]
pub struct ThreadSettingsOverrides {
/// Updated `cwd` for sandbox/tool calls.
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
/// Updated runtime workspace roots used to materialize symbolic
/// `:workspace_roots` filesystem permissions.
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_roots: Option<Vec<AbsolutePathBuf>>,
/// Updated profile-defined workspace roots for status summaries and
/// per-turn config reconstruction.
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_workspace_roots: Option<Vec<AbsolutePathBuf>>,
/// Updated command approval policy.
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_policy: Option<AskForApproval>,
/// Updated approval reviewer for future approval prompts.
#[serde(skip_serializing_if = "Option::is_none")]
pub approvals_reviewer: Option<ApprovalsReviewer>,
/// Updated sandbox policy for tool calls.
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox_policy: Option<SandboxPolicy>,
/// Updated permissions profile for tool calls.
#[serde(skip_serializing_if = "Option::is_none")]
pub permission_profile: Option<PermissionProfile>,
/// Named or built-in profile that produced `permission_profile`, if the
/// update selected a profile rather than supplying raw permissions.
#[serde(skip_serializing_if = "Option::is_none")]
pub active_permission_profile: Option<ActivePermissionProfile>,
/// Updated Windows sandbox mode for tool execution.
#[serde(skip_serializing_if = "Option::is_none")]
pub windows_sandbox_level: Option<WindowsSandboxLevel>,
/// Updated model slug. When set, the model info is derived automatically.
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Updated reasoning effort (honored only for reasoning-capable models).
///
/// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear the
/// effort, or `None` to leave the existing value unchanged.
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<Option<ReasoningEffortConfig>>,
/// Updated reasoning summary preference (honored only for reasoning-capable models).
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<ReasoningSummaryConfig>,
/// Updated service tier preference for future turns.
///
/// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the
/// preference, or `None` to leave the existing value unchanged.
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<Option<String>>,
/// EXPERIMENTAL - set a pre-set collaboration mode.
/// Takes precedence over model, effort, and developer instructions if set.
#[serde(skip_serializing_if = "Option::is_none")]
pub collaboration_mode: Option<CollaborationMode>,
/// Updated personality preference.
#[serde(skip_serializing_if = "Option::is_none")]
pub personality: Option<Personality>,
}
/// Source classification for client-supplied context.
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AdditionalContextKind {
Untrusted,
Application,
}
/// Client-supplied context keyed by an opaque source identifier.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
pub struct AdditionalContextEntry {
pub value: String,
pub kind: AdditionalContextKind,
}
/// Submission operation
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -424,10 +519,7 @@ pub enum Op {
/// Request the list of voices supported by realtime conversation streams.
RealtimeConversationListVoices,
/// Legacy user input.
///
/// Prefer [`Op::UserTurn`] so the caller provides full turn context
/// (cwd/approval/sandbox/model/etc.) for each turn.
/// User input, optionally with thread-settings overrides applied first.
UserInput {
/// User input items, see `InputItem`
items: Vec<UserInput>,
@@ -440,153 +532,23 @@ pub enum Op {
/// Optional turn-scoped Responses API `client_metadata`.
#[serde(default, skip_serializing_if = "Option::is_none")]
responsesapi_client_metadata: Option<HashMap<String, String>>,
/// Client-supplied context fragments keyed by an opaque source identifier.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
additional_context: BTreeMap<String, AdditionalContextEntry>,
/// Persistent thread-settings overrides to apply before the input.
#[serde(default, flatten)]
thread_settings: ThreadSettingsOverrides,
},
/// Similar to [`Op::UserInput`], but first applies persistent turn-context
/// overrides in the same queued operation. This preserves submission order
/// and prevents the input from starting if the overrides are rejected.
UserInputWithTurnContext {
/// User input items, see `InputItem`
items: Vec<UserInput>,
/// Optional turn-scoped environment selections.
#[serde(default, skip_serializing_if = "Option::is_none")]
environments: Option<Vec<TurnEnvironmentSelection>>,
/// Optional JSON Schema used to constrain the final assistant message for this turn.
#[serde(skip_serializing_if = "Option::is_none")]
final_output_json_schema: Option<Value>,
/// Optional turn-scoped Responses API `client_metadata`.
#[serde(default, skip_serializing_if = "Option::is_none")]
responsesapi_client_metadata: Option<HashMap<String, String>>,
/// Updated `cwd` for sandbox/tool calls.
#[serde(skip_serializing_if = "Option::is_none")]
cwd: Option<PathBuf>,
/// Updated command approval policy.
#[serde(skip_serializing_if = "Option::is_none")]
approval_policy: Option<AskForApproval>,
/// Updated approval reviewer for future approval prompts.
#[serde(skip_serializing_if = "Option::is_none")]
approvals_reviewer: Option<ApprovalsReviewer>,
/// Updated sandbox policy for tool calls.
#[serde(skip_serializing_if = "Option::is_none")]
sandbox_policy: Option<SandboxPolicy>,
/// Updated permissions profile for tool calls.
#[serde(skip_serializing_if = "Option::is_none")]
permission_profile: Option<PermissionProfile>,
/// Named or built-in profile that produced `permission_profile`, if
/// the update selected a profile rather than supplying raw
/// permissions.
#[serde(skip_serializing_if = "Option::is_none")]
active_permission_profile: Option<ActivePermissionProfile>,
/// Updated Windows sandbox mode for tool execution.
#[serde(skip_serializing_if = "Option::is_none")]
windows_sandbox_level: Option<WindowsSandboxLevel>,
/// Updated model slug. When set, the model info is derived
/// automatically.
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
/// Updated reasoning effort (honored only for reasoning-capable models).
///
/// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear
/// the effort, or `None` to leave the existing value unchanged.
#[serde(skip_serializing_if = "Option::is_none")]
effort: Option<Option<ReasoningEffortConfig>>,
/// Updated reasoning summary preference (honored only for reasoning-capable models).
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<ReasoningSummaryConfig>,
/// Updated service tier preference for future turns.
///
/// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the
/// preference, or `None` to leave the existing value unchanged.
#[serde(skip_serializing_if = "Option::is_none")]
service_tier: Option<Option<String>>,
/// EXPERIMENTAL - set a pre-set collaboration mode.
/// Takes precedence over model, effort, and developer instructions if set.
#[serde(skip_serializing_if = "Option::is_none")]
collaboration_mode: Option<CollaborationMode>,
/// Updated personality preference.
#[serde(skip_serializing_if = "Option::is_none")]
personality: Option<Personality>,
},
/// Similar to [`Op::UserInput`], but contains additional context required
/// for a turn of a [`crate::codex_thread::CodexThread`].
UserTurn {
/// User input items, see `InputItem`
items: Vec<UserInput>,
/// `cwd` to use with the [`SandboxPolicy`] and potentially tool calls
/// such as `local_shell`.
cwd: PathBuf,
/// Policy to use for command approval.
approval_policy: AskForApproval,
/// Reviewer to use for approval requests raised during this turn.
///
/// When omitted, the session keeps the current setting
approvals_reviewer: Option<ApprovalsReviewer>,
/// Policy to use for tool calls such as `local_shell`.
sandbox_policy: SandboxPolicy,
/// Full permissions profile to use for tool calls such as `local_shell`.
///
/// When omitted, `sandbox_policy` is used as a legacy compatibility
/// projection.
#[serde(default, skip_serializing_if = "Option::is_none")]
permission_profile: Option<PermissionProfile>,
/// Must be a valid model slug for the configured client session
/// associated with this conversation.
model: String,
/// Will only be honored if the model is configured to use reasoning.
#[serde(skip_serializing_if = "Option::is_none")]
effort: Option<ReasoningEffortConfig>,
/// Will only be honored if the model is configured to use reasoning.
///
/// When omitted, the session keeps the current setting (which allows core to
/// fall back to the selected model's default on new sessions).
#[serde(default, skip_serializing_if = "Option::is_none")]
summary: Option<ReasoningSummaryConfig>,
/// Optional service tier override for this turn.
///
/// Use `Some(Some(_))` to set a specific tier for this turn, `Some(None)` to
/// explicitly clear the tier for this turn, or `None` to keep the existing
/// session preference.
#[serde(default, skip_serializing_if = "Option::is_none")]
service_tier: Option<Option<String>>,
// The JSON schema to use for the final assistant message
final_output_json_schema: Option<Value>,
/// EXPERIMENTAL - set a pre-set collaboration mode.
/// Takes precedence over model, effort, and developer instructions if set.
#[serde(skip_serializing_if = "Option::is_none")]
collaboration_mode: Option<CollaborationMode>,
/// Optional personality override for this turn.
#[serde(skip_serializing_if = "Option::is_none")]
personality: Option<Personality>,
/// Optional turn-scoped environments.
#[serde(default, skip_serializing_if = "Option::is_none")]
environments: Option<Vec<TurnEnvironmentSelection>>,
/// Apply persistent thread-settings overrides without starting a turn.
///
/// This uses the same submission queue as turn starts so app-server can
/// preserve caller order between both kinds of mutation.
ThreadSettings {
/// Persistent thread-settings overrides to apply.
#[serde(flatten)]
thread_settings: ThreadSettingsOverrides,
},
/// Inter-agent communication that should be recorded as assistant history
@@ -595,70 +557,6 @@ pub enum Op {
communication: InterAgentCommunication,
},
/// Override parts of the persistent turn context for subsequent turns.
///
/// All fields are optional; when omitted, the existing value is preserved.
/// This does not enqueue any input – it only updates defaults used for
/// turns that rely on persistent session-level context (for example,
/// [`Op::UserInput`]).
OverrideTurnContext {
/// Updated `cwd` for sandbox/tool calls.
#[serde(skip_serializing_if = "Option::is_none")]
cwd: Option<PathBuf>,
/// Updated command approval policy.
#[serde(skip_serializing_if = "Option::is_none")]
approval_policy: Option<AskForApproval>,
/// Updated approval reviewer for future approval prompts.
#[serde(skip_serializing_if = "Option::is_none")]
approvals_reviewer: Option<ApprovalsReviewer>,
/// Updated sandbox policy for tool calls.
#[serde(skip_serializing_if = "Option::is_none")]
sandbox_policy: Option<SandboxPolicy>,
/// Updated permissions profile for tool calls.
#[serde(skip_serializing_if = "Option::is_none")]
permission_profile: Option<PermissionProfile>,
/// Updated Windows sandbox mode for tool execution.
#[serde(skip_serializing_if = "Option::is_none")]
windows_sandbox_level: Option<WindowsSandboxLevel>,
/// Updated model slug. When set, the model info is derived
/// automatically.
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
/// Updated reasoning effort (honored only for reasoning-capable models).
///
/// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear
/// the effort, or `None` to leave the existing value unchanged.
#[serde(skip_serializing_if = "Option::is_none")]
effort: Option<Option<ReasoningEffortConfig>>,
/// Updated reasoning summary preference (honored only for reasoning-capable models).
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<ReasoningSummaryConfig>,
/// Updated service tier preference for future turns.
///
/// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the
/// preference, or `None` to leave the existing value unchanged.
#[serde(skip_serializing_if = "Option::is_none")]
service_tier: Option<Option<String>>,
/// EXPERIMENTAL - set a pre-set collaboration mode.
/// Takes precedence over model, effort, and developer instructions if set.
#[serde(skip_serializing_if = "Option::is_none")]
collaboration_mode: Option<CollaborationMode>,
/// Updated personality preference.
#[serde(skip_serializing_if = "Option::is_none")]
personality: Option<Personality>,
},
/// Approve a command execution
ExecApproval {
/// The id of the submission we are approving
@@ -779,6 +677,8 @@ impl From<Vec<UserInput>> for Op {
items: value,
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: ThreadSettingsOverrides::default(),
}
}
}
@@ -845,10 +745,8 @@ impl Op {
Self::RealtimeConversationClose => "realtime_conversation_close",
Self::RealtimeConversationListVoices => "realtime_conversation_list_voices",
Self::UserInput { .. } => "user_input",
Self::UserInputWithTurnContext { .. } => "user_input_with_turn_context",
Self::UserTurn { .. } => "user_turn",
Self::ThreadSettings { .. } => "thread_settings",
Self::InterAgentCommunication { .. } => "inter_agent_communication",
Self::OverrideTurnContext { .. } => "override_turn_context",
Self::ExecApproval { .. } => "exec_approval",
Self::PatchApproval { .. } => "patch_approval",
Self::ResolveElicitation { .. } => "resolve_elicitation",
@@ -1299,6 +1197,10 @@ pub enum EventMsg {
#[serde(rename = "task_started", alias = "turn_started")]
TurnStarted(TurnStartedEvent),
/// Persistent thread-settings overrides from the correlated submission have
/// been applied to the session configuration.
ThreadSettingsApplied(ThreadSettingsAppliedEvent),
/// Agent has completed all actions.
/// v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop.
#[serde(rename = "task_complete", alias = "turn_complete")]
@@ -1401,9 +1303,6 @@ pub enum EventMsg {
/// List of voices supported by realtime conversation streams.
RealtimeConversationListVoicesResponse(RealtimeConversationListVoicesResponseEvent),
/// Notification that skill data may have been updated and clients may want to reload.
SkillsUpdateAvailable,
PlanUpdate(UpdatePlanArgs),
TurnAborted(TurnAbortedEvent),
@@ -1461,6 +1360,8 @@ pub enum HookEventName {
PostCompact,
SessionStart,
UserPromptSubmit,
SubagentStart,
SubagentStop,
Stop,
}
@@ -1496,6 +1397,7 @@ pub enum HookSource {
SessionFlags,
Plugin,
CloudRequirements,
CloudManagedConfig,
LegacyManagedConfigFile,
LegacyManagedConfigMdm,
#[default]
@@ -1972,6 +1874,10 @@ pub struct TurnCompleteEvent {
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct TurnStartedEvent {
pub turn_id: String,
// Persist for rollout consumers that correlate turns with telemetry traces.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub trace_id: Option<String>,
/// Unix timestamp (in seconds) when the turn started.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(type = "number | null", optional)]
@@ -1982,6 +1888,33 @@ pub struct TurnStartedEvent {
pub collaboration_mode_kind: ModeKind,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ThreadSettingsAppliedEvent {
pub thread_settings: ThreadSettingsSnapshot,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ThreadSettingsSnapshot {
pub model: String,
pub model_provider_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<String>,
pub approval_policy: AskForApproval,
pub approvals_reviewer: ApprovalsReviewer,
pub permission_profile: PermissionProfile,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub active_permission_profile: Option<ActivePermissionProfile>,
pub cwd: AbsolutePathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffortConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_summary: Option<ReasoningSummaryConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub personality: Option<Personality>,
pub collaboration_mode: CollaborationMode,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq, JsonSchema, TS)]
pub struct TokenUsage {
#[ts(type = "number")]
@@ -2091,6 +2024,21 @@ pub enum RateLimitReachedType {
WorkspaceMemberUsageLimitReached,
}
impl FromStr for RateLimitReachedType {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"rate_limit_reached" => Ok(Self::RateLimitReached),
"workspace_owner_credits_depleted" => Ok(Self::WorkspaceOwnerCreditsDepleted),
"workspace_member_credits_depleted" => Ok(Self::WorkspaceMemberCreditsDepleted),
"workspace_owner_usage_limit_reached" => Ok(Self::WorkspaceOwnerUsageLimitReached),
"workspace_member_usage_limit_reached" => Ok(Self::WorkspaceMemberUsageLimitReached),
other => Err(format!("unknown rate limit reached type: {other}")),
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
pub struct RateLimitWindow {
/// Percentage (0-100) of the window that has been consumed.
@@ -2218,19 +2166,29 @@ pub struct AgentMessageEvent {
pub memory_citation: Option<MemoryCitation>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)]
pub struct UserMessageEvent {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
pub message: String,
/// Image URLs sourced from `UserInput::Image`. These are safe
/// to replay in legacy UI history events and correspond to images sent to
/// the model.
#[serde(skip_serializing_if = "Option::is_none")]
pub images: Option<Vec<String>>,
/// Detail hints for `images`, indexed in parallel. Missing entries imply
/// default image detail behavior.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub image_details: Vec<Option<ImageDetail>>,
/// Local file paths sourced from `UserInput::LocalImage`. These are kept so
/// the UI can reattach images when editing history, and should not be sent
/// to the model or treated as API-ready URLs.
#[serde(default)]
pub local_images: Vec<std::path::PathBuf>,
/// Detail hints for `local_images`, indexed in parallel. Missing entries
/// imply default image detail behavior.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub local_image_details: Vec<Option<ImageDetail>>,
/// UI-defined spans within `message` used to render or persist special elements.
#[serde(default)]
pub text_elements: Vec<crate::user_input::TextElement>,
@@ -2273,6 +2231,9 @@ pub struct McpToolCallBeginEvent {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub mcp_app_resource_uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub plugin_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq)]
@@ -2283,6 +2244,9 @@ pub struct McpToolCallEndEvent {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub mcp_app_resource_uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub plugin_id: Option<String>,
#[ts(type = "string")]
pub duration: Duration,
/// Result of the tool call. Note this could be an error.
@@ -2477,12 +2441,27 @@ impl InitialHistory {
}
}
pub fn get_resumed_session_sources(&self) -> Option<(SessionSource, Option<ThreadSource>)> {
let meta = self.get_resumed_session_meta()?;
Some((meta.source.clone(), meta.thread_source))
}
pub fn get_resumed_thread_source(&self) -> Option<ThreadSource> {
self.get_resumed_session_meta()
.and_then(|meta| meta.thread_source)
}
pub fn get_resumed_parent_thread_id(&self) -> Option<ThreadId> {
self.get_resumed_session_meta()
.and_then(|meta| meta.parent_thread_id)
}
fn get_resumed_session_meta(&self) -> Option<&SessionMeta> {
match self {
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None,
InitialHistory::Resumed(resumed) => {
resumed.history.iter().find_map(|item| match item {
RolloutItem::SessionMeta(meta_line) => meta_line.meta.thread_source,
RolloutItem::SessionMeta(meta_line) => Some(&meta_line.meta),
_ => None,
})
}
@@ -2667,6 +2646,19 @@ impl SessionSource {
.restriction_product()
.is_some_and(|product| product.matches_product_restriction(products))
}
pub fn parent_thread_id(&self) -> Option<ThreadId> {
match self {
SessionSource::SubAgent(subagent_source) => subagent_source.parent_thread_id(),
SessionSource::Cli
| SessionSource::VSCode
| SessionSource::Exec
| SessionSource::Mcp
| SessionSource::Custom(_)
| SessionSource::Internal(_)
| SessionSource::Unknown => None,
}
}
}
impl fmt::Display for SubAgentSource {
@@ -2687,6 +2679,30 @@ impl fmt::Display for SubAgentSource {
}
}
impl SubAgentSource {
pub fn kind(&self) -> &str {
match self {
SubAgentSource::Review => "review",
SubAgentSource::Compact => "compact",
SubAgentSource::ThreadSpawn { .. } => "thread_spawn",
SubAgentSource::MemoryConsolidation => "memory_consolidation",
SubAgentSource::Other(other) => other,
}
}
pub fn parent_thread_id(&self) -> Option<ThreadId> {
match self {
SubAgentSource::ThreadSpawn {
parent_thread_id, ..
} => Some(*parent_thread_id),
SubAgentSource::Review
| SubAgentSource::Compact
| SubAgentSource::MemoryConsolidation
| SubAgentSource::Other(_) => None,
}
}
}
impl fmt::Display for InternalSessionSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -2705,6 +2721,8 @@ pub struct SessionMeta {
pub id: ThreadId,
#[serde(skip_serializing_if = "Option::is_none")]
pub forked_from_id: Option<ThreadId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_thread_id: Option<ThreadId>,
pub timestamp: String,
pub cwd: PathBuf,
pub originator: String,
@@ -2739,6 +2757,7 @@ impl Default for SessionMeta {
SessionMeta {
id: ThreadId::default(),
forked_from_id: None,
parent_thread_id: None,
timestamp: String::new(),
cwd: PathBuf::new(),
originator: String::new(),
@@ -2808,9 +2827,11 @@ pub struct TurnContextNetworkItem {
pub struct TurnContextItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trace_id: Option<String>,
pub cwd: PathBuf,
/// Effective workspace roots used to materialize symbolic
/// `:workspace_roots` filesystem permissions in `permission_profile`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_roots: Option<Vec<AbsolutePathBuf>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -2832,15 +2853,11 @@ pub struct TurnContextItem {
pub realtime_active: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<ReasoningEffortConfig>,
// Compatibility-only field written with a default value so older Codex
// versions can deserialize turn-context rollout items. It is no longer
// read by context reconstruction and should be removed in a future schema
// cleanup.
pub summary: ReasoningSummaryConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub developer_instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub final_output_json_schema: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub truncation_policy: Option<TruncationPolicy>,
}
impl TurnContextItem {
@@ -3412,6 +3429,8 @@ pub struct SessionConfiguredEvent {
pub thread_id: ThreadId,
#[serde(skip_serializing_if = "Option::is_none")]
pub forked_from_id: Option<ThreadId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_thread_id: Option<ThreadId>,
/// Optional analytics source classification for this thread.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread_source: Option<ThreadSource>,
@@ -3481,6 +3500,7 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent {
#[serde(default)]
thread_id: Option<ThreadId>,
forked_from_id: Option<ThreadId>,
parent_thread_id: Option<ThreadId>,
#[serde(default)]
thread_source: Option<ThreadSource>,
#[serde(default)]
@@ -3521,6 +3541,7 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent {
session_id: wire.session_id,
thread_id: wire.thread_id.unwrap_or_else(|| wire.session_id.into()),
forked_from_id: wire.forked_from_id,
parent_thread_id: wire.parent_thread_id,
thread_source: wire.thread_source,
thread_name: wire.thread_name,
model: wire.model,
@@ -3545,6 +3566,8 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent {
pub enum ThreadGoalStatus {
Active,
Paused,
Blocked,
UsageLimited,
BudgetLimited,
Complete,
}
@@ -4304,7 +4327,7 @@ mod tests {
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: blocked },
access: FileSystemAccessMode::None,
access: FileSystemAccessMode::Deny,
},
]);
@@ -4365,7 +4388,7 @@ mod tests {
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: secret },
access: FileSystemAccessMode::None,
access: FileSystemAccessMode::Deny,
},
]);
@@ -4637,6 +4660,7 @@ mod tests {
tool: "tool".into(),
arguments: json!({"arg": "value"}),
mcp_app_resource_uri: Some("app://connector".into()),
plugin_id: Some("sample@test".into()),
status: McpToolCallStatus::InProgress,
result: None,
error: None,
@@ -4655,6 +4679,7 @@ mod tests {
event.mcp_app_resource_uri.as_deref(),
Some("app://connector")
);
assert_eq!(event.plugin_id.as_deref(), Some("sample@test"));
}
_ => panic!("expected McpToolCallBegin event"),
}
@@ -4742,6 +4767,7 @@ mod tests {
tool: "tool".into(),
arguments: json!({"arg": "value"}),
mcp_app_resource_uri: Some("app://connector".into()),
plugin_id: Some("sample@test".into()),
status: McpToolCallStatus::Completed,
result: Some(CallToolResult {
content: vec![json!({"type": "text", "text": "ok"})],
@@ -4765,6 +4791,7 @@ mod tests {
event.mcp_app_resource_uri.as_deref(),
Some("app://connector")
);
assert_eq!(event.plugin_id.as_deref(), Some("sample@test"));
assert_eq!(event.duration, Duration::from_millis(42));
assert!(event.is_success());
}
@@ -5026,6 +5053,8 @@ mod tests {
items: Vec::new(),
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
};
let json_op = serde_json::to_value(op)?;
@@ -5045,6 +5074,8 @@ mod tests {
items: Vec::new(),
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
}
);
@@ -5066,6 +5097,8 @@ mod tests {
items: Vec::new(),
final_output_json_schema: Some(schema.clone()),
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
};
let json_op = serde_json::to_value(op)?;
@@ -5091,6 +5124,8 @@ mod tests {
"fiber_run_id".to_string(),
"fiber-123".to_string(),
)])),
additional_context: Default::default(),
thread_settings: Default::default(),
};
let json_op = serde_json::to_value(&op)?;
@@ -5132,10 +5167,12 @@ mod tests {
#[test]
fn user_message_event_serializes_empty_metadata_vectors() -> Result<()> {
let event = UserMessageEvent {
client_id: None,
message: "hello".to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
..Default::default()
};
let json_event = serde_json::to_value(event)?;
@@ -5151,6 +5188,64 @@ mod tests {
Ok(())
}
#[test]
fn user_message_event_deserializes_without_image_detail_fields() -> Result<()> {
let event: UserMessageEvent = serde_json::from_value(json!({
"message": "hello",
"images": ["https://example.com/image.png"],
"local_images": ["/tmp/local.png"],
"text_elements": [],
}))?;
assert_eq!(event.message, "hello");
assert_eq!(
event.images,
Some(vec!["https://example.com/image.png".to_string()])
);
assert_eq!(event.image_details, Vec::<Option<ImageDetail>>::new());
assert_eq!(event.local_images, vec![PathBuf::from("/tmp/local.png")]);
assert_eq!(event.local_image_details, Vec::<Option<ImageDetail>>::new());
assert_eq!(event.text_elements, Vec::new());
Ok(())
}
#[test]
fn user_message_item_legacy_event_preserves_image_details() {
let local_path = PathBuf::from("/tmp/local.png");
let mut item = UserMessageItem::new(&[
crate::user_input::UserInput::Image {
image_url: "https://example.com/first.png".to_string(),
detail: Some(ImageDetail::Original),
},
crate::user_input::UserInput::Image {
image_url: "https://example.com/second.png".to_string(),
detail: None,
},
crate::user_input::UserInput::LocalImage {
path: local_path.clone(),
detail: Some(ImageDetail::Original),
},
]);
item.client_id = Some("client-message-1".to_string());
let EventMsg::UserMessage(event) = item.as_legacy_event() else {
panic!("expected user message event");
};
assert_eq!(
event.images,
Some(vec![
"https://example.com/first.png".to_string(),
"https://example.com/second.png".to_string(),
])
);
assert_eq!(event.client_id, Some("client-message-1".to_string()));
assert_eq!(event.image_details, vec![Some(ImageDetail::Original)]);
assert_eq!(event.local_images, vec![local_path]);
assert_eq!(event.local_image_details, vec![Some(ImageDetail::Original)]);
}
#[test]
fn turn_aborted_event_deserializes_without_turn_id() -> Result<()> {
let event: EventMsg = serde_json::from_value(json!({
@@ -5181,7 +5276,6 @@ mod tests {
"summary": "auto",
}))?;
assert_eq!(item.trace_id, None);
assert_eq!(item.network, None);
assert_eq!(item.file_system_sandbox_policy, None);
Ok(())
@@ -5191,8 +5285,8 @@ mod tests {
fn turn_context_item_serializes_network_when_present() -> Result<()> {
let item = TurnContextItem {
turn_id: None,
trace_id: None,
cwd: test_path_buf("/tmp"),
workspace_roots: None,
current_date: None,
timezone: None,
approval_policy: AskForApproval::Never,
@@ -5207,7 +5301,7 @@ mod tests {
path: FileSystemPath::GlobPattern {
pattern: "/tmp/private/**/*.txt".to_string(),
},
access: FileSystemAccessMode::None,
access: FileSystemAccessMode::Deny,
},
])),
model: "gpt-5".to_string(),
@@ -5216,10 +5310,6 @@ mod tests {
realtime_active: None,
effort: None,
summary: ReasoningSummaryConfig::Auto,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: None,
};
let value = serde_json::to_value(item)?;
@@ -5239,10 +5329,11 @@ mod tests {
"type": "glob_pattern",
"pattern": "/tmp/private/**/*.txt"
},
"access": "none"
"access": "deny"
}]
})
);
assert_eq!(value["summary"], json!("auto"));
Ok(())
}
@@ -5260,6 +5351,7 @@ mod tests {
session_id,
thread_id,
forked_from_id: None,
parent_thread_id: None,
thread_source: None,
thread_name: None,
model: "codex-mini-latest".to_string(),
+21 -7
View File
@@ -1,5 +1,6 @@
use serde::Deserialize;
use serde::Serialize;
use std::cmp::Ordering;
use std::fmt;
/// Identifies a callable tool, preserving the namespace split when the model
@@ -31,13 +32,6 @@ impl ToolName {
namespace: Some(namespace.into()),
}
}
pub fn display(&self) -> String {
match &self.namespace {
Some(namespace) => format!("{namespace}{}", self.name),
None => self.name.clone(),
}
}
}
impl fmt::Display for ToolName {
@@ -49,6 +43,26 @@ impl fmt::Display for ToolName {
}
}
impl Ord for ToolName {
fn cmp(&self, other: &Self) -> Ordering {
let lhs = match &self.namespace {
Some(namespace) => (namespace.as_str(), Some(self.name.as_str())),
None => (self.name.as_str(), None),
};
let rhs = match &other.namespace {
Some(namespace) => (namespace.as_str(), Some(other.name.as_str())),
None => (other.name.as_str(), None),
};
lhs.cmp(&rhs)
}
}
impl PartialOrd for ToolName {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl From<String> for ToolName {
fn from(name: String) -> Self {
Self::plain(name)
+14 -2
View File
@@ -3,6 +3,8 @@ use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
use crate::models::ImageDetail;
/// Conservative cap so one user message cannot monopolize a large context window.
pub const MAX_USER_INPUT_TEXT_CHARS: usize = 1 << 20;
@@ -21,11 +23,21 @@ pub enum UserInput {
text_elements: Vec<TextElement>,
},
/// Pre‑encoded data: URI image.
Image { image_url: String },
Image {
image_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
detail: Option<ImageDetail>,
},
/// Local image path provided by the user. This will be converted to an
/// `Image` variant (base64 data URL) during request serialization.
LocalImage { path: std::path::PathBuf },
LocalImage {
path: std::path::PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
detail: Option<ImageDetail>,
},
/// Skill selected by the user (name + path to SKILL.md).
Skill {