Merge branch 'main' into rhan/surface-updates

This commit is contained in:
rhan-oai
2026-03-19 11:23:44 -07:00
committed by GitHub
489 changed files with 37167 additions and 8086 deletions
+159
View File
@@ -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(),
}
);
}
}
+1
View File
@@ -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;
+20
View File
@@ -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,
}
+53 -34
View File
@@ -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] {
+25 -6
View File
@@ -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");
+82
View File
@@ -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() {
+179 -53
View File
@@ -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 {