mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
Merge branch 'main' into rhan/surface-updates
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
use crate::memory_citation::MemoryCitation;
|
||||
use crate::models::ContentItem;
|
||||
use crate::models::MessagePhase;
|
||||
use crate::models::ResponseItem;
|
||||
use crate::models::WebSearchAction;
|
||||
use crate::protocol::AgentMessageEvent;
|
||||
use crate::protocol::AgentReasoningEvent;
|
||||
@@ -11,6 +14,8 @@ use crate::protocol::WebSearchEndEvent;
|
||||
use crate::user_input::ByteRange;
|
||||
use crate::user_input::TextElement;
|
||||
use crate::user_input::UserInput;
|
||||
use quick_xml::de::from_str as from_xml_str;
|
||||
use quick_xml::se::to_string as to_xml_string;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -21,6 +26,7 @@ use ts_rs::TS;
|
||||
#[ts(tag = "type")]
|
||||
pub enum TurnItem {
|
||||
UserMessage(UserMessageItem),
|
||||
HookPrompt(HookPromptItem),
|
||||
AgentMessage(AgentMessageItem),
|
||||
Plan(PlanItem),
|
||||
Reasoning(ReasoningItem),
|
||||
@@ -35,6 +41,29 @@ pub struct UserMessageItem {
|
||||
pub content: Vec<UserInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
||||
pub struct HookPromptItem {
|
||||
pub id: String,
|
||||
pub fragments: Vec<HookPromptFragment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
pub struct HookPromptFragment {
|
||||
pub text: String,
|
||||
pub hook_run_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename = "hook_prompt")]
|
||||
struct HookPromptXml {
|
||||
#[serde(rename = "@hook_run_id")]
|
||||
hook_run_id: String,
|
||||
#[serde(rename = "$text")]
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
||||
#[serde(tag = "type")]
|
||||
#[ts(tag = "type")]
|
||||
@@ -58,6 +87,9 @@ pub struct AgentMessageItem {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub phase: Option<MessagePhase>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub memory_citation: Option<MemoryCitation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
||||
@@ -195,12 +227,98 @@ impl UserMessageItem {
|
||||
}
|
||||
}
|
||||
|
||||
impl HookPromptItem {
|
||||
pub fn from_fragments(id: Option<&String>, fragments: Vec<HookPromptFragment>) -> Self {
|
||||
Self {
|
||||
id: id
|
||||
.cloned()
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||
fragments,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HookPromptFragment {
|
||||
pub fn from_single_hook(text: impl Into<String>, hook_run_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
hook_run_id: hook_run_id.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option<ResponseItem> {
|
||||
let content = fragments
|
||||
.iter()
|
||||
.filter(|fragment| !fragment.hook_run_id.trim().is_empty())
|
||||
.filter_map(|fragment| {
|
||||
serialize_hook_prompt_fragment(&fragment.text, &fragment.hook_run_id)
|
||||
.map(|text| ContentItem::InputText { text })
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if content.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ResponseItem::Message {
|
||||
id: Some(uuid::Uuid::new_v4().to_string()),
|
||||
role: "user".to_string(),
|
||||
content,
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_hook_prompt_message(
|
||||
id: Option<&String>,
|
||||
content: &[ContentItem],
|
||||
) -> Option<HookPromptItem> {
|
||||
let fragments = content
|
||||
.iter()
|
||||
.map(|content_item| {
|
||||
let ContentItem::InputText { text } = content_item else {
|
||||
return None;
|
||||
};
|
||||
parse_hook_prompt_fragment(text)
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
|
||||
if fragments.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(HookPromptItem::from_fragments(id, fragments))
|
||||
}
|
||||
|
||||
pub fn parse_hook_prompt_fragment(text: &str) -> Option<HookPromptFragment> {
|
||||
let trimmed = text.trim();
|
||||
let HookPromptXml { text, hook_run_id } = from_xml_str::<HookPromptXml>(trimmed).ok()?;
|
||||
if hook_run_id.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(HookPromptFragment { text, hook_run_id })
|
||||
}
|
||||
|
||||
fn serialize_hook_prompt_fragment(text: &str, hook_run_id: &str) -> Option<String> {
|
||||
if hook_run_id.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
to_xml_string(&HookPromptXml {
|
||||
text: text.to_string(),
|
||||
hook_run_id: hook_run_id.to_string(),
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
impl AgentMessageItem {
|
||||
pub fn new(content: &[AgentMessageContent]) -> Self {
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
content: content.to_vec(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +329,7 @@ impl AgentMessageItem {
|
||||
AgentMessageContent::Text { text } => EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: text.clone(),
|
||||
phase: self.phase.clone(),
|
||||
memory_citation: self.memory_citation.clone(),
|
||||
}),
|
||||
})
|
||||
.collect()
|
||||
@@ -266,6 +385,7 @@ impl TurnItem {
|
||||
pub fn id(&self) -> String {
|
||||
match self {
|
||||
TurnItem::UserMessage(item) => item.id.clone(),
|
||||
TurnItem::HookPrompt(item) => item.id.clone(),
|
||||
TurnItem::AgentMessage(item) => item.id.clone(),
|
||||
TurnItem::Plan(item) => item.id.clone(),
|
||||
TurnItem::Reasoning(item) => item.id.clone(),
|
||||
@@ -278,6 +398,7 @@ impl TurnItem {
|
||||
pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec<EventMsg> {
|
||||
match self {
|
||||
TurnItem::UserMessage(item) => vec![item.as_legacy_event()],
|
||||
TurnItem::HookPrompt(_) => Vec::new(),
|
||||
TurnItem::AgentMessage(item) => item.as_legacy_events(),
|
||||
TurnItem::Plan(_) => Vec::new(),
|
||||
TurnItem::WebSearch(item) => vec![item.as_legacy_event()],
|
||||
@@ -287,3 +408,41 @@ impl TurnItem {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn hook_prompt_roundtrips_multiple_fragments() {
|
||||
let original = vec![
|
||||
HookPromptFragment::from_single_hook("Retry with care & joy.", "hook-run-1"),
|
||||
HookPromptFragment::from_single_hook("Then summarize cleanly.", "hook-run-2"),
|
||||
];
|
||||
let message = build_hook_prompt_message(&original).expect("hook prompt");
|
||||
|
||||
let ResponseItem::Message { content, .. } = message else {
|
||||
panic!("expected hook prompt message");
|
||||
};
|
||||
|
||||
let parsed = parse_hook_prompt_message(None, &content).expect("parsed hook prompt");
|
||||
assert_eq!(parsed.fragments, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_prompt_parses_legacy_single_hook_run_id() {
|
||||
let parsed = parse_hook_prompt_fragment(
|
||||
r#"<hook_prompt hook_run_id="hook-run-1">Retry with tests.</hook_prompt>"#,
|
||||
)
|
||||
.expect("legacy hook prompt");
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
HookPromptFragment {
|
||||
text: "Retry with tests.".to_string(),
|
||||
hook_run_id: "hook-run-1".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod custom_prompts;
|
||||
pub mod dynamic_tools;
|
||||
pub mod items;
|
||||
pub mod mcp;
|
||||
pub mod memory_citation;
|
||||
pub mod message_history;
|
||||
pub mod models;
|
||||
pub mod num_format;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MemoryCitation {
|
||||
pub entries: Vec<MemoryCitationEntry>,
|
||||
pub rollout_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MemoryCitationEntry {
|
||||
pub path: String,
|
||||
pub line_start: u32,
|
||||
pub line_end: u32,
|
||||
pub note: String,
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use codex_utils_image::PromptImageMode;
|
||||
use codex_utils_image::load_for_prompt;
|
||||
use codex_utils_image::load_for_prompt_bytes;
|
||||
use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use serde::Serialize;
|
||||
@@ -234,6 +234,8 @@ pub enum ResponseInputItem {
|
||||
},
|
||||
FunctionCallOutput {
|
||||
call_id: String,
|
||||
#[ts(as = "FunctionCallOutputBody")]
|
||||
#[schemars(with = "FunctionCallOutputBody")]
|
||||
output: FunctionCallOutputPayload,
|
||||
},
|
||||
McpToolCallOutput {
|
||||
@@ -242,6 +244,11 @@ pub enum ResponseInputItem {
|
||||
},
|
||||
CustomToolCallOutput {
|
||||
call_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
name: Option<String>,
|
||||
#[ts(as = "FunctionCallOutputBody")]
|
||||
#[schemars(with = "FunctionCallOutputBody")]
|
||||
output: FunctionCallOutputPayload,
|
||||
},
|
||||
ToolSearchOutput {
|
||||
@@ -369,6 +376,7 @@ pub enum ResponseItem {
|
||||
Reasoning {
|
||||
#[serde(default, skip_serializing)]
|
||||
#[ts(skip)]
|
||||
#[schemars(skip)]
|
||||
id: String,
|
||||
summary: Vec<ReasoningItemReasoningSummary>,
|
||||
#[serde(default, skip_serializing_if = "should_serialize_reasoning_content")]
|
||||
@@ -419,6 +427,8 @@ pub enum ResponseItem {
|
||||
// We keep this behavior centralized in `FunctionCallOutputPayload`.
|
||||
FunctionCallOutput {
|
||||
call_id: String,
|
||||
#[ts(as = "FunctionCallOutputBody")]
|
||||
#[schemars(with = "FunctionCallOutputBody")]
|
||||
output: FunctionCallOutputPayload,
|
||||
},
|
||||
CustomToolCall {
|
||||
@@ -438,6 +448,11 @@ pub enum ResponseItem {
|
||||
// text or structured content items.
|
||||
CustomToolCallOutput {
|
||||
call_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
name: Option<String>,
|
||||
#[ts(as = "FunctionCallOutputBody")]
|
||||
#[schemars(with = "FunctionCallOutputBody")]
|
||||
output: FunctionCallOutputPayload,
|
||||
},
|
||||
ToolSearchOutput {
|
||||
@@ -990,7 +1005,7 @@ fn invalid_image_error_placeholder(
|
||||
fn unsupported_image_error_placeholder(path: &std::path::Path, mime: &str) -> ContentItem {
|
||||
ContentItem::InputText {
|
||||
text: format!(
|
||||
"Codex cannot attach image at `{}`: unsupported image format `{}`.",
|
||||
"Codex cannot attach image at `{}`: unsupported image `{}`.",
|
||||
path.display(),
|
||||
mime
|
||||
),
|
||||
@@ -999,10 +1014,11 @@ fn unsupported_image_error_placeholder(path: &std::path::Path, mime: &str) -> Co
|
||||
|
||||
pub fn local_image_content_items_with_label_number(
|
||||
path: &std::path::Path,
|
||||
file_bytes: Vec<u8>,
|
||||
label_number: Option<usize>,
|
||||
mode: PromptImageMode,
|
||||
) -> Vec<ContentItem> {
|
||||
match load_for_prompt(path, mode) {
|
||||
match load_for_prompt_bytes(path, file_bytes, mode) {
|
||||
Ok(image) => {
|
||||
let mut items = Vec::with_capacity(3);
|
||||
if let Some(label_number) = label_number {
|
||||
@@ -1020,28 +1036,20 @@ pub fn local_image_content_items_with_label_number(
|
||||
}
|
||||
items
|
||||
}
|
||||
Err(err) => {
|
||||
if matches!(&err, ImageProcessingError::Read { .. }) {
|
||||
Err(err) => match &err {
|
||||
ImageProcessingError::Read { .. } | ImageProcessingError::Encode { .. } => {
|
||||
vec![local_image_error_placeholder(path, &err)]
|
||||
} else if err.is_invalid_image() {
|
||||
vec![invalid_image_error_placeholder(path, &err)]
|
||||
} else {
|
||||
let Some(mime_guess) = mime_guess::from_path(path).first() else {
|
||||
return vec![local_image_error_placeholder(
|
||||
path,
|
||||
"unsupported MIME type (unknown)",
|
||||
)];
|
||||
};
|
||||
let mime = mime_guess.essence_str().to_owned();
|
||||
if !mime.starts_with("image/") {
|
||||
return vec![local_image_error_placeholder(
|
||||
path,
|
||||
format!("unsupported MIME type `{mime}`"),
|
||||
)];
|
||||
}
|
||||
vec![unsupported_image_error_placeholder(path, &mime)]
|
||||
}
|
||||
}
|
||||
ImageProcessingError::Decode { .. } if err.is_invalid_image() => {
|
||||
vec![invalid_image_error_placeholder(path, &err)]
|
||||
}
|
||||
ImageProcessingError::Decode { .. } => {
|
||||
vec![local_image_error_placeholder(path, &err)]
|
||||
}
|
||||
ImageProcessingError::UnsupportedImageFormat { mime } => {
|
||||
vec![unsupported_image_error_placeholder(path, mime)]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1067,9 +1075,15 @@ impl From<ResponseInputItem> for ResponseItem {
|
||||
let output = output.into_function_call_output_payload();
|
||||
Self::FunctionCallOutput { call_id, output }
|
||||
}
|
||||
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
|
||||
Self::CustomToolCallOutput { call_id, output }
|
||||
}
|
||||
ResponseInputItem::CustomToolCallOutput {
|
||||
call_id,
|
||||
name,
|
||||
output,
|
||||
} => Self::CustomToolCallOutput {
|
||||
call_id,
|
||||
name,
|
||||
output,
|
||||
},
|
||||
ResponseInputItem::ToolSearchOutput {
|
||||
call_id,
|
||||
status,
|
||||
@@ -1174,11 +1188,15 @@ impl From<Vec<UserInput>> for ResponseInputItem {
|
||||
}
|
||||
UserInput::LocalImage { path } => {
|
||||
image_index += 1;
|
||||
local_image_content_items_with_label_number(
|
||||
&path,
|
||||
Some(image_index),
|
||||
PromptImageMode::ResizeToFit,
|
||||
)
|
||||
match std::fs::read(&path) {
|
||||
Ok(file_bytes) => local_image_content_items_with_label_number(
|
||||
&path,
|
||||
file_bytes,
|
||||
Some(image_index),
|
||||
PromptImageMode::ResizeToFit,
|
||||
),
|
||||
Err(err) => vec![local_image_error_placeholder(&path, err)],
|
||||
}
|
||||
}
|
||||
UserInput::Skill { .. } | UserInput::Mention { .. } => Vec::new(), // Tool bodies are injected later in core
|
||||
})
|
||||
@@ -2448,6 +2466,7 @@ mod tests {
|
||||
fn serializes_custom_tool_image_outputs_as_array() -> Result<()> {
|
||||
let item = ResponseInputItem::CustomToolCallOutput {
|
||||
call_id: "call1".into(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,BASE64".into(),
|
||||
@@ -2951,8 +2970,8 @@ mod tests {
|
||||
match &content[0] {
|
||||
ContentItem::InputText { text } => {
|
||||
assert!(
|
||||
text.contains("unsupported MIME type `application/json`"),
|
||||
"placeholder should mention unsupported MIME: {text}"
|
||||
text.contains("unsupported image `application/json`"),
|
||||
"placeholder should mention unsupported image MIME: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(&json_path.display().to_string()),
|
||||
@@ -2986,7 +3005,7 @@ mod tests {
|
||||
ResponseInputItem::Message { content, .. } => {
|
||||
assert_eq!(content.len(), 1);
|
||||
let expected = format!(
|
||||
"Codex cannot attach image at `{}`: unsupported image format `image/svg+xml`.",
|
||||
"Codex cannot attach image at `{}`: unsupported image `image/svg+xml`.",
|
||||
svg_path.display()
|
||||
);
|
||||
match &content[0] {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! are used to preserve compatibility when older payloads omit newly introduced attributes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
@@ -48,6 +49,15 @@ pub enum ReasoningEffort {
|
||||
XHigh,
|
||||
}
|
||||
|
||||
impl FromStr for ReasoningEffort {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
serde_json::from_value(serde_json::Value::String(s.to_string()))
|
||||
.map_err(|_| format!("invalid reasoning_effort: {s}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical user-input modality tags advertised by a model.
|
||||
#[derive(
|
||||
Debug,
|
||||
@@ -274,9 +284,6 @@ pub struct ModelInfo {
|
||||
/// Input modalities accepted by the backend for this model.
|
||||
#[serde(default = "default_input_modalities")]
|
||||
pub input_modalities: Vec<InputModality>,
|
||||
/// When true, this model should use websocket transport even when websocket features are off.
|
||||
#[serde(default)]
|
||||
pub prefer_websockets: bool,
|
||||
/// Internal-only marker set by core when a model slug resolved to fallback metadata.
|
||||
#[serde(default, skip_serializing, skip_deserializing)]
|
||||
#[schemars(skip)]
|
||||
@@ -538,7 +545,6 @@ mod tests {
|
||||
effective_context_window_percent: 95,
|
||||
experimental_supported_tools: vec![],
|
||||
input_modalities: default_input_modalities(),
|
||||
prefer_websockets: false,
|
||||
used_fallback_model_metadata: false,
|
||||
supports_search_tool: false,
|
||||
}
|
||||
@@ -552,6 +558,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_from_str_accepts_known_values() {
|
||||
assert_eq!("high".parse(), Ok(ReasoningEffort::High));
|
||||
assert_eq!("minimal".parse(), Ok(ReasoningEffort::Minimal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_from_str_rejects_unknown_values() {
|
||||
assert_eq!(
|
||||
"unsupported".parse::<ReasoningEffort>(),
|
||||
Err("invalid reasoning_effort: unsupported".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_model_instructions_uses_template_when_placeholder_present() {
|
||||
let model = test_model(Some(ModelMessages {
|
||||
@@ -727,8 +747,7 @@ mod tests {
|
||||
"auto_compact_token_limit": null,
|
||||
"effective_context_window_percent": 95,
|
||||
"experimental_supported_tools": [],
|
||||
"input_modalities": ["text", "image"],
|
||||
"prefer_websockets": false
|
||||
"input_modalities": ["text", "image"]
|
||||
}))
|
||||
.expect("deserialize model info");
|
||||
|
||||
|
||||
@@ -1229,6 +1229,88 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn current_working_directory_special_path_canonicalizes_symlinked_cwd() {
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
let real_root = cwd.path().join("real");
|
||||
let link_root = cwd.path().join("link");
|
||||
let blocked = real_root.join("blocked");
|
||||
let agents_dir = real_root.join(".agents");
|
||||
let codex_dir = real_root.join(".codex");
|
||||
|
||||
fs::create_dir_all(&blocked).expect("create blocked");
|
||||
fs::create_dir_all(&agents_dir).expect("create .agents");
|
||||
fs::create_dir_all(&codex_dir).expect("create .codex");
|
||||
symlink_dir(&real_root, &link_root).expect("create symlinked cwd");
|
||||
|
||||
let link_blocked =
|
||||
AbsolutePathBuf::from_absolute_path(link_root.join("blocked")).expect("link blocked");
|
||||
let expected_root = AbsolutePathBuf::from_absolute_path(
|
||||
real_root.canonicalize().expect("canonicalize real root"),
|
||||
)
|
||||
.expect("absolute canonical root");
|
||||
let expected_blocked = AbsolutePathBuf::from_absolute_path(
|
||||
blocked.canonicalize().expect("canonicalize blocked"),
|
||||
)
|
||||
.expect("absolute canonical blocked");
|
||||
let expected_agents = AbsolutePathBuf::from_absolute_path(
|
||||
agents_dir.canonicalize().expect("canonicalize .agents"),
|
||||
)
|
||||
.expect("absolute canonical .agents");
|
||||
let expected_codex = AbsolutePathBuf::from_absolute_path(
|
||||
codex_dir.canonicalize().expect("canonicalize .codex"),
|
||||
)
|
||||
.expect("absolute canonical .codex");
|
||||
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Minimal,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::CurrentWorkingDirectory,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: link_blocked },
|
||||
access: FileSystemAccessMode::None,
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
policy.get_readable_roots_with_cwd(&link_root),
|
||||
vec![expected_root.clone()]
|
||||
);
|
||||
assert_eq!(
|
||||
policy.get_unreadable_roots_with_cwd(&link_root),
|
||||
vec![expected_blocked.clone()]
|
||||
);
|
||||
|
||||
let writable_roots = policy.get_writable_roots_with_cwd(&link_root);
|
||||
assert_eq!(writable_roots.len(), 1);
|
||||
assert_eq!(writable_roots[0].root, expected_root);
|
||||
assert!(
|
||||
writable_roots[0]
|
||||
.read_only_subpaths
|
||||
.contains(&expected_blocked)
|
||||
);
|
||||
assert!(
|
||||
writable_roots[0]
|
||||
.read_only_subpaths
|
||||
.contains(&expected_agents)
|
||||
);
|
||||
assert!(
|
||||
writable_roots[0]
|
||||
.read_only_subpaths
|
||||
.contains(&expected_codex)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_roots_preserve_symlinked_protected_subpaths() {
|
||||
|
||||
@@ -32,6 +32,7 @@ use crate::mcp::RequestId;
|
||||
use crate::mcp::Resource as McpResource;
|
||||
use crate::mcp::ResourceTemplate as McpResourceTemplate;
|
||||
use crate::mcp::Tool as McpTool;
|
||||
use crate::memory_citation::MemoryCitation;
|
||||
use crate::message_history::HistoryEntry;
|
||||
use crate::models::BaseInstructions;
|
||||
use crate::models::ContentItem;
|
||||
@@ -139,6 +140,8 @@ pub struct RealtimeAudioFrame {
|
||||
pub num_channels: u16,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub samples_per_channel: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub item_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
@@ -160,15 +163,27 @@ pub struct RealtimeHandoffRequested {
|
||||
pub active_transcript: Vec<RealtimeTranscriptEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
pub struct RealtimeInputAudioSpeechStarted {
|
||||
pub item_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
pub struct RealtimeResponseCancelled {
|
||||
pub response_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
pub enum RealtimeEvent {
|
||||
SessionUpdated {
|
||||
session_id: String,
|
||||
instructions: Option<String>,
|
||||
},
|
||||
InputAudioSpeechStarted(RealtimeInputAudioSpeechStarted),
|
||||
InputTranscriptDelta(RealtimeTranscriptDelta),
|
||||
OutputTranscriptDelta(RealtimeTranscriptDelta),
|
||||
AudioOut(RealtimeAudioFrame),
|
||||
ResponseCancelled(RealtimeResponseCancelled),
|
||||
ConversationItemAdded(Value),
|
||||
ConversationItemDone {
|
||||
item_id: String,
|
||||
@@ -438,16 +453,6 @@ pub enum Op {
|
||||
force_reload: bool,
|
||||
},
|
||||
|
||||
/// Request the list of remote skills available via ChatGPT sharing.
|
||||
ListRemoteSkills {
|
||||
hazelnut_scope: RemoteSkillHazelnutScope,
|
||||
product_surface: RemoteSkillProductSurface,
|
||||
enabled: Option<bool>,
|
||||
},
|
||||
|
||||
/// Download a remote skill by id into the local skills cache.
|
||||
DownloadRemoteSkill { hazelnut_id: String },
|
||||
|
||||
/// Request the agent to summarize the current conversation context.
|
||||
/// The agent will use its existing context (either conversation history or previous response id)
|
||||
/// to generate a summary which will be returned as an AgentMessage event.
|
||||
@@ -518,8 +523,6 @@ impl Op {
|
||||
Self::ReloadUserConfig => "reload_user_config",
|
||||
Self::ListCustomPrompts => "list_custom_prompts",
|
||||
Self::ListSkills { .. } => "list_skills",
|
||||
Self::ListRemoteSkills { .. } => "list_remote_skills",
|
||||
Self::DownloadRemoteSkill { .. } => "download_remote_skill",
|
||||
Self::Compact => "compact",
|
||||
Self::DropMemories => "drop_memories",
|
||||
Self::UpdateMemories => "update_memories",
|
||||
@@ -1285,12 +1288,6 @@ pub enum EventMsg {
|
||||
/// List of skills available to the agent.
|
||||
ListSkillsResponse(ListSkillsResponseEvent),
|
||||
|
||||
/// List of remote skills available to the agent.
|
||||
ListRemoteSkillsResponse(ListRemoteSkillsResponseEvent),
|
||||
|
||||
/// Remote skill downloaded to local cache.
|
||||
RemoteSkillDownloaded(RemoteSkillDownloadedEvent),
|
||||
|
||||
/// Notification that skill data may have been updated and clients may want to reload.
|
||||
SkillsUpdateAvailable,
|
||||
|
||||
@@ -1345,6 +1342,7 @@ pub enum EventMsg {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HookEventName {
|
||||
SessionStart,
|
||||
UserPromptSubmit,
|
||||
Stop,
|
||||
}
|
||||
|
||||
@@ -1432,9 +1430,18 @@ pub struct HookCompletedEvent {
|
||||
pub run: HookRunSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RealtimeConversationVersion {
|
||||
#[default]
|
||||
V1,
|
||||
V2,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
|
||||
pub struct RealtimeConversationStartedEvent {
|
||||
pub session_id: Option<String>,
|
||||
pub version: RealtimeConversationVersion,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
|
||||
@@ -1990,6 +1997,8 @@ pub struct AgentMessageEvent {
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub phase: Option<MessagePhase>,
|
||||
#[serde(default)]
|
||||
pub memory_citation: Option<MemoryCitation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
@@ -2263,6 +2272,7 @@ pub enum SessionSource {
|
||||
VSCode,
|
||||
Exec,
|
||||
Mcp,
|
||||
Custom(String),
|
||||
SubAgent(SubAgentSource),
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
@@ -2293,6 +2303,7 @@ impl fmt::Display for SessionSource {
|
||||
SessionSource::VSCode => f.write_str("vscode"),
|
||||
SessionSource::Exec => f.write_str("exec"),
|
||||
SessionSource::Mcp => f.write_str("mcp"),
|
||||
SessionSource::Custom(source) => f.write_str(source),
|
||||
SessionSource::SubAgent(sub_source) => write!(f, "subagent_{sub_source}"),
|
||||
SessionSource::Unknown => f.write_str("unknown"),
|
||||
}
|
||||
@@ -2300,6 +2311,23 @@ impl fmt::Display for SessionSource {
|
||||
}
|
||||
|
||||
impl SessionSource {
|
||||
pub fn from_startup_arg(value: &str) -> Result<Self, &'static str> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("session source must not be empty");
|
||||
}
|
||||
|
||||
let normalized = trimmed.to_ascii_lowercase();
|
||||
Ok(match normalized.as_str() {
|
||||
"cli" => SessionSource::Cli,
|
||||
"vscode" => SessionSource::VSCode,
|
||||
"exec" => SessionSource::Exec,
|
||||
"mcp" | "appserver" | "app-server" | "app_server" => SessionSource::Mcp,
|
||||
"unknown" => SessionSource::Unknown,
|
||||
_ => SessionSource::Custom(normalized),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_nickname(&self) -> Option<String> {
|
||||
match self {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_nickname, .. }) => {
|
||||
@@ -2323,6 +2351,24 @@ impl SessionSource {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn restriction_product(&self) -> Option<Product> {
|
||||
match self {
|
||||
SessionSource::Custom(source) => Product::from_session_source_name(source),
|
||||
SessionSource::Cli
|
||||
| SessionSource::VSCode
|
||||
| SessionSource::Exec
|
||||
| SessionSource::Mcp
|
||||
| SessionSource::Unknown => Some(Product::Codex),
|
||||
SessionSource::SubAgent(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn matches_product_restriction(&self, products: &[Product]) -> bool {
|
||||
products.is_empty()
|
||||
|| self
|
||||
.restriction_product()
|
||||
.is_some_and(|product| product.matches_product_restriction(products))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SubAgentSource {
|
||||
@@ -2904,47 +2950,32 @@ pub struct ListSkillsResponseEvent {
|
||||
pub skills: Vec<SkillsListEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct RemoteSkillSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
#[ts(rename_all = "kebab-case")]
|
||||
pub enum RemoteSkillHazelnutScope {
|
||||
WorkspaceShared,
|
||||
AllShared,
|
||||
Personal,
|
||||
Example,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(rename_all = "lowercase")]
|
||||
pub enum RemoteSkillProductSurface {
|
||||
pub enum Product {
|
||||
#[serde(alias = "CHATGPT")]
|
||||
Chatgpt,
|
||||
#[serde(alias = "CODEX")]
|
||||
Codex,
|
||||
Api,
|
||||
#[serde(alias = "ATLAS")]
|
||||
Atlas,
|
||||
}
|
||||
impl Product {
|
||||
pub fn from_session_source_name(value: &str) -> Option<Self> {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"chatgpt" => Some(Self::Chatgpt),
|
||||
"codex" => Some(Self::Codex),
|
||||
"atlas" => Some(Self::Atlas),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Response payload for `Op::ListRemoteSkills`.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct ListRemoteSkillsResponseEvent {
|
||||
pub skills: Vec<RemoteSkillSummary>,
|
||||
pub fn matches_product_restriction(&self, products: &[Product]) -> bool {
|
||||
products.is_empty() || products.contains(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Response payload for `Op::DownloadRemoteSkill`.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct RemoteSkillDownloadedEvent {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(rename_all = "snake_case")]
|
||||
@@ -3250,9 +3281,9 @@ pub struct CollabAgentSpawnEndEvent {
|
||||
/// Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the
|
||||
/// beginning.
|
||||
pub prompt: String,
|
||||
/// Model requested for the spawned agent.
|
||||
/// Effective model used by the spawned agent after inheritance and role overrides.
|
||||
pub model: String,
|
||||
/// Reasoning effort requested for the spawned agent.
|
||||
/// Effective reasoning effort used by the spawned agent after inheritance and role overrides.
|
||||
pub reasoning_effort: ReasoningEffortConfig,
|
||||
/// Last known status of the new agent reported to the sender agent.
|
||||
pub status: AgentStatus,
|
||||
@@ -3445,6 +3476,100 @@ mod tests {
|
||||
.any(|root| root.is_path_writable(path))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_source_from_startup_arg_maps_known_values() {
|
||||
assert_eq!(
|
||||
SessionSource::from_startup_arg("vscode").unwrap(),
|
||||
SessionSource::VSCode
|
||||
);
|
||||
assert_eq!(
|
||||
SessionSource::from_startup_arg("app-server").unwrap(),
|
||||
SessionSource::Mcp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_source_from_startup_arg_normalizes_custom_values() {
|
||||
assert_eq!(
|
||||
SessionSource::from_startup_arg("atlas").unwrap(),
|
||||
SessionSource::Custom("atlas".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
SessionSource::from_startup_arg(" Atlas ").unwrap(),
|
||||
SessionSource::Custom("atlas".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_source_restriction_product_defaults_non_subagent_sources_to_codex() {
|
||||
assert_eq!(
|
||||
SessionSource::Cli.restriction_product(),
|
||||
Some(Product::Codex)
|
||||
);
|
||||
assert_eq!(
|
||||
SessionSource::VSCode.restriction_product(),
|
||||
Some(Product::Codex)
|
||||
);
|
||||
assert_eq!(
|
||||
SessionSource::Exec.restriction_product(),
|
||||
Some(Product::Codex)
|
||||
);
|
||||
assert_eq!(
|
||||
SessionSource::Mcp.restriction_product(),
|
||||
Some(Product::Codex)
|
||||
);
|
||||
assert_eq!(
|
||||
SessionSource::Unknown.restriction_product(),
|
||||
Some(Product::Codex)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_source_restriction_product_does_not_guess_subagent_products() {
|
||||
assert_eq!(
|
||||
SessionSource::SubAgent(SubAgentSource::Review).restriction_product(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_source_restriction_product_maps_custom_sources_to_products() {
|
||||
assert_eq!(
|
||||
SessionSource::Custom("chatgpt".to_string()).restriction_product(),
|
||||
Some(Product::Chatgpt)
|
||||
);
|
||||
assert_eq!(
|
||||
SessionSource::Custom("ATLAS".to_string()).restriction_product(),
|
||||
Some(Product::Atlas)
|
||||
);
|
||||
assert_eq!(
|
||||
SessionSource::Custom("codex".to_string()).restriction_product(),
|
||||
Some(Product::Codex)
|
||||
);
|
||||
assert_eq!(
|
||||
SessionSource::Custom("atlas-dev".to_string()).restriction_product(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_source_matches_product_restriction() {
|
||||
assert!(
|
||||
SessionSource::Custom("chatgpt".to_string())
|
||||
.matches_product_restriction(&[Product::Chatgpt])
|
||||
);
|
||||
assert!(
|
||||
!SessionSource::Custom("chatgpt".to_string())
|
||||
.matches_product_restriction(&[Product::Codex])
|
||||
);
|
||||
assert!(SessionSource::VSCode.matches_product_restriction(&[Product::Codex]));
|
||||
assert!(
|
||||
!SessionSource::Custom("atlas-dev".to_string())
|
||||
.matches_product_restriction(&[Product::Atlas])
|
||||
);
|
||||
assert!(SessionSource::Custom("atlas-dev".to_string()).matches_product_restriction(&[]));
|
||||
}
|
||||
|
||||
fn sandbox_policy_probe_paths(policy: &SandboxPolicy, cwd: &Path) -> Vec<PathBuf> {
|
||||
let mut paths = vec![cwd.to_path_buf()];
|
||||
paths.extend(
|
||||
@@ -4079,6 +4204,7 @@ mod tests {
|
||||
sample_rate: 24_000,
|
||||
num_channels: 1,
|
||||
samples_per_channel: Some(480),
|
||||
item_id: None,
|
||||
},
|
||||
});
|
||||
let start = Op::RealtimeConversationStart(ConversationStartParams {
|
||||
|
||||
Reference in New Issue
Block a user