mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
Merge remote-tracking branch 'origin/main' into etraut/next-turn-state-core
# Conflicts: # codex-rs/core/src/session/handlers.rs # codex-rs/core/src/session/tests.rs # codex-rs/protocol/src/protocol.rs
This commit is contained in:
@@ -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;
|
||||
@@ -243,7 +244,9 @@ impl UserMessageItem {
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
@@ -290,21 +293,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 {
|
||||
|
||||
+123
-46
@@ -344,21 +344,6 @@ pub struct ActivePermissionProfile {
|
||||
#[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 {
|
||||
@@ -366,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,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,
|
||||
@@ -723,8 +725,6 @@ pub enum ContentItem {
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ImageDetail {
|
||||
Auto,
|
||||
Low,
|
||||
High,
|
||||
Original,
|
||||
}
|
||||
@@ -889,7 +889,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)]
|
||||
@@ -1063,8 +1066,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::High => PromptImageMode::ResizeToFit,
|
||||
};
|
||||
|
||||
match load_for_prompt_bytes(path, file_bytes, mode) {
|
||||
Ok(image) => {
|
||||
let mut items = Vec::with_capacity(3);
|
||||
@@ -1075,7 +1083,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 {
|
||||
@@ -1220,29 +1228,31 @@ 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;
|
||||
let detail = detail.unwrap_or(DEFAULT_IMAGE_DETAIL);
|
||||
vec![
|
||||
ContentItem::InputText {
|
||||
text: image_open_tag_text(),
|
||||
},
|
||||
ContentItem::InputImage {
|
||||
image_url,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
detail: Some(detail),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text: image_close_tag_text(),
|
||||
},
|
||||
]
|
||||
}
|
||||
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)],
|
||||
}
|
||||
@@ -1586,8 +1596,6 @@ fn convert_mcp_content_to_items(
|
||||
.and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(|detail| match detail {
|
||||
"auto" => Some(ImageDetail::Auto),
|
||||
"low" => Some(ImageDetail::Low),
|
||||
"high" => Some(ImageDetail::High),
|
||||
"original" => Some(ImageDetail::Original),
|
||||
_ => None,
|
||||
@@ -1632,6 +1640,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 {
|
||||
@@ -2409,20 +2425,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#"{
|
||||
@@ -2536,6 +2560,7 @@ mod tests {
|
||||
|
||||
let item = ResponseInputItem::from(vec![UserInput::Image {
|
||||
image_url: image_url.clone(),
|
||||
detail: None,
|
||||
}]);
|
||||
|
||||
match item {
|
||||
@@ -2560,6 +2585,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.get(1),
|
||||
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(
|
||||
@@ -2728,20 +2778,17 @@ 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 {
|
||||
@@ -2788,6 +2835,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()?;
|
||||
@@ -2795,6 +2869,7 @@ mod tests {
|
||||
|
||||
let item = ResponseInputItem::from(vec![UserInput::LocalImage {
|
||||
path: missing_path.clone(),
|
||||
detail: None,
|
||||
}]);
|
||||
|
||||
match item {
|
||||
@@ -2829,6 +2904,7 @@ mod tests {
|
||||
|
||||
let item = ResponseInputItem::from(vec![UserInput::LocalImage {
|
||||
path: json_path.clone(),
|
||||
detail: None,
|
||||
}]);
|
||||
|
||||
match item {
|
||||
@@ -2866,6 +2942,7 @@ mod tests {
|
||||
|
||||
let item = ResponseInputItem::from(vec![UserInput::LocalImage {
|
||||
path: svg_path.clone(),
|
||||
detail: None,
|
||||
}]);
|
||||
|
||||
match item {
|
||||
|
||||
@@ -350,6 +350,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 {
|
||||
@@ -703,15 +709,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
|
||||
@@ -1209,6 +1300,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()
|
||||
@@ -2750,6 +2853,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::None,
|
||||
},
|
||||
]);
|
||||
|
||||
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::None,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::GlobPattern {
|
||||
pattern: AbsolutePathBuf::resolve_path_against_base(
|
||||
"**/*.env",
|
||||
second.as_path(),
|
||||
)
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[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::None,
|
||||
}]);
|
||||
|
||||
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::None,
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_additional_legacy_workspace_writable_roots_protects_metadata() {
|
||||
let temp_dir = TempDir::new().expect("tempdir");
|
||||
|
||||
@@ -34,6 +34,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;
|
||||
@@ -403,6 +404,16 @@ pub struct TurnContextOverrides {
|
||||
#[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>,
|
||||
@@ -2199,7 +2210,7 @@ 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 {
|
||||
pub message: String,
|
||||
/// Image URLs sourced from `UserInput::Image`. These are safe
|
||||
@@ -2207,11 +2218,19 @@ pub struct UserMessageEvent {
|
||||
/// 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>,
|
||||
@@ -5107,6 +5126,7 @@ mod tests {
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let json_event = serde_json::to_value(event)?;
|
||||
@@ -5122,6 +5142,62 @@ 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 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),
|
||||
},
|
||||
]);
|
||||
|
||||
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.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!({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user