mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
Merge 4c65f72117 into sapling-pr-archive-bolinfest
This commit is contained in:
Generated
+1
@@ -1524,6 +1524,7 @@ dependencies = [
|
||||
"codex-utils-absolute-path",
|
||||
"landlock",
|
||||
"libc",
|
||||
"pretty_assertions",
|
||||
"seccompiler",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
|
||||
@@ -197,6 +197,8 @@ impl ThreadHistoryBuilder {
|
||||
if !payload.message.trim().is_empty() {
|
||||
content.push(UserInput::Text {
|
||||
text: payload.message.clone(),
|
||||
// TODO: Thread text element ranges into thread history. Empty keeps old behavior.
|
||||
text_elements: Vec::new(),
|
||||
});
|
||||
}
|
||||
if let Some(images) = &payload.images {
|
||||
@@ -244,6 +246,8 @@ mod tests {
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "First turn".into(),
|
||||
images: Some(vec!["https://example.com/one.png".into()]),
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
}),
|
||||
EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "Hi there".into(),
|
||||
@@ -257,6 +261,8 @@ mod tests {
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "Second turn".into(),
|
||||
images: None,
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
}),
|
||||
EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "Reply two".into(),
|
||||
@@ -277,6 +283,7 @@ mod tests {
|
||||
content: vec![
|
||||
UserInput::Text {
|
||||
text: "First turn".into(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
UserInput::Image {
|
||||
url: "https://example.com/one.png".into(),
|
||||
@@ -308,7 +315,8 @@ mod tests {
|
||||
ThreadItem::UserMessage {
|
||||
id: "item-4".into(),
|
||||
content: vec![UserInput::Text {
|
||||
text: "Second turn".into()
|
||||
text: "Second turn".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
}
|
||||
);
|
||||
@@ -327,6 +335,8 @@ mod tests {
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "Turn start".into(),
|
||||
images: None,
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
}),
|
||||
EventMsg::AgentReasoning(AgentReasoningEvent {
|
||||
text: "first summary".into(),
|
||||
@@ -371,6 +381,8 @@ mod tests {
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "Please do the thing".into(),
|
||||
images: None,
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
}),
|
||||
EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "Working...".into(),
|
||||
@@ -381,6 +393,8 @@ mod tests {
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "Let's try again".into(),
|
||||
images: None,
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
}),
|
||||
EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "Second attempt complete.".into(),
|
||||
@@ -398,7 +412,8 @@ mod tests {
|
||||
ThreadItem::UserMessage {
|
||||
id: "item-1".into(),
|
||||
content: vec![UserInput::Text {
|
||||
text: "Please do the thing".into()
|
||||
text: "Please do the thing".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
}
|
||||
);
|
||||
@@ -418,7 +433,8 @@ mod tests {
|
||||
ThreadItem::UserMessage {
|
||||
id: "item-3".into(),
|
||||
content: vec![UserInput::Text {
|
||||
text: "Let's try again".into()
|
||||
text: "Let's try again".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
}
|
||||
);
|
||||
@@ -437,6 +453,8 @@ mod tests {
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "First".into(),
|
||||
images: None,
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
}),
|
||||
EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "A1".into(),
|
||||
@@ -444,6 +462,8 @@ mod tests {
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "Second".into(),
|
||||
images: None,
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
}),
|
||||
EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "A2".into(),
|
||||
@@ -452,6 +472,8 @@ mod tests {
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "Third".into(),
|
||||
images: None,
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
}),
|
||||
EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "A3".into(),
|
||||
@@ -469,6 +491,7 @@ mod tests {
|
||||
id: "item-1".into(),
|
||||
content: vec![UserInput::Text {
|
||||
text: "First".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
},
|
||||
ThreadItem::AgentMessage {
|
||||
@@ -486,6 +509,7 @@ mod tests {
|
||||
id: "item-3".into(),
|
||||
content: vec![UserInput::Text {
|
||||
text: "Third".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
},
|
||||
ThreadItem::AgentMessage {
|
||||
@@ -504,6 +528,8 @@ mod tests {
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "One".into(),
|
||||
images: None,
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
}),
|
||||
EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "A1".into(),
|
||||
@@ -511,6 +537,8 @@ mod tests {
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "Two".into(),
|
||||
images: None,
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
}),
|
||||
EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "A2".into(),
|
||||
|
||||
@@ -16,6 +16,7 @@ use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand;
|
||||
use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg;
|
||||
use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus;
|
||||
use codex_protocol::protocol::AgentStatus as CoreAgentStatus;
|
||||
use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
|
||||
use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo;
|
||||
use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
|
||||
@@ -24,6 +25,7 @@ use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
|
||||
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
|
||||
use codex_protocol::protocol::SessionSource as CoreSessionSource;
|
||||
use codex_protocol::protocol::SkillErrorInfo as CoreSkillErrorInfo;
|
||||
use codex_protocol::protocol::SkillInterface as CoreSkillInterface;
|
||||
use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata;
|
||||
use codex_protocol::protocol::SkillScope as CoreSkillScope;
|
||||
use codex_protocol::protocol::TokenUsage as CoreTokenUsage;
|
||||
@@ -1251,13 +1253,35 @@ pub enum SkillScope {
|
||||
pub struct SkillMetadata {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[ts(optional)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
/// Legacy short_description from SKILL.md. Prefer SKILL.toml interface.short_description.
|
||||
pub short_description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub interface: Option<SkillInterface>,
|
||||
pub path: PathBuf,
|
||||
pub scope: SkillScope,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct SkillInterface {
|
||||
#[ts(optional)]
|
||||
pub display_name: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub short_description: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub icon_small: Option<PathBuf>,
|
||||
#[ts(optional)]
|
||||
pub icon_large: Option<PathBuf>,
|
||||
#[ts(optional)]
|
||||
pub brand_color: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub default_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -1281,12 +1305,26 @@ impl From<CoreSkillMetadata> for SkillMetadata {
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
short_description: value.short_description,
|
||||
interface: value.interface.map(SkillInterface::from),
|
||||
path: value.path,
|
||||
scope: value.scope.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CoreSkillInterface> for SkillInterface {
|
||||
fn from(value: CoreSkillInterface) -> Self {
|
||||
Self {
|
||||
display_name: value.display_name,
|
||||
short_description: value.short_description,
|
||||
brand_color: value.brand_color,
|
||||
default_prompt: value.default_prompt,
|
||||
icon_small: value.icon_small,
|
||||
icon_large: value.icon_large,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CoreSkillScope> for SkillScope {
|
||||
fn from(value: CoreSkillScope) -> Self {
|
||||
match value {
|
||||
@@ -1543,21 +1581,55 @@ pub struct TurnInterruptParams {
|
||||
pub struct TurnInterruptResponse {}
|
||||
|
||||
// User input types
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ByteRange {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct TextElement {
|
||||
/// Byte range in the parent `text` buffer that this element occupies.
|
||||
pub byte_range: ByteRange,
|
||||
/// Optional human-readable placeholder for the element, displayed in the UI.
|
||||
pub placeholder: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
#[ts(tag = "type")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum UserInput {
|
||||
Text { text: String },
|
||||
Image { url: String },
|
||||
LocalImage { path: PathBuf },
|
||||
Skill { name: String, path: PathBuf },
|
||||
Text {
|
||||
text: String,
|
||||
/// UI-defined spans within `text` used to render or persist special elements.
|
||||
#[serde(default)]
|
||||
text_elements: Vec<TextElement>,
|
||||
},
|
||||
Image {
|
||||
url: String,
|
||||
},
|
||||
LocalImage {
|
||||
path: PathBuf,
|
||||
},
|
||||
Skill {
|
||||
name: String,
|
||||
path: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
impl UserInput {
|
||||
pub fn into_core(self) -> CoreUserInput {
|
||||
match self {
|
||||
UserInput::Text { text } => CoreUserInput::Text { text },
|
||||
UserInput::Text { text, .. } => CoreUserInput::Text {
|
||||
text,
|
||||
// TODO: Thread text element ranges into v2 inputs. Empty keeps old behavior.
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
UserInput::Image { url } => CoreUserInput::Image { image_url: url },
|
||||
UserInput::LocalImage { path } => CoreUserInput::LocalImage { path },
|
||||
UserInput::Skill { name, path } => CoreUserInput::Skill { name, path },
|
||||
@@ -1568,7 +1640,11 @@ impl UserInput {
|
||||
impl From<CoreUserInput> for UserInput {
|
||||
fn from(value: CoreUserInput) -> Self {
|
||||
match value {
|
||||
CoreUserInput::Text { text } => UserInput::Text { text },
|
||||
CoreUserInput::Text { text, .. } => UserInput::Text {
|
||||
text,
|
||||
// TODO: Thread text element ranges from core into v2 inputs.
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
CoreUserInput::Image { image_url } => UserInput::Image { url: image_url },
|
||||
CoreUserInput::LocalImage { path } => UserInput::LocalImage { path },
|
||||
CoreUserInput::Skill { name, path } => UserInput::Skill { name, path },
|
||||
@@ -1643,6 +1719,25 @@ pub enum ThreadItem {
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
CollabAgentToolCall {
|
||||
/// Unique identifier for this collab tool call.
|
||||
id: String,
|
||||
/// Name of the collab tool that was invoked.
|
||||
tool: CollabAgentTool,
|
||||
/// Current status of the collab tool call.
|
||||
status: CollabAgentToolCallStatus,
|
||||
/// Thread ID of the agent issuing the collab request.
|
||||
sender_thread_id: String,
|
||||
/// Thread ID of the receiving agent, when applicable. In case of spawn operation,
|
||||
/// this correspond to the newly spawned agent.
|
||||
receiver_thread_id: Option<String>,
|
||||
/// Prompt text sent as part of the collab tool call, when available.
|
||||
prompt: Option<String>,
|
||||
/// Last known status of the target agent, when available.
|
||||
agent_state: Option<CollabAgentState>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
WebSearch { id: String, query: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
@@ -1695,6 +1790,16 @@ pub enum CommandExecutionStatus {
|
||||
Declined,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum CollabAgentTool {
|
||||
SpawnAgent,
|
||||
SendInput,
|
||||
Wait,
|
||||
CloseAgent,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -1733,6 +1838,66 @@ pub enum McpToolCallStatus {
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum CollabAgentToolCallStatus {
|
||||
InProgress,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum CollabAgentStatus {
|
||||
PendingInit,
|
||||
Running,
|
||||
Completed,
|
||||
Errored,
|
||||
Shutdown,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct CollabAgentState {
|
||||
pub status: CollabAgentStatus,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl From<CoreAgentStatus> for CollabAgentState {
|
||||
fn from(value: CoreAgentStatus) -> Self {
|
||||
match value {
|
||||
CoreAgentStatus::PendingInit => Self {
|
||||
status: CollabAgentStatus::PendingInit,
|
||||
message: None,
|
||||
},
|
||||
CoreAgentStatus::Running => Self {
|
||||
status: CollabAgentStatus::Running,
|
||||
message: None,
|
||||
},
|
||||
CoreAgentStatus::Completed(message) => Self {
|
||||
status: CollabAgentStatus::Completed,
|
||||
message,
|
||||
},
|
||||
CoreAgentStatus::Errored(message) => Self {
|
||||
status: CollabAgentStatus::Errored,
|
||||
message: Some(message),
|
||||
},
|
||||
CoreAgentStatus::Shutdown => Self {
|
||||
status: CollabAgentStatus::Shutdown,
|
||||
message: None,
|
||||
},
|
||||
CoreAgentStatus::NotFound => Self {
|
||||
status: CollabAgentStatus::NotFound,
|
||||
message: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -2160,6 +2325,7 @@ mod tests {
|
||||
content: vec![
|
||||
CoreUserInput::Text {
|
||||
text: "hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
CoreUserInput::Image {
|
||||
image_url: "https://example.com/image.png".to_string(),
|
||||
@@ -2181,6 +2347,7 @@ mod tests {
|
||||
content: vec![
|
||||
UserInput::Text {
|
||||
text: "hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
UserInput::Image {
|
||||
url: "https://example.com/image.png".to_string(),
|
||||
|
||||
@@ -256,7 +256,11 @@ fn send_message_v2_with_policies(
|
||||
println!("< thread/start response: {thread_response:?}");
|
||||
let mut turn_params = TurnStartParams {
|
||||
thread_id: thread_response.thread.id.clone(),
|
||||
input: vec![V2UserInput::Text { text: user_message }],
|
||||
input: vec![V2UserInput::Text {
|
||||
text: user_message,
|
||||
// Plain text conversion has no UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
turn_params.approval_policy = approval_policy;
|
||||
@@ -288,6 +292,7 @@ fn send_follow_up_v2(
|
||||
thread_id: thread_response.thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: first_message,
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
@@ -299,6 +304,7 @@ fn send_follow_up_v2(
|
||||
thread_id: thread_response.thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: follow_up_message,
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -375,6 +375,7 @@ Today both notifications carry an empty `items` array even when item events were
|
||||
- `commandExecution` — `{id, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `status` is `inProgress`, `completed`, `failed`, or `declined`.
|
||||
- `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`.
|
||||
- `mcpToolCall` — `{id, server, tool, status, arguments, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`.
|
||||
- `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`.
|
||||
- `webSearch` — `{id, query}` for a web search request issued by the agent.
|
||||
- `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool.
|
||||
- `enteredReviewMode` — `{id, review}` sent when the reviewer starts; `review` is a short user-facing label such as `"current changes"` or the requested target description.
|
||||
|
||||
@@ -14,6 +14,9 @@ use codex_app_server_protocol::AgentMessageDeltaNotification;
|
||||
use codex_app_server_protocol::ApplyPatchApprovalParams;
|
||||
use codex_app_server_protocol::ApplyPatchApprovalResponse;
|
||||
use codex_app_server_protocol::CodexErrorInfo as V2CodexErrorInfo;
|
||||
use codex_app_server_protocol::CollabAgentState as V2CollabAgentStatus;
|
||||
use codex_app_server_protocol::CollabAgentTool;
|
||||
use codex_app_server_protocol::CollabAgentToolCallStatus as V2CollabToolCallStatus;
|
||||
use codex_app_server_protocol::CommandAction as V2ParsedCommand;
|
||||
use codex_app_server_protocol::CommandExecutionApprovalDecision;
|
||||
use codex_app_server_protocol::CommandExecutionOutputDeltaNotification;
|
||||
@@ -278,6 +281,178 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
.send_server_notification(ServerNotification::ItemCompleted(notification))
|
||||
.await;
|
||||
}
|
||||
EventMsg::CollabAgentSpawnBegin(begin_event) => {
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: begin_event.call_id,
|
||||
tool: CollabAgentTool::SpawnAgent,
|
||||
status: V2CollabToolCallStatus::InProgress,
|
||||
sender_thread_id: begin_event.sender_thread_id.to_string(),
|
||||
receiver_thread_id: None,
|
||||
prompt: Some(begin_event.prompt),
|
||||
agent_state: None,
|
||||
};
|
||||
let notification = ItemStartedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemStarted(notification))
|
||||
.await;
|
||||
}
|
||||
EventMsg::CollabAgentSpawnEnd(end_event) => {
|
||||
let status = if end_event.new_thread_id.is_some() {
|
||||
V2CollabToolCallStatus::Completed
|
||||
} else {
|
||||
V2CollabToolCallStatus::Failed
|
||||
};
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: end_event.call_id,
|
||||
tool: CollabAgentTool::SpawnAgent,
|
||||
status,
|
||||
sender_thread_id: end_event.sender_thread_id.to_string(),
|
||||
receiver_thread_id: end_event.new_thread_id.map(|id| id.to_string()),
|
||||
prompt: Some(end_event.prompt),
|
||||
agent_state: Some(V2CollabAgentStatus::from(end_event.status)),
|
||||
};
|
||||
let notification = ItemCompletedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemCompleted(notification))
|
||||
.await;
|
||||
}
|
||||
EventMsg::CollabAgentInteractionBegin(begin_event) => {
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: begin_event.call_id,
|
||||
tool: CollabAgentTool::SendInput,
|
||||
status: V2CollabToolCallStatus::InProgress,
|
||||
sender_thread_id: begin_event.sender_thread_id.to_string(),
|
||||
receiver_thread_id: Some(begin_event.receiver_thread_id.to_string()),
|
||||
prompt: Some(begin_event.prompt),
|
||||
agent_state: None,
|
||||
};
|
||||
let notification = ItemStartedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemStarted(notification))
|
||||
.await;
|
||||
}
|
||||
EventMsg::CollabAgentInteractionEnd(end_event) => {
|
||||
let status = match end_event.status {
|
||||
codex_protocol::protocol::AgentStatus::Errored(_)
|
||||
| codex_protocol::protocol::AgentStatus::NotFound => V2CollabToolCallStatus::Failed,
|
||||
_ => V2CollabToolCallStatus::Completed,
|
||||
};
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: end_event.call_id,
|
||||
tool: CollabAgentTool::SendInput,
|
||||
status,
|
||||
sender_thread_id: end_event.sender_thread_id.to_string(),
|
||||
receiver_thread_id: Some(end_event.receiver_thread_id.to_string()),
|
||||
prompt: Some(end_event.prompt),
|
||||
agent_state: Some(V2CollabAgentStatus::from(end_event.status)),
|
||||
};
|
||||
let notification = ItemCompletedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemCompleted(notification))
|
||||
.await;
|
||||
}
|
||||
EventMsg::CollabWaitingBegin(begin_event) => {
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: begin_event.call_id,
|
||||
tool: CollabAgentTool::Wait,
|
||||
status: V2CollabToolCallStatus::InProgress,
|
||||
sender_thread_id: begin_event.sender_thread_id.to_string(),
|
||||
receiver_thread_id: Some(begin_event.receiver_thread_id.to_string()),
|
||||
prompt: None,
|
||||
agent_state: None,
|
||||
};
|
||||
let notification = ItemStartedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemStarted(notification))
|
||||
.await;
|
||||
}
|
||||
EventMsg::CollabWaitingEnd(end_event) => {
|
||||
let status = match end_event.status {
|
||||
codex_protocol::protocol::AgentStatus::Errored(_)
|
||||
| codex_protocol::protocol::AgentStatus::NotFound => V2CollabToolCallStatus::Failed,
|
||||
_ => V2CollabToolCallStatus::Completed,
|
||||
};
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: end_event.call_id,
|
||||
tool: CollabAgentTool::Wait,
|
||||
status,
|
||||
sender_thread_id: end_event.sender_thread_id.to_string(),
|
||||
receiver_thread_id: Some(end_event.receiver_thread_id.to_string()),
|
||||
prompt: None,
|
||||
agent_state: Some(V2CollabAgentStatus::from(end_event.status)),
|
||||
};
|
||||
let notification = ItemCompletedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemCompleted(notification))
|
||||
.await;
|
||||
}
|
||||
EventMsg::CollabCloseBegin(begin_event) => {
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: begin_event.call_id,
|
||||
tool: CollabAgentTool::CloseAgent,
|
||||
status: V2CollabToolCallStatus::InProgress,
|
||||
sender_thread_id: begin_event.sender_thread_id.to_string(),
|
||||
receiver_thread_id: Some(begin_event.receiver_thread_id.to_string()),
|
||||
prompt: None,
|
||||
agent_state: None,
|
||||
};
|
||||
let notification = ItemStartedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemStarted(notification))
|
||||
.await;
|
||||
}
|
||||
EventMsg::CollabCloseEnd(end_event) => {
|
||||
let status = match end_event.status {
|
||||
codex_protocol::protocol::AgentStatus::Errored(_)
|
||||
| codex_protocol::protocol::AgentStatus::NotFound => V2CollabToolCallStatus::Failed,
|
||||
_ => V2CollabToolCallStatus::Completed,
|
||||
};
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: end_event.call_id,
|
||||
tool: CollabAgentTool::CloseAgent,
|
||||
status,
|
||||
sender_thread_id: end_event.sender_thread_id.to_string(),
|
||||
receiver_thread_id: Some(end_event.receiver_thread_id.to_string()),
|
||||
prompt: None,
|
||||
agent_state: Some(V2CollabAgentStatus::from(end_event.status)),
|
||||
};
|
||||
let notification = ItemCompletedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemCompleted(notification))
|
||||
.await;
|
||||
}
|
||||
EventMsg::AgentMessageContentDelta(event) => {
|
||||
let notification = AgentMessageDeltaNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
|
||||
@@ -3125,7 +3125,11 @@ impl CodexMessageProcessor {
|
||||
let mapped_items: Vec<CoreInputItem> = items
|
||||
.into_iter()
|
||||
.map(|item| match item {
|
||||
WireInputItem::Text { text } => CoreInputItem::Text { text },
|
||||
WireInputItem::Text { text } => CoreInputItem::Text {
|
||||
text,
|
||||
// TODO: Thread text element ranges into v1 input handling.
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
WireInputItem::Image { image_url } => CoreInputItem::Image { image_url },
|
||||
WireInputItem::LocalImage { path } => CoreInputItem::LocalImage { path },
|
||||
})
|
||||
@@ -3171,7 +3175,11 @@ impl CodexMessageProcessor {
|
||||
let mapped_items: Vec<CoreInputItem> = items
|
||||
.into_iter()
|
||||
.map(|item| match item {
|
||||
WireInputItem::Text { text } => CoreInputItem::Text { text },
|
||||
WireInputItem::Text { text } => CoreInputItem::Text {
|
||||
text,
|
||||
// TODO: Thread text element ranges into v1 input handling.
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
WireInputItem::Image { image_url } => CoreInputItem::Image { image_url },
|
||||
WireInputItem::LocalImage { path } => CoreInputItem::LocalImage { path },
|
||||
})
|
||||
@@ -3333,6 +3341,7 @@ impl CodexMessageProcessor {
|
||||
id: turn_id.clone(),
|
||||
content: vec![V2UserInput::Text {
|
||||
text: display_text.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
}]
|
||||
};
|
||||
@@ -3885,6 +3894,16 @@ fn skills_to_info(
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
short_description: skill.short_description.clone(),
|
||||
interface: skill.interface.clone().map(|interface| {
|
||||
codex_app_server_protocol::SkillInterface {
|
||||
display_name: interface.display_name,
|
||||
short_description: interface.short_description,
|
||||
icon_small: interface.icon_small,
|
||||
icon_large: interface.icon_large,
|
||||
brand_color: interface.brand_color,
|
||||
default_prompt: interface.default_prompt,
|
||||
}
|
||||
}),
|
||||
path: skill.path.clone(),
|
||||
scope: skill.scope.into(),
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
|
||||
},
|
||||
supported_in_api: true,
|
||||
priority,
|
||||
upgrade: preset.upgrade.as_ref().map(|u| u.id.clone()),
|
||||
upgrade: preset.upgrade.as_ref().map(|u| u.into()),
|
||||
base_instructions: "base instructions".to_string(),
|
||||
supports_reasoning_summaries: false,
|
||||
support_verbosity: false,
|
||||
|
||||
@@ -61,6 +61,7 @@ async fn turn_start_accepts_output_schema_v2() -> Result<()> {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
output_schema: Some(output_schema.clone()),
|
||||
..Default::default()
|
||||
@@ -142,6 +143,7 @@ async fn turn_start_output_schema_is_per_turn_v2() -> Result<()> {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
output_schema: Some(output_schema.clone()),
|
||||
..Default::default()
|
||||
@@ -183,6 +185,7 @@ async fn turn_start_output_schema_is_per_turn_v2() -> Result<()> {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Hello again".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
output_schema: None,
|
||||
..Default::default()
|
||||
|
||||
@@ -95,7 +95,8 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
|
||||
assert_eq!(
|
||||
content,
|
||||
&vec![UserInput::Text {
|
||||
text: preview.to_string()
|
||||
text: preview.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,7 +118,8 @@ async fn thread_resume_returns_rollout_history() -> Result<()> {
|
||||
assert_eq!(
|
||||
content,
|
||||
&vec![UserInput::Text {
|
||||
text: preview.to_string()
|
||||
text: preview.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<()
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: first_text.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
@@ -77,6 +78,7 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<()
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Second".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
@@ -115,7 +117,8 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<()
|
||||
assert_eq!(
|
||||
content,
|
||||
&vec![V2UserInput::Text {
|
||||
text: first_text.to_string()
|
||||
text: first_text.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
@@ -143,7 +146,8 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<()
|
||||
assert_eq!(
|
||||
content,
|
||||
&vec![V2UserInput::Text {
|
||||
text: first_text.to_string()
|
||||
text: first_text.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "run sleep".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: Some(working_directory.clone()),
|
||||
..Default::default()
|
||||
|
||||
@@ -80,6 +80,7 @@ async fn turn_start_sends_originator_header() -> Result<()> {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
@@ -149,6 +150,7 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<(
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
@@ -181,6 +183,7 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<(
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Second".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
model: Some("mock-model-override".to_string()),
|
||||
..Default::default()
|
||||
@@ -331,6 +334,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "run python".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
@@ -376,6 +380,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "run python again".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
approval_policy: Some(codex_app_server_protocol::AskForApproval::Never),
|
||||
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess),
|
||||
@@ -452,6 +457,7 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "run python".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: Some(workspace.clone()),
|
||||
..Default::default()
|
||||
@@ -600,6 +606,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "first turn".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: Some(first_cwd.clone()),
|
||||
approval_policy: Some(codex_app_server_protocol::AskForApproval::Never),
|
||||
@@ -633,6 +640,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "second turn".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: Some(second_cwd.clone()),
|
||||
approval_policy: Some(codex_app_server_protocol::AskForApproval::Never),
|
||||
@@ -733,6 +741,7 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "apply patch".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: Some(workspace.clone()),
|
||||
..Default::default()
|
||||
@@ -910,6 +919,7 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "apply patch 1".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: Some(workspace.clone()),
|
||||
..Default::default()
|
||||
@@ -986,6 +996,7 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "apply patch 2".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: Some(workspace.clone()),
|
||||
..Default::default()
|
||||
@@ -1083,6 +1094,7 @@ async fn turn_start_file_change_approval_decline_v2() -> Result<()> {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "apply patch".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: Some(workspace.clone()),
|
||||
..Default::default()
|
||||
@@ -1230,6 +1242,7 @@ unified_exec = true
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "run a command".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
|
||||
+20
-11
@@ -241,6 +241,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
|
||||
let new_entry = McpServerConfig {
|
||||
transport: transport.clone(),
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -448,6 +449,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"enabled": cfg.enabled,
|
||||
"disabled_reason": cfg.disabled_reason.as_ref().map(ToString::to_string),
|
||||
"transport": transport,
|
||||
"startup_timeout_sec": cfg
|
||||
.startup_timeout_sec
|
||||
@@ -492,11 +494,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
||||
.map(|path| path.display().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let status = if cfg.enabled {
|
||||
"enabled".to_string()
|
||||
} else {
|
||||
"disabled".to_string()
|
||||
};
|
||||
let status = format_mcp_status(cfg);
|
||||
let auth_status = auth_statuses
|
||||
.get(name.as_str())
|
||||
.map(|entry| entry.auth_status)
|
||||
@@ -517,11 +515,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
||||
bearer_token_env_var,
|
||||
..
|
||||
} => {
|
||||
let status = if cfg.enabled {
|
||||
"enabled".to_string()
|
||||
} else {
|
||||
"disabled".to_string()
|
||||
};
|
||||
let status = format_mcp_status(cfg);
|
||||
let auth_status = auth_statuses
|
||||
.get(name.as_str())
|
||||
.map(|entry| entry.auth_status)
|
||||
@@ -691,6 +685,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
|
||||
let output = serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": get_args.name,
|
||||
"enabled": server.enabled,
|
||||
"disabled_reason": server.disabled_reason.as_ref().map(ToString::to_string),
|
||||
"transport": transport,
|
||||
"enabled_tools": server.enabled_tools.clone(),
|
||||
"disabled_tools": server.disabled_tools.clone(),
|
||||
@@ -706,7 +701,11 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
|
||||
}
|
||||
|
||||
if !server.enabled {
|
||||
println!("{} (disabled)", get_args.name);
|
||||
if let Some(reason) = server.disabled_reason.as_ref() {
|
||||
println!("{name} (disabled: {reason})", name = get_args.name);
|
||||
} else {
|
||||
println!("{name} (disabled)", name = get_args.name);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -828,3 +827,13 @@ fn validate_server_name(name: &str) -> Result<()> {
|
||||
bail!("invalid server name '{name}' (use letters, numbers, '-', '_')");
|
||||
}
|
||||
}
|
||||
|
||||
fn format_mcp_status(config: &McpServerConfig) -> String {
|
||||
if config.enabled {
|
||||
"enabled".to_string()
|
||||
} else if let Some(reason) = config.disabled_reason.as_ref() {
|
||||
format!("disabled: {reason}")
|
||||
} else {
|
||||
"disabled".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ async fn list_and_get_render_expected_output() -> Result<()> {
|
||||
{
|
||||
"name": "docs",
|
||||
"enabled": true,
|
||||
"disabled_reason": null,
|
||||
"transport": {
|
||||
"type": "stdio",
|
||||
"command": "docs-server",
|
||||
|
||||
+148
-124
File diff suppressed because one or more lines are too long
@@ -56,7 +56,11 @@ impl AgentControl {
|
||||
.send_op(
|
||||
agent_id,
|
||||
Op::UserInput {
|
||||
items: vec![UserInput::Text { text: prompt }],
|
||||
items: vec![UserInput::Text {
|
||||
text: prompt,
|
||||
// Plain text conversion has no UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
)
|
||||
@@ -67,6 +71,12 @@ impl AgentControl {
|
||||
result
|
||||
}
|
||||
|
||||
/// Interrupt the current task for an existing agent thread.
|
||||
pub(crate) async fn interrupt_agent(&self, agent_id: ThreadId) -> CodexResult<String> {
|
||||
let state = self.upgrade()?;
|
||||
state.send_op(agent_id, Op::Interrupt).await
|
||||
}
|
||||
|
||||
/// Submit a shutdown request to an existing agent thread.
|
||||
pub(crate) async fn shutdown_agent(&self, agent_id: ThreadId) -> CodexResult<String> {
|
||||
let state = self.upgrade()?;
|
||||
@@ -321,6 +331,7 @@ mod tests {
|
||||
Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello from tests".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
@@ -351,6 +362,7 @@ mod tests {
|
||||
Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "spawned".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
pub(crate) mod control;
|
||||
pub(crate) mod role;
|
||||
pub(crate) mod status;
|
||||
|
||||
pub(crate) use codex_protocol::protocol::AgentStatus;
|
||||
pub(crate) use control::AgentControl;
|
||||
pub(crate) use role::AgentRole;
|
||||
pub(crate) use status::agent_status_from_event;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
use crate::config::Config;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Base instructions for the orchestrator role.
|
||||
const ORCHESTRATOR_PROMPT: &str = include_str!("../../templates/agents/orchestrator.md");
|
||||
/// Base instructions for the worker role.
|
||||
const WORKER_PROMPT: &str = include_str!("../../gpt-5.2-codex_prompt.md");
|
||||
/// Default worker model override used by the worker role.
|
||||
const WORKER_MODEL: &str = "gpt-5.2-codex";
|
||||
|
||||
/// Enumerated list of all supported agent roles.
|
||||
const ALL_ROLES: [AgentRole; 3] = [
|
||||
AgentRole::Default,
|
||||
AgentRole::Orchestrator,
|
||||
AgentRole::Worker,
|
||||
];
|
||||
|
||||
/// Hard-coded agent role selection used when spawning sub-agents.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentRole {
|
||||
/// Inherit the parent agent's configuration unchanged.
|
||||
Default,
|
||||
/// Coordination-only agent that delegates to workers.
|
||||
Orchestrator,
|
||||
/// Task-executing agent with a fixed model override.
|
||||
Worker,
|
||||
}
|
||||
|
||||
/// Immutable profile data that drives per-agent configuration overrides.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct AgentProfile {
|
||||
/// Optional base instructions override.
|
||||
pub base_instructions: Option<&'static str>,
|
||||
/// Optional model override.
|
||||
pub model: Option<&'static str>,
|
||||
/// Whether to force a read-only sandbox policy.
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
impl AgentRole {
|
||||
/// Returns the string values used by JSON schema enums.
|
||||
pub fn enum_values() -> Vec<String> {
|
||||
ALL_ROLES
|
||||
.iter()
|
||||
.filter_map(|role| serde_json::to_string(role).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the hard-coded profile for this role.
|
||||
pub fn profile(self) -> AgentProfile {
|
||||
match self {
|
||||
AgentRole::Default => AgentProfile::default(),
|
||||
AgentRole::Orchestrator => AgentProfile {
|
||||
base_instructions: Some(ORCHESTRATOR_PROMPT),
|
||||
..Default::default()
|
||||
},
|
||||
AgentRole::Worker => AgentProfile {
|
||||
base_instructions: Some(WORKER_PROMPT),
|
||||
model: Some(WORKER_MODEL),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies this role's profile onto the provided config.
|
||||
pub fn apply_to_config(self, config: &mut Config) -> Result<(), String> {
|
||||
let profile = self.profile();
|
||||
if let Some(base_instructions) = profile.base_instructions {
|
||||
config.base_instructions = Some(base_instructions.to_string());
|
||||
}
|
||||
if let Some(model) = profile.model {
|
||||
config.model = Some(model.to_string());
|
||||
}
|
||||
if profile.read_only {
|
||||
config
|
||||
.sandbox_policy
|
||||
.set(SandboxPolicy::new_read_only_policy())
|
||||
.map_err(|err| format!("sandbox_policy is invalid: {err}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ use codex_otel::OtelManager;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
@@ -64,6 +65,8 @@ use crate::model_provider_info::WireApi;
|
||||
use crate::tools::spec::create_tools_json_for_chat_completions_api;
|
||||
use crate::tools::spec::create_tools_json_for_responses_api;
|
||||
|
||||
pub const WEB_SEARCH_ELIGIBLE_HEADER: &str = "x-oai-web-search-eligible";
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ModelClientState {
|
||||
config: Arc<Config>,
|
||||
@@ -319,7 +322,7 @@ impl ModelClientSession {
|
||||
store_override: None,
|
||||
conversation_id: Some(conversation_id),
|
||||
session_source: Some(self.state.session_source.clone()),
|
||||
extra_headers: beta_feature_headers(&self.state.config),
|
||||
extra_headers: build_responses_headers(&self.state.config),
|
||||
compression,
|
||||
}
|
||||
}
|
||||
@@ -635,6 +638,21 @@ fn beta_feature_headers(config: &Config) -> ApiHeaderMap {
|
||||
headers
|
||||
}
|
||||
|
||||
fn build_responses_headers(config: &Config) -> ApiHeaderMap {
|
||||
let mut headers = beta_feature_headers(config);
|
||||
headers.insert(
|
||||
WEB_SEARCH_ELIGIBLE_HEADER,
|
||||
HeaderValue::from_static(
|
||||
if matches!(config.web_search_mode, Some(WebSearchMode::Disabled)) {
|
||||
"false"
|
||||
} else {
|
||||
"true"
|
||||
},
|
||||
),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
fn map_response_stream<S>(api_stream: S, otel_manager: OtelManager) -> ResponseStream
|
||||
where
|
||||
S: futures::Stream<Item = std::result::Result<ResponseEvent, ApiError>>
|
||||
|
||||
+26
-19
@@ -120,6 +120,7 @@ use crate::protocol::ReviewDecision;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::SessionConfiguredEvent;
|
||||
use crate::protocol::SkillErrorInfo;
|
||||
use crate::protocol::SkillInterface as ProtocolSkillInterface;
|
||||
use crate::protocol::SkillMetadata as ProtocolSkillMetadata;
|
||||
use crate::protocol::StreamErrorEvent;
|
||||
use crate::protocol::Submission;
|
||||
@@ -541,24 +542,12 @@ impl Session {
|
||||
web_search_mode: per_turn_config.web_search_mode,
|
||||
});
|
||||
|
||||
let base_instructions = if per_turn_config.features.enabled(Feature::Collab) {
|
||||
const COLLAB_INSTRUCTIONS: &str =
|
||||
include_str!("../templates/collab/experimental_prompt.md");
|
||||
let base = session_configuration
|
||||
.base_instructions
|
||||
.as_deref()
|
||||
.unwrap_or(model_info.base_instructions.as_str());
|
||||
Some(format!("{base}\n\n{COLLAB_INSTRUCTIONS}"))
|
||||
} else {
|
||||
session_configuration.base_instructions.clone()
|
||||
};
|
||||
|
||||
TurnContext {
|
||||
sub_id,
|
||||
client,
|
||||
cwd: session_configuration.cwd.clone(),
|
||||
developer_instructions: session_configuration.developer_instructions.clone(),
|
||||
base_instructions,
|
||||
base_instructions: session_configuration.base_instructions.clone(),
|
||||
compact_prompt: session_configuration.compact_prompt.clone(),
|
||||
user_instructions: session_configuration.user_instructions.clone(),
|
||||
approval_policy: session_configuration.approval_policy.value(),
|
||||
@@ -2076,10 +2065,13 @@ mod handlers {
|
||||
codex_protocol::approvals::ElicitationAction::Decline => ElicitationAction::Decline,
|
||||
codex_protocol::approvals::ElicitationAction::Cancel => ElicitationAction::Cancel,
|
||||
};
|
||||
let response = ElicitationResponse {
|
||||
action,
|
||||
content: None,
|
||||
// When accepting, send an empty object as content to satisfy MCP servers
|
||||
// that expect non-null content on Accept. For Decline/Cancel, content is None.
|
||||
let content = match action {
|
||||
ElicitationAction::Accept => Some(serde_json::json!({})),
|
||||
ElicitationAction::Decline | ElicitationAction::Cancel => None,
|
||||
};
|
||||
let response = ElicitationResponse { action, content };
|
||||
if let Err(err) = sess
|
||||
.resolve_elicitation(server_name, request_id, response)
|
||||
.await
|
||||
@@ -2260,6 +2252,7 @@ mod handlers {
|
||||
Arc::clone(&turn_context),
|
||||
vec![UserInput::Text {
|
||||
text: turn_context.compact_prompt().to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
CompactTask,
|
||||
)
|
||||
@@ -2415,7 +2408,7 @@ async fn spawn_review_thread(
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &review_model_info,
|
||||
features: &review_features,
|
||||
web_search_mode: review_web_search_mode,
|
||||
web_search_mode: Some(review_web_search_mode),
|
||||
});
|
||||
|
||||
let base_instructions = REVIEW_PROMPT.to_string();
|
||||
@@ -2428,7 +2421,7 @@ async fn spawn_review_thread(
|
||||
let mut per_turn_config = (*config).clone();
|
||||
per_turn_config.model = Some(model.clone());
|
||||
per_turn_config.features = review_features.clone();
|
||||
per_turn_config.web_search_mode = review_web_search_mode;
|
||||
per_turn_config.web_search_mode = Some(review_web_search_mode);
|
||||
|
||||
let otel_manager = parent_turn_context
|
||||
.client
|
||||
@@ -2470,6 +2463,7 @@ async fn spawn_review_thread(
|
||||
// Seed the child task with the review prompt as the initial user message.
|
||||
let input: Vec<UserInput> = vec![UserInput::Text {
|
||||
text: review_prompt,
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
let tc = Arc::new(review_turn_context);
|
||||
sess.spawn_task(tc.clone(), input, ReviewTask::new()).await;
|
||||
@@ -2490,6 +2484,17 @@ fn skills_to_info(skills: &[SkillMetadata]) -> Vec<ProtocolSkillMetadata> {
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
short_description: skill.short_description.clone(),
|
||||
interface: skill
|
||||
.interface
|
||||
.clone()
|
||||
.map(|interface| ProtocolSkillInterface {
|
||||
display_name: interface.display_name,
|
||||
short_description: interface.short_description,
|
||||
icon_small: interface.icon_small,
|
||||
icon_large: interface.icon_large,
|
||||
brand_color: interface.brand_color,
|
||||
default_prompt: interface.default_prompt,
|
||||
}),
|
||||
path: skill.path.clone(),
|
||||
scope: skill.scope,
|
||||
})
|
||||
@@ -2960,7 +2965,6 @@ async fn try_run_turn(
|
||||
should_emit_turn_diff = true;
|
||||
|
||||
needs_follow_up |= sess.has_pending_input().await;
|
||||
error!("needs_follow_up: {needs_follow_up}");
|
||||
|
||||
break Ok(TurnRunResult {
|
||||
needs_follow_up,
|
||||
@@ -3964,6 +3968,7 @@ mod tests {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
let input = vec![UserInput::Text {
|
||||
text: "hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
sess.spawn_task(
|
||||
Arc::clone(&tc),
|
||||
@@ -3993,6 +3998,7 @@ mod tests {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
let input = vec![UserInput::Text {
|
||||
text: "hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
sess.spawn_task(
|
||||
Arc::clone(&tc),
|
||||
@@ -4019,6 +4025,7 @@ mod tests {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
let input = vec![UserInput::Text {
|
||||
text: "start review".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
sess.spawn_task(Arc::clone(&tc), input, ReviewTask::new())
|
||||
.await;
|
||||
|
||||
@@ -44,7 +44,11 @@ pub(crate) async fn run_inline_auto_compact_task(
|
||||
turn_context: Arc<TurnContext>,
|
||||
) {
|
||||
let prompt = turn_context.compact_prompt().to_string();
|
||||
let input = vec![UserInput::Text { text: prompt }];
|
||||
let input = vec![UserInput::Text {
|
||||
text: prompt,
|
||||
// Plain text conversion has no UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
|
||||
run_compact_task_inner(sess, turn_context, input).await;
|
||||
}
|
||||
|
||||
@@ -1124,6 +1124,7 @@ gpt-5 = "gpt-5.1"
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: Some(vec!["one".to_string(), "two".to_string()]),
|
||||
@@ -1145,6 +1146,7 @@ gpt-5 = "gpt-5.1"
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(std::time::Duration::from_secs(5)),
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -1209,6 +1211,7 @@ foo = { command = "cmd" }
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -1252,6 +1255,7 @@ foo = { command = "cmd" } # keep me
|
||||
cwd: None,
|
||||
},
|
||||
enabled: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -1294,6 +1298,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -1337,6 +1342,7 @@ foo = { command = "cmd" }
|
||||
cwd: None,
|
||||
},
|
||||
enabled: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::auth::AuthCredentialsStoreMode;
|
||||
use crate::config::types::DEFAULT_OTEL_ENVIRONMENT;
|
||||
use crate::config::types::History;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::McpServerDisabledReason;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use crate::config::types::Notice;
|
||||
use crate::config::types::Notifications;
|
||||
@@ -19,6 +20,7 @@ use crate::config_loader::ConfigRequirements;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
use crate::config_loader::McpServerIdentity;
|
||||
use crate::config_loader::McpServerRequirement;
|
||||
use crate::config_loader::Sourced;
|
||||
use crate::config_loader::load_config_layers_state;
|
||||
use crate::features::Feature;
|
||||
use crate::features::FeatureOverrides;
|
||||
@@ -337,7 +339,8 @@ pub struct Config {
|
||||
/// model info's default preference.
|
||||
pub include_apply_patch_tool: bool,
|
||||
|
||||
pub web_search_mode: WebSearchMode,
|
||||
/// Explicit or feature-derived web search mode.
|
||||
pub web_search_mode: Option<WebSearchMode>,
|
||||
|
||||
/// If set to `true`, used only the experimental unified exec tool.
|
||||
pub use_experimental_unified_exec_tool: bool,
|
||||
@@ -539,25 +542,32 @@ fn deserialize_config_toml_with_base(
|
||||
|
||||
fn filter_mcp_servers_by_requirements(
|
||||
mcp_servers: &mut HashMap<String, McpServerConfig>,
|
||||
mcp_requirements: Option<&BTreeMap<String, McpServerRequirement>>,
|
||||
mcp_requirements: Option<&Sourced<BTreeMap<String, McpServerRequirement>>>,
|
||||
) {
|
||||
let Some(allowlist) = mcp_requirements else {
|
||||
return;
|
||||
};
|
||||
|
||||
let source = allowlist.source.clone();
|
||||
for (name, server) in mcp_servers.iter_mut() {
|
||||
let allowed = allowlist
|
||||
.value
|
||||
.get(name)
|
||||
.is_some_and(|requirement| mcp_server_matches_requirement(requirement, server));
|
||||
if !allowed {
|
||||
if allowed {
|
||||
server.disabled_reason = None;
|
||||
} else {
|
||||
server.enabled = false;
|
||||
server.disabled_reason = Some(McpServerDisabledReason::Requirements {
|
||||
source: source.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn constrain_mcp_servers(
|
||||
mcp_servers: HashMap<String, McpServerConfig>,
|
||||
mcp_requirements: Option<&BTreeMap<String, McpServerRequirement>>,
|
||||
mcp_requirements: Option<&Sourced<BTreeMap<String, McpServerRequirement>>>,
|
||||
) -> ConstraintResult<Constrained<HashMap<String, McpServerConfig>>> {
|
||||
if mcp_requirements.is_none() {
|
||||
return Ok(Constrained::allow_any(mcp_servers));
|
||||
@@ -1182,24 +1192,22 @@ pub fn resolve_oss_provider(
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the web search mode from the config, profile, and features.
|
||||
/// Resolve the web search mode from explicit config and feature flags.
|
||||
fn resolve_web_search_mode(
|
||||
config_toml: &ConfigToml,
|
||||
config_profile: &ConfigProfile,
|
||||
features: &Features,
|
||||
) -> WebSearchMode {
|
||||
// Enum gets precedence over features flags
|
||||
) -> Option<WebSearchMode> {
|
||||
if let Some(mode) = config_profile.web_search.or(config_toml.web_search) {
|
||||
return mode;
|
||||
return Some(mode);
|
||||
}
|
||||
if features.enabled(Feature::WebSearchCached) {
|
||||
return WebSearchMode::Cached;
|
||||
return Some(WebSearchMode::Cached);
|
||||
}
|
||||
if features.enabled(Feature::WebSearchRequest) {
|
||||
return WebSearchMode::Live;
|
||||
return Some(WebSearchMode::Live);
|
||||
}
|
||||
// Fall back to default
|
||||
WebSearchMode::default()
|
||||
None
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -1707,6 +1715,7 @@ mod tests {
|
||||
use crate::config::types::HistoryPersistence;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use crate::config::types::Notifications;
|
||||
use crate::config_loader::RequirementSource;
|
||||
use crate::features::Feature;
|
||||
|
||||
use super::*;
|
||||
@@ -1728,6 +1737,7 @@ mod tests {
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -1744,6 +1754,7 @@ mod tests {
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -1976,9 +1987,9 @@ trust_level = "trusted"
|
||||
(MATCHED_URL_SERVER.to_string(), http_mcp(GOOD_URL)),
|
||||
(DIFFERENT_NAME_SERVER.to_string(), stdio_mcp("same-cmd")),
|
||||
]);
|
||||
filter_mcp_servers_by_requirements(
|
||||
&mut servers,
|
||||
Some(&BTreeMap::from([
|
||||
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
|
||||
let requirements = Sourced::new(
|
||||
BTreeMap::from([
|
||||
(
|
||||
MISMATCHED_URL_SERVER.to_string(),
|
||||
McpServerRequirement {
|
||||
@@ -2011,20 +2022,29 @@ trust_level = "trusted"
|
||||
},
|
||||
},
|
||||
),
|
||||
])),
|
||||
]),
|
||||
source.clone(),
|
||||
);
|
||||
filter_mcp_servers_by_requirements(&mut servers, Some(&requirements));
|
||||
|
||||
let reason = Some(McpServerDisabledReason::Requirements { source });
|
||||
assert_eq!(
|
||||
servers
|
||||
.iter()
|
||||
.map(|(name, server)| (name.clone(), server.enabled))
|
||||
.collect::<HashMap<String, bool>>(),
|
||||
.map(|(name, server)| (
|
||||
name.clone(),
|
||||
(server.enabled, server.disabled_reason.clone())
|
||||
))
|
||||
.collect::<HashMap<String, (bool, Option<McpServerDisabledReason>)>>(),
|
||||
HashMap::from([
|
||||
(MISMATCHED_URL_SERVER.to_string(), false),
|
||||
(MISMATCHED_COMMAND_SERVER.to_string(), false),
|
||||
(MATCHED_URL_SERVER.to_string(), true),
|
||||
(MATCHED_COMMAND_SERVER.to_string(), true),
|
||||
(DIFFERENT_NAME_SERVER.to_string(), false),
|
||||
(MISMATCHED_URL_SERVER.to_string(), (false, reason.clone())),
|
||||
(
|
||||
MISMATCHED_COMMAND_SERVER.to_string(),
|
||||
(false, reason.clone()),
|
||||
),
|
||||
(MATCHED_URL_SERVER.to_string(), (true, None)),
|
||||
(MATCHED_COMMAND_SERVER.to_string(), (true, None)),
|
||||
(DIFFERENT_NAME_SERVER.to_string(), (false, reason)),
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -2041,11 +2061,14 @@ trust_level = "trusted"
|
||||
assert_eq!(
|
||||
servers
|
||||
.iter()
|
||||
.map(|(name, server)| (name.clone(), server.enabled))
|
||||
.collect::<HashMap<String, bool>>(),
|
||||
.map(|(name, server)| (
|
||||
name.clone(),
|
||||
(server.enabled, server.disabled_reason.clone())
|
||||
))
|
||||
.collect::<HashMap<String, (bool, Option<McpServerDisabledReason>)>>(),
|
||||
HashMap::from([
|
||||
("server-a".to_string(), true),
|
||||
("server-b".to_string(), true),
|
||||
("server-a".to_string(), (true, None)),
|
||||
("server-b".to_string(), (true, None)),
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -2057,16 +2080,22 @@ trust_level = "trusted"
|
||||
("server-b".to_string(), http_mcp("https://example.com/b")),
|
||||
]);
|
||||
|
||||
filter_mcp_servers_by_requirements(&mut servers, Some(&BTreeMap::new()));
|
||||
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
|
||||
let requirements = Sourced::new(BTreeMap::new(), source.clone());
|
||||
filter_mcp_servers_by_requirements(&mut servers, Some(&requirements));
|
||||
|
||||
let reason = Some(McpServerDisabledReason::Requirements { source });
|
||||
assert_eq!(
|
||||
servers
|
||||
.iter()
|
||||
.map(|(name, server)| (name.clone(), server.enabled))
|
||||
.collect::<HashMap<String, bool>>(),
|
||||
.map(|(name, server)| (
|
||||
name.clone(),
|
||||
(server.enabled, server.disabled_reason.clone())
|
||||
))
|
||||
.collect::<HashMap<String, (bool, Option<McpServerDisabledReason>)>>(),
|
||||
HashMap::from([
|
||||
("server-a".to_string(), false),
|
||||
("server-b".to_string(), false),
|
||||
("server-a".to_string(), (false, reason.clone())),
|
||||
("server-b".to_string(), (false, reason)),
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -2202,15 +2231,12 @@ trust_level = "trusted"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_search_mode_uses_default_if_unset() {
|
||||
fn web_search_mode_uses_none_if_unset() {
|
||||
let cfg = ConfigToml::default();
|
||||
let profile = ConfigProfile::default();
|
||||
let features = Features::with_defaults();
|
||||
|
||||
assert_eq!(
|
||||
resolve_web_search_mode(&cfg, &profile, &features),
|
||||
WebSearchMode::default()
|
||||
);
|
||||
assert_eq!(resolve_web_search_mode(&cfg, &profile, &features), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2225,7 +2251,7 @@ trust_level = "trusted"
|
||||
|
||||
assert_eq!(
|
||||
resolve_web_search_mode(&cfg, &profile, &features),
|
||||
WebSearchMode::Live
|
||||
Some(WebSearchMode::Live)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2241,7 +2267,7 @@ trust_level = "trusted"
|
||||
|
||||
assert_eq!(
|
||||
resolve_web_search_mode(&cfg, &profile, &features),
|
||||
WebSearchMode::Disabled
|
||||
Some(WebSearchMode::Disabled)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2491,6 +2517,7 @@ trust_level = "trusted"
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(Duration::from_secs(3)),
|
||||
tool_timeout_sec: Some(Duration::from_secs(5)),
|
||||
enabled_tools: None,
|
||||
@@ -2644,6 +2671,7 @@ bearer_token = "secret"
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -2712,6 +2740,7 @@ ZIG_VAR = "3"
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -2760,6 +2789,7 @@ ZIG_VAR = "3"
|
||||
cwd: Some(cwd_path.clone()),
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -2806,6 +2836,7 @@ ZIG_VAR = "3"
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(Duration::from_secs(2)),
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -2868,6 +2899,7 @@ startup_timeout_sec = 2.0
|
||||
)])),
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(Duration::from_secs(2)),
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -2942,6 +2974,7 @@ X-Auth = "DOCS_AUTH"
|
||||
)])),
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(Duration::from_secs(2)),
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -2969,6 +3002,7 @@ X-Auth = "DOCS_AUTH"
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -3034,6 +3068,7 @@ url = "https://example.com/mcp"
|
||||
)])),
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(Duration::from_secs(2)),
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -3051,6 +3086,7 @@ url = "https://example.com/mcp"
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -3131,6 +3167,7 @@ url = "https://example.com/mcp"
|
||||
cwd: None,
|
||||
},
|
||||
enabled: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -3173,6 +3210,7 @@ url = "https://example.com/mcp"
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: Some(vec!["allowed".to_string()]),
|
||||
@@ -3581,7 +3619,7 @@ model_verbosity = "high"
|
||||
forced_chatgpt_workspace_id: None,
|
||||
forced_login_method: None,
|
||||
include_apply_patch_tool: false,
|
||||
web_search_mode: WebSearchMode::default(),
|
||||
web_search_mode: None,
|
||||
use_experimental_unified_exec_tool: false,
|
||||
ghost_snapshot: GhostSnapshotConfig::default(),
|
||||
features: Features::with_defaults(),
|
||||
@@ -3668,7 +3706,7 @@ model_verbosity = "high"
|
||||
forced_chatgpt_workspace_id: None,
|
||||
forced_login_method: None,
|
||||
include_apply_patch_tool: false,
|
||||
web_search_mode: WebSearchMode::default(),
|
||||
web_search_mode: None,
|
||||
use_experimental_unified_exec_tool: false,
|
||||
ghost_snapshot: GhostSnapshotConfig::default(),
|
||||
features: Features::with_defaults(),
|
||||
@@ -3770,7 +3808,7 @@ model_verbosity = "high"
|
||||
forced_chatgpt_workspace_id: None,
|
||||
forced_login_method: None,
|
||||
include_apply_patch_tool: false,
|
||||
web_search_mode: WebSearchMode::default(),
|
||||
web_search_mode: None,
|
||||
use_experimental_unified_exec_tool: false,
|
||||
ghost_snapshot: GhostSnapshotConfig::default(),
|
||||
features: Features::with_defaults(),
|
||||
@@ -3858,7 +3896,7 @@ model_verbosity = "high"
|
||||
forced_chatgpt_workspace_id: None,
|
||||
forced_login_method: None,
|
||||
include_apply_patch_tool: false,
|
||||
web_search_mode: WebSearchMode::default(),
|
||||
web_search_mode: None,
|
||||
use_experimental_unified_exec_tool: false,
|
||||
ghost_snapshot: GhostSnapshotConfig::default(),
|
||||
features: Features::with_defaults(),
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
// Note this file should generally be restricted to simple struct/enum
|
||||
// definitions that do not contain business logic.
|
||||
|
||||
use crate::config_loader::RequirementSource;
|
||||
pub use codex_protocol::config_types::AltScreenMode;
|
||||
pub use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use wildmatch::WildMatchPattern;
|
||||
@@ -20,6 +22,23 @@ use serde::de::Error as SerdeError;
|
||||
|
||||
pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum McpServerDisabledReason {
|
||||
Unknown,
|
||||
Requirements { source: RequirementSource },
|
||||
}
|
||||
|
||||
impl fmt::Display for McpServerDisabledReason {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
McpServerDisabledReason::Unknown => write!(f, "unknown"),
|
||||
McpServerDisabledReason::Requirements { source } => {
|
||||
write!(f, "requirements ({source})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct McpServerConfig {
|
||||
#[serde(flatten)]
|
||||
@@ -29,6 +48,10 @@ pub struct McpServerConfig {
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Reason this server was disabled after applying requirements.
|
||||
#[serde(skip)]
|
||||
pub disabled_reason: Option<McpServerDisabledReason>,
|
||||
|
||||
/// Startup timeout in seconds for initializing MCP server & initially listing tools.
|
||||
#[serde(
|
||||
default,
|
||||
@@ -160,6 +183,7 @@ impl<'de> Deserialize<'de> for McpServerConfig {
|
||||
startup_timeout_sec,
|
||||
tool_timeout_sec,
|
||||
enabled,
|
||||
disabled_reason: None,
|
||||
enabled_tools,
|
||||
disabled_tools,
|
||||
})
|
||||
|
||||
@@ -44,7 +44,7 @@ impl fmt::Display for RequirementSource {
|
||||
pub struct ConfigRequirements {
|
||||
pub approval_policy: Constrained<AskForApproval>,
|
||||
pub sandbox_policy: Constrained<SandboxPolicy>,
|
||||
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
|
||||
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
|
||||
}
|
||||
|
||||
impl Default for ConfigRequirements {
|
||||
@@ -273,7 +273,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
||||
Ok(ConfigRequirements {
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
mcp_servers: mcp_servers.map(|sourced| sourced.value),
|
||||
mcp_servers,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -571,24 +571,27 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
requirements.mcp_servers,
|
||||
Some(BTreeMap::from([
|
||||
(
|
||||
"docs".to_string(),
|
||||
McpServerRequirement {
|
||||
identity: McpServerIdentity::Command {
|
||||
command: "codex-mcp".to_string(),
|
||||
Some(Sourced::new(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"docs".to_string(),
|
||||
McpServerRequirement {
|
||||
identity: McpServerIdentity::Command {
|
||||
command: "codex-mcp".to_string(),
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
(
|
||||
"remote".to_string(),
|
||||
McpServerRequirement {
|
||||
identity: McpServerIdentity::Url {
|
||||
url: "https://example.com/mcp".to_string(),
|
||||
),
|
||||
(
|
||||
"remote".to_string(),
|
||||
McpServerRequirement {
|
||||
identity: McpServerIdentity::Url {
|
||||
url: "https://example.com/mcp".to_string(),
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
]))
|
||||
),
|
||||
]),
|
||||
RequirementSource::Unknown,
|
||||
))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ pub use config_requirements::McpServerIdentity;
|
||||
pub use config_requirements::McpServerRequirement;
|
||||
pub use config_requirements::RequirementSource;
|
||||
pub use config_requirements::SandboxModeRequirement;
|
||||
pub use config_requirements::Sourced;
|
||||
pub use merge::merge_toml_values;
|
||||
pub(crate) use overrides::build_cli_overrides_layer;
|
||||
pub use state::ConfigLayerEntry;
|
||||
|
||||
@@ -50,7 +50,11 @@ fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
|
||||
if is_session_prefix(text) || is_user_shell_command_text(text) {
|
||||
return None;
|
||||
}
|
||||
content.push(UserInput::Text { text: text.clone() });
|
||||
content.push(UserInput::Text {
|
||||
text: text.clone(),
|
||||
// Plain text conversion has no UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
});
|
||||
}
|
||||
ContentItem::InputImage { image_url } => {
|
||||
content.push(UserInput::Image {
|
||||
@@ -179,6 +183,7 @@ mod tests {
|
||||
let expected_content = vec![
|
||||
UserInput::Text {
|
||||
text: "Hello world".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
UserInput::Image { image_url: img1 },
|
||||
UserInput::Image { image_url: img2 },
|
||||
@@ -218,7 +223,10 @@ mod tests {
|
||||
TurnItem::UserMessage(user) => {
|
||||
let expected_content = vec![
|
||||
UserInput::Image { image_url },
|
||||
UserInput::Text { text: user_text },
|
||||
UserInput::Text {
|
||||
text: user_text,
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
];
|
||||
assert_eq!(user.content, expected_content);
|
||||
}
|
||||
@@ -255,7 +263,10 @@ mod tests {
|
||||
TurnItem::UserMessage(user) => {
|
||||
let expected_content = vec![
|
||||
UserInput::Image { image_url },
|
||||
UserInput::Text { text: user_text },
|
||||
UserInput::Text {
|
||||
text: user_text,
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
];
|
||||
assert_eq!(user.content, expected_content);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ mod user_shell_command;
|
||||
pub mod util;
|
||||
|
||||
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
|
||||
pub use client::WEB_SEARCH_ELIGIBLE_HEADER;
|
||||
pub use command_safety::is_dangerous_command;
|
||||
pub use command_safety::is_safe_command;
|
||||
pub use exec_policy::ExecPolicyError;
|
||||
|
||||
@@ -1171,6 +1171,7 @@ mod tests {
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -1215,6 +1216,7 @@ mod tests {
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
|
||||
@@ -28,6 +28,7 @@ use super::policy::is_persisted_response_item;
|
||||
use crate::config::Config;
|
||||
use crate::default_client::originator;
|
||||
use crate::git_info::collect_git_info;
|
||||
use crate::path_utils;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::ResumedHistory;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
@@ -113,6 +114,35 @@ impl RolloutRecorder {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Find the newest recorded thread path, optionally filtering to a matching cwd.
|
||||
pub async fn find_latest_thread_path(
|
||||
codex_home: &Path,
|
||||
allowed_sources: &[SessionSource],
|
||||
model_providers: Option<&[String]>,
|
||||
default_provider: &str,
|
||||
filter_cwd: Option<&Path>,
|
||||
) -> std::io::Result<Option<PathBuf>> {
|
||||
let mut cursor: Option<Cursor> = None;
|
||||
loop {
|
||||
let page = Self::list_threads(
|
||||
codex_home,
|
||||
25,
|
||||
cursor.as_ref(),
|
||||
allowed_sources,
|
||||
model_providers,
|
||||
default_provider,
|
||||
)
|
||||
.await?;
|
||||
if let Some(path) = select_resume_path(&page, filter_cwd) {
|
||||
return Ok(Some(path));
|
||||
}
|
||||
cursor = page.next_cursor;
|
||||
if cursor.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to create a new [`RolloutRecorder`]. If the sessions directory
|
||||
/// cannot be created or the rollout file cannot be opened we return the
|
||||
/// error so the caller can decide whether to disable persistence.
|
||||
@@ -431,3 +461,36 @@ impl JsonlWriter {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn select_resume_path(page: &ThreadsPage, filter_cwd: Option<&Path>) -> Option<PathBuf> {
|
||||
match filter_cwd {
|
||||
Some(cwd) => page.items.iter().find_map(|item| {
|
||||
if session_cwd_matches(&item.head, cwd) {
|
||||
Some(item.path.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
None => page.items.first().map(|item| item.path.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn session_cwd_matches(head: &[serde_json::Value], cwd: &Path) -> bool {
|
||||
let Some(session_cwd) = extract_session_cwd(head) else {
|
||||
return false;
|
||||
};
|
||||
if let (Ok(ca), Ok(cb)) = (
|
||||
path_utils::normalize_for_path_comparison(&session_cwd),
|
||||
path_utils::normalize_for_path_comparison(cwd),
|
||||
) {
|
||||
return ca == cb;
|
||||
}
|
||||
session_cwd == cwd
|
||||
}
|
||||
|
||||
fn extract_session_cwd(head: &[serde_json::Value]) -> Option<PathBuf> {
|
||||
head.iter().find_map(|value| {
|
||||
let meta_line = serde_json::from_value::<SessionMetaLine>(value.clone()).ok()?;
|
||||
Some(meta_line.meta.cwd)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -603,6 +603,8 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> {
|
||||
item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "hello".into(),
|
||||
images: None,
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
})),
|
||||
};
|
||||
writeln!(file, "{}", serde_json::to_string(&user_event_line)?)?;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::config::Config;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::skills::model::SkillError;
|
||||
use crate::skills::model::SkillInterface;
|
||||
use crate::skills::model::SkillLoadOutcome;
|
||||
use crate::skills::model::SkillMetadata;
|
||||
use crate::skills::system::system_cache_root_dir;
|
||||
@@ -13,6 +14,7 @@ use std::collections::VecDeque;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tracing::error;
|
||||
@@ -31,11 +33,29 @@ struct SkillFrontmatterMetadata {
|
||||
short_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct SkillToml {
|
||||
#[serde(default)]
|
||||
interface: Option<Interface>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct Interface {
|
||||
display_name: Option<String>,
|
||||
short_description: Option<String>,
|
||||
icon_small: Option<PathBuf>,
|
||||
icon_large: Option<PathBuf>,
|
||||
brand_color: Option<String>,
|
||||
default_prompt: Option<String>,
|
||||
}
|
||||
|
||||
const SKILLS_FILENAME: &str = "SKILL.md";
|
||||
const SKILLS_TOML_FILENAME: &str = "SKILL.toml";
|
||||
const SKILLS_DIR_NAME: &str = "skills";
|
||||
const MAX_NAME_LEN: usize = 64;
|
||||
const MAX_DESCRIPTION_LEN: usize = 1024;
|
||||
const MAX_SHORT_DESCRIPTION_LEN: usize = MAX_DESCRIPTION_LEN;
|
||||
const MAX_DEFAULT_PROMPT_LEN: usize = MAX_DESCRIPTION_LEN;
|
||||
// Traversal depth from the skills root.
|
||||
const MAX_SCAN_DEPTH: usize = 6;
|
||||
const MAX_SKILLS_DIRS_PER_ROOT: usize = 2000;
|
||||
@@ -195,7 +215,7 @@ fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut Skil
|
||||
}
|
||||
}
|
||||
|
||||
// Follow symlinks for user, admin, and repo skills. System skills are written by Codex itself.
|
||||
// Follow symlinked directories for user, admin, and repo skills. System skills are written by Codex itself.
|
||||
let follow_symlinks = matches!(
|
||||
scope,
|
||||
SkillScope::Repo | SkillScope::User | SkillScope::Admin
|
||||
@@ -262,20 +282,6 @@ fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut Skil
|
||||
continue;
|
||||
}
|
||||
|
||||
if metadata.is_file() && file_name == SKILLS_FILENAME {
|
||||
match parse_skill_file(&path, scope) {
|
||||
Ok(skill) => outcome.skills.push(skill),
|
||||
Err(err) => {
|
||||
if scope != SkillScope::System {
|
||||
outcome.errors.push(SkillError {
|
||||
path,
|
||||
message: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -336,11 +342,12 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result<SkillMetadata, Ski
|
||||
.as_deref()
|
||||
.map(sanitize_single_line)
|
||||
.filter(|value| !value.is_empty());
|
||||
let interface = load_skill_interface(path);
|
||||
|
||||
validate_field(&name, MAX_NAME_LEN, "name")?;
|
||||
validate_field(&description, MAX_DESCRIPTION_LEN, "description")?;
|
||||
validate_len(&name, MAX_NAME_LEN, "name")?;
|
||||
validate_len(&description, MAX_DESCRIPTION_LEN, "description")?;
|
||||
if let Some(short_description) = short_description.as_deref() {
|
||||
validate_field(
|
||||
validate_len(
|
||||
short_description,
|
||||
MAX_SHORT_DESCRIPTION_LEN,
|
||||
"metadata.short-description",
|
||||
@@ -353,16 +360,115 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result<SkillMetadata, Ski
|
||||
name,
|
||||
description,
|
||||
short_description,
|
||||
interface,
|
||||
path: resolved_path,
|
||||
scope,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_skill_interface(skill_path: &Path) -> Option<SkillInterface> {
|
||||
// Fail open: optional SKILL.toml metadata should not block loading SKILL.md.
|
||||
let skill_dir = skill_path.parent()?;
|
||||
let interface_path = skill_dir.join(SKILLS_TOML_FILENAME);
|
||||
if !interface_path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let contents = match fs::read_to_string(&interface_path) {
|
||||
Ok(contents) => contents,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"ignoring {path}: failed to read SKILL.toml: {error}",
|
||||
path = interface_path.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let parsed: SkillToml = match toml::from_str(&contents) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"ignoring {path}: invalid TOML: {error}",
|
||||
path = interface_path.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let interface = parsed.interface?;
|
||||
|
||||
let interface = SkillInterface {
|
||||
display_name: resolve_str(
|
||||
interface.display_name,
|
||||
MAX_NAME_LEN,
|
||||
"interface.display_name",
|
||||
),
|
||||
short_description: resolve_str(
|
||||
interface.short_description,
|
||||
MAX_SHORT_DESCRIPTION_LEN,
|
||||
"interface.short_description",
|
||||
),
|
||||
icon_small: resolve_asset_path(skill_dir, "interface.icon_small", interface.icon_small),
|
||||
icon_large: resolve_asset_path(skill_dir, "interface.icon_large", interface.icon_large),
|
||||
brand_color: resolve_color_str(interface.brand_color, "interface.brand_color"),
|
||||
default_prompt: resolve_str(
|
||||
interface.default_prompt,
|
||||
MAX_DEFAULT_PROMPT_LEN,
|
||||
"interface.default_prompt",
|
||||
),
|
||||
};
|
||||
let has_fields = interface.display_name.is_some()
|
||||
|| interface.short_description.is_some()
|
||||
|| interface.icon_small.is_some()
|
||||
|| interface.icon_large.is_some()
|
||||
|| interface.brand_color.is_some()
|
||||
|| interface.default_prompt.is_some();
|
||||
if has_fields { Some(interface) } else { None }
|
||||
}
|
||||
|
||||
fn resolve_asset_path(
|
||||
skill_dir: &Path,
|
||||
field: &'static str,
|
||||
path: Option<PathBuf>,
|
||||
) -> Option<PathBuf> {
|
||||
// Icons must be relative paths under the skill's assets/ directory; otherwise return None.
|
||||
let path = path?;
|
||||
if path.as_os_str().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let assets_dir = skill_dir.join("assets");
|
||||
if path.is_absolute() {
|
||||
tracing::warn!(
|
||||
"ignoring {field}: icon must be a relative assets path (not {})",
|
||||
assets_dir.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut components = path.components().peekable();
|
||||
while matches!(components.peek(), Some(Component::CurDir)) {
|
||||
components.next();
|
||||
}
|
||||
match components.next() {
|
||||
Some(Component::Normal(component)) if component == "assets" => {}
|
||||
_ => {
|
||||
tracing::warn!("ignoring {field}: icon path must be under assets/");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if components.any(|component| matches!(component, Component::ParentDir)) {
|
||||
tracing::warn!("ignoring {field}: icon path must not contain '..'");
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(skill_dir.join(path))
|
||||
}
|
||||
|
||||
fn sanitize_single_line(raw: &str) -> String {
|
||||
raw.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn validate_field(
|
||||
fn validate_len(
|
||||
value: &str,
|
||||
max_len: usize,
|
||||
field_name: &'static str,
|
||||
@@ -379,6 +485,36 @@ fn validate_field(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_str(value: Option<String>, max_len: usize, field: &'static str) -> Option<String> {
|
||||
let value = value?;
|
||||
let value = sanitize_single_line(&value);
|
||||
if value.is_empty() {
|
||||
tracing::warn!("ignoring {field}: value is empty");
|
||||
return None;
|
||||
}
|
||||
if value.chars().count() > max_len {
|
||||
tracing::warn!("ignoring {field}: exceeds maximum length of {max_len} characters");
|
||||
return None;
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn resolve_color_str(value: Option<String>, field: &'static str) -> Option<String> {
|
||||
let value = value?;
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
tracing::warn!("ignoring {field}: value is empty");
|
||||
return None;
|
||||
}
|
||||
let mut chars = value.chars();
|
||||
if value.len() == 7 && chars.next() == Some('#') && chars.all(|c| c.is_ascii_hexdigit()) {
|
||||
Some(value.to_string())
|
||||
} else {
|
||||
tracing::warn!("ignoring {field}: expected #RRGGBB, got {value}");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_frontmatter(contents: &str) -> Option<String> {
|
||||
let mut lines = contents.lines();
|
||||
if !matches!(lines.next(), Some(line) if line.trim() == "---") {
|
||||
@@ -528,6 +664,224 @@ mod tests {
|
||||
path
|
||||
}
|
||||
|
||||
fn write_skill_interface_at(skill_dir: &Path, contents: &str) -> PathBuf {
|
||||
let path = skill_dir.join(SKILLS_TOML_FILENAME);
|
||||
fs::write(&path, contents).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loads_skill_interface_metadata_happy_path() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from toml");
|
||||
let skill_dir = skill_path.parent().expect("skill dir");
|
||||
let normalized_skill_dir = normalized(skill_dir);
|
||||
|
||||
write_skill_interface_at(
|
||||
skill_dir,
|
||||
r##"
|
||||
[interface]
|
||||
display_name = "UI Skill"
|
||||
short_description = " short desc "
|
||||
icon_small = "./assets/small-400px.png"
|
||||
icon_large = "./assets/large-logo.svg"
|
||||
brand_color = "#3B82F6"
|
||||
default_prompt = " default prompt "
|
||||
"##,
|
||||
);
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills(&cfg);
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "ui-skill".to_string(),
|
||||
description: "from toml".to_string(),
|
||||
short_description: None,
|
||||
interface: Some(SkillInterface {
|
||||
display_name: Some("UI Skill".to_string()),
|
||||
short_description: Some("short desc".to_string()),
|
||||
icon_small: Some(normalized_skill_dir.join("./assets/small-400px.png")),
|
||||
icon_large: Some(normalized_skill_dir.join("./assets/large-logo.svg")),
|
||||
brand_color: Some("#3B82F6".to_string()),
|
||||
default_prompt: Some("default prompt".to_string()),
|
||||
}),
|
||||
path: normalized(&skill_path),
|
||||
scope: SkillScope::User,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_icon_paths_under_assets_dir() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from toml");
|
||||
let skill_dir = skill_path.parent().expect("skill dir");
|
||||
let normalized_skill_dir = normalized(skill_dir);
|
||||
|
||||
write_skill_interface_at(
|
||||
skill_dir,
|
||||
r#"
|
||||
[interface]
|
||||
display_name = "UI Skill"
|
||||
icon_small = "assets/icon.png"
|
||||
icon_large = "./assets/logo.svg"
|
||||
"#,
|
||||
);
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills(&cfg);
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "ui-skill".to_string(),
|
||||
description: "from toml".to_string(),
|
||||
short_description: None,
|
||||
interface: Some(SkillInterface {
|
||||
display_name: Some("UI Skill".to_string()),
|
||||
short_description: None,
|
||||
icon_small: Some(normalized_skill_dir.join("assets/icon.png")),
|
||||
icon_large: Some(normalized_skill_dir.join("./assets/logo.svg")),
|
||||
brand_color: None,
|
||||
default_prompt: None,
|
||||
}),
|
||||
path: normalized(&skill_path),
|
||||
scope: SkillScope::User,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ignores_invalid_brand_color() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from toml");
|
||||
let skill_dir = skill_path.parent().expect("skill dir");
|
||||
|
||||
write_skill_interface_at(
|
||||
skill_dir,
|
||||
r#"
|
||||
[interface]
|
||||
brand_color = "blue"
|
||||
"#,
|
||||
);
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills(&cfg);
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "ui-skill".to_string(),
|
||||
description: "from toml".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&skill_path),
|
||||
scope: SkillScope::User,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ignores_default_prompt_over_max_length() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from toml");
|
||||
let skill_dir = skill_path.parent().expect("skill dir");
|
||||
let normalized_skill_dir = normalized(skill_dir);
|
||||
let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1);
|
||||
|
||||
write_skill_interface_at(
|
||||
skill_dir,
|
||||
&format!(
|
||||
r##"
|
||||
[interface]
|
||||
display_name = "UI Skill"
|
||||
icon_small = "./assets/small-400px.png"
|
||||
default_prompt = "{too_long}"
|
||||
"##
|
||||
),
|
||||
);
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills(&cfg);
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "ui-skill".to_string(),
|
||||
description: "from toml".to_string(),
|
||||
short_description: None,
|
||||
interface: Some(SkillInterface {
|
||||
display_name: Some("UI Skill".to_string()),
|
||||
short_description: None,
|
||||
icon_small: Some(normalized_skill_dir.join("./assets/small-400px.png")),
|
||||
icon_large: None,
|
||||
brand_color: None,
|
||||
default_prompt: None,
|
||||
}),
|
||||
path: normalized(&skill_path),
|
||||
scope: SkillScope::User,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drops_interface_when_icons_are_invalid() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from toml");
|
||||
let skill_dir = skill_path.parent().expect("skill dir");
|
||||
|
||||
write_skill_interface_at(
|
||||
skill_dir,
|
||||
r#"
|
||||
[interface]
|
||||
icon_small = "icon.png"
|
||||
icon_large = "./assets/../logo.svg"
|
||||
"#,
|
||||
);
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills(&cfg);
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "ui-skill".to_string(),
|
||||
description: "from toml".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&skill_path),
|
||||
scope: SkillScope::User,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn symlink_dir(target: &Path, link: &Path) {
|
||||
std::os::unix::fs::symlink(target, link).unwrap();
|
||||
@@ -563,6 +917,7 @@ mod tests {
|
||||
name: "linked-skill".to_string(),
|
||||
description: "from link".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&shared_skill_path),
|
||||
scope: SkillScope::User,
|
||||
}]
|
||||
@@ -571,7 +926,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(unix)]
|
||||
async fn loads_skills_via_symlinked_skill_file_for_user_scope() {
|
||||
async fn ignores_symlinked_skill_file_for_user_scope() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let shared = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
@@ -590,16 +945,7 @@ mod tests {
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "linked-file-skill".to_string(),
|
||||
description: "from link".to_string(),
|
||||
short_description: None,
|
||||
path: normalized(&shared_skill_path),
|
||||
scope: SkillScope::User,
|
||||
}]
|
||||
);
|
||||
assert_eq!(outcome.skills, Vec::new());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -629,6 +975,7 @@ mod tests {
|
||||
name: "cycle-skill".to_string(),
|
||||
description: "still loads".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&skill_path),
|
||||
scope: SkillScope::User,
|
||||
}]
|
||||
@@ -662,6 +1009,7 @@ mod tests {
|
||||
name: "admin-linked-skill".to_string(),
|
||||
description: "from link".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&shared_skill_path),
|
||||
scope: SkillScope::Admin,
|
||||
}]
|
||||
@@ -699,6 +1047,7 @@ mod tests {
|
||||
name: "repo-linked-skill".to_string(),
|
||||
description: "from link".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&linked_skill_path),
|
||||
scope: SkillScope::Repo,
|
||||
}]
|
||||
@@ -759,6 +1108,7 @@ mod tests {
|
||||
name: "within-depth-skill".to_string(),
|
||||
description: "loads".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&within_depth_path),
|
||||
scope: SkillScope::User,
|
||||
}]
|
||||
@@ -783,6 +1133,7 @@ mod tests {
|
||||
name: "demo-skill".to_string(),
|
||||
description: "does things carefully".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&skill_path),
|
||||
scope: SkillScope::User,
|
||||
}]
|
||||
@@ -811,6 +1162,7 @@ mod tests {
|
||||
name: "demo-skill".to_string(),
|
||||
description: "long description".to_string(),
|
||||
short_description: Some("short summary".to_string()),
|
||||
interface: None,
|
||||
path: normalized(&skill_path),
|
||||
scope: SkillScope::User,
|
||||
}]
|
||||
@@ -920,6 +1272,7 @@ mod tests {
|
||||
name: "repo-skill".to_string(),
|
||||
description: "from repo".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&skill_path),
|
||||
scope: SkillScope::Repo,
|
||||
}]
|
||||
@@ -970,6 +1323,7 @@ mod tests {
|
||||
name: "nested-skill".to_string(),
|
||||
description: "from nested".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&nested_skill_path),
|
||||
scope: SkillScope::Repo,
|
||||
},
|
||||
@@ -977,6 +1331,7 @@ mod tests {
|
||||
name: "root-skill".to_string(),
|
||||
description: "from root".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&root_skill_path),
|
||||
scope: SkillScope::Repo,
|
||||
},
|
||||
@@ -1013,6 +1368,7 @@ mod tests {
|
||||
name: "local-skill".to_string(),
|
||||
description: "from cwd".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&skill_path),
|
||||
scope: SkillScope::Repo,
|
||||
}]
|
||||
@@ -1050,6 +1406,7 @@ mod tests {
|
||||
name: "dupe-skill".to_string(),
|
||||
description: "from repo".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&repo_skill_path),
|
||||
scope: SkillScope::Repo,
|
||||
}]
|
||||
@@ -1077,6 +1434,7 @@ mod tests {
|
||||
name: "dupe-skill".to_string(),
|
||||
description: "from user".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&user_skill_path),
|
||||
scope: SkillScope::User,
|
||||
}]
|
||||
@@ -1145,6 +1503,7 @@ mod tests {
|
||||
name: "repo-skill".to_string(),
|
||||
description: "from repo".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&skill_path),
|
||||
scope: SkillScope::Repo,
|
||||
}]
|
||||
@@ -1200,6 +1559,7 @@ mod tests {
|
||||
name: "system-skill".to_string(),
|
||||
description: "from system".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&skill_path),
|
||||
scope: SkillScope::System,
|
||||
}]
|
||||
@@ -1254,6 +1614,7 @@ mod tests {
|
||||
name: "dupe-skill".to_string(),
|
||||
description: "from system".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&system_skill_path),
|
||||
scope: SkillScope::System,
|
||||
}]
|
||||
@@ -1283,6 +1644,7 @@ mod tests {
|
||||
name: "dupe-skill".to_string(),
|
||||
description: "from user".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&user_skill_path),
|
||||
scope: SkillScope::User,
|
||||
}]
|
||||
@@ -1321,6 +1683,7 @@ mod tests {
|
||||
name: "dupe-skill".to_string(),
|
||||
description: "from repo".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: normalized(&repo_skill_path),
|
||||
scope: SkillScope::Repo,
|
||||
}]
|
||||
@@ -1371,6 +1734,7 @@ mod tests {
|
||||
name: "dupe-skill".to_string(),
|
||||
description: "from nested".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
path: expected_path,
|
||||
scope: SkillScope::Repo,
|
||||
}],
|
||||
|
||||
@@ -7,10 +7,21 @@ pub struct SkillMetadata {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub short_description: Option<String>,
|
||||
pub interface: Option<SkillInterface>,
|
||||
pub path: PathBuf,
|
||||
pub scope: SkillScope,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SkillInterface {
|
||||
pub display_name: Option<String>,
|
||||
pub short_description: Option<String>,
|
||||
pub icon_small: Option<PathBuf>,
|
||||
pub icon_large: Option<PathBuf>,
|
||||
pub brand_color: Option<String>,
|
||||
pub default_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SkillError {
|
||||
pub path: PathBuf,
|
||||
|
||||
@@ -86,7 +86,7 @@ async fn start_review_conversation(
|
||||
let mut sub_agent_config = config.as_ref().clone();
|
||||
// Carry over review-only feature restrictions so the delegate cannot
|
||||
// re-enable blocked tools (web search, view image).
|
||||
sub_agent_config.web_search_mode = WebSearchMode::Disabled;
|
||||
sub_agent_config.web_search_mode = Some(WebSearchMode::Disabled);
|
||||
|
||||
// Set explicit review rubric for the sub-agent
|
||||
sub_agent_config.base_instructions = Some(crate::REVIEW_PROMPT.to_string());
|
||||
|
||||
@@ -76,11 +76,13 @@ impl ToolHandler for CollabHandler {
|
||||
|
||||
mod spawn {
|
||||
use super::*;
|
||||
use crate::agent::AgentRole;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentArgs {
|
||||
message: String,
|
||||
agent_type: Option<AgentRole>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -95,6 +97,7 @@ mod spawn {
|
||||
arguments: String,
|
||||
) -> Result<ToolOutput, FunctionCallError> {
|
||||
let args: SpawnAgentArgs = parse_arguments(&arguments)?;
|
||||
let agent_role = args.agent_type.unwrap_or(AgentRole::Default);
|
||||
let prompt = args.message;
|
||||
if prompt.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
@@ -112,7 +115,10 @@ mod spawn {
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
let config = build_agent_spawn_config(turn.as_ref())?;
|
||||
let mut config = build_agent_spawn_config(turn.as_ref())?;
|
||||
agent_role
|
||||
.apply_to_config(&mut config)
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
let result = session
|
||||
.services
|
||||
.agent_control
|
||||
@@ -164,6 +170,8 @@ mod send_input {
|
||||
struct SendInputArgs {
|
||||
id: String,
|
||||
message: String,
|
||||
#[serde(default)]
|
||||
interrupt: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -185,6 +193,14 @@ mod send_input {
|
||||
"Empty message can't be sent to an agent".to_string(),
|
||||
));
|
||||
}
|
||||
if args.interrupt {
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.interrupt_agent(receiver_thread_id)
|
||||
.await
|
||||
.map_err(|err| collab_agent_error(receiver_thread_id, err))?;
|
||||
}
|
||||
session
|
||||
.send_event(
|
||||
&turn,
|
||||
@@ -717,6 +733,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_input_interrupts_before_prompt() {
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
session.services.agent_control = manager.agent_control();
|
||||
let config = turn.client.config().as_ref().clone();
|
||||
let thread = manager.start_thread(config).await.expect("start thread");
|
||||
let agent_id = thread.thread_id;
|
||||
let invocation = invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"send_input",
|
||||
function_payload(json!({
|
||||
"id": agent_id.to_string(),
|
||||
"message": "hi",
|
||||
"interrupt": true
|
||||
})),
|
||||
);
|
||||
CollabHandler
|
||||
.handle(invocation)
|
||||
.await
|
||||
.expect("send_input should succeed");
|
||||
|
||||
let ops = manager.captured_ops();
|
||||
let ops_for_agent: Vec<&Op> = ops
|
||||
.iter()
|
||||
.filter_map(|(id, op)| (*id == agent_id).then_some(op))
|
||||
.collect();
|
||||
assert_eq!(ops_for_agent.len(), 2);
|
||||
assert!(matches!(ops_for_agent[0], Op::Interrupt));
|
||||
assert!(matches!(ops_for_agent[1], Op::UserInput { .. }));
|
||||
|
||||
let _ = thread
|
||||
.thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_rejects_non_positive_timeout() {
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::agent::AgentRole;
|
||||
use crate::client_common::tools::ResponsesApiTool;
|
||||
use crate::client_common::tools::ToolSpec;
|
||||
use crate::features::Feature;
|
||||
@@ -24,7 +25,7 @@ use std::collections::HashMap;
|
||||
pub(crate) struct ToolsConfig {
|
||||
pub shell_type: ConfigShellToolType,
|
||||
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
|
||||
pub web_search_mode: WebSearchMode,
|
||||
pub web_search_mode: Option<WebSearchMode>,
|
||||
pub collab_tools: bool,
|
||||
pub experimental_supported_tools: Vec<String>,
|
||||
}
|
||||
@@ -32,7 +33,7 @@ pub(crate) struct ToolsConfig {
|
||||
pub(crate) struct ToolsConfigParams<'a> {
|
||||
pub(crate) model_info: &'a ModelInfo,
|
||||
pub(crate) features: &'a Features,
|
||||
pub(crate) web_search_mode: WebSearchMode,
|
||||
pub(crate) web_search_mode: Option<WebSearchMode>,
|
||||
}
|
||||
|
||||
impl ToolsConfig {
|
||||
@@ -441,6 +442,15 @@ fn create_spawn_agent_tool() -> ToolSpec {
|
||||
description: Some("Initial message to send to the new agent.".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"agent_type".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(format!(
|
||||
"Optional agent type to spawn ({}).",
|
||||
AgentRole::enum_values().join(", ")
|
||||
)),
|
||||
},
|
||||
);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "spawn_agent".to_string(),
|
||||
@@ -468,6 +478,15 @@ fn create_send_input_tool() -> ToolSpec {
|
||||
description: Some("Message to send to the agent.".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"interrupt".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"When true, interrupt the agent's current task before sending the message. When false (default), the message will be processed when the agent is done on its current task."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "send_input".to_string(),
|
||||
@@ -1225,17 +1244,17 @@ pub(crate) fn build_specs(
|
||||
}
|
||||
|
||||
match config.web_search_mode {
|
||||
WebSearchMode::Disabled => {}
|
||||
WebSearchMode::Cached => {
|
||||
Some(WebSearchMode::Cached) => {
|
||||
builder.push_spec(ToolSpec::WebSearch {
|
||||
external_web_access: Some(false),
|
||||
});
|
||||
}
|
||||
WebSearchMode::Live => {
|
||||
Some(WebSearchMode::Live) => {
|
||||
builder.push_spec(ToolSpec::WebSearch {
|
||||
external_web_access: Some(true),
|
||||
});
|
||||
}
|
||||
Some(WebSearchMode::Disabled) | None => {}
|
||||
}
|
||||
|
||||
builder.push_spec_with_parallel_support(create_view_image_tool(), true);
|
||||
@@ -1379,7 +1398,7 @@ mod tests {
|
||||
let config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: WebSearchMode::Live,
|
||||
web_search_mode: Some(WebSearchMode::Live),
|
||||
});
|
||||
let (tools, _) = build_specs(&config, None).build();
|
||||
|
||||
@@ -1441,7 +1460,7 @@ mod tests {
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: WebSearchMode::Cached,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None).build();
|
||||
assert_contains_tool_names(
|
||||
@@ -1453,7 +1472,7 @@ mod tests {
|
||||
fn assert_model_tools(
|
||||
model_slug: &str,
|
||||
features: &Features,
|
||||
web_search_mode: WebSearchMode,
|
||||
web_search_mode: Option<WebSearchMode>,
|
||||
expected_tools: &[&str],
|
||||
) {
|
||||
let config = test_config();
|
||||
@@ -1477,7 +1496,7 @@ mod tests {
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: WebSearchMode::Cached,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None).build();
|
||||
|
||||
@@ -1499,7 +1518,7 @@ mod tests {
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: WebSearchMode::Live,
|
||||
web_search_mode: Some(WebSearchMode::Live),
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None).build();
|
||||
|
||||
@@ -1517,7 +1536,7 @@ mod tests {
|
||||
assert_model_tools(
|
||||
"gpt-5-codex",
|
||||
&Features::with_defaults(),
|
||||
WebSearchMode::Cached,
|
||||
Some(WebSearchMode::Cached),
|
||||
&[
|
||||
"shell_command",
|
||||
"list_mcp_resources",
|
||||
@@ -1536,7 +1555,7 @@ mod tests {
|
||||
assert_model_tools(
|
||||
"gpt-5.1-codex",
|
||||
&Features::with_defaults(),
|
||||
WebSearchMode::Cached,
|
||||
Some(WebSearchMode::Cached),
|
||||
&[
|
||||
"shell_command",
|
||||
"list_mcp_resources",
|
||||
@@ -1555,7 +1574,7 @@ mod tests {
|
||||
assert_model_tools(
|
||||
"gpt-5-codex",
|
||||
Features::with_defaults().enable(Feature::UnifiedExec),
|
||||
WebSearchMode::Live,
|
||||
Some(WebSearchMode::Live),
|
||||
&[
|
||||
"exec_command",
|
||||
"write_stdin",
|
||||
@@ -1575,7 +1594,7 @@ mod tests {
|
||||
assert_model_tools(
|
||||
"gpt-5.1-codex",
|
||||
Features::with_defaults().enable(Feature::UnifiedExec),
|
||||
WebSearchMode::Live,
|
||||
Some(WebSearchMode::Live),
|
||||
&[
|
||||
"exec_command",
|
||||
"write_stdin",
|
||||
@@ -1595,7 +1614,7 @@ mod tests {
|
||||
assert_model_tools(
|
||||
"codex-mini-latest",
|
||||
&Features::with_defaults(),
|
||||
WebSearchMode::Cached,
|
||||
Some(WebSearchMode::Cached),
|
||||
&[
|
||||
"local_shell",
|
||||
"list_mcp_resources",
|
||||
@@ -1613,7 +1632,7 @@ mod tests {
|
||||
assert_model_tools(
|
||||
"gpt-5.1-codex-mini",
|
||||
&Features::with_defaults(),
|
||||
WebSearchMode::Cached,
|
||||
Some(WebSearchMode::Cached),
|
||||
&[
|
||||
"shell_command",
|
||||
"list_mcp_resources",
|
||||
@@ -1632,7 +1651,7 @@ mod tests {
|
||||
assert_model_tools(
|
||||
"gpt-5",
|
||||
&Features::with_defaults(),
|
||||
WebSearchMode::Cached,
|
||||
Some(WebSearchMode::Cached),
|
||||
&[
|
||||
"shell",
|
||||
"list_mcp_resources",
|
||||
@@ -1650,7 +1669,7 @@ mod tests {
|
||||
assert_model_tools(
|
||||
"gpt-5.1",
|
||||
&Features::with_defaults(),
|
||||
WebSearchMode::Cached,
|
||||
Some(WebSearchMode::Cached),
|
||||
&[
|
||||
"shell_command",
|
||||
"list_mcp_resources",
|
||||
@@ -1669,7 +1688,7 @@ mod tests {
|
||||
assert_model_tools(
|
||||
"exp-5.1",
|
||||
&Features::with_defaults(),
|
||||
WebSearchMode::Cached,
|
||||
Some(WebSearchMode::Cached),
|
||||
&[
|
||||
"exec_command",
|
||||
"write_stdin",
|
||||
@@ -1689,7 +1708,7 @@ mod tests {
|
||||
assert_model_tools(
|
||||
"codex-mini-latest",
|
||||
Features::with_defaults().enable(Feature::UnifiedExec),
|
||||
WebSearchMode::Live,
|
||||
Some(WebSearchMode::Live),
|
||||
&[
|
||||
"exec_command",
|
||||
"write_stdin",
|
||||
@@ -1712,7 +1731,7 @@ mod tests {
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: WebSearchMode::Live,
|
||||
web_search_mode: Some(WebSearchMode::Live),
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, Some(HashMap::new())).build();
|
||||
|
||||
@@ -1734,7 +1753,7 @@ mod tests {
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: WebSearchMode::Cached,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None).build();
|
||||
|
||||
@@ -1753,7 +1772,7 @@ mod tests {
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: WebSearchMode::Cached,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None).build();
|
||||
|
||||
@@ -1784,7 +1803,7 @@ mod tests {
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: WebSearchMode::Live,
|
||||
web_search_mode: Some(WebSearchMode::Live),
|
||||
});
|
||||
let (tools, _) = build_specs(
|
||||
&tools_config,
|
||||
@@ -1879,7 +1898,7 @@ mod tests {
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: WebSearchMode::Cached,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
|
||||
// Intentionally construct a map with keys that would sort alphabetically.
|
||||
@@ -1956,7 +1975,7 @@ mod tests {
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: WebSearchMode::Cached,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
|
||||
let (tools, _) = build_specs(
|
||||
@@ -2013,7 +2032,7 @@ mod tests {
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: WebSearchMode::Cached,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
|
||||
let (tools, _) = build_specs(
|
||||
@@ -2067,7 +2086,7 @@ mod tests {
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: WebSearchMode::Cached,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
|
||||
let (tools, _) = build_specs(
|
||||
@@ -2123,7 +2142,7 @@ mod tests {
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: WebSearchMode::Cached,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
|
||||
let (tools, _) = build_specs(
|
||||
@@ -2235,7 +2254,7 @@ Examples of valid command strings:
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: WebSearchMode::Cached,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
let (tools, _) = build_specs(
|
||||
&tools_config,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
You are Codex Orchestrator, based on GPT-5. You are running as an orchestration agent in the Codex CLI on a user's computer.
|
||||
|
||||
## Role
|
||||
- The interface between the user and the workers. Your role is to understand a problem and then delegate/coordinate workers to solve the task.
|
||||
- Monitor progress, resolve conflicts, and integrate results into a single, coherent outcome.
|
||||
- You can perform basic actions such as code exploration or running basic commands if needed to understand the problem, but you must delegate the hard work to workers.
|
||||
- If a task can be split in well scoped sub-tasks, use multiple workers to solve it, and you take care of the global orchestration.
|
||||
- Your job is not finished before the entire task is completed. While this is not the case, keep monitoring and coordinating your workers.
|
||||
- Do not rush the workers. If they are working, let them work and don't ask them to "finalize now" unless requested by the user.
|
||||
|
||||
## Multi-agent workflow
|
||||
|
||||
1. Understand the request and identify the optimal set of workers needed.
|
||||
2. Spawn worker(s) with precise goals, constraints, and expected deliverables.
|
||||
3. Monitor workers with `wait`, route questions via `send_input`, and keep scope boundaries clear.
|
||||
4. When all workers report done, verify their work to make sure the task was correctly solved.
|
||||
5. If you spot issues, assign fixes to the relevant worker(s) and repeat steps 3–5 until the task is correctly completed.
|
||||
6. Close all agents when you don't need them anymore (i.e. when the task if fully finished).
|
||||
|
||||
## Collaboration rules
|
||||
|
||||
- Tell every worker they are not alone in the environment and must not revert or overwrite others' work.
|
||||
- Default: workers must not spawn sub-agents unless you explicitly allow it.
|
||||
- When multiple workers are running, you can provide multiple ids to `wait` in order to wait for the first worker to finish. This will make your workflow event-based as the tool will return when the first agent is done (i.e. when you need to react on it).
|
||||
|
||||
## Collab tools
|
||||
|
||||
- `spawn_agent`: create a worker with an initial prompt (set `agent_type`).
|
||||
- `send_input`: send follow-ups, clarifications, or fix requests (`interrupt` can stop the current task first).
|
||||
- `wait`: poll the completion status of a list of workers. Return once at least one worker in the list is done.
|
||||
- `close_agent`: close the agent when done.
|
||||
|
||||
## Presenting your work and final message
|
||||
|
||||
- Keep responses concise, factual, and in plain text.
|
||||
- Summarize: what was delegated, key outcomes, tests/verification status, and any remaining risks.
|
||||
- If verification failed, state the issues clearly and what you asked workers to change.
|
||||
- Do not dump large files; reference paths with backticks.
|
||||
@@ -270,6 +270,7 @@ impl TestCodex {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: prompt.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: self.cwd.path().to_path_buf(),
|
||||
|
||||
@@ -9,15 +9,18 @@ use codex_core::ModelProviderInfo;
|
||||
use codex_core::Prompt;
|
||||
use codex_core::ResponseEvent;
|
||||
use codex_core::ResponseItem;
|
||||
use codex_core::WEB_SEARCH_ELIGIBLE_HEADER;
|
||||
use codex_core::WireApi;
|
||||
use codex_core::models_manager::manager::ModelsManager;
|
||||
use codex_otel::OtelManager;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use futures::StreamExt;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::matchers::header;
|
||||
@@ -213,6 +216,66 @@ async fn responses_stream_includes_subagent_header_on_other() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_stream_includes_web_search_eligible_header_true_by_default() {
|
||||
core_test_support::skip_if_no_network!();
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let response_body = responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]);
|
||||
|
||||
let request_recorder = responses::mount_sse_once_match(
|
||||
&server,
|
||||
header(WEB_SEARCH_ELIGIBLE_HEADER, "true"),
|
||||
response_body,
|
||||
)
|
||||
.await;
|
||||
|
||||
let test = test_codex().build(&server).await.expect("build test codex");
|
||||
test.submit_turn("hello").await.expect("submit test prompt");
|
||||
|
||||
let request = request_recorder.single_request();
|
||||
assert_eq!(
|
||||
request.header(WEB_SEARCH_ELIGIBLE_HEADER).as_deref(),
|
||||
Some("true")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_stream_includes_web_search_eligible_header_false_when_disabled() {
|
||||
core_test_support::skip_if_no_network!();
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let response_body = responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]);
|
||||
|
||||
let request_recorder = responses::mount_sse_once_match(
|
||||
&server,
|
||||
header(WEB_SEARCH_ELIGIBLE_HEADER, "false"),
|
||||
response_body,
|
||||
)
|
||||
.await;
|
||||
|
||||
let test = test_codex()
|
||||
.with_config(|config| {
|
||||
config.web_search_mode = Some(WebSearchMode::Disabled);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
.expect("build test codex");
|
||||
test.submit_turn("hello").await.expect("submit test prompt");
|
||||
|
||||
let request = request_recorder.single_request();
|
||||
assert_eq!(
|
||||
request.header(WEB_SEARCH_ELIGIBLE_HEADER).as_deref(),
|
||||
Some("false")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_respects_model_info_overrides_from_config() {
|
||||
core_test_support::skip_if_no_network!();
|
||||
|
||||
@@ -48,6 +48,7 @@ async fn interrupt_long_running_tool_emits_turn_aborted() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "start sleep".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -101,6 +102,7 @@ async fn interrupt_tool_records_history_entries() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "start history recording".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -118,6 +120,7 @@ async fn interrupt_tool_records_history_entries() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "follow up".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
|
||||
@@ -302,6 +302,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff(
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "rename without content change".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -888,6 +889,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<(
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "apply via shell heredoc with cd".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -965,6 +967,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() ->
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "apply patch via shell".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -1112,6 +1115,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff(
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "emit diff".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -1172,6 +1176,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change(
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "rename with change".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -1240,6 +1245,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()>
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "aggregate diffs".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -1308,6 +1314,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "apply patch twice with failure".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
|
||||
@@ -492,6 +492,7 @@ async fn submit_turn(
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: prompt.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
|
||||
@@ -14,6 +14,7 @@ use codex_core::ThreadManager;
|
||||
use codex_core::WireApi;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use codex_core::built_in_model_providers;
|
||||
use codex_core::default_client::originator;
|
||||
use codex_core::error::CodexErr;
|
||||
use codex_core::models_manager::manager::ModelsManager;
|
||||
use codex_core::protocol::EventMsg;
|
||||
@@ -289,6 +290,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -387,6 +389,7 @@ async fn includes_conversation_id_and_model_headers_in_request() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -404,7 +407,7 @@ async fn includes_conversation_id_and_model_headers_in_request() {
|
||||
let request_originator = request.header("originator").expect("originator header");
|
||||
|
||||
assert_eq!(request_session_id, session_id.to_string());
|
||||
assert_eq!(request_originator, "codex_cli_rs");
|
||||
assert_eq!(request_originator, originator().value);
|
||||
assert_eq!(request_authorization, "Bearer Test API Key");
|
||||
}
|
||||
|
||||
@@ -440,6 +443,7 @@ async fn includes_base_instructions_override_in_request() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -496,6 +500,7 @@ async fn chatgpt_auth_sends_correct_request() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -518,7 +523,7 @@ async fn chatgpt_auth_sends_correct_request() {
|
||||
let session_id = request.header("session_id").expect("session_id header");
|
||||
assert_eq!(session_id, thread_id.to_string());
|
||||
|
||||
assert_eq!(request_originator, "codex_cli_rs");
|
||||
assert_eq!(request_originator, originator().value);
|
||||
assert_eq!(request_authorization, "Bearer Access Token");
|
||||
assert_eq!(request_chatgpt_account_id, "account_id");
|
||||
assert!(request_body["stream"].as_bool().unwrap());
|
||||
@@ -588,6 +593,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -629,6 +635,7 @@ async fn includes_user_instructions_message_in_request() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -708,6 +715,7 @@ async fn skills_append_to_instructions() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -760,6 +768,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -797,6 +806,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -832,6 +842,7 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -871,6 +882,7 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -910,6 +922,7 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -943,6 +956,7 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -983,6 +997,7 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1022,6 +1037,7 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1077,6 +1093,7 @@ async fn includes_developer_instructions_message_in_request() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1326,6 +1343,7 @@ async fn token_count_includes_rate_limits_snapshot() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1484,6 +1502,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1554,6 +1573,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "seed turn".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1565,6 +1585,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "trigger context window".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1685,6 +1706,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1768,6 +1790,7 @@ async fn env_var_overrides_loaded_auth() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1840,7 +1863,10 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
|
||||
// Turn 1: user sends U1; wait for completion.
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text { text: "U1".into() }],
|
||||
items: vec![UserInput::Text {
|
||||
text: "U1".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
@@ -1850,7 +1876,10 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
|
||||
// Turn 2: user sends U2; wait for completion.
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text { text: "U2".into() }],
|
||||
items: vec![UserInput::Text {
|
||||
text: "U2".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
@@ -1860,7 +1889,10 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
|
||||
// Turn 3: user sends U3; wait for completion.
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text { text: "U3".into() }],
|
||||
items: vec![UserInput::Text {
|
||||
text: "U3".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -159,6 +159,7 @@ async fn summarize_context_three_requests_and_instructions() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello world".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -180,6 +181,7 @@ async fn summarize_context_three_requests_and_instructions() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: THIRD_USER_MSG.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -573,6 +575,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: user_message.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1049,6 +1052,7 @@ async fn auto_compact_runs_after_token_limit_hit() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: FIRST_AUTO_MSG.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1061,6 +1065,7 @@ async fn auto_compact_runs_after_token_limit_hit() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: SECOND_AUTO_MSG.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1073,6 +1078,7 @@ async fn auto_compact_runs_after_token_limit_hit() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: POST_AUTO_USER_MSG.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1273,6 +1279,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: follow_up_user.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: resumed.cwd.path().to_path_buf(),
|
||||
@@ -1382,6 +1389,7 @@ async fn auto_compact_persists_rollout_entries() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: FIRST_AUTO_MSG.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1393,6 +1401,7 @@ async fn auto_compact_persists_rollout_entries() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: SECOND_AUTO_MSG.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1404,6 +1413,7 @@ async fn auto_compact_persists_rollout_entries() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: POST_AUTO_USER_MSG.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1496,6 +1506,7 @@ async fn manual_compact_retries_after_context_window_error() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "first turn".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1629,6 +1640,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: first_user_message.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1643,6 +1655,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: second_user_message.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1657,6 +1670,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: final_user_message.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1835,7 +1849,10 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_
|
||||
for user in [MULTI_AUTO_MSG, follow_up_user, final_user] {
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text { text: user.into() }],
|
||||
items: vec![UserInput::Text {
|
||||
text: user.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
@@ -1948,6 +1965,7 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: FUNCTION_CALL_LIMIT_MSG.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1960,6 +1978,7 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: follow_up_user.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -2075,7 +2094,10 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() {
|
||||
{
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text { text: user.into() }],
|
||||
items: vec![UserInput::Text {
|
||||
text: user.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -73,6 +73,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello remote compact".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -86,6 +87,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "after compact".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -192,6 +194,7 @@ async fn remote_compact_runs_automatically() -> Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello remote compact".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -265,6 +268,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()>
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "needs compaction".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
|
||||
@@ -983,7 +983,10 @@ async fn start_test_conversation(
|
||||
async fn user_turn(conversation: &Arc<CodexThread>, text: &str) {
|
||||
conversation
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text { text: text.into() }],
|
||||
items: vec![UserInput::Text {
|
||||
text: text.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -72,6 +72,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "run shell command".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
|
||||
@@ -70,6 +70,7 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: text.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
|
||||
@@ -117,6 +117,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu
|
||||
},
|
||||
UserInput::Text {
|
||||
text: "pasted image".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
],
|
||||
final_output_json_schema: None,
|
||||
@@ -194,6 +195,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()>
|
||||
},
|
||||
UserInput::Text {
|
||||
text: "dropped image".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
],
|
||||
final_output_json_schema: None,
|
||||
|
||||
@@ -42,6 +42,7 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: (vec![UserInput::Text {
|
||||
text: "please inspect sample.txt".into(),
|
||||
text_elements: Vec::new(),
|
||||
}]),
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -69,12 +70,14 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> {
|
||||
started_item.content,
|
||||
vec![UserInput::Text {
|
||||
text: "please inspect sample.txt".into(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
completed_item.content,
|
||||
vec![UserInput::Text {
|
||||
text: "please inspect sample.txt".into(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
);
|
||||
Ok(())
|
||||
@@ -99,6 +102,7 @@ async fn assistant_message_item_is_emitted() -> anyhow::Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "please summarize results".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -156,6 +160,7 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "explain your reasoning".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -215,6 +220,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "find the weather".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -268,6 +274,7 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "please stream text".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -334,6 +341,7 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "reason through it".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -392,6 +400,7 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "show raw reasoning".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
|
||||
@@ -76,6 +76,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello world".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: Some(serde_json::from_str(SCHEMA)?),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
|
||||
@@ -36,7 +36,7 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec<String> {
|
||||
let mut builder = test_codex()
|
||||
.with_model(model)
|
||||
// Keep tool expectations stable when the default web_search mode changes.
|
||||
.with_config(|config| config.web_search_mode = WebSearchMode::Cached);
|
||||
.with_config(|config| config.web_search_mode = Some(WebSearchMode::Cached));
|
||||
let test = builder
|
||||
.build(&server)
|
||||
.await
|
||||
|
||||
@@ -87,7 +87,10 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> {
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text { text: "hi".into() }],
|
||||
items: vec![UserInput::Text {
|
||||
text: "hi".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: codex_core::protocol::AskForApproval::Never,
|
||||
|
||||
@@ -100,6 +100,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "please run a tool".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
|
||||
@@ -45,6 +45,7 @@ async fn responses_api_emits_api_request_event() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -87,6 +88,7 @@ async fn process_sse_emits_tracing_for_output_item() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -126,6 +128,7 @@ async fn process_sse_emits_failed_event_on_parse_error() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -166,6 +169,7 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed()
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -226,6 +230,7 @@ async fn process_sse_failed_event_records_response_error_message() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -284,6 +289,7 @@ async fn process_sse_failed_event_logs_parse_error() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -329,6 +335,7 @@ async fn process_sse_failed_event_logs_missing_error() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -383,6 +390,7 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -434,6 +442,7 @@ async fn process_sse_emits_completed_telemetry() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -502,6 +511,7 @@ async fn handle_responses_span_records_response_kind_and_tool_name() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -567,6 +577,7 @@ async fn record_responses_sets_span_fields_for_response_events() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -647,6 +658,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -715,6 +727,7 @@ async fn handle_response_item_records_tool_result_for_function_call() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -793,6 +806,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids()
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -855,6 +869,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -960,6 +975,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1009,6 +1025,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "approved".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1068,6 +1085,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision()
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "persist".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1127,6 +1145,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "retry".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1186,6 +1205,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "deny".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1245,6 +1265,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "persist".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -1305,6 +1326,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "deny".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
|
||||
@@ -100,6 +100,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "first prompt".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -115,6 +116,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "second prompt".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
|
||||
@@ -61,6 +61,7 @@ async fn permissions_message_sent_once_on_start() -> Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -93,6 +94,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -114,6 +116,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -152,6 +155,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -162,6 +166,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -203,6 +208,7 @@ async fn resume_replays_permissions_messages() -> Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -226,6 +232,7 @@ async fn resume_replays_permissions_messages() -> Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -238,6 +245,7 @@ async fn resume_replays_permissions_messages() -> Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "after resume".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -276,6 +284,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -299,6 +308,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -319,6 +329,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "after resume".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -346,6 +357,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "after fork".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -393,6 +405,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
|
||||
@@ -88,7 +88,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
|
||||
config.user_instructions = Some("be consistent and helpful".to_string());
|
||||
config.model = Some("gpt-5.1-codex-max".to_string());
|
||||
// Keep tool expectations stable when the default web_search mode changes.
|
||||
config.web_search_mode = WebSearchMode::Cached;
|
||||
config.web_search_mode = Some(WebSearchMode::Cached);
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
@@ -108,6 +108,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -118,6 +119,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -180,6 +182,7 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -190,6 +193,7 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -241,6 +245,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -251,6 +256,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -316,6 +322,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -345,6 +352,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -406,6 +414,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "first message".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -517,6 +526,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -536,6 +546,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: new_cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
@@ -627,6 +638,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: default_cwd.clone(),
|
||||
approval_policy: default_approval_policy,
|
||||
@@ -643,6 +655,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: default_cwd.clone(),
|
||||
approval_policy: default_approval_policy,
|
||||
@@ -720,6 +733,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: default_cwd.clone(),
|
||||
approval_policy: default_approval_policy,
|
||||
@@ -736,6 +750,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: default_cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
|
||||
@@ -43,6 +43,7 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "quota?".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
|
||||
@@ -165,6 +165,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "run call".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -369,6 +370,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello remote".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
|
||||
@@ -39,6 +39,7 @@ async fn request_body_is_zstd_compressed_for_codex_backend_when_enabled() -> any
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "compress me".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -82,6 +83,7 @@ async fn request_body_is_not_compressed_for_api_key_auth_even_when_enabled() ->
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "do not compress".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
|
||||
@@ -36,6 +36,7 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "Record some messages".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -89,6 +90,7 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()>
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "Record reasoning messages".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
|
||||
@@ -707,6 +707,7 @@ async fn review_history_surfaces_in_parent_session() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: followup.clone(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
|
||||
@@ -88,6 +88,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(Duration::from_secs(10)),
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -108,6 +109,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "call the rmcp echo tool".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
@@ -224,6 +226,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(Duration::from_secs(10)),
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -244,6 +247,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "call the rmcp image tool".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
@@ -418,6 +422,7 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> {
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(Duration::from_secs(10)),
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -438,6 +443,7 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "call the rmcp image tool".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
@@ -560,6 +566,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(Duration::from_secs(10)),
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -580,6 +587,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "call the rmcp echo tool".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
@@ -713,6 +721,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(Duration::from_secs(10)),
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -733,6 +742,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "call the rmcp streamable http echo tool".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
@@ -898,6 +908,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> {
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(Duration::from_secs(10)),
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -918,6 +929,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "call the rmcp streamable http oauth echo tool".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
|
||||
@@ -71,6 +71,7 @@ async fn run_snapshot_command(command: &str) -> Result<SnapshotRun> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "run unified exec with shell snapshot".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd,
|
||||
@@ -147,6 +148,7 @@ async fn run_shell_command_snapshot(command: &str) -> Result<SnapshotRun> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "run shell_command with shell snapshot".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd,
|
||||
@@ -284,6 +286,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "apply patch via shell_command with snapshot".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.clone(),
|
||||
|
||||
@@ -64,6 +64,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> {
|
||||
items: vec![
|
||||
UserInput::Text {
|
||||
text: "please use $demo".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
UserInput::Skill {
|
||||
name: "demo".to_string(),
|
||||
|
||||
@@ -88,6 +88,7 @@ async fn continue_after_stream_error() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "first message".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
@@ -106,6 +107,7 @@ async fn continue_after_stream_error() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "follow up".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
|
||||
@@ -95,6 +95,7 @@ async fn retries_on_early_close() {
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
|
||||
@@ -82,6 +82,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()>
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "please run the shell command".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -148,6 +149,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "please update the plan".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -224,6 +226,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "please update the plan".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -312,6 +315,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<()
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "please apply a patch".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -408,6 +412,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "please apply a patch".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
|
||||
@@ -38,6 +38,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: prompt.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
@@ -353,6 +354,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "stream delayed completion".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
|
||||
@@ -426,6 +426,7 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()>
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(std::time::Duration::from_secs(10)),
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -517,6 +518,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> {
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(Duration::from_secs(10)),
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
@@ -536,6 +538,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "call the rmcp image tool".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
@@ -776,6 +779,7 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> {
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(std::time::Duration::from_secs(10)),
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
|
||||
@@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "apply patch via unified exec".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd,
|
||||
@@ -326,6 +327,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "emit begin event".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -401,6 +403,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "run relative workdir test".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -479,6 +482,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "run workdir test".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -569,6 +573,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "emit end event".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -641,6 +646,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "emit delta".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -714,6 +720,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "exercise full unified exec lifecycle".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -840,6 +847,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()>
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "stdin delta".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -973,6 +981,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<(
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "delayed terminal interaction output".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -1130,6 +1139,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "check poll event behavior".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -1225,6 +1235,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "run metadata test".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -1340,6 +1351,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "check default pipe mode".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -1427,6 +1439,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "check tty enabled".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -1505,6 +1518,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "watch early exit timing".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -1633,6 +1647,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "test write_stdin exit behavior".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -1798,6 +1813,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<()
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "end on exit".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -1872,6 +1888,7 @@ async fn unified_exec_closes_long_running_session_at_turn_end() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "close unified exec processes on turn end".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -1990,6 +2007,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "run unified exec".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -2122,6 +2140,7 @@ PY
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "exercise lag handling".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -2233,6 +2252,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "check timeout".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -2326,6 +2346,7 @@ PY
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "summarize large output".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -2404,6 +2425,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "summarize large output".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -2506,6 +2528,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "start python under seatbelt".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -2598,6 +2621,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "summarize large output".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -2728,6 +2752,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "fill session cache".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
|
||||
@@ -60,6 +60,7 @@ echo -n "${@: -1}" > $(dirname "${0}")/notify.txt"#,
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello world".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
|
||||
@@ -171,6 +171,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "please add the screenshot".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -301,6 +302,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "please attach the folder".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -373,6 +375,7 @@ async fn view_image_tool_placeholder_for_non_image_files() -> anyhow::Result<()>
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "please use the view_image tool to read the json file".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
@@ -464,6 +467,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "please attach the missing image".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
|
||||
@@ -35,7 +35,7 @@ async fn web_search_mode_cached_sets_external_web_access_false_in_request_body()
|
||||
let mut builder = test_codex()
|
||||
.with_model("gpt-5-codex")
|
||||
.with_config(|config| {
|
||||
config.web_search_mode = WebSearchMode::Cached;
|
||||
config.web_search_mode = Some(WebSearchMode::Cached);
|
||||
});
|
||||
let test = builder
|
||||
.build(&server)
|
||||
@@ -67,7 +67,7 @@ async fn web_search_mode_takes_precedence_over_legacy_flags_in_request_body() {
|
||||
.with_model("gpt-5-codex")
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::WebSearchRequest);
|
||||
config.web_search_mode = WebSearchMode::Cached;
|
||||
config.web_search_mode = Some(WebSearchMode::Cached);
|
||||
});
|
||||
let test = builder
|
||||
.build(&server)
|
||||
|
||||
@@ -184,7 +184,11 @@ impl AppServerClient {
|
||||
request_id: request_id.clone(),
|
||||
params: TurnStartParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
input: vec![UserInput::Text { text }],
|
||||
input: vec![UserInput::Text {
|
||||
text,
|
||||
// Plain text conversion has no UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
@@ -105,6 +105,25 @@ While a conversation runs, the server sends notifications:
|
||||
|
||||
Clients should render events and, when present, surface approval requests (see next section).
|
||||
|
||||
## Tool responses
|
||||
|
||||
The `codex` and `codex-reply` tools return standard MCP `CallToolResult` payloads. For
|
||||
compatibility with MCP clients that prefer `structuredContent`, Codex mirrors the
|
||||
assistant text inside `structuredContent` alongside the `threadId`.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": [{ "type": "text", "text": "Hello from Codex" }],
|
||||
"structuredContent": {
|
||||
"threadId": "019bbed6-1e9e-7f31-984c-a05b65045719",
|
||||
"text": "Hello from Codex",
|
||||
"content": [{ "type": "text", "text": "Hello from Codex" }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Approvals (server → client)
|
||||
|
||||
When Codex needs approval to apply changes or run commands, the server issues JSON‑RPC requests to the client:
|
||||
|
||||
+164
-3
@@ -360,6 +360,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
.collect();
|
||||
items.push(UserInput::Text {
|
||||
text: prompt_text.clone(),
|
||||
text_elements: Vec::new(),
|
||||
});
|
||||
let output_schema = load_output_schema(output_schema_path.clone());
|
||||
(
|
||||
@@ -378,6 +379,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
.collect();
|
||||
items.push(UserInput::Text {
|
||||
text: prompt_text.clone(),
|
||||
text_elements: Vec::new(),
|
||||
});
|
||||
let output_schema = load_output_schema(output_schema_path);
|
||||
(
|
||||
@@ -555,6 +557,79 @@ fn load_output_schema(path: Option<PathBuf>) -> Option<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum PromptDecodeError {
|
||||
InvalidUtf8 { valid_up_to: usize },
|
||||
InvalidUtf16 { encoding: &'static str },
|
||||
UnsupportedBom { encoding: &'static str },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PromptDecodeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
PromptDecodeError::InvalidUtf8 { valid_up_to } => write!(
|
||||
f,
|
||||
"input is not valid UTF-8 (invalid byte at offset {valid_up_to}). Convert it to UTF-8 and retry (e.g., `iconv -f <ENC> -t UTF-8 prompt.txt`)."
|
||||
),
|
||||
PromptDecodeError::InvalidUtf16 { encoding } => write!(
|
||||
f,
|
||||
"input looked like {encoding} but could not be decoded. Convert it to UTF-8 and retry."
|
||||
),
|
||||
PromptDecodeError::UnsupportedBom { encoding } => write!(
|
||||
f,
|
||||
"input appears to be {encoding}. Convert it to UTF-8 and retry."
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_prompt_bytes(input: &[u8]) -> Result<String, PromptDecodeError> {
|
||||
let input = input.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(input);
|
||||
|
||||
if input.starts_with(&[0xFF, 0xFE, 0x00, 0x00]) {
|
||||
return Err(PromptDecodeError::UnsupportedBom {
|
||||
encoding: "UTF-32LE",
|
||||
});
|
||||
}
|
||||
|
||||
if input.starts_with(&[0x00, 0x00, 0xFE, 0xFF]) {
|
||||
return Err(PromptDecodeError::UnsupportedBom {
|
||||
encoding: "UTF-32BE",
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(rest) = input.strip_prefix(&[0xFF, 0xFE]) {
|
||||
return decode_utf16(rest, "UTF-16LE", u16::from_le_bytes);
|
||||
}
|
||||
|
||||
if let Some(rest) = input.strip_prefix(&[0xFE, 0xFF]) {
|
||||
return decode_utf16(rest, "UTF-16BE", u16::from_be_bytes);
|
||||
}
|
||||
|
||||
std::str::from_utf8(input)
|
||||
.map(str::to_string)
|
||||
.map_err(|e| PromptDecodeError::InvalidUtf8 {
|
||||
valid_up_to: e.valid_up_to(),
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_utf16(
|
||||
input: &[u8],
|
||||
encoding: &'static str,
|
||||
decode_unit: fn([u8; 2]) -> u16,
|
||||
) -> Result<String, PromptDecodeError> {
|
||||
if !input.len().is_multiple_of(2) {
|
||||
return Err(PromptDecodeError::InvalidUtf16 { encoding });
|
||||
}
|
||||
|
||||
let units: Vec<u16> = input
|
||||
.chunks_exact(2)
|
||||
.map(|chunk| decode_unit([chunk[0], chunk[1]]))
|
||||
.collect();
|
||||
|
||||
String::from_utf16(&units).map_err(|_| PromptDecodeError::InvalidUtf16 { encoding })
|
||||
}
|
||||
|
||||
fn resolve_prompt(prompt_arg: Option<String>) -> String {
|
||||
match prompt_arg {
|
||||
Some(p) if p != "-" => p,
|
||||
@@ -571,11 +646,22 @@ fn resolve_prompt(prompt_arg: Option<String>) -> String {
|
||||
if !force_stdin {
|
||||
eprintln!("Reading prompt from stdin...");
|
||||
}
|
||||
let mut buffer = String::new();
|
||||
if let Err(e) = std::io::stdin().read_to_string(&mut buffer) {
|
||||
|
||||
let mut bytes = Vec::new();
|
||||
if let Err(e) = std::io::stdin().read_to_end(&mut bytes) {
|
||||
eprintln!("Failed to read prompt from stdin: {e}");
|
||||
std::process::exit(1);
|
||||
} else if buffer.trim().is_empty() {
|
||||
}
|
||||
|
||||
let buffer = match decode_prompt_bytes(&bytes) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to read prompt from stdin: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
if buffer.trim().is_empty() {
|
||||
eprintln!("No prompt provided via stdin.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -680,4 +766,79 @@ mod tests {
|
||||
|
||||
assert_eq!(request, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_prompt_bytes_strips_utf8_bom() {
|
||||
let input = [0xEF, 0xBB, 0xBF, b'h', b'i', b'\n'];
|
||||
|
||||
let out = decode_prompt_bytes(&input).expect("decode utf-8 with BOM");
|
||||
|
||||
assert_eq!(out, "hi\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_prompt_bytes_decodes_utf16le_bom() {
|
||||
// UTF-16LE BOM + "hi\n"
|
||||
let input = [0xFF, 0xFE, b'h', 0x00, b'i', 0x00, b'\n', 0x00];
|
||||
|
||||
let out = decode_prompt_bytes(&input).expect("decode utf-16le with BOM");
|
||||
|
||||
assert_eq!(out, "hi\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_prompt_bytes_decodes_utf16be_bom() {
|
||||
// UTF-16BE BOM + "hi\n"
|
||||
let input = [0xFE, 0xFF, 0x00, b'h', 0x00, b'i', 0x00, b'\n'];
|
||||
|
||||
let out = decode_prompt_bytes(&input).expect("decode utf-16be with BOM");
|
||||
|
||||
assert_eq!(out, "hi\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_prompt_bytes_rejects_utf32le_bom() {
|
||||
// UTF-32LE BOM + "hi\n"
|
||||
let input = [
|
||||
0xFF, 0xFE, 0x00, 0x00, b'h', 0x00, 0x00, 0x00, b'i', 0x00, 0x00, 0x00, b'\n', 0x00,
|
||||
0x00, 0x00,
|
||||
];
|
||||
|
||||
let err = decode_prompt_bytes(&input).expect_err("utf-32le should be rejected");
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
PromptDecodeError::UnsupportedBom {
|
||||
encoding: "UTF-32LE"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_prompt_bytes_rejects_utf32be_bom() {
|
||||
// UTF-32BE BOM + "hi\n"
|
||||
let input = [
|
||||
0x00, 0x00, 0xFE, 0xFF, 0x00, 0x00, 0x00, b'h', 0x00, 0x00, 0x00, b'i', 0x00, 0x00,
|
||||
0x00, b'\n',
|
||||
];
|
||||
|
||||
let err = decode_prompt_bytes(&input).expect_err("utf-32be should be rejected");
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
PromptDecodeError::UnsupportedBom {
|
||||
encoding: "UTF-32BE"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_prompt_bytes_rejects_invalid_utf8() {
|
||||
// Invalid UTF-8 sequence: 0xC3 0x28
|
||||
let input = [0xC3, 0x28];
|
||||
|
||||
let err = decode_prompt_bytes(&input).expect_err("invalid utf-8 should fail");
|
||||
|
||||
assert_eq!(err, PromptDecodeError::InvalidUtf8 { valid_up_to: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ libc = { workspace = true }
|
||||
seccompiler = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"io-std",
|
||||
|
||||
@@ -6,52 +6,3 @@ This crate is responsible for producing:
|
||||
- a lib crate that exposes the business logic of the executable as `run_main()` so that
|
||||
- the `codex-exec` CLI can check if its arg0 is `codex-linux-sandbox` and, if so, execute as if it were `codex-linux-sandbox`
|
||||
- this should also be true of the `codex` multitool CLI
|
||||
|
||||
## Git safety mounts (Linux)
|
||||
|
||||
When the sandbox policy allows workspace writes, the Linux sandbox uses a user
|
||||
namespace plus a mount namespace to bind-mount sensitive subpaths read-only
|
||||
before applying Landlock rules. This keeps Git and Codex metadata immutable
|
||||
while still allowing writes to other workspace files, including worktree setups
|
||||
where `.git` is a pointer file.
|
||||
|
||||
Protected subpaths under each writable root include:
|
||||
|
||||
- `.git` (directory or pointer file)
|
||||
- the resolved `gitdir:` target when `.git` is a pointer file
|
||||
- `.codex` when present
|
||||
|
||||
### How this plays with Landlock
|
||||
|
||||
Mount permissions and Landlock intersect: if a bind mount is read-only, writes
|
||||
are denied even if Landlock would allow them. For that reason, the sandbox sets
|
||||
up the read-only mounts *before* calling `landlock_restrict_self()` and then
|
||||
applies Landlock rules on top.
|
||||
|
||||
### Quick manual test
|
||||
|
||||
Run the sandbox directly with a workspace-write policy (from a Git repository
|
||||
root):
|
||||
|
||||
```bash
|
||||
codex-linux-sandbox \
|
||||
--sandbox-policy-cwd "$PWD" \
|
||||
--sandbox-policy '{"type":"workspace-write"}' \
|
||||
-- bash -lc '
|
||||
set -euo pipefail
|
||||
|
||||
echo "should fail" > .git/config && exit 1 || true
|
||||
echo "should fail" > .git/hooks/pre-commit && exit 1 || true
|
||||
echo "should fail" > .git/index.lock && exit 1 || true
|
||||
echo "should fail" > .codex/config.toml && exit 1 || true
|
||||
echo "ok" > sandbox-write-test.txt
|
||||
'
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- Writes to `.git/config` fail with `Read-only file system`.
|
||||
- Creating or modifying files under `.git/hooks/` fails.
|
||||
- Writing `.git/index.lock` fails (since `.git` is read-only).
|
||||
- Writes under `.codex/` fail when the directory exists.
|
||||
- Writing a normal repo file succeeds.
|
||||
|
||||
@@ -7,8 +7,6 @@ use codex_core::error::SandboxErr;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use crate::mounts::apply_read_only_mounts;
|
||||
|
||||
use landlock::ABI;
|
||||
use landlock::Access;
|
||||
use landlock::AccessFs;
|
||||
@@ -33,8 +31,8 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
cwd: &Path,
|
||||
) -> Result<()> {
|
||||
if !sandbox_policy.has_full_disk_write_access() {
|
||||
apply_read_only_mounts(sandbox_policy, cwd)?;
|
||||
if !sandbox_policy.has_full_disk_write_access() || !sandbox_policy.has_full_network_access() {
|
||||
set_no_new_privs()?;
|
||||
}
|
||||
|
||||
if !sandbox_policy.has_full_network_access() {
|
||||
@@ -56,6 +54,14 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_no_new_privs() -> Result<()> {
|
||||
let result = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) };
|
||||
if result != 0 {
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Installs Landlock file-system rules on the current thread allowing read
|
||||
/// access to the entire file-system while restricting write access to
|
||||
/// `/dev/null` and the provided list of `writable_roots`.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::path::Path;
|
||||
@@ -253,3 +255,85 @@ fn bind_mount_read_only(path: &Path) -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn collect_read_only_mount_targets_errors_on_missing_path() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let missing = AbsolutePathBuf::try_from(tempdir.path().join("missing").as_path())
|
||||
.expect("missing path");
|
||||
let root = AbsolutePathBuf::try_from(tempdir.path()).expect("root");
|
||||
let writable_root = WritableRoot {
|
||||
root,
|
||||
read_only_subpaths: vec![missing],
|
||||
};
|
||||
|
||||
let err = collect_read_only_mount_targets(&[writable_root])
|
||||
.expect_err("expected missing path error");
|
||||
let message = match err {
|
||||
CodexErr::UnsupportedOperation(message) => message,
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
};
|
||||
assert_eq!(
|
||||
message,
|
||||
format!(
|
||||
"Sandbox expected to protect {path}, but it does not exist. Ensure the repository contains this path or create it before running Codex.",
|
||||
path = tempdir.path().join("missing").display()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_read_only_mount_targets_adds_gitdir_for_pointer_file() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let gitdir = tempdir.path().join("actual-gitdir");
|
||||
std::fs::create_dir_all(&gitdir).expect("create gitdir");
|
||||
let dot_git = tempdir.path().join(".git");
|
||||
std::fs::write(&dot_git, format!("gitdir: {}\n", gitdir.display()))
|
||||
.expect("write gitdir pointer");
|
||||
let root = AbsolutePathBuf::try_from(tempdir.path()).expect("root");
|
||||
let writable_root = WritableRoot {
|
||||
root,
|
||||
read_only_subpaths: vec![
|
||||
AbsolutePathBuf::try_from(dot_git.as_path()).expect("dot git"),
|
||||
],
|
||||
};
|
||||
|
||||
let targets = collect_read_only_mount_targets(&[writable_root]).expect("collect targets");
|
||||
assert_eq!(targets.len(), 2);
|
||||
assert_eq!(targets[0].as_path(), dot_git.as_path());
|
||||
assert_eq!(targets[1].as_path(), gitdir.as_path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_read_only_mount_targets_errors_on_invalid_gitdir_pointer() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let dot_git = tempdir.path().join(".git");
|
||||
std::fs::write(&dot_git, "not-a-pointer\n").expect("write invalid pointer");
|
||||
let root = AbsolutePathBuf::try_from(tempdir.path()).expect("root");
|
||||
let writable_root = WritableRoot {
|
||||
root,
|
||||
read_only_subpaths: vec![
|
||||
AbsolutePathBuf::try_from(dot_git.as_path()).expect("dot git"),
|
||||
],
|
||||
};
|
||||
|
||||
let err = collect_read_only_mount_targets(&[writable_root])
|
||||
.expect_err("expected invalid pointer error");
|
||||
let message = match err {
|
||||
CodexErr::UnsupportedOperation(message) => message,
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
};
|
||||
assert_eq!(
|
||||
message,
|
||||
format!(
|
||||
"Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
|
||||
path = dot_git.display()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ use codex_core::exec_env::create_env;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::sandboxing::SandboxPermissions;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::process::Command;
|
||||
|
||||
// At least on GitHub CI, the arm64 tests appear to need longer timeouts.
|
||||
|
||||
@@ -36,8 +36,22 @@ fn create_env_from_core_vars() -> HashMap<String, String> {
|
||||
create_env(&policy)
|
||||
}
|
||||
|
||||
#[expect(clippy::print_stdout, clippy::expect_used, clippy::unwrap_used)]
|
||||
#[expect(clippy::print_stdout)]
|
||||
async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
|
||||
let output = run_cmd_output(cmd, writable_roots, timeout_ms).await;
|
||||
if output.exit_code != 0 {
|
||||
println!("stdout:\n{}", output.stdout.text);
|
||||
println!("stderr:\n{}", output.stderr.text);
|
||||
panic!("exit code: {}", output.exit_code);
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used, clippy::unwrap_used)]
|
||||
async fn run_cmd_output(
|
||||
cmd: &[&str],
|
||||
writable_roots: &[PathBuf],
|
||||
timeout_ms: u64,
|
||||
) -> codex_core::exec::ExecToolCallOutput {
|
||||
let cwd = std::env::current_dir().expect("cwd should exist");
|
||||
let sandbox_cwd = cwd.clone();
|
||||
let params = ExecParams {
|
||||
@@ -64,7 +78,8 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
|
||||
};
|
||||
let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox");
|
||||
let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program));
|
||||
let res = process_exec_tool_call(
|
||||
|
||||
process_exec_tool_call(
|
||||
params,
|
||||
&sandbox_policy,
|
||||
sandbox_cwd.as_path(),
|
||||
@@ -72,58 +87,7 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
if res.exit_code != 0 {
|
||||
println!("stdout:\n{}", res.stdout.text);
|
||||
println!("stderr:\n{}", res.stderr.text);
|
||||
panic!("exit code: {}", res.exit_code);
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
async fn assert_write_blocked(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
|
||||
let cwd = std::env::current_dir().expect("cwd should exist");
|
||||
let sandbox_cwd = cwd.clone();
|
||||
let params = ExecParams {
|
||||
command: cmd.iter().copied().map(str::to_owned).collect(),
|
||||
cwd,
|
||||
expiration: timeout_ms.into(),
|
||||
env: create_env_from_core_vars(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
justification: None,
|
||||
arg0: None,
|
||||
};
|
||||
|
||||
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: writable_roots
|
||||
.iter()
|
||||
.map(|p| AbsolutePathBuf::try_from(p.as_path()).unwrap())
|
||||
.collect(),
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox");
|
||||
let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program));
|
||||
let result = process_exec_tool_call(
|
||||
params,
|
||||
&sandbox_policy,
|
||||
sandbox_cwd.as_path(),
|
||||
&codex_linux_sandbox_exe,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(output) => {
|
||||
if output.exit_code == 0 {
|
||||
panic!("expected command to fail, but exit code was 0");
|
||||
}
|
||||
}
|
||||
Err(CodexErr::Sandbox(SandboxErr::Denied { .. })) => {}
|
||||
Err(err) => panic!("expected sandbox denial, got: {err:?}"),
|
||||
}
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -175,131 +139,20 @@ async fn test_writable_root() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_git_dir_write_blocked() {
|
||||
let tmpdir = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmpdir.path();
|
||||
Command::new("git")
|
||||
.arg("init")
|
||||
.arg(".")
|
||||
.current_dir(repo_root)
|
||||
.output()
|
||||
.await
|
||||
.expect("git init .");
|
||||
|
||||
let git_config = repo_root.join(".git").join("config");
|
||||
let git_index_lock = repo_root.join(".git").join("index.lock");
|
||||
|
||||
assert_write_blocked(
|
||||
&[
|
||||
"bash",
|
||||
"-lc",
|
||||
&format!("echo pwned > {}", git_config.to_string_lossy()),
|
||||
],
|
||||
&[repo_root.to_path_buf()],
|
||||
LONG_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_write_blocked(
|
||||
&[
|
||||
"bash",
|
||||
"-lc",
|
||||
&format!("echo pwned > {}", git_index_lock.to_string_lossy()),
|
||||
],
|
||||
&[repo_root.to_path_buf()],
|
||||
LONG_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_git_dir_move_blocked() {
|
||||
let tmpdir = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmpdir.path();
|
||||
Command::new("git")
|
||||
.arg("init")
|
||||
.arg(".")
|
||||
.current_dir(repo_root)
|
||||
.output()
|
||||
.await
|
||||
.expect("git init .");
|
||||
|
||||
let git_dir = repo_root.join(".git");
|
||||
let git_dir_backup = repo_root.join(".git.bak");
|
||||
|
||||
assert_write_blocked(
|
||||
&[
|
||||
"bash",
|
||||
"-lc",
|
||||
&format!(
|
||||
"mv {} {}",
|
||||
git_dir.to_string_lossy(),
|
||||
git_dir_backup.to_string_lossy()
|
||||
),
|
||||
],
|
||||
&[repo_root.to_path_buf()],
|
||||
LONG_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_codex_dir_write_blocked() {
|
||||
let tmpdir = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmpdir.path();
|
||||
std::fs::create_dir_all(repo_root.join(".codex")).unwrap();
|
||||
|
||||
let codex_config = repo_root.join(".codex").join("config.toml");
|
||||
|
||||
assert_write_blocked(
|
||||
&[
|
||||
"bash",
|
||||
"-lc",
|
||||
&format!("echo pwned > {}", codex_config.to_string_lossy()),
|
||||
],
|
||||
&[repo_root.to_path_buf()],
|
||||
LONG_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_git_pointer_file_blocks_gitdir_writes() {
|
||||
let tmpdir = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmpdir.path();
|
||||
let gitdir = repo_root.join("actual-gitdir");
|
||||
std::fs::create_dir_all(&gitdir).unwrap();
|
||||
|
||||
let gitdir_config = gitdir.join("config");
|
||||
std::fs::write(&gitdir_config, "[core]\n\trepositoryformatversion = 0\n").unwrap();
|
||||
|
||||
std::fs::write(
|
||||
repo_root.join(".git"),
|
||||
format!("gitdir: {}\n", gitdir.to_string_lossy()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_write_blocked(
|
||||
&[
|
||||
"bash",
|
||||
"-lc",
|
||||
&format!("echo pwned > {}", gitdir_config.to_string_lossy()),
|
||||
],
|
||||
&[repo_root.to_path_buf()],
|
||||
LONG_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_write_blocked(
|
||||
&[
|
||||
"bash",
|
||||
"-lc",
|
||||
&format!("echo pwned > {}", repo_root.join(".git").to_string_lossy()),
|
||||
],
|
||||
&[repo_root.to_path_buf()],
|
||||
LONG_TIMEOUT_MS,
|
||||
async fn test_no_new_privs_is_enabled() {
|
||||
let output = run_cmd_output(
|
||||
&["bash", "-lc", "grep '^NoNewPrivs:' /proc/self/status"],
|
||||
&[],
|
||||
SHORT_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
let line = output
|
||||
.stdout
|
||||
.text
|
||||
.lines()
|
||||
.find(|line| line.starts_with("NoNewPrivs:"))
|
||||
.unwrap_or("");
|
||||
assert_eq!(line.trim(), "NoNewPrivs:\t1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -34,21 +34,27 @@ pub(crate) const INVALID_PARAMS_ERROR_CODE: i64 = -32602;
|
||||
|
||||
/// To adhere to MCP `tools/call` response format, include the Codex
|
||||
/// `threadId` in the `structured_content` field of the response.
|
||||
fn create_call_tool_result_with_thread_id(
|
||||
/// Some MCP clients ignore `content` when `structuredContent` is present, so
|
||||
/// mirror the text content there as well.
|
||||
pub(crate) fn create_call_tool_result_with_thread_id(
|
||||
thread_id: ThreadId,
|
||||
text: String,
|
||||
is_error: Option<bool>,
|
||||
) -> CallToolResult {
|
||||
let content = vec![ContentBlock::TextContent(TextContent {
|
||||
r#type: "text".to_string(),
|
||||
text: text.clone(),
|
||||
annotations: None,
|
||||
})];
|
||||
let structured_content = json!({
|
||||
"threadId": thread_id,
|
||||
"text": text,
|
||||
"content": content.clone(),
|
||||
});
|
||||
CallToolResult {
|
||||
content: vec![ContentBlock::TextContent(TextContent {
|
||||
r#type: "text".to_string(),
|
||||
text,
|
||||
annotations: None,
|
||||
})],
|
||||
content,
|
||||
is_error,
|
||||
structured_content: Some(json!({
|
||||
"threadId": thread_id,
|
||||
})),
|
||||
structured_content: Some(structured_content),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +122,7 @@ pub async fn run_codex_tool_session(
|
||||
op: Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: initial_prompt.clone(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
@@ -158,7 +165,11 @@ pub async fn run_codex_tool_session_reply(
|
||||
.insert(request_id.clone(), thread_id);
|
||||
if let Err(e) = thread
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text { text: prompt }],
|
||||
items: vec![UserInput::Text {
|
||||
text: prompt,
|
||||
// Plain text conversion has no UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
@@ -393,6 +404,12 @@ mod tests {
|
||||
result.structured_content,
|
||||
Some(json!({
|
||||
"threadId": thread_id,
|
||||
"text": "done",
|
||||
"content": vec![ContentBlock::TextContent(TextContent {
|
||||
r#type: "text".to_string(),
|
||||
text: "done".to_string(),
|
||||
annotations: None,
|
||||
})],
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -498,17 +498,11 @@ impl MessageProcessor {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
tracing::warn!("Session not found for thread_id: {thread_id}");
|
||||
let result = CallToolResult {
|
||||
content: vec![ContentBlock::TextContent(TextContent {
|
||||
r#type: "text".to_owned(),
|
||||
text: format!("Session not found for thread_id: {thread_id}"),
|
||||
annotations: None,
|
||||
})],
|
||||
is_error: Some(true),
|
||||
structured_content: Some(json!({
|
||||
"threadId": thread_id,
|
||||
})),
|
||||
};
|
||||
let result = crate::codex_tool_runner::create_call_tool_result_with_thread_id(
|
||||
thread_id,
|
||||
format!("Session not found for thread_id: {thread_id}"),
|
||||
Some(true),
|
||||
);
|
||||
outgoing.send_response(request_id, result).await;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -162,6 +162,13 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||||
],
|
||||
"structuredContent": {
|
||||
"threadId": params.thread_id,
|
||||
"text": "File created!",
|
||||
"content": [
|
||||
{
|
||||
"text": "File created!",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
},
|
||||
@@ -323,6 +330,13 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||||
],
|
||||
"structuredContent": {
|
||||
"threadId": params.thread_id,
|
||||
"text": "Patch has been applied successfully!",
|
||||
"content": [
|
||||
{
|
||||
"text": "Patch has been applied successfully!",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
},
|
||||
@@ -394,6 +408,13 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> {
|
||||
.and_then(|v| v.get("threadId"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("codex tool response should include structuredContent.threadId"),
|
||||
"text": "Enjoy!",
|
||||
"content": [
|
||||
{
|
||||
"text": "Enjoy!",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -324,7 +324,7 @@ impl OtelManager {
|
||||
let prompt = items
|
||||
.iter()
|
||||
.flat_map(|item| match item {
|
||||
UserInput::Text { text } => Some(text.as_str()),
|
||||
UserInput::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<String>();
|
||||
|
||||
@@ -65,6 +65,10 @@ impl UserMessageItem {
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
message: self.message(),
|
||||
images: Some(self.image_urls()),
|
||||
// TODO: Thread text element ranges into legacy user message events.
|
||||
text_elements: Vec::new(),
|
||||
// TODO: Thread local image paths into legacy user message events.
|
||||
local_images: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,7 +76,7 @@ impl UserMessageItem {
|
||||
self.content
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
UserInput::Text { text } => text.clone(),
|
||||
UserInput::Text { text, .. } => text.clone(),
|
||||
_ => String::new(),
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
|
||||
@@ -550,7 +550,7 @@ impl From<Vec<UserInput>> for ResponseInputItem {
|
||||
content: items
|
||||
.into_iter()
|
||||
.flat_map(|c| match c {
|
||||
UserInput::Text { text } => vec![ContentItem::InputText { text }],
|
||||
UserInput::Text { text, .. } => vec![ContentItem::InputText { text }],
|
||||
UserInput::Image { image_url } => vec![
|
||||
ContentItem::InputText {
|
||||
text: image_open_tag_text(),
|
||||
|
||||
@@ -178,7 +178,7 @@ pub struct ModelInfo {
|
||||
pub visibility: ModelVisibility,
|
||||
pub supported_in_api: bool,
|
||||
pub priority: i32,
|
||||
pub upgrade: Option<String>,
|
||||
pub upgrade: Option<ModelInfoUpgrade>,
|
||||
pub base_instructions: String,
|
||||
pub supports_reasoning_summaries: bool,
|
||||
pub support_verbosity: bool,
|
||||
@@ -208,6 +208,21 @@ impl ModelInfo {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
||||
pub struct ModelInfoUpgrade {
|
||||
pub model: String,
|
||||
pub migration_markdown: String,
|
||||
}
|
||||
|
||||
impl From<&ModelUpgrade> for ModelInfoUpgrade {
|
||||
fn from(upgrade: &ModelUpgrade) -> Self {
|
||||
ModelInfoUpgrade {
|
||||
model: upgrade.id.clone(),
|
||||
migration_markdown: upgrade.migration_markdown.clone().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Response wrapper for `/models`.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema, Default)]
|
||||
pub struct ModelsResponse {
|
||||
@@ -227,8 +242,8 @@ impl From<ModelInfo> for ModelPreset {
|
||||
.unwrap_or(ReasoningEffort::None),
|
||||
supported_reasoning_efforts: info.supported_reasoning_levels.clone(),
|
||||
is_default: false, // default is the highest priority available model
|
||||
upgrade: info.upgrade.as_ref().map(|upgrade_slug| ModelUpgrade {
|
||||
id: upgrade_slug.clone(),
|
||||
upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade {
|
||||
id: upgrade.model.clone(),
|
||||
reasoning_effort_mapping: reasoning_effort_mapping_from_presets(
|
||||
&info.supported_reasoning_levels,
|
||||
),
|
||||
@@ -236,7 +251,7 @@ impl From<ModelInfo> for ModelPreset {
|
||||
// todo(aibrahim): add the model link here.
|
||||
model_link: None,
|
||||
upgrade_copy: None,
|
||||
migration_markdown: None,
|
||||
migration_markdown: Some(upgrade.migration_markdown.clone()),
|
||||
}),
|
||||
show_in_picker: info.visibility == ModelVisibility::List,
|
||||
supported_in_api: info.supported_in_api,
|
||||
|
||||
@@ -1255,8 +1255,19 @@ pub struct AgentMessageEvent {
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct UserMessageEvent {
|
||||
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>>,
|
||||
/// 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>,
|
||||
/// UI-defined spans within `message` used to render or persist special elements.
|
||||
#[serde(default)]
|
||||
pub text_elements: Vec<crate::user_input::TextElement>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
@@ -1951,13 +1962,33 @@ pub enum SkillScope {
|
||||
pub struct SkillMetadata {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[ts(optional)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
/// Legacy short_description from SKILL.md. Prefer SKILL.toml interface.short_description.
|
||||
pub short_description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub interface: Option<SkillInterface>,
|
||||
pub path: PathBuf,
|
||||
pub scope: SkillScope,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq, Eq)]
|
||||
pub struct SkillInterface {
|
||||
#[ts(optional)]
|
||||
pub display_name: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub short_description: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub icon_small: Option<PathBuf>,
|
||||
#[ts(optional)]
|
||||
pub icon_large: Option<PathBuf>,
|
||||
#[ts(optional)]
|
||||
pub brand_color: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub default_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct SkillErrorInfo {
|
||||
pub path: PathBuf,
|
||||
@@ -2298,6 +2329,48 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_input_text_serializes_empty_text_elements() -> Result<()> {
|
||||
let input = UserInput::Text {
|
||||
text: "hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
};
|
||||
|
||||
let json_input = serde_json::to_value(input)?;
|
||||
assert_eq!(
|
||||
json_input,
|
||||
json!({
|
||||
"type": "text",
|
||||
"text": "hello",
|
||||
"text_elements": [],
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_message_event_serializes_empty_metadata_vectors() -> Result<()> {
|
||||
let event = UserMessageEvent {
|
||||
message: "hello".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
};
|
||||
|
||||
let json_event = serde_json::to_value(event)?;
|
||||
assert_eq!(
|
||||
json_event,
|
||||
json!({
|
||||
"message": "hello",
|
||||
"local_images": [],
|
||||
"text_elements": [],
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize Event to verify that its JSON representation has the expected
|
||||
/// amount of nesting.
|
||||
#[test]
|
||||
|
||||
@@ -10,17 +10,19 @@ use ts_rs::TS;
|
||||
pub enum UserInput {
|
||||
Text {
|
||||
text: String,
|
||||
/// UI-defined spans within `text` that should be treated as special elements.
|
||||
/// These are byte ranges into the UTF-8 `text` buffer and are used to render
|
||||
/// or persist rich input markers (e.g., image placeholders) across history
|
||||
/// and resume without mutating the literal text.
|
||||
#[serde(default)]
|
||||
text_elements: Vec<TextElement>,
|
||||
},
|
||||
/// Pre‑encoded data: URI image.
|
||||
Image {
|
||||
image_url: String,
|
||||
},
|
||||
Image { image_url: String },
|
||||
|
||||
/// 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 },
|
||||
|
||||
/// Skill selected by the user (name + path to SKILL.md).
|
||||
Skill {
|
||||
@@ -28,3 +30,28 @@ pub enum UserInput {
|
||||
path: std::path::PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS, JsonSchema)]
|
||||
pub struct TextElement {
|
||||
/// Byte range in the parent `text` buffer that this element occupies.
|
||||
pub byte_range: ByteRange,
|
||||
/// Optional human-readable placeholder for the element, displayed in the UI.
|
||||
pub placeholder: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, TS, JsonSchema)]
|
||||
pub struct ByteRange {
|
||||
/// Start byte offset (inclusive) within the UTF-8 text buffer.
|
||||
pub start: usize,
|
||||
/// End byte offset (exclusive) within the UTF-8 text buffer.
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
impl From<std::ops::Range<usize>> for ByteRange {
|
||||
fn from(range: std::ops::Range<usize>) -> Self {
|
||||
Self {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,7 +589,7 @@ impl App {
|
||||
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
|
||||
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
|
||||
let pasted = pasted.replace("\r", "\n");
|
||||
self.chat_widget.handle_paste_event(pasted);
|
||||
self.chat_widget.handle_paste(pasted);
|
||||
}
|
||||
TuiEvent::Draw => {
|
||||
self.chat_widget.maybe_post_pending_notification(tui);
|
||||
|
||||
@@ -2230,7 +2230,7 @@ impl Renderable for ChatComposer {
|
||||
.unwrap_or("Input disabled.")
|
||||
.to_string()
|
||||
};
|
||||
let placeholder = Span::from(text).dim().italic();
|
||||
let placeholder = Span::from(text).dim();
|
||||
Line::from(vec![placeholder]).render_ref(textarea_rect.inner(Margin::new(0, 0)), buf);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,12 +87,8 @@ impl SkillPopup {
|
||||
.into_iter()
|
||||
.map(|(idx, indices, _score)| {
|
||||
let skill = &self.skills[idx];
|
||||
let name = truncate_text(&skill.name, 21);
|
||||
let description = skill
|
||||
.short_description
|
||||
.as_ref()
|
||||
.unwrap_or(&skill.description)
|
||||
.clone();
|
||||
let name = truncate_text(skill_display_name(skill), 21);
|
||||
let description = skill_description(skill).to_string();
|
||||
GenericDisplayRow {
|
||||
name,
|
||||
match_indices: indices,
|
||||
@@ -117,15 +113,20 @@ impl SkillPopup {
|
||||
}
|
||||
|
||||
for (idx, skill) in self.skills.iter().enumerate() {
|
||||
if let Some((indices, score)) = fuzzy_match(&skill.name, filter) {
|
||||
let display_name = skill_display_name(skill);
|
||||
if let Some((indices, score)) = fuzzy_match(display_name, filter) {
|
||||
out.push((idx, Some(indices), score));
|
||||
} else if display_name != skill.name
|
||||
&& let Some((_indices, score)) = fuzzy_match(&skill.name, filter)
|
||||
{
|
||||
out.push((idx, None, score));
|
||||
}
|
||||
}
|
||||
|
||||
out.sort_by(|a, b| {
|
||||
a.2.cmp(&b.2).then_with(|| {
|
||||
let an = &self.skills[a.0].name;
|
||||
let bn = &self.skills[b.0].name;
|
||||
let an = skill_display_name(&self.skills[a.0]);
|
||||
let bn = skill_display_name(&self.skills[b.0]);
|
||||
an.cmp(bn)
|
||||
})
|
||||
});
|
||||
@@ -177,3 +178,20 @@ fn skill_popup_hint_line() -> Line<'static> {
|
||||
" to close".into(),
|
||||
])
|
||||
}
|
||||
|
||||
fn skill_display_name(skill: &SkillMetadata) -> &str {
|
||||
skill
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.display_name.as_deref())
|
||||
.unwrap_or(&skill.name)
|
||||
}
|
||||
|
||||
fn skill_description(skill: &SkillMetadata) -> &str {
|
||||
skill
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.short_description.as_deref())
|
||||
.or(skill.short_description.as_deref())
|
||||
.unwrap_or(&skill.description)
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ use codex_core::protocol::ViewImageToolCallEvent;
|
||||
use codex_core::protocol::WarningEvent;
|
||||
use codex_core::protocol::WebSearchBeginEvent;
|
||||
use codex_core::protocol::WebSearchEndEvent;
|
||||
use codex_core::skills::model::SkillInterface;
|
||||
use codex_core::skills::model::SkillMetadata;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::account::PlanType;
|
||||
@@ -134,6 +135,7 @@ use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
|
||||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use crate::clipboard_paste::paste_image_to_temp_png;
|
||||
use crate::collab;
|
||||
use crate::diff_render::display_path_for;
|
||||
use crate::exec_cell::CommandOutput;
|
||||
use crate::exec_cell::ExecCell;
|
||||
@@ -744,6 +746,7 @@ impl ChatWidget {
|
||||
self.suppressed_exec_calls.clear();
|
||||
self.last_unified_wait = None;
|
||||
self.unified_exec_wait_streak = None;
|
||||
self.clear_unified_exec_processes();
|
||||
self.request_redraw();
|
||||
|
||||
// If there is a queued user message, send exactly one now to begin the next turn.
|
||||
@@ -880,6 +883,7 @@ impl ChatWidget {
|
||||
self.running_commands.clear();
|
||||
self.suppressed_exec_calls.clear();
|
||||
self.last_unified_wait = None;
|
||||
self.clear_unified_exec_processes();
|
||||
self.stream_controller = None;
|
||||
self.maybe_show_pending_rate_limit_prompt();
|
||||
}
|
||||
@@ -972,8 +976,6 @@ impl ChatWidget {
|
||||
fn on_interrupted_turn(&mut self, reason: TurnAbortReason) {
|
||||
// Finalize, log a gentle prompt, and clear running state.
|
||||
self.finalize_turn();
|
||||
self.unified_exec_processes.clear();
|
||||
self.sync_unified_exec_footer();
|
||||
|
||||
if reason != TurnAbortReason::ReviewEnded {
|
||||
self.add_to_history(history_cell::new_error_event(
|
||||
@@ -1194,6 +1196,14 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_unified_exec_processes(processes);
|
||||
}
|
||||
|
||||
fn clear_unified_exec_processes(&mut self) {
|
||||
if self.unified_exec_processes.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.unified_exec_processes.clear();
|
||||
self.sync_unified_exec_footer();
|
||||
}
|
||||
|
||||
fn on_mcp_tool_call_begin(&mut self, ev: McpToolCallBeginEvent) {
|
||||
let ev2 = ev.clone();
|
||||
self.defer_or_handle(|q| q.push_mcp_begin(ev), |s| s.handle_mcp_begin_now(ev2));
|
||||
@@ -1213,6 +1223,12 @@ impl ChatWidget {
|
||||
self.add_to_history(history_cell::new_web_search_call(ev.query));
|
||||
}
|
||||
|
||||
fn on_collab_event(&mut self, cell: PlainHistoryCell) {
|
||||
self.flush_answer_stream_with_separator();
|
||||
self.add_to_history(cell);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn on_get_history_entry_response(
|
||||
&mut self,
|
||||
event: codex_core::protocol::GetHistoryEntryResponseEvent,
|
||||
@@ -1811,10 +1827,26 @@ impl ChatWidget {
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if c.eq_ignore_ascii_case(&'v')
|
||||
&& modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
|
||||
} if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||||
&& c.eq_ignore_ascii_case(&'v') =>
|
||||
{
|
||||
self.paste_image_from_clipboard();
|
||||
match paste_image_to_temp_png() {
|
||||
Ok((path, info)) => {
|
||||
tracing::debug!(
|
||||
"pasted image size={}x{} format={}",
|
||||
info.width,
|
||||
info.height,
|
||||
info.encoded_format.label()
|
||||
);
|
||||
self.attach_image(path);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to paste image: {err}");
|
||||
self.add_to_history(history_cell::new_error_event(format!(
|
||||
"Failed to paste image: {err}",
|
||||
)));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
other if other.kind == KeyEventKind::Press => {
|
||||
@@ -1883,32 +1915,6 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Attempt to attach an image from the system clipboard.
|
||||
///
|
||||
/// This is a best-effort path used when we receive an empty paste event,
|
||||
/// which some terminals emit when the clipboard contains non-text data
|
||||
/// (like images). When the clipboard can't be read or no image exists,
|
||||
/// surface a helpful follow-up so the user can retry with a file path.
|
||||
fn paste_image_from_clipboard(&mut self) {
|
||||
match paste_image_to_temp_png() {
|
||||
Ok((path, info)) => {
|
||||
tracing::debug!(
|
||||
"pasted image size={}x{} format={}",
|
||||
info.width,
|
||||
info.height,
|
||||
info.encoded_format.label()
|
||||
);
|
||||
self.attach_image(path);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to paste image: {err}");
|
||||
self.add_to_history(history_cell::new_error_event(format!(
|
||||
"Failed to paste image: {err}. Try saving the image to a file and pasting the file path instead.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn composer_text_with_pending(&self) -> String {
|
||||
self.bottom_pane.composer_text_with_pending()
|
||||
}
|
||||
@@ -2163,20 +2169,6 @@ impl ChatWidget {
|
||||
self.bottom_pane.handle_paste(text);
|
||||
}
|
||||
|
||||
/// Route paste events through image detection.
|
||||
///
|
||||
/// Terminals vary in how they represent paste: some emit an empty paste
|
||||
/// payload when the clipboard isn't text (common for image-only clipboard
|
||||
/// contents). Treat the empty payload as a hint to attempt a clipboard
|
||||
/// image read; otherwise, fall back to text handling.
|
||||
pub(crate) fn handle_paste_event(&mut self, text: String) {
|
||||
if text.is_empty() {
|
||||
self.paste_image_from_clipboard();
|
||||
} else {
|
||||
self.handle_paste(text);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns true if caller should skip rendering this frame (a future frame is scheduled).
|
||||
pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool {
|
||||
if self.bottom_pane.flush_paste_burst_if_due() {
|
||||
@@ -2266,7 +2258,11 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
if !text.is_empty() {
|
||||
items.push(UserInput::Text { text: text.clone() });
|
||||
// TODO: Thread text element ranges from the composer input. Empty keeps old behavior.
|
||||
items.push(UserInput::Text {
|
||||
text: text.clone(),
|
||||
text_elements: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(skills) = self.bottom_pane.skills() {
|
||||
@@ -2440,16 +2436,16 @@ impl ChatWidget {
|
||||
}
|
||||
EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review),
|
||||
EventMsg::ContextCompacted(_) => self.on_agent_message("Context compacted".to_owned()),
|
||||
EventMsg::CollabAgentSpawnBegin(_)
|
||||
| EventMsg::CollabAgentSpawnEnd(_)
|
||||
| EventMsg::CollabAgentInteractionBegin(_)
|
||||
| EventMsg::CollabAgentInteractionEnd(_)
|
||||
| EventMsg::CollabWaitingBegin(_)
|
||||
| EventMsg::CollabWaitingEnd(_)
|
||||
| EventMsg::CollabCloseBegin(_)
|
||||
| EventMsg::CollabCloseEnd(_) => {
|
||||
// TODO(jif) handle collab tools.
|
||||
EventMsg::CollabAgentSpawnBegin(_) => {}
|
||||
EventMsg::CollabAgentSpawnEnd(ev) => self.on_collab_event(collab::spawn_end(ev)),
|
||||
EventMsg::CollabAgentInteractionBegin(_) => {}
|
||||
EventMsg::CollabAgentInteractionEnd(ev) => {
|
||||
self.on_collab_event(collab::interaction_end(ev))
|
||||
}
|
||||
EventMsg::CollabWaitingBegin(ev) => self.on_collab_event(collab::waiting_begin(ev)),
|
||||
EventMsg::CollabWaitingEnd(ev) => self.on_collab_event(collab::waiting_end(ev)),
|
||||
EventMsg::CollabCloseBegin(_) => {}
|
||||
EventMsg::CollabCloseEnd(ev) => self.on_collab_event(collab::close_end(ev)),
|
||||
EventMsg::ThreadRolledBack(_) => {}
|
||||
EventMsg::RawResponseItem(_)
|
||||
| EventMsg::ItemStarted(_)
|
||||
@@ -4575,6 +4571,14 @@ fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec<SkillMe
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
short_description: skill.short_description.clone(),
|
||||
interface: skill.interface.clone().map(|interface| SkillInterface {
|
||||
display_name: interface.display_name,
|
||||
short_description: interface.short_description,
|
||||
icon_small: interface.icon_small,
|
||||
icon_large: interface.icon_large,
|
||||
brand_color: interface.brand_color,
|
||||
default_prompt: interface.default_prompt,
|
||||
}),
|
||||
path: skill.path.clone(),
|
||||
scope: skill.scope,
|
||||
})
|
||||
|
||||
@@ -143,6 +143,8 @@ async fn resumed_initial_messages_render_history() {
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "hello from user".to_string(),
|
||||
images: None,
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
}),
|
||||
EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "assistant reply".to_string(),
|
||||
@@ -2793,6 +2795,26 @@ async fn interrupt_clears_unified_exec_processes() {
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_complete_clears_unified_exec_processes() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5");
|
||||
begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6");
|
||||
assert_eq!(chat.unified_exec_processes.len(), 2);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
last_agent_message: None,
|
||||
}),
|
||||
});
|
||||
|
||||
assert!(chat.unified_exec_processes.is_empty());
|
||||
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
}
|
||||
|
||||
// Snapshot test: ChatWidget at very small heights (idle)
|
||||
// Ensures overall layout behaves when terminal height is extremely constrained.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
use crate::text_formatting::truncate_text;
|
||||
use codex_core::protocol::AgentStatus;
|
||||
use codex_core::protocol::CollabAgentInteractionEndEvent;
|
||||
use codex_core::protocol::CollabAgentSpawnEndEvent;
|
||||
use codex_core::protocol::CollabCloseEndEvent;
|
||||
use codex_core::protocol::CollabWaitingBeginEvent;
|
||||
use codex_core::protocol::CollabWaitingEndEvent;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
|
||||
const COLLAB_PROMPT_PREVIEW_GRAPHEMES: usize = 160;
|
||||
|
||||
pub(crate) fn spawn_end(ev: CollabAgentSpawnEndEvent) -> PlainHistoryCell {
|
||||
let CollabAgentSpawnEndEvent {
|
||||
call_id,
|
||||
sender_thread_id,
|
||||
new_thread_id,
|
||||
prompt,
|
||||
status,
|
||||
} = ev;
|
||||
let new_agent = new_thread_id
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| "none".to_string());
|
||||
let mut details = vec![
|
||||
detail_line("call", call_id),
|
||||
detail_line("sender", sender_thread_id),
|
||||
detail_line("new_agent", new_agent),
|
||||
status_line(&status),
|
||||
];
|
||||
if let Some(line) = prompt_line(&prompt) {
|
||||
details.push(line);
|
||||
}
|
||||
collab_event("Collab spawn", details)
|
||||
}
|
||||
|
||||
pub(crate) fn interaction_end(ev: CollabAgentInteractionEndEvent) -> PlainHistoryCell {
|
||||
let CollabAgentInteractionEndEvent {
|
||||
call_id,
|
||||
sender_thread_id,
|
||||
receiver_thread_id,
|
||||
prompt,
|
||||
status,
|
||||
} = ev;
|
||||
let mut details = vec![
|
||||
detail_line("call", call_id),
|
||||
detail_line("sender", sender_thread_id),
|
||||
detail_line("receiver", receiver_thread_id),
|
||||
status_line(&status),
|
||||
];
|
||||
if let Some(line) = prompt_line(&prompt) {
|
||||
details.push(line);
|
||||
}
|
||||
collab_event("Collab send input", details)
|
||||
}
|
||||
|
||||
pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell {
|
||||
let CollabWaitingBeginEvent {
|
||||
call_id,
|
||||
sender_thread_id,
|
||||
receiver_thread_id,
|
||||
} = ev;
|
||||
let details = vec![
|
||||
detail_line("call", call_id),
|
||||
detail_line("sender", sender_thread_id),
|
||||
detail_line("receiver", receiver_thread_id),
|
||||
];
|
||||
collab_event("Collab wait begin", details)
|
||||
}
|
||||
|
||||
pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell {
|
||||
let CollabWaitingEndEvent {
|
||||
call_id,
|
||||
sender_thread_id,
|
||||
receiver_thread_id,
|
||||
status,
|
||||
} = ev;
|
||||
let details = vec![
|
||||
detail_line("call", call_id),
|
||||
detail_line("sender", sender_thread_id),
|
||||
detail_line("receiver", receiver_thread_id),
|
||||
status_line(&status),
|
||||
];
|
||||
collab_event("Collab wait end", details)
|
||||
}
|
||||
|
||||
pub(crate) fn close_end(ev: CollabCloseEndEvent) -> PlainHistoryCell {
|
||||
let CollabCloseEndEvent {
|
||||
call_id,
|
||||
sender_thread_id,
|
||||
receiver_thread_id,
|
||||
status,
|
||||
} = ev;
|
||||
let details = vec![
|
||||
detail_line("call", call_id),
|
||||
detail_line("sender", sender_thread_id),
|
||||
detail_line("receiver", receiver_thread_id),
|
||||
status_line(&status),
|
||||
];
|
||||
collab_event("Collab close", details)
|
||||
}
|
||||
|
||||
fn collab_event(title: impl Into<String>, details: Vec<Line<'static>>) -> PlainHistoryCell {
|
||||
let title = title.into();
|
||||
let mut lines: Vec<Line<'static>> = vec![vec!["• ".dim(), title.bold()].into()];
|
||||
if !details.is_empty() {
|
||||
lines.extend(prefix_lines(details, " └ ".dim(), " ".into()));
|
||||
}
|
||||
PlainHistoryCell::new(lines)
|
||||
}
|
||||
|
||||
fn detail_line(label: &str, value: impl std::fmt::Display) -> Line<'static> {
|
||||
Line::from(format!("{label}: {value}").dim())
|
||||
}
|
||||
|
||||
fn status_line(status: &AgentStatus) -> Line<'static> {
|
||||
Line::from(format!("status: {}", status_text(status)).dim())
|
||||
}
|
||||
|
||||
fn status_text(status: &AgentStatus) -> &'static str {
|
||||
match status {
|
||||
AgentStatus::PendingInit => "pending_init",
|
||||
AgentStatus::Running => "running",
|
||||
AgentStatus::Completed(_) => "completed",
|
||||
AgentStatus::Errored(_) => "errored",
|
||||
AgentStatus::Shutdown => "shutdown",
|
||||
AgentStatus::NotFound => "not_found",
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_line(prompt: &str) -> Option<Line<'static>> {
|
||||
let trimmed = prompt.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(detail_line(
|
||||
"prompt",
|
||||
truncate_text(trimmed, COLLAB_PROMPT_PREVIEW_GRAPHEMES),
|
||||
))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user