mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
Merge remote-tracking branch 'origin/abhinav/subagent-stop-stack' into abhinav/subagent-hook-context-stack
# Conflicts: # codex-rs/core/src/hook_runtime.rs # codex-rs/core/tests/suite/subagent_notifications.rs
This commit is contained in:
@@ -309,6 +309,25 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"AutoCompactTokenLimitScope": {
|
||||
"description": "Selects which part of the active context is charged against `model_auto_compact_token_limit`.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Count the full active context against the limit.",
|
||||
"enum": [
|
||||
"total"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Count sampled output and later growth after the carried window prefix.",
|
||||
"enum": [
|
||||
"body_after_prefix"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"AutoReviewToml": {
|
||||
"properties": {
|
||||
"policy": {
|
||||
@@ -817,13 +836,23 @@
|
||||
"type": "object"
|
||||
},
|
||||
"FileSystemAccessMode": {
|
||||
"description": "Access mode for a filesystem entry.\n\nWhen two equally specific entries target the same path, we compare these by conflict precedence rather than by capability breadth: `none` beats `write`, and `write` beats `read`.",
|
||||
"enum": [
|
||||
"read",
|
||||
"write",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Access mode for a filesystem entry.\n\nWhen two equally specific entries target the same path, we compare these by conflict precedence rather than by capability breadth: `deny` beats `write`, and `write` beats `read`.",
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"read",
|
||||
"write"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "`none` is a legacy input alias retained temporarily for compatibility.",
|
||||
"enum": [
|
||||
"deny"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FilesystemPermissionToml": {
|
||||
"anyOf": [
|
||||
@@ -1530,6 +1559,12 @@
|
||||
"subagent_usage_hint_text": {
|
||||
"type": "string"
|
||||
},
|
||||
"tool_namespace": {
|
||||
"maxLength": 64,
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-zA-Z0-9_-]+$",
|
||||
"type": "string"
|
||||
},
|
||||
"usage_hint_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -4030,6 +4065,10 @@
|
||||
"default": null,
|
||||
"description": "Settings for app-specific controls."
|
||||
},
|
||||
"apps_mcp_product_sku": {
|
||||
"description": "Optional product SKU forwarded on host-owned Codex Apps MCP requests.",
|
||||
"type": "string"
|
||||
},
|
||||
"audio": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -4548,6 +4587,14 @@
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"model_auto_compact_token_limit_scope": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AutoCompactTokenLimitScope"
|
||||
}
|
||||
],
|
||||
"description": "Controls whether the auto-compaction limit applies to the full context or only to tokens after the carried prefix in the current compaction window."
|
||||
},
|
||||
"model_catalog_json": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -96,7 +96,7 @@ fn agent_nickname_candidates(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn keep_forked_rollout_item(item: &RolloutItem) -> bool {
|
||||
fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: bool) -> bool {
|
||||
match item {
|
||||
RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => match role.as_str()
|
||||
{
|
||||
@@ -116,16 +116,34 @@ fn keep_forked_rollout_item(item: &RolloutItem) -> bool {
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::CompactionTrigger
|
||||
| ResponseItem::ContextCompaction { .. }
|
||||
| ResponseItem::Other,
|
||||
) => false,
|
||||
// A forked child gets its own runtime config, including spawned-agent
|
||||
// instructions, so it must establish a fresh context diff baseline.
|
||||
RolloutItem::TurnContext(_) => false,
|
||||
// Full-history forks preserve the cached prompt prefix and can keep diffing
|
||||
// from the parent's durable baseline. Truncated forks drop part of that prompt,
|
||||
// so they must rebuild context on their first child turn.
|
||||
RolloutItem::TurnContext(_) => preserve_reference_context_item,
|
||||
RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_multi_agent_v2_usage_hint_message(item: &ResponseItem, usage_hint_texts: &[String]) -> bool {
|
||||
let ResponseItem::Message { role, content, .. } = item else {
|
||||
return false;
|
||||
};
|
||||
if role != "developer" {
|
||||
return false;
|
||||
}
|
||||
let [ContentItem::InputText { text }] = content.as_slice() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
usage_hint_texts
|
||||
.iter()
|
||||
.any(|usage_hint_text| usage_hint_text == text)
|
||||
}
|
||||
|
||||
/// Control-plane handle for multi-agent operations.
|
||||
/// `AgentControl` is held by each session (via `SessionServices`). It provides capability to
|
||||
/// spawn new agents and the inter-agent communication layer.
|
||||
@@ -395,16 +413,26 @@ impl AgentControl {
|
||||
forked_rollout_items =
|
||||
truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns);
|
||||
}
|
||||
// MultiAgentV2 root/subagent usage hints are injected as standalone developer
|
||||
// messages at thread start. When forking history, drop hints from the parent
|
||||
// so the child gets a fresh hint that matches its own session source/config.
|
||||
let multi_agent_v2_usage_hint_texts_to_filter: Vec<String> =
|
||||
if let Some(parent_thread) = parent_thread.as_ref() {
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.configured_multi_agent_v2_usage_hint_texts()
|
||||
.await
|
||||
if parent_thread.enabled(Feature::MultiAgentV2) {
|
||||
let parent_config = parent_thread.codex.session.get_config().await;
|
||||
[
|
||||
parent_config
|
||||
.multi_agent_v2
|
||||
.root_agent_usage_hint_text
|
||||
.clone(),
|
||||
parent_config
|
||||
.multi_agent_v2
|
||||
.subagent_usage_hint_text
|
||||
.clone(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
} else if config.features.enabled(Feature::MultiAgentV2) {
|
||||
[
|
||||
config.multi_agent_v2.root_agent_usage_hint_text.clone(),
|
||||
@@ -416,19 +444,41 @@ impl AgentControl {
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let preserve_reference_context_item = matches!(fork_mode, SpawnAgentForkMode::FullHistory);
|
||||
forked_rollout_items.retain(|item| {
|
||||
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = item
|
||||
&& role == "developer"
|
||||
&& let [ContentItem::InputText { text }] = content.as_slice()
|
||||
&& multi_agent_v2_usage_hint_texts_to_filter
|
||||
.iter()
|
||||
.any(|usage_hint_text| usage_hint_text == text)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
keep_forked_rollout_item(item)
|
||||
keep_forked_rollout_item(item, preserve_reference_context_item)
|
||||
&& !matches!(
|
||||
item,
|
||||
RolloutItem::ResponseItem(response_item)
|
||||
if is_multi_agent_v2_usage_hint_message(
|
||||
response_item,
|
||||
&multi_agent_v2_usage_hint_texts_to_filter,
|
||||
)
|
||||
)
|
||||
});
|
||||
for item in &mut forked_rollout_items {
|
||||
if let RolloutItem::Compacted(compacted) = item
|
||||
&& let Some(replacement_history) = compacted.replacement_history.as_mut()
|
||||
{
|
||||
replacement_history.retain(|response_item| {
|
||||
!is_multi_agent_v2_usage_hint_message(
|
||||
response_item,
|
||||
&multi_agent_v2_usage_hint_texts_to_filter,
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
if preserve_reference_context_item
|
||||
&& config.features.enabled(Feature::MultiAgentV2)
|
||||
&& let Some(subagent_usage_hint_text) =
|
||||
config.multi_agent_v2.subagent_usage_hint_text.clone()
|
||||
&& let Some(subagent_usage_hint_message) =
|
||||
crate::context_manager::updates::build_developer_update_item(vec![
|
||||
subagent_usage_hint_text,
|
||||
])
|
||||
{
|
||||
forked_rollout_items.push(RolloutItem::ResponseItem(subagent_usage_hint_message));
|
||||
}
|
||||
|
||||
state
|
||||
.fork_thread_with_source(
|
||||
@@ -498,13 +548,12 @@ impl AgentControl {
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
});
|
||||
match self
|
||||
.resume_single_agent_from_rollout(
|
||||
config.clone(),
|
||||
child_thread_id,
|
||||
child_session_source,
|
||||
)
|
||||
.await
|
||||
match Box::pin(self.resume_single_agent_from_rollout(
|
||||
config.clone(),
|
||||
child_thread_id,
|
||||
child_session_source,
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(_) => true,
|
||||
Err(err) => {
|
||||
@@ -1234,7 +1283,9 @@ pub(crate) fn render_input_preview(initial_operation: &Op) -> String {
|
||||
.map(|item| match item {
|
||||
UserInput::Text { text, .. } => text.clone(),
|
||||
UserInput::Image { .. } => "[image]".to_string(),
|
||||
UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()),
|
||||
UserInput::LocalImage { path, .. } => {
|
||||
format!("[local_image:{}]", path.display())
|
||||
}
|
||||
UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()),
|
||||
UserInput::Mention { name, path } => format!("[mention:${name}]({path})"),
|
||||
_ => "[input]".to_string(),
|
||||
|
||||
@@ -17,6 +17,7 @@ use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::CompactedItem;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
@@ -442,6 +443,7 @@ async fn send_input_submits_user_message() {
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
thread_settings: Default::default(),
|
||||
},
|
||||
);
|
||||
let captured = harness
|
||||
@@ -486,7 +488,13 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig
|
||||
|
||||
timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if thread.codex.session.has_pending_input().await {
|
||||
if thread
|
||||
.codex
|
||||
.session
|
||||
.input_queue
|
||||
.has_pending_input(&thread.codex.session.active_turn)
|
||||
.await
|
||||
{
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
@@ -589,6 +597,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() {
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
thread_settings: Default::default(),
|
||||
},
|
||||
);
|
||||
let captured = harness
|
||||
@@ -669,6 +678,14 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let parent_reference_context_item = turn_context.to_turn_context_item();
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.persist_rollout_items(&[RolloutItem::TurnContext(
|
||||
parent_reference_context_item.clone(),
|
||||
)])
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
@@ -720,11 +737,26 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
|
||||
phase: None,
|
||||
},
|
||||
assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)),
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "Child subagent guidance.".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
history.raw_items(),
|
||||
&expected_history,
|
||||
"forked child history should keep only parent user messages and assistant final answers"
|
||||
"full-history forked child history should replace parent usage hints with the child subagent hint while filtering non-final assistant/tool chatter"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(child_thread.codex.session.reference_context_item().await)
|
||||
.expect("serialize child reference context item"),
|
||||
serde_json::to_value(Some(parent_reference_context_item))
|
||||
.expect("serialize expected reference context item"),
|
||||
"full-history forked child should preserve the parent diff baseline"
|
||||
);
|
||||
|
||||
let expected = (
|
||||
@@ -737,6 +769,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
thread_settings: Default::default(),
|
||||
},
|
||||
);
|
||||
let captured = harness
|
||||
@@ -757,6 +790,124 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
|
||||
.expect("parent shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let mut parent_config = harness.config.clone();
|
||||
let _ = parent_config.features.enable(Feature::MultiAgentV2);
|
||||
parent_config.multi_agent_v2.root_agent_usage_hint_text =
|
||||
Some("Parent root guidance.".to_string());
|
||||
parent_config.multi_agent_v2.subagent_usage_hint_text =
|
||||
Some("Parent subagent guidance.".to_string());
|
||||
let mut child_config = harness.config.clone();
|
||||
let _ = child_config.features.enable(Feature::MultiAgentV2);
|
||||
child_config.multi_agent_v2.root_agent_usage_hint_text =
|
||||
Some("Child root guidance.".to_string());
|
||||
child_config.multi_agent_v2.subagent_usage_hint_text =
|
||||
Some("Child subagent guidance.".to_string());
|
||||
let new_thread = harness
|
||||
.manager
|
||||
.start_thread(parent_config)
|
||||
.await
|
||||
.expect("start parent thread");
|
||||
let parent_thread_id = new_thread.thread_id;
|
||||
let parent_thread = new_thread.thread;
|
||||
let turn_context = parent_thread.codex.session.new_default_turn().await;
|
||||
let parent_spawn_call_id = "spawn-call-compacted-usage-hints".to_string();
|
||||
let replacement_history = vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "compacted parent summary".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "Parent root guidance.".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
},
|
||||
];
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.persist_rollout_items(&[
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(replacement_history),
|
||||
}),
|
||||
RolloutItem::TurnContext(turn_context.to_turn_context_item()),
|
||||
RolloutItem::ResponseItem(spawn_agent_call(&parent_spawn_call_id)),
|
||||
])
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.ensure_rollout_materialized()
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.flush_rollout()
|
||||
.await
|
||||
.expect("parent rollout should flush");
|
||||
|
||||
let child_thread_id = harness
|
||||
.control
|
||||
.spawn_agent_with_metadata(
|
||||
child_config,
|
||||
text_input("child task"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
|
||||
fork_mode: Some(SpawnAgentForkMode::FullHistory),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("forked spawn should sanitize compacted usage hints")
|
||||
.thread_id;
|
||||
|
||||
let child_thread = harness
|
||||
.manager
|
||||
.get_thread(child_thread_id)
|
||||
.await
|
||||
.expect("child thread should be registered");
|
||||
let history = child_thread.codex.session.clone_history().await;
|
||||
assert!(
|
||||
history_contains_text(history.raw_items(), "compacted parent summary"),
|
||||
"forked child history should retain compacted non-hint content"
|
||||
);
|
||||
assert!(
|
||||
!history_contains_text(history.raw_items(), "Parent root guidance."),
|
||||
"forked child history should strip stale parent hints from compacted replacement history"
|
||||
);
|
||||
assert!(
|
||||
history_contains_text(history.raw_items(), "Child subagent guidance."),
|
||||
"full-history forked child should add the child subagent hint after compacted-history sanitization"
|
||||
);
|
||||
|
||||
let _ = harness
|
||||
.control
|
||||
.shutdown_live_agent(child_thread_id)
|
||||
.await
|
||||
.expect("child shutdown should submit");
|
||||
let _ = parent_thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("parent shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
@@ -873,6 +1024,13 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
|
||||
&[spawn_agent_call(&parent_spawn_call_id)],
|
||||
)
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.persist_rollout_items(&[RolloutItem::TurnContext(
|
||||
spawn_turn_context.to_turn_context_item(),
|
||||
)])
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
@@ -930,6 +1088,116 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
|
||||
history_contains_text(history.raw_items(), "current parent task"),
|
||||
"forked child history should keep the parent user message from the requested last-N turn window"
|
||||
);
|
||||
assert!(
|
||||
child_thread
|
||||
.codex
|
||||
.session
|
||||
.reference_context_item()
|
||||
.await
|
||||
.is_none(),
|
||||
"last-N forked child should rebuild context after truncating the cached prefix"
|
||||
);
|
||||
|
||||
let _ = harness
|
||||
.control
|
||||
.shutdown_live_agent(child_thread_id)
|
||||
.await
|
||||
.expect("child shutdown should submit");
|
||||
let _ = parent_thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("parent shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let mut parent_config = harness.config.clone();
|
||||
let _ = parent_config.features.enable(Feature::MultiAgentV2);
|
||||
parent_config.multi_agent_v2.root_agent_usage_hint_text =
|
||||
Some("Parent root guidance.".to_string());
|
||||
let mut child_config = harness.config.clone();
|
||||
let _ = child_config.features.enable(Feature::MultiAgentV2);
|
||||
child_config.multi_agent_v2.subagent_usage_hint_text =
|
||||
Some("Child subagent guidance.".to_string());
|
||||
let new_thread = harness
|
||||
.manager
|
||||
.start_thread(parent_config)
|
||||
.await
|
||||
.expect("start parent thread");
|
||||
let parent_thread_id = new_thread.thread_id;
|
||||
let parent_thread = new_thread.thread;
|
||||
parent_thread
|
||||
.inject_user_message_without_turn("parent task".to_string())
|
||||
.await;
|
||||
let turn_context = parent_thread.codex.session.new_default_turn().await;
|
||||
let parent_spawn_call_id = "spawn-call-last-n-usage-hints".to_string();
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.record_conversation_items(
|
||||
turn_context.as_ref(),
|
||||
&[
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "Parent root guidance.".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
},
|
||||
spawn_agent_call(&parent_spawn_call_id),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.ensure_rollout_materialized()
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.flush_rollout()
|
||||
.await
|
||||
.expect("parent rollout should flush");
|
||||
|
||||
let child_thread_id = harness
|
||||
.control
|
||||
.spawn_agent_with_metadata(
|
||||
child_config,
|
||||
text_input("child task"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
|
||||
fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("bounded forked spawn should sanitize parent usage hints")
|
||||
.thread_id;
|
||||
|
||||
let child_thread = harness
|
||||
.manager
|
||||
.get_thread(child_thread_id)
|
||||
.await
|
||||
.expect("child thread should be registered");
|
||||
let history = child_thread.codex.session.clone_history().await;
|
||||
assert!(
|
||||
history_contains_text(history.raw_items(), "parent task"),
|
||||
"bounded fork should retain the requested recent parent turn"
|
||||
);
|
||||
assert!(
|
||||
!history_contains_text(history.raw_items(), "Parent root guidance."),
|
||||
"bounded fork should strip stale parent root hints before the child rebuilds startup context"
|
||||
);
|
||||
|
||||
let _ = harness
|
||||
.control
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::watch;
|
||||
|
||||
#[cfg(test)]
|
||||
use codex_protocol::AgentPath;
|
||||
|
||||
pub(crate) struct Mailbox {
|
||||
tx: mpsc::UnboundedSender<InterAgentCommunication>,
|
||||
next_seq: AtomicU64,
|
||||
seq_tx: watch::Sender<u64>,
|
||||
}
|
||||
|
||||
pub(crate) struct MailboxReceiver {
|
||||
rx: mpsc::UnboundedReceiver<InterAgentCommunication>,
|
||||
pending_mails: VecDeque<InterAgentCommunication>,
|
||||
}
|
||||
|
||||
impl Mailbox {
|
||||
pub(crate) fn new() -> (Self, MailboxReceiver) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let (seq_tx, _) = watch::channel(0);
|
||||
(
|
||||
Self {
|
||||
tx,
|
||||
next_seq: AtomicU64::new(0),
|
||||
seq_tx,
|
||||
},
|
||||
MailboxReceiver {
|
||||
rx,
|
||||
pending_mails: VecDeque::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe(&self) -> watch::Receiver<u64> {
|
||||
self.seq_tx.subscribe()
|
||||
}
|
||||
|
||||
pub(crate) fn send(&self, communication: InterAgentCommunication) -> u64 {
|
||||
let seq = self.next_seq.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let _ = self.tx.send(communication);
|
||||
self.seq_tx.send_replace(seq);
|
||||
seq
|
||||
}
|
||||
}
|
||||
|
||||
impl MailboxReceiver {
|
||||
fn sync_pending_mails(&mut self) {
|
||||
while let Ok(mail) = self.rx.try_recv() {
|
||||
self.pending_mails.push_back(mail);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn has_pending(&mut self) -> bool {
|
||||
self.sync_pending_mails();
|
||||
!self.pending_mails.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn has_pending_trigger_turn(&mut self) -> bool {
|
||||
self.sync_pending_mails();
|
||||
self.pending_mails.iter().any(|mail| mail.trigger_turn)
|
||||
}
|
||||
|
||||
pub(crate) fn drain(&mut self) -> Vec<InterAgentCommunication> {
|
||||
self.sync_pending_mails();
|
||||
self.pending_mails.drain(..).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn make_mail(
|
||||
author: AgentPath,
|
||||
recipient: AgentPath,
|
||||
content: &str,
|
||||
trigger_turn: bool,
|
||||
) -> InterAgentCommunication {
|
||||
InterAgentCommunication::new(
|
||||
author,
|
||||
recipient,
|
||||
Vec::new(),
|
||||
content.to_string(),
|
||||
trigger_turn,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mailbox_assigns_monotonic_sequence_numbers() {
|
||||
let (mailbox, _receiver) = Mailbox::new();
|
||||
let mut seq_rx = mailbox.subscribe();
|
||||
|
||||
let seq_a = mailbox.send(make_mail(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
"one",
|
||||
/*trigger_turn*/ false,
|
||||
));
|
||||
let seq_b = mailbox.send(make_mail(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
"two",
|
||||
/*trigger_turn*/ false,
|
||||
));
|
||||
|
||||
seq_rx.changed().await.expect("first seq update");
|
||||
assert_eq!(*seq_rx.borrow(), seq_b);
|
||||
assert_eq!(seq_a, 1);
|
||||
assert_eq!(seq_b, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mailbox_drains_in_delivery_order() {
|
||||
let (mailbox, mut receiver) = Mailbox::new();
|
||||
let mail_one = make_mail(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
"one",
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
let mail_two = make_mail(
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
AgentPath::root(),
|
||||
"two",
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
|
||||
mailbox.send(mail_one.clone());
|
||||
mailbox.send(mail_two.clone());
|
||||
|
||||
assert_eq!(receiver.drain(), vec![mail_one, mail_two]);
|
||||
assert!(!receiver.has_pending());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mailbox_tracks_pending_trigger_turn_mail() {
|
||||
let (mailbox, mut receiver) = Mailbox::new();
|
||||
|
||||
mailbox.send(make_mail(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
"queued",
|
||||
/*trigger_turn*/ false,
|
||||
));
|
||||
assert!(!receiver.has_pending_trigger_turn());
|
||||
|
||||
mailbox.send(make_mail(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
"wake",
|
||||
/*trigger_turn*/ true,
|
||||
));
|
||||
assert!(receiver.has_pending_trigger_turn());
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
pub(crate) mod agent_resolver;
|
||||
pub(crate) mod control;
|
||||
pub(crate) mod mailbox;
|
||||
mod registry;
|
||||
pub(crate) mod role;
|
||||
pub(crate) mod status;
|
||||
|
||||
pub(crate) use codex_protocol::protocol::AgentStatus;
|
||||
pub(crate) use control::AgentControl;
|
||||
pub(crate) use mailbox::Mailbox;
|
||||
pub(crate) use mailbox::MailboxReceiver;
|
||||
pub(crate) use registry::exceeds_thread_spawn_depth_limit;
|
||||
pub(crate) use registry::next_thread_spawn_depth;
|
||||
pub(crate) use status::agent_status_from_event;
|
||||
|
||||
@@ -23,6 +23,7 @@ use codex_config::merge_toml_values;
|
||||
use codex_config::project_root_markers_from_config;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_features::Feature;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
@@ -58,13 +59,14 @@ impl<'a> AgentsMdManager<'a> {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
pub(crate) fn load_global_instructions(
|
||||
pub(crate) async fn load_global_instructions(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
codex_dir: Option<&AbsolutePathBuf>,
|
||||
) -> Option<LoadedAgentsMd> {
|
||||
let base = codex_dir?;
|
||||
for candidate in [LOCAL_AGENTS_MD_FILENAME, DEFAULT_AGENTS_MD_FILENAME] {
|
||||
let path = base.join(candidate);
|
||||
if let Ok(contents) = std::fs::read_to_string(&path) {
|
||||
if let Ok(contents) = fs.read_file_text(&path, /*sandbox*/ None).await {
|
||||
let trimmed = contents.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(LoadedAgentsMd {
|
||||
@@ -128,9 +130,11 @@ impl<'a> AgentsMdManager<'a> {
|
||||
|
||||
/// Returns all instruction source files included in the current config.
|
||||
pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec<AbsolutePathBuf> {
|
||||
let mut paths = Self::load_global_instructions(Some(&self.config.codex_home))
|
||||
.map(|loaded| vec![loaded.path])
|
||||
.unwrap_or_default();
|
||||
let mut paths =
|
||||
Self::load_global_instructions(LOCAL_FS.as_ref(), Some(&self.config.codex_home))
|
||||
.await
|
||||
.map(|loaded| vec![loaded.path])
|
||||
.unwrap_or_default();
|
||||
match self.agents_md_paths(fs).await {
|
||||
Ok(agents_md_paths) => paths.extend(agents_md_paths),
|
||||
Err(err) => {
|
||||
|
||||
@@ -384,6 +384,7 @@ fn build_arc_monitor_message_item(
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::CompactionTrigger
|
||||
| ResponseItem::ContextCompaction { .. }
|
||||
| ResponseItem::Other => None,
|
||||
}
|
||||
|
||||
@@ -2,13 +2,10 @@ pub use codex_api::ResponseEvent;
|
||||
use codex_config::types::Personality;
|
||||
use codex_protocol::error::Result;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_tools::ToolSpec;
|
||||
use futures::Stream;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::pin::Pin;
|
||||
use std::task::Context;
|
||||
use std::task::Poll;
|
||||
@@ -64,116 +61,10 @@ impl Default for Prompt {
|
||||
|
||||
impl Prompt {
|
||||
pub(crate) fn get_formatted_input(&self) -> Vec<ResponseItem> {
|
||||
let mut input = self.input.clone();
|
||||
|
||||
// when using the *Freeform* apply_patch tool specifically, tool outputs
|
||||
// should be structured text, not json. Do NOT reserialize when using
|
||||
// the Function tool - note that this differs from the check above for
|
||||
// instructions. We declare the result as a named variable for clarity.
|
||||
let is_freeform_apply_patch_tool_present = self.tools.iter().any(|tool| match tool {
|
||||
ToolSpec::Freeform(f) => f.name == "apply_patch",
|
||||
_ => false,
|
||||
});
|
||||
if is_freeform_apply_patch_tool_present {
|
||||
reserialize_shell_outputs(&mut input);
|
||||
}
|
||||
|
||||
input
|
||||
self.input.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn reserialize_shell_outputs(items: &mut [ResponseItem]) {
|
||||
let mut shell_call_ids: HashSet<String> = HashSet::new();
|
||||
|
||||
items.iter_mut().for_each(|item| match item {
|
||||
ResponseItem::LocalShellCall { call_id, id, .. } => {
|
||||
if let Some(identifier) = call_id.clone().or_else(|| id.clone()) {
|
||||
shell_call_ids.insert(identifier);
|
||||
}
|
||||
}
|
||||
ResponseItem::CustomToolCall {
|
||||
id: _,
|
||||
status: _,
|
||||
call_id,
|
||||
name,
|
||||
input: _,
|
||||
} => {
|
||||
if name == "apply_patch" {
|
||||
shell_call_ids.insert(call_id.clone());
|
||||
}
|
||||
}
|
||||
ResponseItem::FunctionCall { name, call_id, .. }
|
||||
if is_shell_tool_name(name) || name == "apply_patch" =>
|
||||
{
|
||||
shell_call_ids.insert(call_id.clone());
|
||||
}
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id, output, ..
|
||||
}
|
||||
| ResponseItem::CustomToolCallOutput {
|
||||
call_id, output, ..
|
||||
} => {
|
||||
if shell_call_ids.remove(call_id)
|
||||
&& let Some(structured) = output
|
||||
.text_content()
|
||||
.and_then(parse_structured_shell_output)
|
||||
{
|
||||
output.body = FunctionCallOutputBody::Text(structured);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
}
|
||||
|
||||
fn is_shell_tool_name(name: &str) -> bool {
|
||||
name == "shell"
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ExecOutputJson {
|
||||
output: String,
|
||||
metadata: ExecOutputMetadataJson,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ExecOutputMetadataJson {
|
||||
exit_code: i32,
|
||||
duration_seconds: f32,
|
||||
}
|
||||
|
||||
fn parse_structured_shell_output(raw: &str) -> Option<String> {
|
||||
let parsed: ExecOutputJson = serde_json::from_str(raw).ok()?;
|
||||
Some(build_structured_output(&parsed))
|
||||
}
|
||||
|
||||
fn build_structured_output(parsed: &ExecOutputJson) -> String {
|
||||
let mut sections = Vec::new();
|
||||
sections.push(format!("Exit code: {}", parsed.metadata.exit_code));
|
||||
sections.push(format!(
|
||||
"Wall time: {} seconds",
|
||||
parsed.metadata.duration_seconds
|
||||
));
|
||||
|
||||
let mut output = parsed.output.clone();
|
||||
if let Some((stripped, total_lines)) = strip_total_output_header(&parsed.output) {
|
||||
sections.push(format!("Total output lines: {total_lines}"));
|
||||
output = stripped.to_string();
|
||||
}
|
||||
|
||||
sections.push("Output:".to_string());
|
||||
sections.push(output);
|
||||
|
||||
sections.join("\n")
|
||||
}
|
||||
|
||||
fn strip_total_output_header(output: &str) -> Option<(&str, u32)> {
|
||||
let after_prefix = output.strip_prefix("Total output lines: ")?;
|
||||
let (total_segment, remainder) = after_prefix.split_once('\n')?;
|
||||
let total_lines = total_segment.parse::<u32>().ok()?;
|
||||
let remainder = remainder.strip_prefix('\n').unwrap_or(remainder);
|
||||
Some((remainder, total_lines))
|
||||
}
|
||||
|
||||
pub struct ResponseStream {
|
||||
pub(crate) rx_event: mpsc::Receiver<Result<ResponseEvent>>,
|
||||
/// Signals the mapper task that the consumer stopped polling before the
|
||||
|
||||
@@ -3,7 +3,6 @@ use codex_api::ResponsesApiRequest;
|
||||
use codex_api::TextControls;
|
||||
use codex_api::create_text_param_for_request;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
@@ -166,65 +165,3 @@ fn serializes_flex_service_tier_when_set() {
|
||||
Some("flex")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserializes_shell_outputs_for_function_and_custom_tool_calls() {
|
||||
let raw_output = r#"{"output":"hello","metadata":{"exit_code":0,"duration_seconds":0.5}}"#;
|
||||
let expected_output = "Exit code: 0\nWall time: 0.5 seconds\nOutput:\nhello";
|
||||
let mut items = vec![
|
||||
ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "shell".to_string(),
|
||||
namespace: None,
|
||||
arguments: "{}".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
},
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text(raw_output.to_string()),
|
||||
},
|
||||
ResponseItem::CustomToolCall {
|
||||
id: None,
|
||||
status: None,
|
||||
call_id: "call-2".to_string(),
|
||||
name: "apply_patch".to_string(),
|
||||
input: "*** Begin Patch".to_string(),
|
||||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "call-2".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_text(raw_output.to_string()),
|
||||
},
|
||||
];
|
||||
|
||||
reserialize_shell_outputs(&mut items);
|
||||
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![
|
||||
ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "shell".to_string(),
|
||||
namespace: None,
|
||||
arguments: "{}".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
},
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text(expected_output.to_string()),
|
||||
},
|
||||
ResponseItem::CustomToolCall {
|
||||
id: None,
|
||||
status: None,
|
||||
call_id: "call-2".to_string(),
|
||||
name: "apply_patch".to_string(),
|
||||
input: "*** Begin Patch".to_string(),
|
||||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "call-2".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_text(expected_output.to_string()),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -192,6 +192,7 @@ pub(crate) async fn run_codex_thread_one_shot(
|
||||
items: input,
|
||||
final_output_json_schema,
|
||||
responsesapi_client_metadata: None,
|
||||
thread_settings: Default::default(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -59,9 +59,13 @@ pub struct ThreadConfigSnapshot {
|
||||
pub permission_profile: PermissionProfile,
|
||||
pub active_permission_profile: Option<ActivePermissionProfile>,
|
||||
pub cwd: AbsolutePathBuf,
|
||||
pub workspace_roots: Vec<AbsolutePathBuf>,
|
||||
pub profile_workspace_roots: Vec<AbsolutePathBuf>,
|
||||
pub ephemeral: bool,
|
||||
pub reasoning_effort: Option<ReasoningEffort>,
|
||||
pub reasoning_summary: Option<ReasoningSummary>,
|
||||
pub personality: Option<Personality>,
|
||||
pub collaboration_mode: CollaborationMode,
|
||||
pub session_source: SessionSource,
|
||||
pub thread_source: Option<ThreadSource>,
|
||||
}
|
||||
@@ -78,10 +82,12 @@ impl ThreadConfigSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn context overrides that app-server validates before starting a turn.
|
||||
/// Thread settings overrides that app-server validates before starting a turn.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CodexThreadTurnContextOverrides {
|
||||
pub struct CodexThreadSettingsOverrides {
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub workspace_roots: Option<Vec<AbsolutePathBuf>>,
|
||||
pub profile_workspace_roots: Option<Vec<AbsolutePathBuf>>,
|
||||
pub approval_policy: Option<AskForApproval>,
|
||||
pub approvals_reviewer: Option<ApprovalsReviewer>,
|
||||
pub sandbox_policy: Option<SandboxPolicy>,
|
||||
@@ -140,7 +146,7 @@ impl CodexThread {
|
||||
self.codex.session_loop_termination.clone().await;
|
||||
}
|
||||
|
||||
pub(crate) fn emit_thread_resume_lifecycle(&self) {
|
||||
pub(crate) async fn emit_thread_resume_lifecycle(&self) {
|
||||
for contributor in self
|
||||
.codex
|
||||
.session
|
||||
@@ -148,10 +154,12 @@ impl CodexThread {
|
||||
.extensions
|
||||
.thread_lifecycle_contributors()
|
||||
{
|
||||
contributor.on_thread_resume(codex_extension_api::ThreadResumeInput {
|
||||
session_store: &self.codex.session.services.session_extension_data,
|
||||
thread_store: &self.codex.session.services.thread_extension_data,
|
||||
});
|
||||
contributor
|
||||
.on_thread_resume(codex_extension_api::ThreadResumeInput {
|
||||
session_store: &self.codex.session.services.session_extension_data,
|
||||
thread_store: &self.codex.session.services.thread_extension_data,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,13 +259,23 @@ impl CodexThread {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Validate persistent turn context overrides without committing them.
|
||||
pub async fn validate_turn_context_overrides(
|
||||
/// Preview persistent thread settings overrides without committing them.
|
||||
pub async fn preview_thread_settings_overrides(
|
||||
&self,
|
||||
overrides: CodexThreadTurnContextOverrides,
|
||||
) -> ConstraintResult<()> {
|
||||
let CodexThreadTurnContextOverrides {
|
||||
overrides: CodexThreadSettingsOverrides,
|
||||
) -> ConstraintResult<ThreadConfigSnapshot> {
|
||||
let updates = self.thread_settings_update(overrides).await;
|
||||
self.codex.session.preview_settings(&updates).await
|
||||
}
|
||||
|
||||
async fn thread_settings_update(
|
||||
&self,
|
||||
overrides: CodexThreadSettingsOverrides,
|
||||
) -> SessionSettingsUpdate {
|
||||
let CodexThreadSettingsOverrides {
|
||||
cwd,
|
||||
workspace_roots,
|
||||
profile_workspace_roots,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
@@ -281,8 +299,10 @@ impl CodexThread {
|
||||
.with_updates(model, effort, /*developer_instructions*/ None)
|
||||
};
|
||||
|
||||
let updates = SessionSettingsUpdate {
|
||||
SessionSettingsUpdate {
|
||||
cwd,
|
||||
workspace_roots,
|
||||
profile_workspace_roots,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
@@ -294,8 +314,7 @@ impl CodexThread {
|
||||
service_tier,
|
||||
personality,
|
||||
..Default::default()
|
||||
};
|
||||
self.codex.session.validate_settings(&updates).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Use sparingly: this is intended to be removed soon.
|
||||
@@ -373,6 +392,7 @@ impl CodexThread {
|
||||
{
|
||||
self.codex
|
||||
.session
|
||||
.input_queue
|
||||
.queue_response_items_for_next_turn(items)
|
||||
.await;
|
||||
self.codex.session.maybe_start_turn_for_pending_work().await;
|
||||
|
||||
@@ -282,7 +282,6 @@ async fn run_compact_task_inner_impl(
|
||||
};
|
||||
sess.replace_compacted_history(new_history, reference_context_item, compacted_item)
|
||||
.await;
|
||||
client_session.reset_websocket_session();
|
||||
sess.recompute_token_usage(&turn_context).await;
|
||||
|
||||
sess.emit_turn_item_completed(&turn_context, compaction_item)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::Prompt;
|
||||
@@ -174,9 +173,6 @@ async fn run_remote_compact_task_inner_impl(
|
||||
let tool_router = built_tools(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
&prompt_input,
|
||||
&HashSet::new(),
|
||||
/*skills_outcome*/ None,
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await?;
|
||||
@@ -302,6 +298,7 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
|
||||
ResponseItem::Message { role, .. } if role == "assistant" => true,
|
||||
ResponseItem::Message { .. } => false,
|
||||
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true,
|
||||
ResponseItem::CompactionTrigger => false,
|
||||
ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::Prompt;
|
||||
@@ -12,6 +11,10 @@ use crate::compact_remote::build_compact_request_log_data;
|
||||
use crate::compact_remote::log_remote_compact_failure;
|
||||
use crate::compact_remote::process_compacted_history;
|
||||
use crate::compact_remote::trim_function_call_history_to_fit_context_window;
|
||||
use crate::hook_runtime::PostCompactHookOutcome;
|
||||
use crate::hook_runtime::PreCompactHookOutcome;
|
||||
use crate::hook_runtime::run_post_compact_hooks;
|
||||
use crate::hook_runtime::run_pre_compact_hooks;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn::built_tools;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
@@ -97,6 +100,21 @@ async fn run_remote_compact_task_inner(
|
||||
phase,
|
||||
)
|
||||
.await;
|
||||
let pre_compact_outcome = run_pre_compact_hooks(sess, turn_context, trigger).await;
|
||||
match pre_compact_outcome {
|
||||
PreCompactHookOutcome::Continue => {}
|
||||
PreCompactHookOutcome::Stopped { reason } => {
|
||||
let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string());
|
||||
attempt
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
codex_analytics::CompactionStatus::Interrupted,
|
||||
Some(error),
|
||||
)
|
||||
.await;
|
||||
return Err(CodexErr::TurnAborted);
|
||||
}
|
||||
}
|
||||
let result = run_remote_compact_task_inner_impl(
|
||||
sess,
|
||||
turn_context,
|
||||
@@ -104,13 +122,16 @@ async fn run_remote_compact_task_inner(
|
||||
initial_context_injection,
|
||||
)
|
||||
.await;
|
||||
attempt
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
compaction_status_from_result(&result),
|
||||
result.as_ref().err().map(ToString::to_string),
|
||||
)
|
||||
.await;
|
||||
let status = compaction_status_from_result(&result);
|
||||
let error = result.as_ref().err().map(ToString::to_string);
|
||||
if result.is_ok() {
|
||||
let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await;
|
||||
if let PostCompactHookOutcome::Stopped = post_compact_outcome {
|
||||
attempt.track(sess.as_ref(), status, error).await;
|
||||
return Err(CodexErr::TurnAborted);
|
||||
}
|
||||
}
|
||||
attempt.track(sess.as_ref(), status, error.clone()).await;
|
||||
if let Err(err) = result {
|
||||
let event = EventMsg::Error(
|
||||
err.to_error_event(Some("Error running remote compact task".to_string())),
|
||||
@@ -158,16 +179,11 @@ async fn run_remote_compact_task_inner_impl(
|
||||
let tool_router = built_tools(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
&prompt_input,
|
||||
&HashSet::new(),
|
||||
/*skills_outcome*/ None,
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await?;
|
||||
let mut input = prompt_input.clone();
|
||||
input.push(ResponseItem::ContextCompaction {
|
||||
encrypted_content: None,
|
||||
});
|
||||
input.push(ResponseItem::CompactionTrigger);
|
||||
let prompt = Prompt {
|
||||
input,
|
||||
tools: tool_router.model_visible_specs(),
|
||||
@@ -276,38 +292,25 @@ async fn run_remote_compaction_request_v2(
|
||||
Err(err)
|
||||
})
|
||||
.await?;
|
||||
collect_context_compaction_output(stream).await
|
||||
collect_compaction_output(stream).await
|
||||
}
|
||||
|
||||
async fn collect_context_compaction_output(
|
||||
async fn collect_compaction_output(
|
||||
mut stream: ResponseStream,
|
||||
) -> CodexResult<(ResponseItem, String)> {
|
||||
let mut output_item_count = 0usize;
|
||||
let mut context_compaction_count = 0usize;
|
||||
let mut context_compaction_output = None;
|
||||
let mut compaction_count = 0usize;
|
||||
let mut compaction_output = None;
|
||||
let mut completed_response_id = None;
|
||||
while let Some(event) = stream.next().await {
|
||||
match event? {
|
||||
ResponseEvent::OutputItemDone(item) => {
|
||||
output_item_count += 1;
|
||||
match item {
|
||||
ResponseItem::ContextCompaction {
|
||||
encrypted_content: Some(_),
|
||||
} => {
|
||||
context_compaction_count += 1;
|
||||
if context_compaction_output.is_none() {
|
||||
context_compaction_output = Some(item);
|
||||
}
|
||||
if let ResponseItem::Compaction { .. } = item {
|
||||
compaction_count += 1;
|
||||
if compaction_output.is_none() {
|
||||
compaction_output = Some(item);
|
||||
}
|
||||
ResponseItem::ContextCompaction {
|
||||
encrypted_content: None,
|
||||
} => {
|
||||
return Err(CodexErr::Fatal(
|
||||
"remote compaction v2 returned context_compaction without encrypted_content"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ResponseEvent::Completed { response_id, .. } => {
|
||||
@@ -324,16 +327,16 @@ async fn collect_context_compaction_output(
|
||||
));
|
||||
};
|
||||
|
||||
if context_compaction_count != 1 {
|
||||
if compaction_count != 1 {
|
||||
return Err(CodexErr::Fatal(format!(
|
||||
"remote compaction v2 expected exactly one context_compaction output item, got {context_compaction_count} from {output_item_count} output items"
|
||||
"remote compaction v2 expected exactly one compaction output item, got {compaction_count} from {output_item_count} output items"
|
||||
)));
|
||||
}
|
||||
|
||||
let Some(context_compaction_output) = context_compaction_output else {
|
||||
unreachable!("context compaction output must exist when count is exactly one");
|
||||
let Some(compaction_output) = compaction_output else {
|
||||
unreachable!("compaction output must exist when count is exactly one");
|
||||
};
|
||||
Ok((context_compaction_output, response_id))
|
||||
Ok((compaction_output, response_id))
|
||||
}
|
||||
|
||||
fn build_v2_compacted_history(
|
||||
@@ -410,8 +413,8 @@ mod tests {
|
||||
encrypted_content: "old".to_string(),
|
||||
},
|
||||
];
|
||||
let output = ResponseItem::ContextCompaction {
|
||||
encrypted_content: Some("new".to_string()),
|
||||
let output = ResponseItem::Compaction {
|
||||
encrypted_content: "new".to_string(),
|
||||
};
|
||||
|
||||
let history = build_v2_compacted_history(&input, output.clone());
|
||||
@@ -428,9 +431,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collect_context_compaction_output_accepts_additional_output_items() {
|
||||
let context_compaction = ResponseItem::ContextCompaction {
|
||||
encrypted_content: Some("encrypted".to_string()),
|
||||
async fn collect_compaction_output_accepts_additional_output_items() {
|
||||
let compaction = ResponseItem::Compaction {
|
||||
encrypted_content: "encrypted".to_string(),
|
||||
};
|
||||
let stream = response_stream(vec![
|
||||
Ok(ResponseEvent::OutputItemDone(message(
|
||||
@@ -438,7 +441,7 @@ mod tests {
|
||||
"IGNORED_COMPACT_REPLY",
|
||||
Some(MessagePhase::FinalAnswer),
|
||||
))),
|
||||
Ok(ResponseEvent::OutputItemDone(context_compaction.clone())),
|
||||
Ok(ResponseEvent::OutputItemDone(compaction.clone())),
|
||||
Ok(ResponseEvent::Completed {
|
||||
response_id: "resp-compact".to_string(),
|
||||
token_usage: None,
|
||||
@@ -446,11 +449,11 @@ mod tests {
|
||||
}),
|
||||
]);
|
||||
|
||||
let (output, response_id) = collect_context_compaction_output(stream)
|
||||
let (output, response_id) = collect_compaction_output(stream)
|
||||
.await
|
||||
.expect("context compaction should be collected");
|
||||
.expect("compaction should be collected");
|
||||
|
||||
assert_eq!(output, context_compaction);
|
||||
assert_eq!(output, compaction);
|
||||
assert_eq!(response_id, "resp-compact");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -821,6 +820,7 @@ flag = false
|
||||
async fn managed_preferences_expand_home_directory_in_workspace_write_roots() -> anyhow::Result<()>
|
||||
{
|
||||
use base64::Engine;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return Ok(());
|
||||
@@ -913,14 +913,7 @@ allowed_sandbox_modes = ["read-only"]
|
||||
state
|
||||
.requirements()
|
||||
.permission_profile
|
||||
.can_set(&PermissionProfile::from_legacy_sandbox_policy(
|
||||
&SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
},
|
||||
))
|
||||
.can_set(&PermissionProfile::workspace_write())
|
||||
.is_err()
|
||||
);
|
||||
|
||||
@@ -1233,11 +1226,9 @@ allowed_sandbox_modes = ["read-only"]
|
||||
let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?;
|
||||
|
||||
assert_eq!(
|
||||
config_requirements.permission_profile.can_set(
|
||||
&PermissionProfile::from_legacy_sandbox_policy(
|
||||
&SandboxPolicy::new_workspace_write_policy()
|
||||
)
|
||||
),
|
||||
config_requirements
|
||||
.permission_profile
|
||||
.can_set(&PermissionProfile::workspace_write()),
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "sandbox_mode",
|
||||
candidate: "WorkspaceWrite".into(),
|
||||
@@ -1570,9 +1561,7 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow::
|
||||
layers
|
||||
.requirements()
|
||||
.permission_profile
|
||||
.can_set(&PermissionProfile::from_legacy_sandbox_policy(
|
||||
&SandboxPolicy::new_workspace_write_policy()
|
||||
))
|
||||
.can_set(&PermissionProfile::workspace_write())
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
@@ -2353,6 +2342,7 @@ model = "project-model"
|
||||
model_instructions_file = "instructions.md"
|
||||
openai_base_url = "https://attacker.example/v1"
|
||||
chatgpt_base_url = "https://attacker.example/backend-api"
|
||||
apps_mcp_product_sku = "attacker"
|
||||
model_provider = "attacker"
|
||||
notify = ["sh", "-c", "echo attacker"]
|
||||
profile = "attacker"
|
||||
@@ -2404,6 +2394,7 @@ wire_api = "responses"
|
||||
let ignored_project_config_keys = vec![
|
||||
"openai_base_url",
|
||||
"chatgpt_base_url",
|
||||
"apps_mcp_product_sku",
|
||||
"model_provider",
|
||||
"model_providers",
|
||||
"notify",
|
||||
|
||||
@@ -740,6 +740,7 @@ default_permissions = "workspace"
|
||||
|
||||
[permissions.workspace.filesystem]
|
||||
":minimal" = "read"
|
||||
"/tmp/secret.env" = "deny"
|
||||
|
||||
[permissions.workspace.filesystem.":workspace_roots"]
|
||||
"." = "write"
|
||||
@@ -777,6 +778,10 @@ allow_upstream_proxy = false
|
||||
":minimal".to_string(),
|
||||
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
|
||||
),
|
||||
(
|
||||
"/tmp/secret.env".to_string(),
|
||||
FilesystemPermissionToml::Access(FileSystemAccessMode::Deny),
|
||||
),
|
||||
(
|
||||
":workspace_roots".to_string(),
|
||||
FilesystemPermissionToml::Scoped(BTreeMap::from([
|
||||
@@ -1453,6 +1458,30 @@ async fn permission_profile_override_populates_runtime_permissions() -> std::io:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_snapshot_setter_preserves_permission_constraints() {
|
||||
let initial_profile = PermissionProfile::read_only();
|
||||
let mut permissions = Permissions::from_approval_and_profile(
|
||||
Constrained::allow_any(AskForApproval::Never),
|
||||
Constrained::allow_only(initial_profile.clone()),
|
||||
)
|
||||
.expect("initial permissions should satisfy constraints");
|
||||
|
||||
let err = permissions
|
||||
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
|
||||
PermissionProfile::workspace_write(),
|
||||
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE),
|
||||
))
|
||||
.expect_err("workspace profile should violate read-only constraint");
|
||||
|
||||
assert_eq!(permissions.permission_profile(), &initial_profile);
|
||||
assert_eq!(permissions.active_permission_profile(), None);
|
||||
assert!(
|
||||
matches!(err, ConstraintError::InvalidValue { .. }),
|
||||
"expected invalid value constraint error, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn permission_profile_override_preserves_managed_unrestricted_filesystem()
|
||||
-> std::io::Result<()> {
|
||||
@@ -1676,7 +1705,7 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std:
|
||||
":workspace_roots".to_string(),
|
||||
FilesystemPermissionToml::Scoped(BTreeMap::from([
|
||||
(".".to_string(), FileSystemAccessMode::Write),
|
||||
("**/*.env".to_string(), FileSystemAccessMode::None),
|
||||
("**/*.env".to_string(), FileSystemAccessMode::Deny),
|
||||
])),
|
||||
)]),
|
||||
}),
|
||||
@@ -1715,7 +1744,7 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std:
|
||||
path: FileSystemPath::GlobPattern {
|
||||
pattern: expected_pattern,
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
access: FileSystemAccessMode::Deny,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1798,6 +1827,8 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(config.explicit_permission_profile_mode);
|
||||
assert!(config.custom_permission_profile_ids.is_empty());
|
||||
let policy = config.permissions.file_system_sandbox_policy();
|
||||
assert_eq!(
|
||||
config
|
||||
@@ -1905,9 +1936,28 @@ async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cwd_abs = cwd.abs();
|
||||
let runtime_root_abs = runtime_root.abs();
|
||||
let profile_root_abs = profile_root.abs();
|
||||
assert_eq!(
|
||||
config.workspace_roots,
|
||||
vec![cwd_abs.clone(), runtime_root_abs.clone()]
|
||||
);
|
||||
assert_eq!(
|
||||
config.permissions.workspace_roots(),
|
||||
&[cwd_abs.clone(), runtime_root_abs.clone()]
|
||||
);
|
||||
assert_eq!(
|
||||
config.effective_workspace_roots(),
|
||||
vec![
|
||||
cwd_abs.clone(),
|
||||
runtime_root_abs.clone(),
|
||||
profile_root_abs.clone()
|
||||
]
|
||||
);
|
||||
|
||||
let policy = config.permissions.file_system_sandbox_policy();
|
||||
for root in [cwd.abs(), runtime_root.abs(), profile_root_abs.clone()] {
|
||||
for root in [cwd_abs, runtime_root_abs, profile_root_abs.clone()] {
|
||||
assert!(
|
||||
policy.can_write_path_with_cwd(root.as_path(), cwd.as_path()),
|
||||
"expected workspace root to be writable, policy: {policy:?}"
|
||||
@@ -2334,6 +2384,10 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root()
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.custom_permission_profile_ids,
|
||||
vec!["workspace".to_string()]
|
||||
);
|
||||
let memories_root = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(
|
||||
codex_home.path().join("memories"),
|
||||
)?)?;
|
||||
@@ -3806,6 +3860,63 @@ async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config()
|
||||
mcp_config.configured_mcp_servers.get("sample"),
|
||||
Some(&http_mcp("https://sample.example/mcp"))
|
||||
);
|
||||
assert_eq!(
|
||||
mcp_config.plugin_ids_by_mcp_server_name,
|
||||
HashMap::from([("sample".to_string(), "sample@test".to_string())])
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn to_mcp_config_omits_plugin_id_when_user_server_shadows_plugin_mcp() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache")
|
||||
.join("test/sample/local");
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".mcp.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"sample": {
|
||||
"type": "http",
|
||||
"url": "https://plugin.example/mcp"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[features]
|
||||
plugins = true
|
||||
|
||||
[mcp_servers.sample]
|
||||
url = "https://user.example/mcp"
|
||||
|
||||
[plugins."sample@test"]
|
||||
enabled = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
let mcp_config = config.to_mcp_config(&plugins_manager).await;
|
||||
|
||||
assert_eq!(
|
||||
mcp_config.configured_mcp_servers.get("sample"),
|
||||
Some(&http_mcp("https://user.example/mcp"))
|
||||
);
|
||||
assert!(mcp_config.plugin_ids_by_mcp_server_name.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -4260,8 +4371,7 @@ fn web_search_mode_disabled_overrides_legacy_request() {
|
||||
#[test]
|
||||
fn web_search_mode_for_turn_uses_preference_for_read_only() {
|
||||
let web_search_mode = Constrained::allow_any(WebSearchMode::Cached);
|
||||
let permission_profile =
|
||||
PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_read_only_policy());
|
||||
let permission_profile = PermissionProfile::read_only();
|
||||
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &permission_profile);
|
||||
|
||||
assert_eq!(mode, WebSearchMode::Cached);
|
||||
@@ -4858,12 +4968,14 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<(
|
||||
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
|
||||
config.apps_mcp_path_override = Some("/custom/mcp".to_string());
|
||||
config.apps_mcp_product_sku = Some("tpp".to_string());
|
||||
let mcp_config = config.to_mcp_config(&plugins_manager).await;
|
||||
assert!(mcp_config.apps_enabled);
|
||||
assert_eq!(
|
||||
mcp_config.apps_mcp_path_override.as_deref(),
|
||||
Some("/custom/mcp")
|
||||
);
|
||||
assert_eq!(mcp_config.apps_mcp_product_sku.as_deref(), Some("tpp"));
|
||||
|
||||
let _ = config.features.disable(Feature::Apps);
|
||||
let mcp_config = config.to_mcp_config(&plugins_manager).await;
|
||||
@@ -7604,6 +7716,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
review_model: None,
|
||||
model_context_window: None,
|
||||
model_auto_compact_token_limit: None,
|
||||
model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total,
|
||||
service_tier: None,
|
||||
model_provider_id: "openai".to_string(),
|
||||
model_provider: fixture.openai_provider.clone(),
|
||||
@@ -7620,6 +7733,8 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
windows_sandbox_mode: None,
|
||||
windows_sandbox_private_desktop: true,
|
||||
},
|
||||
explicit_permission_profile_mode: false,
|
||||
custom_permission_profile_ids: Vec::new(),
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
enforce_residency: Constrained::allow_any(/*initial_value*/ None),
|
||||
user_instructions: None,
|
||||
@@ -7673,6 +7788,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
personality: Some(Personality::Pragmatic),
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
apps_mcp_path_override: None,
|
||||
apps_mcp_product_sku: None,
|
||||
realtime_audio: RealtimeAudioConfig::default(),
|
||||
experimental_realtime_start_instructions: None,
|
||||
experimental_realtime_ws_base_url: None,
|
||||
@@ -8054,6 +8170,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
review_model: None,
|
||||
model_context_window: None,
|
||||
model_auto_compact_token_limit: None,
|
||||
model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total,
|
||||
service_tier: None,
|
||||
model_provider_id: "openai-custom".to_string(),
|
||||
model_provider: fixture.openai_custom_provider.clone(),
|
||||
@@ -8070,6 +8187,8 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
windows_sandbox_mode: None,
|
||||
windows_sandbox_private_desktop: true,
|
||||
},
|
||||
explicit_permission_profile_mode: false,
|
||||
custom_permission_profile_ids: Vec::new(),
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
enforce_residency: Constrained::allow_any(/*initial_value*/ None),
|
||||
user_instructions: None,
|
||||
@@ -8123,6 +8242,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
personality: Some(Personality::Pragmatic),
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
apps_mcp_path_override: None,
|
||||
apps_mcp_product_sku: None,
|
||||
realtime_audio: RealtimeAudioConfig::default(),
|
||||
experimental_realtime_start_instructions: None,
|
||||
experimental_realtime_ws_base_url: None,
|
||||
@@ -8218,6 +8338,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
review_model: None,
|
||||
model_context_window: None,
|
||||
model_auto_compact_token_limit: None,
|
||||
model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total,
|
||||
service_tier: None,
|
||||
model_provider_id: "openai".to_string(),
|
||||
model_provider: fixture.openai_provider.clone(),
|
||||
@@ -8234,6 +8355,8 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
windows_sandbox_mode: None,
|
||||
windows_sandbox_private_desktop: true,
|
||||
},
|
||||
explicit_permission_profile_mode: false,
|
||||
custom_permission_profile_ids: Vec::new(),
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
enforce_residency: Constrained::allow_any(/*initial_value*/ None),
|
||||
user_instructions: None,
|
||||
@@ -8287,6 +8410,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
personality: Some(Personality::Pragmatic),
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
apps_mcp_path_override: None,
|
||||
apps_mcp_product_sku: None,
|
||||
realtime_audio: RealtimeAudioConfig::default(),
|
||||
experimental_realtime_start_instructions: None,
|
||||
experimental_realtime_ws_base_url: None,
|
||||
@@ -8367,6 +8491,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
review_model: None,
|
||||
model_context_window: None,
|
||||
model_auto_compact_token_limit: None,
|
||||
model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total,
|
||||
service_tier: None,
|
||||
model_provider_id: "openai".to_string(),
|
||||
model_provider: fixture.openai_provider.clone(),
|
||||
@@ -8383,6 +8508,8 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
windows_sandbox_mode: None,
|
||||
windows_sandbox_private_desktop: true,
|
||||
},
|
||||
explicit_permission_profile_mode: false,
|
||||
custom_permission_profile_ids: Vec::new(),
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
enforce_residency: Constrained::allow_any(/*initial_value*/ None),
|
||||
user_instructions: None,
|
||||
@@ -8436,6 +8563,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
personality: Some(Personality::Pragmatic),
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
apps_mcp_path_override: None,
|
||||
apps_mcp_product_sku: None,
|
||||
realtime_audio: RealtimeAudioConfig::default(),
|
||||
experimental_realtime_start_instructions: None,
|
||||
experimental_realtime_ws_base_url: None,
|
||||
@@ -8877,29 +9005,26 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb
|
||||
let active_project = ProjectConfig {
|
||||
trust_level: Some(TrustLevel::Trusted),
|
||||
};
|
||||
let constrained = Constrained::new(
|
||||
PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|
||||
|candidate| {
|
||||
if matches!(
|
||||
candidate,
|
||||
PermissionProfile::Managed {
|
||||
file_system: ManagedFileSystemPermissions::Restricted { entries, .. },
|
||||
..
|
||||
} if entries
|
||||
.iter()
|
||||
.any(|entry| entry.access.can_write())
|
||||
) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "sandbox_mode",
|
||||
candidate: format!("{candidate:?}"),
|
||||
allowed: "[WorkspaceWrite]".to_string(),
|
||||
requirement_source: RequirementSource::Unknown,
|
||||
})
|
||||
}
|
||||
},
|
||||
)?;
|
||||
let constrained = Constrained::new(PermissionProfile::workspace_write(), |candidate| {
|
||||
if matches!(
|
||||
candidate,
|
||||
PermissionProfile::Managed {
|
||||
file_system: ManagedFileSystemPermissions::Restricted { entries, .. },
|
||||
..
|
||||
} if entries
|
||||
.iter()
|
||||
.any(|entry| entry.access.can_write())
|
||||
) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "sandbox_mode",
|
||||
candidate: format!("{candidate:?}"),
|
||||
allowed: "[WorkspaceWrite]".to_string(),
|
||||
requirement_source: RequirementSource::Unknown,
|
||||
})
|
||||
}
|
||||
})?;
|
||||
|
||||
let resolution = derive_legacy_sandbox_policy_for_test(
|
||||
&cfg,
|
||||
@@ -9158,6 +9283,27 @@ path = "/custom/mcp"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_loads_apps_mcp_product_sku_from_toml() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let toml = r#"
|
||||
model = "gpt-5.4"
|
||||
apps_mcp_product_sku = "tpp"
|
||||
"#;
|
||||
let cfg: ConfigToml =
|
||||
toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP SKU");
|
||||
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigOverrides::default(),
|
||||
codex_home.abs(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(config.apps_mcp_product_sku.as_deref(), Some("tpp"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -10046,6 +10192,7 @@ usage_hint_enabled = false
|
||||
usage_hint_text = "Custom delegation guidance."
|
||||
root_agent_usage_hint_text = "Root guidance."
|
||||
subagent_usage_hint_text = "Subagent guidance."
|
||||
tool_namespace = "agents"
|
||||
hide_spawn_agent_metadata = true
|
||||
non_code_mode_only = true
|
||||
"#,
|
||||
@@ -10076,6 +10223,10 @@ non_code_mode_only = true
|
||||
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
|
||||
Some("Subagent guidance.")
|
||||
);
|
||||
assert_eq!(
|
||||
config.multi_agent_v2.tool_namespace.as_deref(),
|
||||
Some("agents")
|
||||
);
|
||||
assert!(config.multi_agent_v2.hide_spawn_agent_metadata);
|
||||
assert!(config.multi_agent_v2.non_code_mode_only);
|
||||
|
||||
@@ -10098,6 +10249,7 @@ usage_hint_enabled = true
|
||||
usage_hint_text = "base hint"
|
||||
root_agent_usage_hint_text = "base root hint"
|
||||
subagent_usage_hint_text = "base subagent hint"
|
||||
tool_namespace = "base_agents"
|
||||
hide_spawn_agent_metadata = true
|
||||
non_code_mode_only = false
|
||||
|
||||
@@ -10110,6 +10262,7 @@ usage_hint_enabled = false
|
||||
usage_hint_text = "profile hint"
|
||||
root_agent_usage_hint_text = "profile root hint"
|
||||
subagent_usage_hint_text = "profile subagent hint"
|
||||
tool_namespace = "profile_agents"
|
||||
hide_spawn_agent_metadata = false
|
||||
non_code_mode_only = true
|
||||
"#,
|
||||
@@ -10138,6 +10291,10 @@ non_code_mode_only = true
|
||||
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
|
||||
Some("profile subagent hint")
|
||||
);
|
||||
assert_eq!(
|
||||
config.multi_agent_v2.tool_namespace.as_deref(),
|
||||
Some("profile_agents")
|
||||
);
|
||||
assert!(!config.multi_agent_v2.hide_spawn_agent_metadata);
|
||||
assert!(config.multi_agent_v2.non_code_mode_only);
|
||||
|
||||
@@ -10398,6 +10555,42 @@ default_wait_timeout_ms = 2500
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_rejects_invalid_tool_namespace() -> std::io::Result<()> {
|
||||
for (namespace, expected_message) in [
|
||||
(
|
||||
"bad namespace",
|
||||
"features.multi_agent_v2.tool_namespace must match ^[a-zA-Z0-9_-]+$",
|
||||
),
|
||||
(
|
||||
"functions",
|
||||
"features.multi_agent_v2.tool_namespace uses a reserved namespace: functions",
|
||||
),
|
||||
] {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
format!(
|
||||
r#"[features.multi_agent_v2]
|
||||
enabled = true
|
||||
tool_namespace = "{namespace}"
|
||||
"#
|
||||
),
|
||||
)?;
|
||||
|
||||
let err = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
.await
|
||||
.expect_err("invalid multi_agent_v2 tool namespace should fail");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert_eq!(err.to_string(), expected_message);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_session_thread_cap_one_disallows_subagents() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
+136
-46
@@ -77,6 +77,7 @@ use codex_model_provider_info::built_in_model_providers;
|
||||
use codex_model_provider_info::merge_configured_model_providers;
|
||||
use codex_models_manager::ModelsManagerConfig;
|
||||
use codex_protocol::config_types::AltScreenMode;
|
||||
use codex_protocol::config_types::AutoCompactTokenLimitScope;
|
||||
use codex_protocol::config_types::ForcedLoginMethod;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
@@ -107,6 +108,7 @@ use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -149,6 +151,7 @@ pub use managed_features::ManagedFeatures;
|
||||
pub use network_proxy_spec::NetworkProxySpec;
|
||||
pub use network_proxy_spec::StartedNetworkProxy;
|
||||
pub(crate) use permissions::resolve_permission_profile;
|
||||
pub use resolved_permission_profile::PermissionProfileSnapshot;
|
||||
pub(crate) use resolved_permission_profile::PermissionProfileState;
|
||||
|
||||
const DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS: i64 = 200;
|
||||
@@ -311,30 +314,13 @@ impl Permissions {
|
||||
///
|
||||
/// This is a trusted-state bridge for consumers of `SessionConfigured`.
|
||||
/// Config loading and app-server selection should resolve named profiles
|
||||
/// through config instead of constructing this pair directly.
|
||||
/// through config instead of constructing a snapshot directly.
|
||||
pub fn set_permission_profile_from_session_snapshot(
|
||||
&mut self,
|
||||
permission_profile: PermissionProfile,
|
||||
active_permission_profile: Option<ActivePermissionProfile>,
|
||||
snapshot: PermissionProfileSnapshot,
|
||||
) -> ConstraintResult<()> {
|
||||
self.set_permission_profile_from_session_snapshot_with_profile_workspace_roots(
|
||||
permission_profile,
|
||||
active_permission_profile,
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_permission_profile_from_session_snapshot_with_profile_workspace_roots(
|
||||
&mut self,
|
||||
permission_profile: PermissionProfile,
|
||||
active_permission_profile: Option<ActivePermissionProfile>,
|
||||
profile_workspace_roots: Vec<AbsolutePathBuf>,
|
||||
) -> ConstraintResult<()> {
|
||||
self.permission_profile_state.set_active_permission_profile(
|
||||
permission_profile,
|
||||
active_permission_profile,
|
||||
profile_workspace_roots,
|
||||
)
|
||||
self.permission_profile_state
|
||||
.set_permission_profile_snapshot(snapshot)
|
||||
}
|
||||
|
||||
/// Replace the current permission constraints with a trusted session
|
||||
@@ -342,26 +328,12 @@ impl Permissions {
|
||||
/// after their local config constraints reject the snapshot.
|
||||
pub fn replace_permission_profile_from_session_snapshot(
|
||||
&mut self,
|
||||
permission_profile: Constrained<PermissionProfile>,
|
||||
active_permission_profile: Option<ActivePermissionProfile>,
|
||||
snapshot: PermissionProfileSnapshot,
|
||||
) -> ConstraintResult<()> {
|
||||
self.replace_permission_profile_from_session_snapshot_with_profile_workspace_roots(
|
||||
let permission_profile = Constrained::allow_only(snapshot.permission_profile().clone());
|
||||
self.permission_profile_state = PermissionProfileState::from_constrained_resolved(
|
||||
permission_profile,
|
||||
active_permission_profile,
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn replace_permission_profile_from_session_snapshot_with_profile_workspace_roots(
|
||||
&mut self,
|
||||
permission_profile: Constrained<PermissionProfile>,
|
||||
active_permission_profile: Option<ActivePermissionProfile>,
|
||||
profile_workspace_roots: Vec<AbsolutePathBuf>,
|
||||
) -> ConstraintResult<()> {
|
||||
self.permission_profile_state = PermissionProfileState::from_constrained_active_profile(
|
||||
permission_profile,
|
||||
active_permission_profile,
|
||||
profile_workspace_roots,
|
||||
snapshot.into_resolved_permission_profile(),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -556,6 +528,10 @@ pub struct Config {
|
||||
/// Token usage threshold triggering auto-compaction of conversation history.
|
||||
pub model_auto_compact_token_limit: Option<i64>,
|
||||
|
||||
/// Controls whether `model_auto_compact_token_limit` applies to the full
|
||||
/// active context or only tokens after the carried compaction-window prefix.
|
||||
pub model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope,
|
||||
|
||||
/// Key into the model_providers map that specifies which provider to use.
|
||||
pub model_provider_id: String,
|
||||
|
||||
@@ -568,6 +544,13 @@ pub struct Config {
|
||||
/// Effective permission configuration for shell tool execution.
|
||||
pub permissions: Permissions,
|
||||
|
||||
/// Whether config explicitly selected named permissions profiles instead
|
||||
/// of the legacy `sandbox_mode` syntax.
|
||||
pub explicit_permission_profile_mode: bool,
|
||||
|
||||
/// User-defined permission profile IDs available from effective config.
|
||||
pub custom_permission_profile_ids: Vec<String>,
|
||||
|
||||
/// Configures who approval requests are routed to for review once they have
|
||||
/// been escalated. This does not disable separate safety checks such as
|
||||
/// ARC.
|
||||
@@ -871,6 +854,9 @@ pub struct Config {
|
||||
/// Optional path override for the host-owned apps MCP server.
|
||||
pub apps_mcp_path_override: Option<String>,
|
||||
|
||||
/// Optional product SKU forwarded to the host-owned apps MCP server.
|
||||
pub apps_mcp_product_sku: Option<String>,
|
||||
|
||||
/// Machine-local realtime audio device preferences used by realtime voice.
|
||||
pub realtime_audio: RealtimeAudioConfig,
|
||||
|
||||
@@ -980,6 +966,7 @@ pub struct MultiAgentV2Config {
|
||||
pub usage_hint_text: Option<String>,
|
||||
pub root_agent_usage_hint_text: Option<String>,
|
||||
pub subagent_usage_hint_text: Option<String>,
|
||||
pub tool_namespace: Option<String>,
|
||||
pub hide_spawn_agent_metadata: bool,
|
||||
pub non_code_mode_only: bool,
|
||||
}
|
||||
@@ -996,6 +983,7 @@ impl Default for MultiAgentV2Config {
|
||||
usage_hint_text: None,
|
||||
root_agent_usage_hint_text: None,
|
||||
subagent_usage_hint_text: None,
|
||||
tool_namespace: None,
|
||||
hide_spawn_agent_metadata: false,
|
||||
non_code_mode_only: false,
|
||||
}
|
||||
@@ -1231,6 +1219,13 @@ impl Config {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn effective_workspace_roots(&self) -> Vec<AbsolutePathBuf> {
|
||||
let mut workspace_roots = self.workspace_roots.clone();
|
||||
workspace_roots.extend(self.permissions.profile_workspace_roots().iter().cloned());
|
||||
dedupe_absolute_paths(&mut workspace_roots);
|
||||
workspace_roots
|
||||
}
|
||||
|
||||
pub fn to_models_manager_config(&self) -> ModelsManagerConfig {
|
||||
ModelsManagerConfig {
|
||||
model_context_window: self.model_context_window,
|
||||
@@ -1261,6 +1256,7 @@ impl Config {
|
||||
let plugins_input = self.plugins_config_input();
|
||||
let loaded_plugins = plugins_manager.plugins_for_config(&plugins_input).await;
|
||||
let mut configured_mcp_servers = self.mcp_servers.get().clone();
|
||||
let mut plugin_ids_by_mcp_server_name = HashMap::new();
|
||||
for plugin in loaded_plugins
|
||||
.plugins()
|
||||
.iter()
|
||||
@@ -1273,7 +1269,10 @@ impl Config {
|
||||
self.config_layer_stack.requirements().plugins.as_ref(),
|
||||
);
|
||||
for (name, plugin_server) in plugin_mcp_servers {
|
||||
configured_mcp_servers.entry(name).or_insert(plugin_server);
|
||||
if let Entry::Vacant(entry) = configured_mcp_servers.entry(name.clone()) {
|
||||
entry.insert(plugin_server);
|
||||
plugin_ids_by_mcp_server_name.insert(name, plugin.config_name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(mcp_requirements) = self.config_layer_stack.requirements().mcp_servers.as_ref()
|
||||
@@ -1283,10 +1282,13 @@ impl Config {
|
||||
// above.
|
||||
filter_mcp_servers_by_requirements(&mut configured_mcp_servers, Some(mcp_requirements));
|
||||
}
|
||||
plugin_ids_by_mcp_server_name
|
||||
.retain(|server_name, _| configured_mcp_servers.contains_key(server_name));
|
||||
|
||||
McpConfig {
|
||||
chatgpt_base_url: self.chatgpt_base_url.clone(),
|
||||
apps_mcp_path_override: self.apps_mcp_path_override.clone(),
|
||||
apps_mcp_product_sku: self.apps_mcp_product_sku.clone(),
|
||||
codex_home: self.codex_home.to_path_buf(),
|
||||
mcp_oauth_credentials_store_mode: self.mcp_oauth_credentials_store_mode,
|
||||
mcp_oauth_callback_port: self.mcp_oauth_callback_port,
|
||||
@@ -1309,6 +1311,7 @@ impl Config {
|
||||
ElicitationCapability::default()
|
||||
},
|
||||
configured_mcp_servers,
|
||||
plugin_ids_by_mcp_server_name,
|
||||
plugin_capability_summaries: loaded_plugins.capability_summaries().to_vec(),
|
||||
}
|
||||
}
|
||||
@@ -2028,7 +2031,7 @@ fn apply_managed_filesystem_constraints(
|
||||
path: codex_protocol::permissions::FileSystemPath::GlobPattern {
|
||||
pattern: deny_read.as_str().to_string(),
|
||||
},
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::None,
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::Deny,
|
||||
}
|
||||
} else {
|
||||
let Ok(path) = AbsolutePathBuf::try_from(deny_read.as_str()) else {
|
||||
@@ -2036,7 +2039,7 @@ fn apply_managed_filesystem_constraints(
|
||||
};
|
||||
codex_protocol::permissions::FileSystemSandboxEntry {
|
||||
path: codex_protocol::permissions::FileSystemPath::Path { path },
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::None,
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::Deny,
|
||||
}
|
||||
};
|
||||
if !file_system_sandbox_policy
|
||||
@@ -2197,6 +2200,11 @@ fn resolve_multi_agent_v2_config(
|
||||
.or_else(|| base.and_then(|config| config.subagent_usage_hint_text.as_ref()))
|
||||
.cloned()
|
||||
.or(default.subagent_usage_hint_text);
|
||||
let tool_namespace = profile
|
||||
.and_then(|config| config.tool_namespace.as_ref())
|
||||
.or_else(|| base.and_then(|config| config.tool_namespace.as_ref()))
|
||||
.cloned()
|
||||
.or(default.tool_namespace);
|
||||
let hide_spawn_agent_metadata = profile
|
||||
.and_then(|config| config.hide_spawn_agent_metadata)
|
||||
.or_else(|| base.and_then(|config| config.hide_spawn_agent_metadata))
|
||||
@@ -2215,6 +2223,7 @@ fn resolve_multi_agent_v2_config(
|
||||
usage_hint_text,
|
||||
root_agent_usage_hint_text,
|
||||
subagent_usage_hint_text,
|
||||
tool_namespace,
|
||||
hide_spawn_agent_metadata,
|
||||
non_code_mode_only,
|
||||
}
|
||||
@@ -2309,6 +2318,69 @@ fn validate_multi_agent_v2_wait_timeout(label: &str, value: i64) -> std::io::Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_multi_agent_v2_tool_namespace(namespace: Option<&str>) -> std::io::Result<()> {
|
||||
const LABEL: &str = "features.multi_agent_v2.tool_namespace";
|
||||
const MAX_LEN: usize = 64;
|
||||
const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[
|
||||
"api_tool",
|
||||
"browser",
|
||||
"computer",
|
||||
"container",
|
||||
"file_search",
|
||||
"functions",
|
||||
"image_gen",
|
||||
"multi_tool_use",
|
||||
"python",
|
||||
"python_user_visible",
|
||||
"submodel_delegator",
|
||||
"terminal",
|
||||
"tool_search",
|
||||
"web",
|
||||
];
|
||||
|
||||
let Some(namespace) = namespace else {
|
||||
return Ok(());
|
||||
};
|
||||
if namespace.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{LABEL} must not be empty"),
|
||||
));
|
||||
}
|
||||
if namespace.trim() != namespace {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{LABEL} must not have leading or trailing whitespace"),
|
||||
));
|
||||
}
|
||||
if !namespace
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{LABEL} must match ^[a-zA-Z0-9_-]+$"),
|
||||
));
|
||||
}
|
||||
if namespace.chars().count() > MAX_LEN {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{LABEL} must be at most {MAX_LEN} characters"),
|
||||
));
|
||||
}
|
||||
if namespace == "mcp"
|
||||
|| namespace.starts_with("mcp__")
|
||||
|| RESERVED_RESPONSES_NAMESPACES.contains(&namespace)
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{LABEL} uses a reserved namespace: {namespace}"),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Config {
|
||||
#[cfg(test)]
|
||||
async fn load_from_base_config_with_overrides(
|
||||
@@ -2365,8 +2437,10 @@ impl Config {
|
||||
guardian_policy_config_source: _,
|
||||
} = config_layer_stack.requirements().clone();
|
||||
|
||||
let user_instructions = AgentsMdManager::load_global_instructions(Some(&codex_home))
|
||||
.map(|loaded| loaded.contents);
|
||||
let user_instructions =
|
||||
AgentsMdManager::load_global_instructions(LOCAL_FS.as_ref(), Some(&codex_home))
|
||||
.await
|
||||
.map(|loaded| loaded.contents);
|
||||
let mut startup_warnings = config_layer_stack
|
||||
.startup_warnings()
|
||||
.unwrap_or_default()
|
||||
@@ -2542,6 +2616,17 @@ impl Config {
|
||||
Some(PermissionConfigSyntax::Profiles)
|
||||
)
|
||||
|| permission_config_syntax.is_none();
|
||||
let explicit_permission_profile_mode = default_permissions_override.is_some()
|
||||
|| matches!(
|
||||
permission_config_syntax,
|
||||
Some(PermissionConfigSyntax::Profiles)
|
||||
);
|
||||
let custom_permission_profile_ids = cfg
|
||||
.permissions
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |permissions| {
|
||||
permissions.entries.keys().cloned().collect()
|
||||
});
|
||||
let using_implicit_builtin_profile =
|
||||
permission_config_syntax.is_none() && default_permissions.is_none();
|
||||
let should_seed_legacy_workspace_roots = default_permissions.is_none()
|
||||
@@ -2671,8 +2756,6 @@ impl Config {
|
||||
configured_workspace_roots.extend(sandbox_workspace_write.writable_roots.clone());
|
||||
}
|
||||
dedupe_absolute_paths(&mut configured_workspace_roots);
|
||||
workspace_roots.extend(configured_workspace_roots.iter().cloned());
|
||||
dedupe_absolute_paths(&mut workspace_roots);
|
||||
file_system_sandbox_policy = file_system_sandbox_policy
|
||||
.with_materialized_project_roots_for_workspace_roots(&configured_workspace_roots);
|
||||
let mut permission_profile = if let Some(permission_profile) =
|
||||
@@ -2932,6 +3015,7 @@ impl Config {
|
||||
"features.multi_agent_v2.default_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms",
|
||||
));
|
||||
}
|
||||
validate_multi_agent_v2_tool_namespace(multi_agent_v2.tool_namespace.as_deref())?;
|
||||
let agent_max_threads_from_config = cfg.agents.as_ref().and_then(|agents| agents.max_threads);
|
||||
let agent_max_threads = if features.enabled(Feature::MultiAgentV2) {
|
||||
if agent_max_threads_from_config.is_some() {
|
||||
@@ -3294,6 +3378,9 @@ impl Config {
|
||||
review_model,
|
||||
model_context_window: cfg.model_context_window,
|
||||
model_auto_compact_token_limit: cfg.model_auto_compact_token_limit,
|
||||
model_auto_compact_token_limit_scope: cfg
|
||||
.model_auto_compact_token_limit_scope
|
||||
.unwrap_or_default(),
|
||||
model_provider_id,
|
||||
model_provider,
|
||||
cwd: resolved_cwd,
|
||||
@@ -3310,6 +3397,8 @@ impl Config {
|
||||
windows_sandbox_mode,
|
||||
windows_sandbox_private_desktop,
|
||||
},
|
||||
explicit_permission_profile_mode,
|
||||
custom_permission_profile_ids,
|
||||
approvals_reviewer: constrained_approvals_reviewer.value(),
|
||||
enforce_residency: enforce_residency.value,
|
||||
notify: cfg.notify,
|
||||
@@ -3414,6 +3503,7 @@ impl Config {
|
||||
.or(cfg.chatgpt_base_url)
|
||||
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
|
||||
apps_mcp_path_override,
|
||||
apps_mcp_product_sku: cfg.apps_mcp_product_sku.clone(),
|
||||
realtime_audio: cfg
|
||||
.audio
|
||||
.map_or_else(RealtimeAudioConfig::default, |audio| RealtimeAudioConfig {
|
||||
|
||||
@@ -5,13 +5,8 @@ use codex_network_proxy::NetworkDomainPermission;
|
||||
use codex_protocol::models::ManagedFileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn permission_profile_for_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile {
|
||||
PermissionProfile::from_legacy_sandbox_policy(sandbox_policy)
|
||||
}
|
||||
|
||||
fn domain_permissions(
|
||||
entries: impl IntoIterator<Item = (&'static str, NetworkDomainPermissionToml)>,
|
||||
) -> NetworkDomainPermissionsToml {
|
||||
@@ -62,7 +57,7 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_read_only_policy()),
|
||||
&PermissionProfile::read_only(),
|
||||
)
|
||||
.expect("config should stay within the managed allowlist");
|
||||
|
||||
@@ -97,7 +92,7 @@ fn requirements_allowed_domains_do_not_override_user_denies_for_same_pattern() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|
||||
&PermissionProfile::workspace_write(),
|
||||
)
|
||||
.expect("managed allowlist should not erase a user deny");
|
||||
|
||||
@@ -129,7 +124,7 @@ fn requirements_allowlist_expansion_keeps_user_entries_mutable() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|
||||
&PermissionProfile::workspace_write(),
|
||||
)
|
||||
.expect("managed baseline should still allow user edits");
|
||||
|
||||
@@ -207,7 +202,7 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
|
||||
&PermissionProfile::Disabled,
|
||||
)
|
||||
.expect("yolo mode should pin the effective policy to the managed baseline");
|
||||
|
||||
@@ -241,7 +236,7 @@ fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|
||||
&PermissionProfile::workspace_write(),
|
||||
)
|
||||
.expect("managed baseline should still load");
|
||||
|
||||
@@ -270,7 +265,7 @@ fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses()
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|
||||
&PermissionProfile::workspace_write(),
|
||||
)
|
||||
.expect("managed-only allowlist should still load");
|
||||
|
||||
@@ -300,7 +295,7 @@ fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domain
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|
||||
&PermissionProfile::workspace_write(),
|
||||
)
|
||||
.expect("managed-only mode should treat missing managed allowlist as empty");
|
||||
|
||||
@@ -324,7 +319,7 @@ fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_m
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
|
||||
&PermissionProfile::Disabled,
|
||||
)
|
||||
.expect("managed-only mode should treat missing managed allowlist as empty");
|
||||
|
||||
@@ -351,7 +346,7 @@ fn deny_only_requirements_do_not_create_allow_constraints_in_full_access() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
|
||||
&PermissionProfile::Disabled,
|
||||
)
|
||||
.expect("deny-only requirements should not constrain the allowlist");
|
||||
|
||||
@@ -384,7 +379,7 @@ fn allow_only_requirements_do_not_create_deny_constraints_in_full_access() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
|
||||
&PermissionProfile::Disabled,
|
||||
)
|
||||
.expect("allow-only requirements should not constrain the denylist");
|
||||
|
||||
@@ -417,7 +412,7 @@ fn requirements_denied_domains_are_a_baseline_for_default_mode() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|
||||
&PermissionProfile::workspace_write(),
|
||||
)
|
||||
.expect("default mode should merge managed and user deny entries");
|
||||
|
||||
@@ -452,7 +447,7 @@ fn requirements_denylist_expansion_keeps_user_entries_mutable() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
config,
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|
||||
&PermissionProfile::workspace_write(),
|
||||
)
|
||||
.expect("managed baseline should still allow user edits");
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ pub(crate) fn compile_permission_profile(
|
||||
push_warning(
|
||||
startup_warnings,
|
||||
format!(
|
||||
"Filesystem glob `{pattern}` uses `read` or `write` access, which is not fully supported by this platform's sandboxing. Use an exact path or trailing `/**` subtree rule instead. `none` deny-read globs are supported."
|
||||
"Filesystem glob `{pattern}` uses `read` or `write` access, which is not fully supported by this platform's sandboxing. Use an exact path or trailing `/**` subtree rule instead. `deny` globs are supported."
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -414,7 +414,7 @@ fn compile_filesystem_permission(
|
||||
Some(FileSystemSpecialPath::ProjectRoots { .. }) | None => true,
|
||||
Some(_) => false,
|
||||
};
|
||||
if has_glob && *access == FileSystemAccessMode::None && can_compile_as_pattern {
|
||||
if has_glob && *access == FileSystemAccessMode::Deny && can_compile_as_pattern {
|
||||
// Scoped glob syntax is a first-class filesystem policy
|
||||
// pattern entry. Literal scoped paths continue through the
|
||||
// exact-path parser so existing path semantics stay intact.
|
||||
@@ -449,7 +449,7 @@ fn compile_filesystem_access_path(
|
||||
return compile_filesystem_path(path, startup_warnings);
|
||||
}
|
||||
|
||||
if access == FileSystemAccessMode::None {
|
||||
if access == FileSystemAccessMode::Deny {
|
||||
// At this point `path` is an unscoped filesystem table key. Top-level
|
||||
// glob deny entries still go through the absolute-path parser before
|
||||
// becoming policy patterns; relative project-root glob syntax is
|
||||
@@ -520,10 +520,10 @@ fn compile_scoped_filesystem_pattern(
|
||||
// Pattern entries currently mean deny-read only. Supporting broader access
|
||||
// modes here would imply glob-based read/write allow semantics that the
|
||||
// sandbox policy does not express yet.
|
||||
if access != FileSystemAccessMode::None {
|
||||
if access != FileSystemAccessMode::Deny {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("filesystem glob subpath `{subpath}` only supports `none` access"),
|
||||
format!("filesystem glob subpath `{subpath}` only supports `deny` access"),
|
||||
));
|
||||
}
|
||||
let subpath = parse_relative_subpath(subpath)?;
|
||||
@@ -559,7 +559,7 @@ fn compile_read_write_glob_path(path: &str, access: FileSystemAccessMode) -> io:
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"filesystem glob path `{path}` only supports `none` access; use an exact path or trailing `/**` for `{access}` subtree access"
|
||||
"filesystem glob path `{path}` only supports `deny` access; use an exact path or trailing `/**` for `{access}` subtree access"
|
||||
),
|
||||
))
|
||||
}
|
||||
@@ -569,7 +569,7 @@ fn unsupported_read_write_glob_paths(filesystem: &FilesystemPermissionsToml) ->
|
||||
for (path, permission) in &filesystem.entries {
|
||||
match permission {
|
||||
FilesystemPermissionToml::Access(access) => {
|
||||
if *access != FileSystemAccessMode::None
|
||||
if *access != FileSystemAccessMode::Deny
|
||||
&& contains_glob_chars(remove_trailing_glob_suffix(path))
|
||||
{
|
||||
patterns.push(path.clone());
|
||||
@@ -577,7 +577,7 @@ fn unsupported_read_write_glob_paths(filesystem: &FilesystemPermissionsToml) ->
|
||||
}
|
||||
FilesystemPermissionToml::Scoped(scoped_entries) => {
|
||||
for (subpath, access) in scoped_entries {
|
||||
if *access != FileSystemAccessMode::None
|
||||
if *access != FileSystemAccessMode::Deny
|
||||
&& contains_glob_chars(remove_trailing_glob_suffix(subpath))
|
||||
{
|
||||
patterns.push(format!("{path}/{subpath}"));
|
||||
@@ -597,7 +597,7 @@ fn unbounded_unreadable_globstar_paths(filesystem: &FilesystemPermissionsToml) -
|
||||
let mut patterns = Vec::new();
|
||||
for (path, permission) in &filesystem.entries {
|
||||
match permission {
|
||||
FilesystemPermissionToml::Access(FileSystemAccessMode::None) => {
|
||||
FilesystemPermissionToml::Access(FileSystemAccessMode::Deny) => {
|
||||
if path.contains("**") {
|
||||
patterns.push(path.clone());
|
||||
}
|
||||
@@ -605,7 +605,7 @@ fn unbounded_unreadable_globstar_paths(filesystem: &FilesystemPermissionsToml) -
|
||||
FilesystemPermissionToml::Access(_) => {}
|
||||
FilesystemPermissionToml::Scoped(scoped_entries) => {
|
||||
for (subpath, access) in scoped_entries {
|
||||
if *access == FileSystemAccessMode::None && subpath.contains("**") {
|
||||
if *access == FileSystemAccessMode::Deny && subpath.contains("**") {
|
||||
patterns.push(format!("{path}/{subpath}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +326,7 @@ fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths
|
||||
(
|
||||
":workspace_roots".to_string(),
|
||||
FilesystemPermissionToml::Scoped(BTreeMap::from([
|
||||
("**/*.env".to_string(), FileSystemAccessMode::None),
|
||||
("**/*.env".to_string(), FileSystemAccessMode::Deny),
|
||||
("docs/**".to_string(), FileSystemAccessMode::Read),
|
||||
("src/**/*.rs".to_string(), FileSystemAccessMode::Write),
|
||||
])),
|
||||
@@ -340,7 +340,7 @@ fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths
|
||||
"/tmp/**/*.log".to_string(),
|
||||
":workspace_roots/src/**/*.rs".to_string()
|
||||
],
|
||||
"`none` glob patterns are supported as deny-read rules; only `read`/`write` globs should warn"
|
||||
"`deny` glob patterns are supported as deny-read rules; only `read`/`write` globs should warn"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -351,8 +351,8 @@ fn unreadable_globstar_warning_is_suppressed_when_scan_depth_is_configured() {
|
||||
entries: BTreeMap::from([(
|
||||
":workspace_roots".to_string(),
|
||||
FilesystemPermissionToml::Scoped(BTreeMap::from([
|
||||
("**/*.env".to_string(), FileSystemAccessMode::None),
|
||||
("*.pem".to_string(), FileSystemAccessMode::None),
|
||||
("**/*.env".to_string(), FileSystemAccessMode::Deny),
|
||||
("*.pem".to_string(), FileSystemAccessMode::Deny),
|
||||
])),
|
||||
)]),
|
||||
};
|
||||
@@ -435,7 +435,7 @@ fn read_write_glob_patterns_still_reject_non_subpath_globs() {
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("filesystem glob path `src/**/*.rs` only supports `none` access"),
|
||||
.contains("filesystem glob path `src/**/*.rs` only supports `deny` access"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,19 @@ pub(crate) enum ResolvedPermissionProfile {
|
||||
Named(NamedPermissionProfile),
|
||||
}
|
||||
|
||||
/// Trusted snapshot of a resolved permission profile.
|
||||
///
|
||||
/// This is a bridge for already-resolved session/config state. It keeps the
|
||||
/// concrete `PermissionProfile`, optional active profile id, and
|
||||
/// profile-defined workspace roots together so `Permissions` can validate and
|
||||
/// install them atomically. It is not a resolver: callers that are handling
|
||||
/// user-selected profile ids should resolve those ids through config instead
|
||||
/// of constructing this type directly.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PermissionProfileSnapshot {
|
||||
resolved_permission_profile: ResolvedPermissionProfile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct LegacyPermissionProfile {
|
||||
permission_profile: PermissionProfile,
|
||||
@@ -101,24 +114,6 @@ impl ResolvedPermissionProfile {
|
||||
}
|
||||
}
|
||||
|
||||
fn with_permission_profile(&self, permission_profile: PermissionProfile) -> Self {
|
||||
match self {
|
||||
Self::Legacy(_) => Self::legacy(permission_profile),
|
||||
Self::BuiltIn(profile) => Self::BuiltIn(BuiltInPermissionProfile {
|
||||
id: profile.id,
|
||||
extends: profile.extends.clone(),
|
||||
permission_profile,
|
||||
profile_workspace_roots: profile.profile_workspace_roots.clone(),
|
||||
}),
|
||||
Self::Named(profile) => Self::Named(NamedPermissionProfile {
|
||||
id: profile.id.clone(),
|
||||
extends: profile.extends.clone(),
|
||||
permission_profile,
|
||||
profile_workspace_roots: profile.profile_workspace_roots.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn active_permission_profile(&self) -> Option<ActivePermissionProfile> {
|
||||
match self {
|
||||
Self::Legacy(_) => None,
|
||||
@@ -142,6 +137,93 @@ impl ResolvedPermissionProfile {
|
||||
}
|
||||
}
|
||||
|
||||
impl PermissionProfileSnapshot {
|
||||
/// Create a snapshot with no active profile id.
|
||||
///
|
||||
/// Prefer this only for legacy data or local overrides that genuinely do
|
||||
/// not have a named/built-in profile identity. Using this for a built-in or
|
||||
/// named profile will intentionally clear the active profile metadata.
|
||||
pub fn legacy(permission_profile: PermissionProfile) -> Self {
|
||||
Self {
|
||||
resolved_permission_profile: ResolvedPermissionProfile::legacy(permission_profile),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a snapshot for a known active profile id.
|
||||
///
|
||||
/// Use this only after a trusted caller has already resolved the active id
|
||||
/// to the supplied concrete `PermissionProfile`. This constructor does not
|
||||
/// verify that the id and profile match; `Permissions` will still enforce
|
||||
/// configured permission constraints when the snapshot is installed.
|
||||
pub fn active(
|
||||
permission_profile: PermissionProfile,
|
||||
active_permission_profile: ActivePermissionProfile,
|
||||
) -> Self {
|
||||
Self::active_with_profile_workspace_roots(
|
||||
permission_profile,
|
||||
active_permission_profile,
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a snapshot for a known active profile id with profile roots.
|
||||
///
|
||||
/// As with `active`, the caller is responsible for passing the concrete
|
||||
/// profile and active id that were resolved together. Use this variant when
|
||||
/// the selected profile declared workspace roots that should remain
|
||||
/// distinct from turn-scoped runtime workspace roots.
|
||||
pub fn active_with_profile_workspace_roots(
|
||||
permission_profile: PermissionProfile,
|
||||
active_permission_profile: ActivePermissionProfile,
|
||||
profile_workspace_roots: Vec<AbsolutePathBuf>,
|
||||
) -> Self {
|
||||
Self {
|
||||
resolved_permission_profile: ResolvedPermissionProfile::from_active_profile(
|
||||
permission_profile,
|
||||
Some(active_permission_profile),
|
||||
profile_workspace_roots,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct a trusted snapshot from session state.
|
||||
///
|
||||
/// This is intended for session responses emitted by core, where the
|
||||
/// concrete profile and active profile id were captured together. Avoid
|
||||
/// using this as a shortcut for arbitrary user input because mismatched
|
||||
/// arguments can still misrepresent the active profile identity.
|
||||
pub fn from_session_snapshot(
|
||||
permission_profile: PermissionProfile,
|
||||
active_permission_profile: Option<ActivePermissionProfile>,
|
||||
) -> Self {
|
||||
match active_permission_profile {
|
||||
Some(active_permission_profile) => {
|
||||
Self::active(permission_profile, active_permission_profile)
|
||||
}
|
||||
None => Self::legacy(permission_profile),
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow the concrete permission profile captured in this snapshot.
|
||||
pub fn permission_profile(&self) -> &PermissionProfile {
|
||||
self.resolved_permission_profile.permission_profile()
|
||||
}
|
||||
|
||||
/// Return the active profile id captured in this snapshot, if any.
|
||||
pub fn active_permission_profile(&self) -> Option<ActivePermissionProfile> {
|
||||
self.resolved_permission_profile.active_permission_profile()
|
||||
}
|
||||
|
||||
/// Borrow profile-declared workspace roots captured in this snapshot.
|
||||
pub fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] {
|
||||
self.resolved_permission_profile.profile_workspace_roots()
|
||||
}
|
||||
|
||||
pub(crate) fn into_resolved_permission_profile(self) -> ResolvedPermissionProfile {
|
||||
self.resolved_permission_profile
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct PermissionProfileState {
|
||||
resolved_permission_profile: Constrained<ResolvedPermissionProfile>,
|
||||
@@ -189,19 +271,6 @@ impl PermissionProfileState {
|
||||
self.resolved_permission_profile.get().permission_profile()
|
||||
}
|
||||
|
||||
pub(crate) fn clone_with_permission_profile(
|
||||
&self,
|
||||
permission_profile: PermissionProfile,
|
||||
) -> ConstraintResult<Self> {
|
||||
let candidate = self
|
||||
.resolved_permission_profile
|
||||
.get()
|
||||
.with_permission_profile(permission_profile);
|
||||
let mut state = self.clone();
|
||||
state.resolved_permission_profile.set(candidate)?;
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
pub(crate) fn active_permission_profile(&self) -> Option<ActivePermissionProfile> {
|
||||
self.resolved_permission_profile
|
||||
.get()
|
||||
@@ -230,17 +299,11 @@ impl PermissionProfileState {
|
||||
.set(ResolvedPermissionProfile::legacy(permission_profile))
|
||||
}
|
||||
|
||||
pub(crate) fn set_active_permission_profile(
|
||||
pub(crate) fn set_permission_profile_snapshot(
|
||||
&mut self,
|
||||
permission_profile: PermissionProfile,
|
||||
active_permission_profile: Option<ActivePermissionProfile>,
|
||||
profile_workspace_roots: Vec<AbsolutePathBuf>,
|
||||
snapshot: PermissionProfileSnapshot,
|
||||
) -> ConstraintResult<()> {
|
||||
let candidate = ResolvedPermissionProfile::from_active_profile(
|
||||
permission_profile,
|
||||
active_permission_profile,
|
||||
profile_workspace_roots,
|
||||
);
|
||||
self.resolved_permission_profile.set(candidate)
|
||||
self.resolved_permission_profile
|
||||
.set(snapshot.into_resolved_permission_profile())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +198,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
config.codex_linux_sandbox_exe.clone(),
|
||||
)?;
|
||||
let environment_manager =
|
||||
EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths).await?;
|
||||
EnvironmentManager::from_codex_home(config.codex_home.clone(), Some(local_runtime_paths))
|
||||
.await?;
|
||||
list_accessible_connectors_from_mcp_tools_with_environment_manager(
|
||||
config,
|
||||
force_refetch,
|
||||
@@ -261,10 +262,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
|
||||
let (tx_event, rx_event) = unbounded();
|
||||
drop(rx_event);
|
||||
|
||||
let environment = environment_manager
|
||||
.default_environment()
|
||||
.unwrap_or_else(|| environment_manager.local_environment());
|
||||
|
||||
let (mut mcp_connection_manager, cancel_token) = McpConnectionManager::new(
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
@@ -273,7 +270,13 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
|
||||
INITIAL_SUBMIT_ID.to_owned(),
|
||||
tx_event,
|
||||
PermissionProfile::default(),
|
||||
McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()),
|
||||
// Connector discovery is threadless. Use an actually configured env if
|
||||
// one exists, but do not reintroduce the old hidden-local fallback.
|
||||
McpRuntimeEnvironment::new(
|
||||
environment_manager.default_or_local_environment(),
|
||||
environment_manager.try_local_environment(),
|
||||
config.cwd.to_path_buf(),
|
||||
),
|
||||
config.codex_home.to_path_buf(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
host_owned_codex_apps_enabled,
|
||||
|
||||
@@ -14,9 +14,17 @@ impl ApprovedCommandPrefixSaved {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for ApprovedCommandPrefixSaved {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = "";
|
||||
const END_MARKER: &'static str = "";
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!("Approved command prefix saved:\n{}", self.prefixes)
|
||||
|
||||
@@ -18,9 +18,17 @@ impl AppsInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for AppsInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = APPS_INSTRUCTIONS_OPEN_TAG;
|
||||
const END_MARKER: &'static str = APPS_INSTRUCTIONS_CLOSE_TAG;
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
(APPS_INSTRUCTIONS_OPEN_TAG, APPS_INSTRUCTIONS_CLOSE_TAG)
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!(
|
||||
|
||||
@@ -22,9 +22,20 @@ impl AvailablePluginsInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for AvailablePluginsInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = PLUGINS_INSTRUCTIONS_OPEN_TAG;
|
||||
const END_MARKER: &'static str = PLUGINS_INSTRUCTIONS_CLOSE_TAG;
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
(
|
||||
PLUGINS_INSTRUCTIONS_OPEN_TAG,
|
||||
PLUGINS_INSTRUCTIONS_CLOSE_TAG,
|
||||
)
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
let mut lines = vec![
|
||||
|
||||
@@ -21,9 +21,17 @@ impl From<AvailableSkills> for AvailableSkillsInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for AvailableSkillsInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = SKILLS_INSTRUCTIONS_OPEN_TAG;
|
||||
const END_MARKER: &'static str = SKILLS_INSTRUCTIONS_CLOSE_TAG;
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
(SKILLS_INSTRUCTIONS_OPEN_TAG, SKILLS_INSTRUCTIONS_CLOSE_TAG)
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
render_available_skills_body(&self.skill_root_lines, &self.skill_lines)
|
||||
|
||||
@@ -22,9 +22,17 @@ impl CollaborationModeInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for CollaborationModeInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = COLLABORATION_MODE_OPEN_TAG;
|
||||
const END_MARKER: &'static str = COLLABORATION_MODE_CLOSE_TAG;
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
(COLLABORATION_MODE_OPEN_TAG, COLLABORATION_MODE_CLOSE_TAG)
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
self.instructions.clone()
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use super::*;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::GoalContext;
|
||||
use crate::context::SubagentNotification;
|
||||
use codex_protocol::items::HookPromptFragment;
|
||||
use codex_protocol::items::build_hook_prompt_message;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn detects_environment_context_fragment() {
|
||||
@@ -38,6 +41,18 @@ fn detects_goal_context_fragment() {
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_user_fragment_is_dyn_compatible() {
|
||||
let fragment: Box<dyn ContextualUserFragment> = Box::new(GoalContext {
|
||||
prompt: "Continue working toward the active thread goal.".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
fragment.render(),
|
||||
"<goal_context>\nContinue working toward the active thread goal.\n</goal_context>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_regular_user_text() {
|
||||
assert!(!is_contextual_user_fragment(&ContentItem::InputText {
|
||||
|
||||
@@ -269,9 +269,20 @@ impl EnvironmentContext {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for EnvironmentContext {
|
||||
const ROLE: &'static str = "user";
|
||||
const START_MARKER: &'static str = codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
|
||||
const END_MARKER: &'static str = codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
|
||||
fn role() -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
(
|
||||
codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG,
|
||||
codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG,
|
||||
)
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
@@ -38,37 +38,34 @@ impl<T: ContextualUserFragment> FragmentRegistration for FragmentRegistrationPro
|
||||
/// in which case the default helpers render only the body and never match
|
||||
/// arbitrary text.
|
||||
pub trait ContextualUserFragment {
|
||||
const ROLE: &'static str;
|
||||
const START_MARKER: &'static str;
|
||||
const END_MARKER: &'static str;
|
||||
fn role() -> &'static str
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str);
|
||||
|
||||
fn body(&self) -> String;
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str)
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
fn matches_text(text: &str) -> bool
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
if Self::START_MARKER.is_empty() || Self::END_MARKER.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let trimmed = text.trim_start();
|
||||
let starts_with_marker = trimmed
|
||||
.get(..Self::START_MARKER.len())
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(Self::START_MARKER));
|
||||
let trimmed = trimmed.trim_end();
|
||||
let ends_with_marker = trimmed
|
||||
.get(trimmed.len().saturating_sub(Self::END_MARKER.len())..)
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(Self::END_MARKER));
|
||||
starts_with_marker && ends_with_marker
|
||||
let (start_marker, end_marker) = Self::type_markers();
|
||||
matches_marked_text(start_marker, end_marker, text)
|
||||
}
|
||||
|
||||
fn render(&self) -> String {
|
||||
if Self::START_MARKER.is_empty() && Self::END_MARKER.is_empty() {
|
||||
return self.body();
|
||||
let (start_marker, end_marker) = self.markers();
|
||||
let body = self.body();
|
||||
if start_marker.is_empty() && end_marker.is_empty() {
|
||||
return body;
|
||||
}
|
||||
|
||||
format!("{}{}{}", Self::START_MARKER, self.body(), Self::END_MARKER)
|
||||
format!("{start_marker}{body}{end_marker}")
|
||||
}
|
||||
|
||||
fn into(self) -> ResponseItem
|
||||
@@ -77,7 +74,7 @@ pub trait ContextualUserFragment {
|
||||
{
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: Self::ROLE.to_string(),
|
||||
role: Self::role().to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: self.render(),
|
||||
}],
|
||||
@@ -85,3 +82,19 @@ pub trait ContextualUserFragment {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_marked_text(start_marker: &str, end_marker: &str, text: &str) -> bool {
|
||||
if start_marker.is_empty() || end_marker.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let trimmed = text.trim_start();
|
||||
let starts_with_marker = trimmed
|
||||
.get(..start_marker.len())
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(start_marker));
|
||||
let trimmed = trimmed.trim_end();
|
||||
let ends_with_marker = trimmed
|
||||
.get(trimmed.len().saturating_sub(end_marker.len())..)
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(end_marker));
|
||||
starts_with_marker && ends_with_marker
|
||||
}
|
||||
|
||||
@@ -8,9 +8,17 @@ pub(crate) struct GoalContext {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for GoalContext {
|
||||
const ROLE: &'static str = "user";
|
||||
const START_MARKER: &'static str = "<goal_context>";
|
||||
const END_MARKER: &'static str = "</goal_context>";
|
||||
fn role() -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("<goal_context>", "</goal_context>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!("\n{}\n", self.prompt)
|
||||
|
||||
@@ -4,9 +4,17 @@ use super::ContextualUserFragment;
|
||||
pub(crate) struct GuardianFollowupReviewReminder;
|
||||
|
||||
impl ContextualUserFragment for GuardianFollowupReviewReminder {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = "";
|
||||
const END_MARKER: &'static str = "";
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
concat!(
|
||||
|
||||
@@ -12,9 +12,17 @@ impl HookAdditionalContext {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for HookAdditionalContext {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = "";
|
||||
const END_MARKER: &'static str = "";
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
self.text.clone()
|
||||
|
||||
@@ -17,9 +17,17 @@ impl ImageGenerationInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for ImageGenerationInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = "";
|
||||
const END_MARKER: &'static str = "";
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!(
|
||||
|
||||
@@ -5,9 +5,17 @@ use super::ContextualUserFragment;
|
||||
pub(crate) struct LegacyApplyPatchExecCommandWarning;
|
||||
|
||||
impl ContextualUserFragment for LegacyApplyPatchExecCommandWarning {
|
||||
const ROLE: &'static str = "user";
|
||||
const START_MARKER: &'static str = "";
|
||||
const END_MARKER: &'static str = "";
|
||||
fn role() -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn matches_text(text: &str) -> bool {
|
||||
let trimmed = text.trim();
|
||||
|
||||
@@ -5,9 +5,17 @@ use super::ContextualUserFragment;
|
||||
pub(crate) struct LegacyModelMismatchWarning;
|
||||
|
||||
impl ContextualUserFragment for LegacyModelMismatchWarning {
|
||||
const ROLE: &'static str = "user";
|
||||
const START_MARKER: &'static str = "";
|
||||
const END_MARKER: &'static str = "";
|
||||
fn role() -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn matches_text(text: &str) -> bool {
|
||||
text.trim().starts_with(
|
||||
|
||||
@@ -5,9 +5,17 @@ use super::ContextualUserFragment;
|
||||
pub(crate) struct LegacyUnifiedExecProcessLimitWarning;
|
||||
|
||||
impl ContextualUserFragment for LegacyUnifiedExecProcessLimitWarning {
|
||||
const ROLE: &'static str = "user";
|
||||
const START_MARKER: &'static str = "";
|
||||
const END_MARKER: &'static str = "";
|
||||
fn role() -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn matches_text(text: &str) -> bool {
|
||||
text.trim().starts_with(
|
||||
|
||||
@@ -14,9 +14,17 @@ impl ModelSwitchInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for ModelSwitchInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = "<model_switch>";
|
||||
const END_MARKER: &'static str = "</model_switch>";
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("<model_switch>", "</model_switch>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!(
|
||||
|
||||
@@ -18,9 +18,17 @@ impl NetworkRuleSaved {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for NetworkRuleSaved {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = "";
|
||||
const END_MARKER: &'static str = "";
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
let (action, list_name) = match self.action {
|
||||
|
||||
@@ -8,7 +8,6 @@ use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use codex_protocol::protocol::NetworkAccess;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::WritableRoot;
|
||||
use codex_utils_template::Template;
|
||||
use std::path::Path;
|
||||
@@ -85,27 +84,6 @@ impl PermissionsInstructions {
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds permissions instructions from a legacy sandbox policy.
|
||||
pub fn from_policy(
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
approval_policy: AskForApproval,
|
||||
approvals_reviewer: ApprovalsReviewer,
|
||||
exec_policy: &Policy,
|
||||
cwd: &Path,
|
||||
exec_permission_approvals_enabled: bool,
|
||||
request_permissions_tool_enabled: bool,
|
||||
) -> Self {
|
||||
Self::from_permission_profile(
|
||||
&PermissionProfile::from_legacy_sandbox_policy(sandbox_policy),
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
exec_policy,
|
||||
cwd,
|
||||
exec_permission_approvals_enabled,
|
||||
request_permissions_tool_enabled,
|
||||
)
|
||||
}
|
||||
|
||||
fn from_permissions_with_network(
|
||||
sandbox_mode: SandboxMode,
|
||||
network_access: NetworkAccess,
|
||||
@@ -167,9 +145,17 @@ fn network_access_from_policy(network_policy: NetworkSandboxPolicy) -> NetworkAc
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for PermissionsInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = "<permissions instructions>";
|
||||
const END_MARKER: &'static str = "</permissions instructions>";
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("<permissions instructions>", "</permissions instructions>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
self.text.clone()
|
||||
|
||||
@@ -53,29 +53,6 @@ fn builds_permissions_with_network_access_override() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_permissions_from_policy() {
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
};
|
||||
|
||||
let instructions = PermissionsInstructions::from_policy(
|
||||
&policy,
|
||||
AskForApproval::UnlessTrusted,
|
||||
ApprovalsReviewer::User,
|
||||
&Policy::empty(),
|
||||
&PathBuf::from("/tmp"),
|
||||
/*exec_permission_approvals_enabled*/ false,
|
||||
/*request_permissions_tool_enabled*/ false,
|
||||
);
|
||||
let text = instructions.body();
|
||||
assert!(text.contains("Network access is enabled."));
|
||||
assert!(text.contains("`approval_policy` is `unless-trusted`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_permissions_from_profile() {
|
||||
let cwd = PathBuf::from("/tmp");
|
||||
|
||||
@@ -12,9 +12,17 @@ impl PersonalitySpecInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for PersonalitySpecInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = "<personality_spec>";
|
||||
const END_MARKER: &'static str = "</personality_spec>";
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("<personality_spec>", "</personality_spec>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!(
|
||||
|
||||
@@ -12,9 +12,17 @@ impl PluginInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for PluginInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = "";
|
||||
const END_MARKER: &'static str = "";
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
self.text.clone()
|
||||
|
||||
@@ -18,9 +18,20 @@ impl RealtimeEndInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for RealtimeEndInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = REALTIME_CONVERSATION_OPEN_TAG;
|
||||
const END_MARKER: &'static str = REALTIME_CONVERSATION_CLOSE_TAG;
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
(
|
||||
REALTIME_CONVERSATION_OPEN_TAG,
|
||||
REALTIME_CONVERSATION_CLOSE_TAG,
|
||||
)
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!(
|
||||
|
||||
@@ -8,9 +8,20 @@ const REALTIME_START_INSTRUCTIONS: &str = include_str!("prompts/realtime/realtim
|
||||
pub(crate) struct RealtimeStartInstructions;
|
||||
|
||||
impl ContextualUserFragment for RealtimeStartInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = REALTIME_CONVERSATION_OPEN_TAG;
|
||||
const END_MARKER: &'static str = REALTIME_CONVERSATION_CLOSE_TAG;
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
(
|
||||
REALTIME_CONVERSATION_OPEN_TAG,
|
||||
REALTIME_CONVERSATION_CLOSE_TAG,
|
||||
)
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!("\n{}\n", REALTIME_START_INSTRUCTIONS.trim())
|
||||
|
||||
@@ -16,9 +16,20 @@ impl RealtimeStartWithInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for RealtimeStartWithInstructions {
|
||||
const ROLE: &'static str = "developer";
|
||||
const START_MARKER: &'static str = REALTIME_CONVERSATION_OPEN_TAG;
|
||||
const END_MARKER: &'static str = REALTIME_CONVERSATION_CLOSE_TAG;
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
(
|
||||
REALTIME_CONVERSATION_OPEN_TAG,
|
||||
REALTIME_CONVERSATION_CLOSE_TAG,
|
||||
)
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!("\n{}\n", self.instructions)
|
||||
|
||||
@@ -20,9 +20,17 @@ impl From<&SkillInjection> for SkillInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for SkillInstructions {
|
||||
const ROLE: &'static str = "user";
|
||||
const START_MARKER: &'static str = "<skill>";
|
||||
const END_MARKER: &'static str = "</skill>";
|
||||
fn role() -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("<skill>", "</skill>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!(
|
||||
|
||||
@@ -18,9 +18,17 @@ impl SubagentNotification {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for SubagentNotification {
|
||||
const ROLE: &'static str = "user";
|
||||
const START_MARKER: &'static str = "<subagent_notification>";
|
||||
const END_MARKER: &'static str = "</subagent_notification>";
|
||||
fn role() -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("<subagent_notification>", "</subagent_notification>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!(
|
||||
|
||||
@@ -17,9 +17,17 @@ impl TurnAborted {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for TurnAborted {
|
||||
const ROLE: &'static str = "user";
|
||||
const START_MARKER: &'static str = "<turn_aborted>";
|
||||
const END_MARKER: &'static str = "</turn_aborted>";
|
||||
fn role() -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("<turn_aborted>", "</turn_aborted>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!("\n{}\n", self.guidance)
|
||||
|
||||
@@ -7,9 +7,17 @@ pub(crate) struct UserInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for UserInstructions {
|
||||
const ROLE: &'static str = "user";
|
||||
const START_MARKER: &'static str = "# AGENTS.md instructions for ";
|
||||
const END_MARKER: &'static str = "</INSTRUCTIONS>";
|
||||
fn role() -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("# AGENTS.md instructions for ", "</INSTRUCTIONS>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!("{}\n\n<INSTRUCTIONS>\n{}\n", self.directory, self.text)
|
||||
|
||||
@@ -27,9 +27,17 @@ impl UserShellCommand {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for UserShellCommand {
|
||||
const ROLE: &'static str = "user";
|
||||
const START_MARKER: &'static str = "<user_shell_command>";
|
||||
const END_MARKER: &'static str = "</user_shell_command>";
|
||||
fn role() -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("<user_shell_command>", "</user_shell_command>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
format!(
|
||||
|
||||
@@ -400,6 +400,7 @@ impl ContextManager {
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::CompactionTrigger
|
||||
| ResponseItem::ContextCompaction { .. }
|
||||
| ResponseItem::Other => item.clone(),
|
||||
}
|
||||
@@ -490,6 +491,7 @@ fn is_api_message(message: &ResponseItem) -> bool {
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::ContextCompaction { .. } => true,
|
||||
ResponseItem::CompactionTrigger => false,
|
||||
ResponseItem::Other => false,
|
||||
}
|
||||
}
|
||||
@@ -688,6 +690,7 @@ fn is_model_generated_item(item: &ResponseItem) -> bool {
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::ContextCompaction { .. } => true,
|
||||
ResponseItem::CompactionTrigger => false,
|
||||
ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
|
||||
@@ -2,7 +2,6 @@ use super::*;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
|
||||
@@ -121,7 +120,6 @@ fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem {
|
||||
fn reference_context_item() -> TurnContextItem {
|
||||
TurnContextItem {
|
||||
turn_id: Some("reference-turn".to_string()),
|
||||
trace_id: None,
|
||||
cwd: PathBuf::from("/tmp/reference-cwd"),
|
||||
current_date: Some("2026-03-23".to_string()),
|
||||
timezone: Some("America/Los_Angeles".to_string()),
|
||||
@@ -135,11 +133,7 @@ fn reference_context_item() -> TurnContextItem {
|
||||
collaboration_mode: None,
|
||||
realtime_active: Some(false),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(codex_protocol::protocol::TruncationPolicy::Tokens(10_000)),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ mod tests {
|
||||
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
|
||||
let manager = EnvironmentManager::create_for_tests(
|
||||
Some("ws://127.0.0.1:8765".to_string()),
|
||||
test_runtime_paths(),
|
||||
Some(test_runtime_paths()),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -132,9 +132,10 @@ url = "ws://127.0.0.1:8765"
|
||||
)
|
||||
.expect("write environments.toml");
|
||||
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
|
||||
let manager = EnvironmentManager::from_codex_home(temp_dir.path(), test_runtime_paths())
|
||||
.await
|
||||
.expect("environment manager");
|
||||
let manager =
|
||||
EnvironmentManager::from_codex_home(temp_dir.path(), Some(test_runtime_paths()))
|
||||
.await
|
||||
.expect("environment manager");
|
||||
|
||||
assert_eq!(
|
||||
default_thread_environment_selections(&manager, &cwd),
|
||||
@@ -154,7 +155,7 @@ url = "ws://127.0.0.1:8765"
|
||||
#[tokio::test]
|
||||
async fn default_thread_environment_selections_empty_when_default_disabled() {
|
||||
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
|
||||
let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths());
|
||||
let manager = EnvironmentManager::without_environments();
|
||||
|
||||
assert_eq!(
|
||||
default_thread_environment_selections(&manager, &cwd),
|
||||
|
||||
@@ -90,9 +90,10 @@ fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
|
||||
text_elements: Vec::new(),
|
||||
});
|
||||
}
|
||||
ContentItem::InputImage { image_url, .. } => {
|
||||
ContentItem::InputImage { image_url, detail } => {
|
||||
content.push(UserInput::Image {
|
||||
image_url: image_url.clone(),
|
||||
detail: *detail,
|
||||
});
|
||||
}
|
||||
ContentItem::OutputText { text } => {
|
||||
|
||||
@@ -48,8 +48,14 @@ fn parses_user_message_with_text_and_two_images() {
|
||||
text: "Hello world".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
UserInput::Image { image_url: img1 },
|
||||
UserInput::Image { image_url: img2 },
|
||||
UserInput::Image {
|
||||
image_url: img1,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
UserInput::Image {
|
||||
image_url: img2,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
];
|
||||
assert_eq!(user.content, expected_content);
|
||||
}
|
||||
@@ -87,7 +93,10 @@ fn skips_local_image_label_text() {
|
||||
match turn_item {
|
||||
TurnItem::UserMessage(user) => {
|
||||
let expected_content = vec![
|
||||
UserInput::Image { image_url },
|
||||
UserInput::Image {
|
||||
image_url,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
UserInput::Text {
|
||||
text: user_text,
|
||||
text_elements: Vec::new(),
|
||||
@@ -165,7 +174,10 @@ fn skips_unnamed_image_label_text() {
|
||||
match turn_item {
|
||||
TurnItem::UserMessage(user) => {
|
||||
let expected_content = vec![
|
||||
UserInput::Image { image_url },
|
||||
UserInput::Image {
|
||||
image_url,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
UserInput::Text {
|
||||
text: user_text,
|
||||
text_elements: Vec::new(),
|
||||
|
||||
@@ -23,7 +23,6 @@ use codex_protocol::permissions::FileSystemSpecialPath;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
@@ -130,10 +129,6 @@ fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
|
||||
FileSystemSandboxPolicy::external_sandbox()
|
||||
}
|
||||
|
||||
fn permission_profile_from_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile {
|
||||
PermissionProfile::from_legacy_sandbox_policy(sandbox_policy)
|
||||
}
|
||||
|
||||
async fn test_config() -> (TempDir, Config) {
|
||||
let home = TempDir::new().expect("create temp dir");
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
@@ -669,7 +664,7 @@ async fn evaluates_bash_lc_inner_commands() {
|
||||
"rm -rf /some/important/folder".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: PermissionProfile::Disabled,
|
||||
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -765,7 +760,7 @@ async fn evaluates_heredoc_script_against_prefix_rules() {
|
||||
policy_src: Some(r#"prefix_rule(pattern=["python3"], decision="allow")"#.to_string()),
|
||||
command,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -789,7 +784,7 @@ async fn omits_auto_amendment_for_heredoc_fallback_prompts() {
|
||||
"python3 <<'PY'\nprint('hello')\nPY".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -813,7 +808,7 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_mat
|
||||
"python3 <<'PY'\nprint('hello')\nPY".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: Some(vec![
|
||||
@@ -841,7 +836,7 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_matches(
|
||||
"python3 <<'PY'\nprint('hello')\nPY".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: Some(vec!["python3".to_string()]),
|
||||
@@ -866,7 +861,7 @@ async fn heredoc_with_variable_assignment_is_not_reduced_to_allowed_prefix() {
|
||||
"PATH=/tmp/evil:$PATH cat <<'EOF'\nhello\nEOF".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -897,7 +892,7 @@ EOF"#
|
||||
.to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
|
||||
permission_profile: PermissionProfile::workspace_write(),
|
||||
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -931,7 +926,7 @@ EOF"#
|
||||
.to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
|
||||
permission_profile: PermissionProfile::workspace_write(),
|
||||
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
prefix_rule: None,
|
||||
@@ -971,7 +966,7 @@ prefix_rule(
|
||||
"/some/important/folder".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: PermissionProfile::Disabled,
|
||||
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -990,7 +985,7 @@ async fn exec_approval_requirement_prefers_execpolicy_match() {
|
||||
policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()),
|
||||
command: vec!["rm".to_string()],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: PermissionProfile::Disabled,
|
||||
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -1018,7 +1013,7 @@ prefix_rule(pattern=["git"], decision="allow")
|
||||
policy_src: Some(policy_src),
|
||||
command: vec![git_path, "status".to_string()],
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -1052,7 +1047,7 @@ prefix_rule(pattern=["git"], decision="prompt")
|
||||
policy_src: Some(policy_src),
|
||||
command: vec![disallowed_git_path.clone(), "status".to_string()],
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -1079,7 +1074,7 @@ async fn requested_prefix_rule_can_approve_absolute_path_commands() {
|
||||
"cargo-insta".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
|
||||
@@ -1102,7 +1097,7 @@ async fn exec_approval_requirement_respects_approval_policy() {
|
||||
policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()),
|
||||
command: vec!["rm".to_string()],
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: PermissionProfile::Disabled,
|
||||
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -1130,9 +1125,7 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() {
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
}),
|
||||
permission_profile: &permission_profile_from_sandbox_policy(
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
),
|
||||
permission_profile: &PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_cwd: Path::new("/tmp"),
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
@@ -1175,9 +1168,7 @@ fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() {
|
||||
&command,
|
||||
UnmatchedCommandContext {
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
permission_profile: &permission_profile_from_sandbox_policy(
|
||||
&SandboxPolicy::new_workspace_write_policy(),
|
||||
),
|
||||
permission_profile: &PermissionProfile::workspace_write(),
|
||||
file_system_sandbox_policy: &workspace_write_file_system_sandbox_policy(),
|
||||
sandbox_cwd: Path::new("/tmp"),
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
@@ -1258,7 +1249,7 @@ async fn exec_approval_requirement_prompts_for_inline_additional_permissions_und
|
||||
"touch requested-dir/requested-but-unused.txt".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::WithAdditionalPermissions,
|
||||
prefix_rule: None,
|
||||
@@ -1281,7 +1272,7 @@ async fn exec_approval_requirement_prompts_for_known_safe_escalation_under_on_re
|
||||
policy_src: None,
|
||||
command: vec!["echo".to_string(), "hello".to_string()],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
|
||||
permission_profile: PermissionProfile::workspace_write(),
|
||||
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
prefix_rule: None,
|
||||
@@ -1311,7 +1302,7 @@ async fn exec_approval_requirement_rejects_known_safe_escalation_when_granular_s
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
}),
|
||||
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
|
||||
permission_profile: PermissionProfile::workspace_write(),
|
||||
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
prefix_rule: None,
|
||||
@@ -1337,7 +1328,7 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
}),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
prefix_rule: None,
|
||||
@@ -1373,9 +1364,7 @@ async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision()
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
}),
|
||||
permission_profile: permission_profile_from_sandbox_policy(
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_cwd: Path::new("/tmp"),
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
@@ -1413,9 +1402,7 @@ async fn mixed_rule_and_sandbox_prompt_rejects_when_granular_rules_are_disabled(
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
}),
|
||||
permission_profile: permission_profile_from_sandbox_policy(
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_cwd: Path::new("/tmp"),
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
@@ -1440,9 +1427,7 @@ async fn exec_approval_requirement_falls_back_to_heuristics() {
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
permission_profile: permission_profile_from_sandbox_policy(
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_cwd: Path::new("/tmp"),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
@@ -1468,9 +1453,7 @@ async fn empty_bash_lc_script_falls_back_to_original_command() {
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
permission_profile: permission_profile_from_sandbox_policy(
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_cwd: Path::new("/tmp"),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
@@ -1500,9 +1483,7 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() {
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
permission_profile: permission_profile_from_sandbox_policy(
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_cwd: Path::new("/tmp"),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
@@ -1532,9 +1513,7 @@ async fn request_rule_uses_prefix_rule() {
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
permission_profile: permission_profile_from_sandbox_policy(
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_cwd: Path::new("/tmp"),
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
@@ -1683,7 +1662,7 @@ async fn proposed_execpolicy_amendment_is_present_for_single_command_without_pol
|
||||
policy_src: None,
|
||||
command: command.clone(),
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -1703,7 +1682,7 @@ async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() {
|
||||
policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()),
|
||||
command: vec!["rm".to_string()],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: PermissionProfile::Disabled,
|
||||
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -1727,7 +1706,7 @@ async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() {
|
||||
"cargo build && echo ok".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -1757,7 +1736,7 @@ async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scri
|
||||
policy_src: Some(policy_src.to_string()),
|
||||
command,
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -1781,7 +1760,7 @@ async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() {
|
||||
policy_src: None,
|
||||
command: command.clone(),
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -1805,7 +1784,7 @@ async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow()
|
||||
"print(1)".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -1836,7 +1815,7 @@ prefix_rule(pattern=["cat"], decision="allow")
|
||||
policy_src: Some(policy_src.to_string()),
|
||||
command: command.clone(),
|
||||
approval_policy,
|
||||
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
|
||||
permission_profile: PermissionProfile::workspace_write(),
|
||||
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -1868,7 +1847,7 @@ prefix_rule(pattern=["bash"], decision="allow")
|
||||
.to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -2034,7 +2013,7 @@ async fn dangerous_rm_rf_requires_approval_in_danger_full_access() {
|
||||
policy_src: None,
|
||||
command: command.clone(),
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: PermissionProfile::Disabled,
|
||||
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -2098,9 +2077,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &sneaky_command,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
permission_profile: permission_profile_from_sandbox_policy(
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_cwd: Path::new("/tmp"),
|
||||
sandbox_permissions: permissions,
|
||||
@@ -2125,9 +2102,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &dangerous_command,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
permission_profile: permission_profile_from_sandbox_policy(
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_cwd: Path::new("/tmp"),
|
||||
sandbox_permissions: permissions,
|
||||
@@ -2148,9 +2123,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &dangerous_command,
|
||||
approval_policy: AskForApproval::Never,
|
||||
permission_profile: permission_profile_from_sandbox_policy(
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_cwd: Path::new("/tmp"),
|
||||
sandbox_permissions: permissions,
|
||||
@@ -2170,8 +2143,8 @@ async fn dangerous_command_allowed_when_sandbox_is_explicitly_disabled() {
|
||||
policy_src: None,
|
||||
command,
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ExternalSandbox {
|
||||
network_access: Default::default(),
|
||||
permission_profile: PermissionProfile::External {
|
||||
network: NetworkSandboxPolicy::Restricted,
|
||||
},
|
||||
file_system_sandbox_policy: external_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
@@ -2195,8 +2168,8 @@ async fn dangerous_command_forbidden_in_external_sandbox_when_policy_matches() {
|
||||
policy_src: Some("prefix_rule(pattern=['rm'], decision='prompt')".to_string()),
|
||||
command,
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ExternalSandbox {
|
||||
network_access: Default::default(),
|
||||
permission_profile: PermissionProfile::External {
|
||||
network: NetworkSandboxPolicy::Restricted,
|
||||
},
|
||||
file_system_sandbox_policy: external_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
@@ -2214,7 +2187,7 @@ struct ExecApprovalRequirementScenario {
|
||||
policy_src: Option<String>,
|
||||
command: Vec<String>,
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
permission_profile: PermissionProfile,
|
||||
file_system_sandbox_policy: FileSystemSandboxPolicy,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
prefix_rule: Option<Vec<String>>,
|
||||
@@ -2238,7 +2211,7 @@ async fn exec_approval_requirement_for_command(
|
||||
policy_src,
|
||||
command,
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
file_system_sandbox_policy,
|
||||
sandbox_permissions,
|
||||
prefix_rule,
|
||||
@@ -2246,7 +2219,6 @@ async fn exec_approval_requirement_for_command(
|
||||
|
||||
let policy = policy_from_src(policy_src.as_deref());
|
||||
|
||||
let permission_profile = permission_profile_from_sandbox_policy(&sandbox_policy);
|
||||
ExecPolicyManager::new(policy)
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
|
||||
@@ -14,7 +14,7 @@ async fn evaluates_powershell_inner_commands_against_prompt_rules() {
|
||||
"echo blocked".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: PermissionProfile::Disabled,
|
||||
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -38,7 +38,7 @@ async fn evaluates_powershell_inner_commands_against_allow_rules() {
|
||||
"echo blocked".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
@@ -80,9 +80,7 @@ fn unmatched_safe_powershell_words_are_allowed() {
|
||||
&command,
|
||||
UnmatchedCommandContext {
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
permission_profile: &permission_profile_from_sandbox_policy(
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
),
|
||||
permission_profile: &PermissionProfile::read_only(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_cwd: Path::new("/tmp"),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
@@ -111,7 +109,7 @@ async fn unmatched_dangerous_powershell_inner_commands_require_approval() {
|
||||
"Remove-Item test -Force".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: PermissionProfile::Disabled,
|
||||
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
|
||||
@@ -347,8 +347,7 @@ async fn process_exec_tool_call_preserves_full_buffer_capture_policy() -> Result
|
||||
];
|
||||
|
||||
let cwd = codex_utils_absolute_path::AbsolutePathBuf::current_dir()?;
|
||||
let sandbox_policy = SandboxPolicy::DangerFullAccess;
|
||||
let permission_profile = PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy);
|
||||
let permission_profile = PermissionProfile::Disabled;
|
||||
let output = process_exec_tool_call(
|
||||
ExecParams {
|
||||
command,
|
||||
@@ -699,7 +698,7 @@ fn windows_restricted_token_rejects_unreadable_split_carveouts() {
|
||||
},
|
||||
codex_protocol::permissions::FileSystemSandboxEntry {
|
||||
path: codex_protocol::permissions::FileSystemPath::Path { path: blocked },
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::None,
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::Deny,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -847,7 +846,7 @@ fn windows_elevated_supports_unreadable_split_carveouts() {
|
||||
path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&blocked)
|
||||
.expect("absolute blocked"),
|
||||
},
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::None,
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::Deny,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -909,7 +908,7 @@ fn windows_elevated_supports_unreadable_globs() {
|
||||
path: codex_protocol::permissions::FileSystemPath::GlobPattern {
|
||||
pattern: "**/*.env".to_string(),
|
||||
},
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::None,
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::Deny,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use codex_exec_server::LOCAL_FS;
|
||||
use codex_git_utils::GitInfo;
|
||||
use codex_git_utils::GitSha;
|
||||
use codex_git_utils::collect_git_info;
|
||||
use codex_git_utils::get_git_repo_root_with_fs;
|
||||
use codex_git_utils::get_has_changes;
|
||||
use codex_git_utils::git_diff_to_remote;
|
||||
use codex_git_utils::recent_commits;
|
||||
@@ -379,6 +380,50 @@ async fn test_get_has_changes_ignores_repo_fsmonitor_config() {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn test_get_has_changes_ignores_configured_hooks_path() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let repo_path = create_test_git_repo(&temp_dir).await;
|
||||
let hooks_dir = repo_path.join(".git/hooks-path-test");
|
||||
let hook_path = hooks_dir.join("post-index-change");
|
||||
let marker_path = repo_path.join("hook-ran");
|
||||
|
||||
fs::create_dir_all(&hooks_dir).expect("create hook dir");
|
||||
fs::write(
|
||||
&hook_path,
|
||||
format!(
|
||||
"#!/bin/sh\nprintf ran > \"{}\"\n",
|
||||
marker_path.to_string_lossy()
|
||||
),
|
||||
)
|
||||
.expect("write post-index-change hook");
|
||||
let mut permissions = fs::metadata(&hook_path)
|
||||
.expect("read hook metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&hook_path, permissions).expect("mark hook executable");
|
||||
|
||||
Command::new("git")
|
||||
.args([
|
||||
"config",
|
||||
"core.hooksPath",
|
||||
hooks_dir.to_string_lossy().as_ref(),
|
||||
])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("configure hooks path");
|
||||
|
||||
fs::write(repo_path.join("test.txt"), "test content").expect("refresh tracked file");
|
||||
|
||||
assert_eq!(get_has_changes(&repo_path).await, Some(false));
|
||||
assert!(
|
||||
!marker_path.exists(),
|
||||
"metadata collection should not invoke configured hook directories"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_git_working_tree_state_clean_repo() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
@@ -482,6 +527,20 @@ async fn resolve_root_git_project_for_trust_returns_none_outside_repo() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_git_repo_root_with_fs_detects_gitdir_pointer() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let proj = tmp.path().join("proj");
|
||||
let nested = proj.join("nested");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
std::fs::write(proj.join(".git"), "gitdir: /tmp/fake-worktree\n").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
get_git_repo_root_with_fs(LOCAL_FS.as_ref(), &nested.abs()).await,
|
||||
Some(proj.abs())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
|
||||
+176
-62
@@ -7,6 +7,7 @@
|
||||
use crate::StateDbHandle;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::GoalContext;
|
||||
use crate::session::TurnInput;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::state::ActiveTurn;
|
||||
@@ -15,21 +16,23 @@ use crate::tasks::RegularTask;
|
||||
use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME;
|
||||
use anyhow::Context;
|
||||
use codex_features::Feature;
|
||||
use codex_otel::GOAL_BLOCKED_METRIC;
|
||||
use codex_otel::GOAL_BUDGET_LIMITED_METRIC;
|
||||
use codex_otel::GOAL_COMPLETED_METRIC;
|
||||
use codex_otel::GOAL_CREATED_METRIC;
|
||||
use codex_otel::GOAL_DURATION_SECONDS_METRIC;
|
||||
use codex_otel::GOAL_RESUMED_METRIC;
|
||||
use codex_otel::GOAL_TOKEN_COUNT_METRIC;
|
||||
use codex_otel::GOAL_USAGE_LIMITED_METRIC;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ThreadGoal;
|
||||
use codex_protocol::protocol::ThreadGoalStatus;
|
||||
use codex_protocol::protocol::ThreadGoalUpdatedEvent;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::validate_thread_goal_objective;
|
||||
use codex_rollout::state_db::reconcile_rollout;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
@@ -153,7 +156,9 @@ pub(crate) enum GoalRuntimeEvent<'a> {
|
||||
MaybeContinueIfIdle,
|
||||
TaskAborted {
|
||||
turn_context: Option<&'a TurnContext>,
|
||||
reason: TurnAbortReason,
|
||||
},
|
||||
UsageLimitReached {
|
||||
turn_context: &'a TurnContext,
|
||||
},
|
||||
ExternalMutationStarting,
|
||||
ExternalSet {
|
||||
@@ -331,8 +336,8 @@ impl Session {
|
||||
/// starts capture the active goal and token baseline, tool completions
|
||||
/// account usage and may inject budget steering, completion accounting
|
||||
/// suppresses that steering, external mutations account best-effort before
|
||||
/// changing state, interrupts pause active goals, thread resumes restore
|
||||
/// runtime state for already-active goals, explicit maybe-continue events
|
||||
/// changing state, thread resumes restore runtime state for already-active
|
||||
/// goals, explicit maybe-continue events
|
||||
/// start idle goal continuation turns, and continuation turns with no counted
|
||||
/// autonomous activity suppress the next automatic continuation until
|
||||
/// user/tool/external activity resets it.
|
||||
@@ -384,12 +389,13 @@ impl Session {
|
||||
self.maybe_continue_goal_if_idle_runtime().await;
|
||||
Ok(())
|
||||
}),
|
||||
GoalRuntimeEvent::TaskAborted {
|
||||
turn_context,
|
||||
reason,
|
||||
} => Box::pin(async move {
|
||||
self.handle_thread_goal_task_abort(turn_context, reason)
|
||||
.await;
|
||||
GoalRuntimeEvent::TaskAborted { turn_context } => Box::pin(async move {
|
||||
self.handle_thread_goal_task_abort(turn_context).await;
|
||||
Ok(())
|
||||
}),
|
||||
GoalRuntimeEvent::UsageLimitReached { turn_context } => Box::pin(async move {
|
||||
self.usage_limit_active_thread_goal_for_turn(turn_context)
|
||||
.await?;
|
||||
Ok(())
|
||||
}),
|
||||
GoalRuntimeEvent::ExternalMutationStarting => Box::pin(async move {
|
||||
@@ -422,6 +428,7 @@ impl Session {
|
||||
|
||||
let state_db = self.require_state_db_for_thread_goals().await?;
|
||||
state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.await
|
||||
.map(|goal| goal.map(protocol_goal_from_state))
|
||||
@@ -459,10 +466,14 @@ impl Session {
|
||||
let mut replacing_goal = false;
|
||||
let previous_status;
|
||||
let goal = if let Some(objective) = objective.as_deref() {
|
||||
let existing_goal = state_db.get_thread_goal(self.conversation_id).await?;
|
||||
let existing_goal = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.await?;
|
||||
previous_status = existing_goal.as_ref().map(|goal| goal.status);
|
||||
if let Some(existing_goal) = existing_goal.as_ref() {
|
||||
state_db
|
||||
.thread_goals()
|
||||
.update_thread_goal(
|
||||
self.conversation_id,
|
||||
codex_state::ThreadGoalUpdate {
|
||||
@@ -482,6 +493,7 @@ impl Session {
|
||||
} else {
|
||||
replacing_goal = true;
|
||||
state_db
|
||||
.thread_goals()
|
||||
.replace_thread_goal(
|
||||
self.conversation_id,
|
||||
objective,
|
||||
@@ -493,11 +505,15 @@ impl Session {
|
||||
.await?
|
||||
}
|
||||
} else {
|
||||
let existing_goal = state_db.get_thread_goal(self.conversation_id).await?;
|
||||
let existing_goal = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.await?;
|
||||
previous_status = existing_goal.as_ref().map(|goal| goal.status);
|
||||
let expected_goal_id = existing_goal.map(|goal| goal.goal_id);
|
||||
let status = status.map(state_goal_status_from_protocol);
|
||||
state_db
|
||||
.thread_goals()
|
||||
.update_thread_goal(
|
||||
self.conversation_id,
|
||||
codex_state::ThreadGoalUpdate {
|
||||
@@ -516,6 +532,14 @@ impl Session {
|
||||
})?
|
||||
};
|
||||
|
||||
if objective.is_some() {
|
||||
set_thread_preview_from_goal_objective(
|
||||
&state_db,
|
||||
self.conversation_id,
|
||||
goal.objective.as_str(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let goal_status = goal.status;
|
||||
let goal_id = goal.goal_id.clone();
|
||||
let previous_status_for_goal = if replacing_goal {
|
||||
@@ -526,6 +550,7 @@ impl Session {
|
||||
if replacing_goal {
|
||||
self.emit_goal_created_metric();
|
||||
}
|
||||
self.emit_goal_resumed_metric_if_status_changed(previous_status_for_goal, goal_status);
|
||||
self.emit_goal_terminal_metrics_if_status_changed(previous_status_for_goal, &goal);
|
||||
let goal = protocol_goal_from_state(goal);
|
||||
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
|
||||
@@ -581,6 +606,7 @@ impl Session {
|
||||
)
|
||||
.await?;
|
||||
let goal = state_db
|
||||
.thread_goals()
|
||||
.insert_thread_goal(
|
||||
self.conversation_id,
|
||||
objective,
|
||||
@@ -595,6 +621,12 @@ impl Session {
|
||||
)
|
||||
})?;
|
||||
|
||||
set_thread_preview_from_goal_objective(
|
||||
&state_db,
|
||||
self.conversation_id,
|
||||
goal.objective.as_str(),
|
||||
)
|
||||
.await;
|
||||
let goal_id = goal.goal_id.clone();
|
||||
self.emit_goal_created_metric();
|
||||
let goal = protocol_goal_from_state(goal);
|
||||
@@ -641,6 +673,7 @@ impl Session {
|
||||
let previous_status = previous_goal
|
||||
.as_ref()
|
||||
.and_then(|previous_goal| (!replaced_existing_goal).then_some(previous_goal.status));
|
||||
self.emit_goal_resumed_metric_if_status_changed(previous_status, goal.status);
|
||||
self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal);
|
||||
let goal_for_steering = objective_changed.then(|| protocol_goal_from_state(goal.clone()));
|
||||
let goal_id = goal.goal_id;
|
||||
@@ -669,7 +702,10 @@ impl Session {
|
||||
self.clear_stopped_thread_goal_runtime_state().await;
|
||||
}
|
||||
}
|
||||
codex_state::ThreadGoalStatus::Paused | codex_state::ThreadGoalStatus::Complete => {
|
||||
codex_state::ThreadGoalStatus::Paused
|
||||
| codex_state::ThreadGoalStatus::Blocked
|
||||
| codex_state::ThreadGoalStatus::UsageLimited
|
||||
| codex_state::ThreadGoalStatus::Complete => {
|
||||
self.clear_stopped_thread_goal_runtime_state().await;
|
||||
}
|
||||
}
|
||||
@@ -723,6 +759,31 @@ impl Session {
|
||||
.counter(GOAL_CREATED_METRIC, /*inc*/ 1, &[]);
|
||||
}
|
||||
|
||||
fn emit_goal_resumed_metric(&self) {
|
||||
self.services
|
||||
.session_telemetry
|
||||
.counter(GOAL_RESUMED_METRIC, /*inc*/ 1, &[]);
|
||||
}
|
||||
|
||||
fn emit_goal_resumed_metric_if_status_changed(
|
||||
&self,
|
||||
previous_status: Option<codex_state::ThreadGoalStatus>,
|
||||
goal_status: codex_state::ThreadGoalStatus,
|
||||
) {
|
||||
if goal_status == codex_state::ThreadGoalStatus::Active
|
||||
&& matches!(
|
||||
previous_status,
|
||||
Some(
|
||||
codex_state::ThreadGoalStatus::Paused
|
||||
| codex_state::ThreadGoalStatus::Blocked
|
||||
| codex_state::ThreadGoalStatus::UsageLimited
|
||||
)
|
||||
)
|
||||
{
|
||||
self.emit_goal_resumed_metric();
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_goal_terminal_metrics_if_status_changed(
|
||||
&self,
|
||||
previous_status: Option<codex_state::ThreadGoalStatus>,
|
||||
@@ -733,6 +794,8 @@ impl Session {
|
||||
}
|
||||
|
||||
let counter = match goal.status {
|
||||
codex_state::ThreadGoalStatus::Blocked => GOAL_BLOCKED_METRIC,
|
||||
codex_state::ThreadGoalStatus::UsageLimited => GOAL_USAGE_LIMITED_METRIC,
|
||||
codex_state::ThreadGoalStatus::BudgetLimited => GOAL_BUDGET_LIMITED_METRIC,
|
||||
codex_state::ThreadGoalStatus::Complete => GOAL_COMPLETED_METRIC,
|
||||
codex_state::ThreadGoalStatus::Active | codex_state::ThreadGoalStatus::Paused => {
|
||||
@@ -760,7 +823,10 @@ impl Session {
|
||||
state_db: &StateDbHandle,
|
||||
expected_goal_id: Option<&str>,
|
||||
) -> anyhow::Result<Option<codex_state::ThreadGoalStatus>> {
|
||||
let goal = state_db.get_thread_goal(self.conversation_id).await?;
|
||||
let goal = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.await?;
|
||||
Ok(goal.and_then(|goal| {
|
||||
expected_goal_id
|
||||
.is_none_or(|expected_goal_id| goal.goal_id == expected_goal_id)
|
||||
@@ -801,7 +867,11 @@ impl Session {
|
||||
return;
|
||||
}
|
||||
};
|
||||
match state_db.get_thread_goal(self.conversation_id).await {
|
||||
match state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(goal))
|
||||
if matches!(
|
||||
goal.status,
|
||||
@@ -886,11 +956,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_thread_goal_task_abort(
|
||||
&self,
|
||||
turn_context: Option<&TurnContext>,
|
||||
reason: TurnAbortReason,
|
||||
) {
|
||||
async fn handle_thread_goal_task_abort(&self, turn_context: Option<&TurnContext>) {
|
||||
if let Some(turn_context) = turn_context {
|
||||
self.take_thread_goal_continuation_turn(&turn_context.sub_id)
|
||||
.await;
|
||||
@@ -913,12 +979,6 @@ impl Session {
|
||||
accounting.turn = None;
|
||||
}
|
||||
}
|
||||
|
||||
if reason == TurnAbortReason::Interrupted
|
||||
&& let Err(err) = self.pause_active_thread_goal_for_interrupt().await
|
||||
{
|
||||
tracing::warn!("failed to pause active thread goal after interrupt: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn account_thread_goal_progress(
|
||||
@@ -963,6 +1023,7 @@ impl Session {
|
||||
.current_goal_status_for_metrics(&state_db, expected_goal_id.as_deref())
|
||||
.await?;
|
||||
let outcome = state_db
|
||||
.thread_goals()
|
||||
.account_thread_goal_usage(
|
||||
self.conversation_id,
|
||||
time_delta_seconds,
|
||||
@@ -985,6 +1046,8 @@ impl Session {
|
||||
matches!(budget_limit_steering, BudgetLimitSteering::Suppressed)
|
||||
}
|
||||
codex_state::ThreadGoalStatus::Paused
|
||||
| codex_state::ThreadGoalStatus::Blocked
|
||||
| codex_state::ThreadGoalStatus::UsageLimited
|
||||
| codex_state::ThreadGoalStatus::Complete => true,
|
||||
};
|
||||
{
|
||||
@@ -1085,6 +1148,7 @@ impl Session {
|
||||
.await?;
|
||||
|
||||
match state_db
|
||||
.thread_goals()
|
||||
.account_thread_goal_usage(
|
||||
self.conversation_id,
|
||||
time_delta_seconds,
|
||||
@@ -1122,8 +1186,11 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
async fn pause_active_thread_goal_for_interrupt(&self) -> anyhow::Result<()> {
|
||||
if should_ignore_goal_for_mode(self.collaboration_mode().await.mode) {
|
||||
async fn usage_limit_active_thread_goal_for_turn(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
) -> anyhow::Result<()> {
|
||||
if should_ignore_goal_for_mode(turn_context.collaboration_mode.mode) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1140,34 +1207,34 @@ impl Session {
|
||||
let Some(state_db) = self.state_db_for_thread_goals().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
self.account_thread_goal_wall_clock_usage(
|
||||
&state_db,
|
||||
codex_state::ThreadGoalAccountingMode::ActiveStatusOnly,
|
||||
self.account_thread_goal_progress(
|
||||
turn_context,
|
||||
BudgetLimitSteering::Suppressed,
|
||||
TerminalMetricEmission::Emit,
|
||||
)
|
||||
.await?;
|
||||
let previous_status = self
|
||||
.current_goal_status_for_metrics(&state_db, /*expected_goal_id*/ None)
|
||||
.await?;
|
||||
let Some(goal) = state_db
|
||||
.pause_active_thread_goal(self.conversation_id)
|
||||
.thread_goals()
|
||||
.usage_limit_active_thread_goal(self.conversation_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal);
|
||||
let goal = protocol_goal_from_state(goal);
|
||||
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
|
||||
self.goal_runtime
|
||||
.accounting
|
||||
.lock()
|
||||
.await
|
||||
.wall_clock
|
||||
.clear_active_goal();
|
||||
self.send_event_raw(Event {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
msg: EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent {
|
||||
self.clear_active_goal_accounting(turn_context).await;
|
||||
self.send_event(
|
||||
turn_context,
|
||||
EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent {
|
||||
thread_id: self.conversation_id,
|
||||
turn_id: None,
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
goal,
|
||||
}),
|
||||
})
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1192,7 +1259,11 @@ impl Session {
|
||||
let Some(state_db) = self.state_db_for_thread_goals().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(goal) = state_db.get_thread_goal(self.conversation_id).await? else {
|
||||
let Some(goal) = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.await?
|
||||
else {
|
||||
self.clear_stopped_thread_goal_runtime_state().await;
|
||||
return Ok(());
|
||||
};
|
||||
@@ -1204,8 +1275,11 @@ impl Session {
|
||||
.await
|
||||
.wall_clock
|
||||
.mark_active_goal(goal.goal_id);
|
||||
self.emit_goal_resumed_metric();
|
||||
}
|
||||
codex_state::ThreadGoalStatus::Paused
|
||||
| codex_state::ThreadGoalStatus::Blocked
|
||||
| codex_state::ThreadGoalStatus::UsageLimited
|
||||
| codex_state::ThreadGoalStatus::BudgetLimited
|
||||
| codex_state::ThreadGoalStatus::Complete => {
|
||||
self.clear_stopped_thread_goal_runtime_state().await;
|
||||
@@ -1237,7 +1311,11 @@ impl Session {
|
||||
Arc::clone(&active_turn.turn_state)
|
||||
};
|
||||
let goal_is_current = match self.state_db_for_thread_goals().await {
|
||||
Ok(Some(state_db)) => match state_db.get_thread_goal(self.conversation_id).await {
|
||||
Ok(Some(state_db)) => match state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(goal))
|
||||
if goal.goal_id == candidate.goal_id
|
||||
&& goal.status == codex_state::ThreadGoalStatus::Active =>
|
||||
@@ -1269,12 +1347,16 @@ impl Session {
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut turn_state = turn_state.lock().await;
|
||||
for item in candidate.items {
|
||||
turn_state.push_pending_input(item);
|
||||
}
|
||||
}
|
||||
self.input_queue
|
||||
.extend_pending_input_for_turn_state(
|
||||
turn_state.as_ref(),
|
||||
candidate
|
||||
.items
|
||||
.into_iter()
|
||||
.map(TurnInput::ResponseInputItem)
|
||||
.collect(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let turn_context = self
|
||||
.new_default_turn_with_sub_id(uuid::Uuid::new_v4().to_string())
|
||||
@@ -1312,11 +1394,15 @@ impl Session {
|
||||
tracing::debug!("skipping active goal continuation because a turn is already active");
|
||||
return None;
|
||||
}
|
||||
if self.has_queued_response_items_for_next_turn().await {
|
||||
if self
|
||||
.input_queue
|
||||
.has_queued_response_items_for_next_turn()
|
||||
.await
|
||||
{
|
||||
tracing::debug!("skipping active goal continuation because queued input exists");
|
||||
return None;
|
||||
}
|
||||
if self.has_trigger_turn_mailbox_items().await {
|
||||
if self.input_queue.has_trigger_turn_mailbox_items().await {
|
||||
tracing::debug!(
|
||||
"skipping active goal continuation because trigger-turn mailbox input is pending"
|
||||
);
|
||||
@@ -1333,7 +1419,11 @@ impl Session {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let goal = match state_db.get_thread_goal(self.conversation_id).await {
|
||||
let goal = match state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(goal)) => goal,
|
||||
Ok(None) => {
|
||||
tracing::debug!("skipping active goal continuation because no goal is set");
|
||||
@@ -1349,8 +1439,11 @@ impl Session {
|
||||
return None;
|
||||
}
|
||||
if self.active_turn.lock().await.is_some()
|
||||
|| self.has_queued_response_items_for_next_turn().await
|
||||
|| self.has_trigger_turn_mailbox_items().await
|
||||
|| self
|
||||
.input_queue
|
||||
.has_queued_response_items_for_next_turn()
|
||||
.await
|
||||
|| self.input_queue.has_trigger_turn_mailbox_items().await
|
||||
{
|
||||
tracing::debug!("skipping active goal continuation because pending work appeared");
|
||||
return None;
|
||||
@@ -1438,6 +1531,21 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_thread_preview_from_goal_objective(
|
||||
state_db: &StateDbHandle,
|
||||
thread_id: ThreadId,
|
||||
objective: &str,
|
||||
) {
|
||||
if let Err(err) = state_db
|
||||
.set_thread_preview_if_empty(thread_id, objective)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"failed to set empty thread preview from goal objective for {thread_id}: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn should_ignore_goal_for_mode(mode: ModeKind) -> bool {
|
||||
mode == ModeKind::Plan
|
||||
}
|
||||
@@ -1526,7 +1634,7 @@ fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseInputItem {
|
||||
fn goal_context_input_item(prompt: String) -> ResponseInputItem {
|
||||
let context = GoalContext { prompt };
|
||||
ResponseInputItem::Message {
|
||||
role: <GoalContext as ContextualUserFragment>::ROLE.to_string(),
|
||||
role: GoalContext::role().to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: context.render(),
|
||||
}],
|
||||
@@ -1553,6 +1661,8 @@ pub(crate) fn protocol_goal_status_from_state(
|
||||
match status {
|
||||
codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active,
|
||||
codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused,
|
||||
codex_state::ThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked,
|
||||
codex_state::ThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited,
|
||||
codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited,
|
||||
codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete,
|
||||
}
|
||||
@@ -1564,6 +1674,8 @@ pub(crate) fn state_goal_status_from_protocol(
|
||||
match status {
|
||||
ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active,
|
||||
ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused,
|
||||
ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked,
|
||||
ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited,
|
||||
ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited,
|
||||
ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete,
|
||||
}
|
||||
@@ -1642,7 +1754,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuation_prompt_only_tells_model_to_update_goal_when_complete() {
|
||||
fn continuation_prompt_allows_complete_and_strict_blocked_updates() {
|
||||
let prompt = continuation_prompt(&ThreadGoal {
|
||||
thread_id: ThreadId::new(),
|
||||
objective: "finish the stack".to_string(),
|
||||
@@ -1659,9 +1771,11 @@ mod tests {
|
||||
assert!(prompt.contains("<objective>\nfinish the stack\n</objective>"));
|
||||
assert!(prompt.contains("Token budget: 10000"));
|
||||
assert!(prompt.contains("call update_goal with status \"complete\""));
|
||||
assert!(!prompt.contains(
|
||||
"explain the blocker or next required input to the user and wait for new input"
|
||||
));
|
||||
assert!(prompt.contains("status \"blocked\""));
|
||||
assert!(prompt.contains("at least three consecutive goal turns"));
|
||||
assert!(prompt.contains("same blocking condition"));
|
||||
assert!(prompt.contains("original/user-triggered turn"));
|
||||
assert!(prompt.contains("truly at an impasse"));
|
||||
assert!(!prompt.contains("budgetLimited"));
|
||||
assert!(!prompt.contains("status \"paused\""));
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ use std::time::Duration;
|
||||
use anyhow::anyhow;
|
||||
use codex_analytics::GuardianReviewAnalyticsResult;
|
||||
use codex_analytics::GuardianReviewSessionKind;
|
||||
use codex_protocol::config_types::AutoCompactTokenLimitScope;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
@@ -137,6 +139,7 @@ struct GuardianReviewSessionReuseKey {
|
||||
model_provider: ModelProviderInfo,
|
||||
model_context_window: Option<i64>,
|
||||
model_auto_compact_token_limit: Option<i64>,
|
||||
model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope,
|
||||
model_reasoning_effort: Option<ReasoningEffortConfig>,
|
||||
model_reasoning_summary: Option<ReasoningSummaryConfig>,
|
||||
permissions: Permissions,
|
||||
@@ -161,6 +164,7 @@ impl GuardianReviewSessionReuseKey {
|
||||
model_provider: spawn_config.model_provider.clone(),
|
||||
model_context_window: spawn_config.model_context_window,
|
||||
model_auto_compact_token_limit: spawn_config.model_auto_compact_token_limit,
|
||||
model_auto_compact_token_limit_scope: spawn_config.model_auto_compact_token_limit_scope,
|
||||
model_reasoning_effort: spawn_config.model_reasoning_effort,
|
||||
model_reasoning_summary: spawn_config.model_reasoning_summary,
|
||||
permissions: spawn_config.permissions.clone(),
|
||||
@@ -698,26 +702,36 @@ async fn run_review_on_session(
|
||||
.total_token_usage()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
// The legacy SandboxPolicy should match the PermissionProfile.
|
||||
let guardian_permission_profile = PermissionProfile::read_only();
|
||||
let legacy_sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
|
||||
let submit_result = run_before_review_deadline(
|
||||
deadline,
|
||||
params.external_cancel.as_ref(),
|
||||
Box::pin(review_session.codex.submit(Op::UserTurn {
|
||||
environments: None,
|
||||
Box::pin(review_session.codex.submit(Op::UserInput {
|
||||
items: prompt_items.items,
|
||||
#[allow(deprecated)]
|
||||
cwd: params.parent_turn.cwd.to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: None,
|
||||
model: params.model.clone(),
|
||||
effort: params.reasoning_effort,
|
||||
summary: Some(params.reasoning_summary),
|
||||
service_tier: None,
|
||||
environments: None,
|
||||
final_output_json_schema: Some(params.schema.clone()),
|
||||
collaboration_mode: None,
|
||||
personality: params.personality,
|
||||
responsesapi_client_metadata: None,
|
||||
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
|
||||
#[allow(deprecated)]
|
||||
cwd: Some(params.parent_turn.cwd.to_path_buf()),
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
sandbox_policy: Some(legacy_sandbox_policy),
|
||||
permission_profile: Some(guardian_permission_profile),
|
||||
summary: Some(params.reasoning_summary),
|
||||
personality: params.personality,
|
||||
collaboration_mode: Some(codex_protocol::config_types::CollaborationMode {
|
||||
mode: codex_protocol::config_types::ModeKind::Default,
|
||||
settings: codex_protocol::config_types::Settings {
|
||||
model: params.model.clone(),
|
||||
reasoning_effort: params.reasoning_effort,
|
||||
developer_instructions: None,
|
||||
},
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
@@ -892,12 +906,11 @@ pub(crate) fn build_guardian_review_session_config(
|
||||
);
|
||||
guardian_config.developer_instructions = None;
|
||||
guardian_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never);
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
guardian_config
|
||||
.permissions
|
||||
.set_legacy_sandbox_policy(sandbox_policy, guardian_config.cwd.as_path())
|
||||
.set_permission_profile(PermissionProfile::read_only())
|
||||
.map_err(|err| {
|
||||
anyhow::anyhow!("guardian review session could not set sandbox policy: {err}")
|
||||
anyhow::anyhow!("guardian review session could not set permission profile: {err}")
|
||||
})?;
|
||||
guardian_config.include_apps_instructions = false;
|
||||
guardian_config
|
||||
@@ -1145,6 +1158,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guardian_review_session_compact_scope_change_invalidates_cached_session() {
|
||||
let parent_config = crate::config::test_config().await;
|
||||
let cached_spawn_config = build_guardian_review_session_config(
|
||||
&parent_config,
|
||||
/*live_network_config*/ None,
|
||||
"active-model",
|
||||
/*reasoning_effort*/ None,
|
||||
)
|
||||
.expect("cached guardian config");
|
||||
let cached_reuse_key =
|
||||
GuardianReviewSessionReuseKey::from_spawn_config(&cached_spawn_config);
|
||||
|
||||
let mut changed_parent_config = parent_config;
|
||||
changed_parent_config.model_auto_compact_token_limit_scope =
|
||||
AutoCompactTokenLimitScope::BodyAfterPrefix;
|
||||
let next_spawn_config = build_guardian_review_session_config(
|
||||
&changed_parent_config,
|
||||
/*live_network_config*/ None,
|
||||
"active-model",
|
||||
/*reasoning_effort*/ None,
|
||||
)
|
||||
.expect("next guardian config");
|
||||
let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(&next_spawn_config);
|
||||
|
||||
assert_ne!(cached_reuse_key, next_reuse_key);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guardian_review_session_config_disables_hooks() {
|
||||
let mut parent_config = crate::config::test_config().await;
|
||||
|
||||
@@ -42,7 +42,6 @@ use codex_protocol::protocol::GuardianRiskLevel;
|
||||
use codex_protocol::protocol::GuardianUserAuthorization;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::TurnCompleteEvent;
|
||||
use core_test_support::PathBufExt;
|
||||
use core_test_support::TempDirExt;
|
||||
@@ -2191,7 +2190,7 @@ async fn guardian_review_session_config_preserves_parent_network_proxy() {
|
||||
);
|
||||
assert_eq!(
|
||||
guardian_config.permissions.permission_profile(),
|
||||
&PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_read_only_policy())
|
||||
&PermissionProfile::read_only()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2253,9 +2252,7 @@ async fn guardian_review_session_config_uses_live_network_proxy_state() {
|
||||
NetworkProxySpec::from_config_and_constraints(
|
||||
live_network,
|
||||
/*requirements*/ None,
|
||||
&PermissionProfile::from_legacy_sandbox_policy(
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
),
|
||||
&PermissionProfile::read_only(),
|
||||
)
|
||||
.expect("live network proxy spec")
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ use codex_hooks::PostToolUseRequest;
|
||||
use codex_hooks::PreToolUseOutcome;
|
||||
use codex_hooks::PreToolUseRequest;
|
||||
use codex_hooks::SessionStartOutcome;
|
||||
use codex_hooks::SessionStartTarget;
|
||||
use codex_hooks::StartHookTarget;
|
||||
use codex_hooks::StopHookTarget;
|
||||
use codex_hooks::StopOutcome;
|
||||
use codex_hooks::SubagentHookContext;
|
||||
@@ -22,7 +22,7 @@ use codex_hooks::UserPromptSubmitRequest;
|
||||
use codex_otel::HOOK_RUN_DURATION_METRIC;
|
||||
use codex_otel::HOOK_RUN_METRIC;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::items::UserMessageItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -34,13 +34,13 @@ use codex_protocol::protocol::HookSource;
|
||||
use codex_protocol::protocol::HookStartedEvent;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_thread_store::ReadThreadParams;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::HookAdditionalContext;
|
||||
use crate::event_mapping::parse_turn_item;
|
||||
use crate::session::TurnInput;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::tools::hook_names::HookToolName;
|
||||
@@ -56,22 +56,6 @@ pub(crate) enum PreToolUseHookResult {
|
||||
Blocked(String),
|
||||
}
|
||||
|
||||
pub(crate) enum PendingInputHookDisposition {
|
||||
Accepted(Box<PendingInputRecord>),
|
||||
Blocked { additional_contexts: Vec<String> },
|
||||
}
|
||||
|
||||
pub(crate) enum PendingInputRecord {
|
||||
UserMessage {
|
||||
content: Vec<UserInput>,
|
||||
response_item: ResponseItem,
|
||||
additional_contexts: Vec<String>,
|
||||
},
|
||||
ConversationItem {
|
||||
response_item: ResponseItem,
|
||||
},
|
||||
}
|
||||
|
||||
struct ContextInjectingHookOutcome {
|
||||
hook_events: Vec<HookCompletedEvent>,
|
||||
outcome: HookRuntimeOutcome,
|
||||
@@ -121,6 +105,9 @@ pub(crate) async fn run_pending_session_start_hooks(
|
||||
return false;
|
||||
};
|
||||
|
||||
// Pending session-start hooks are reused to dispatch thread-spawn subagent
|
||||
// starts. Other subagent sessions are internal/system work and do not run
|
||||
// start hooks.
|
||||
let target = match &turn_context.session_source {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. })
|
||||
if matches!(
|
||||
@@ -129,14 +116,14 @@ pub(crate) async fn run_pending_session_start_hooks(
|
||||
) =>
|
||||
{
|
||||
let context = subagent_hook_context(sess, agent_role);
|
||||
SessionStartTarget::SubagentStart {
|
||||
StartHookTarget::SubagentStart {
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
agent_id: context.agent_id,
|
||||
agent_type: context.agent_type,
|
||||
}
|
||||
}
|
||||
SessionSource::SubAgent(_) => return false,
|
||||
_ => SessionStartTarget::SessionStart {
|
||||
_ => StartHookTarget::SessionStart {
|
||||
source: session_start_source,
|
||||
},
|
||||
};
|
||||
@@ -307,6 +294,8 @@ pub(crate) async fn run_turn_stop_hooks(
|
||||
stop_hook_active: bool,
|
||||
last_assistant_message: Option<String>,
|
||||
) -> StopOutcome {
|
||||
// Resolve the stop hook kind from the session source before building the
|
||||
// request. Root turns run Stop; thread-spawned child turns run SubagentStop.
|
||||
let (target, transcript_path) = match &turn_context.session_source {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
agent_role,
|
||||
@@ -344,6 +333,8 @@ pub(crate) async fn run_turn_stop_hooks(
|
||||
parent_transcript_path,
|
||||
)
|
||||
}
|
||||
// Internal/synthetic subagents do not expose user-configured lifecycle
|
||||
// hooks, so there is no Stop or SubagentStop request to dispatch.
|
||||
SessionSource::SubAgent(_) => return StopOutcome::default(),
|
||||
_ => (StopHookTarget::Stop, sess.hook_transcript_path().await),
|
||||
};
|
||||
@@ -433,84 +424,126 @@ pub(crate) async fn run_post_compact_hooks(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run_user_prompt_submit_hooks(
|
||||
pub(crate) async fn run_legacy_after_agent_hook(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
prompt: String,
|
||||
) -> HookRuntimeOutcome {
|
||||
let request = UserPromptSubmitRequest {
|
||||
session_id: sess.session_id().into(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
transcript_path: sess.hook_transcript_path().await,
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
permission_mode: hook_permission_mode(turn_context),
|
||||
prompt,
|
||||
};
|
||||
input: &[ResponseItem],
|
||||
last_assistant_message: Option<String>,
|
||||
) -> bool {
|
||||
let mut abort_message = None;
|
||||
let input_messages = input
|
||||
.iter()
|
||||
.filter_map(|item| match parse_turn_item(item) {
|
||||
Some(TurnItem::UserMessage(user_message)) => Some(user_message.message()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let hooks = sess.hooks();
|
||||
let preview_runs = hooks.preview_user_prompt_submit(&request);
|
||||
run_context_injecting_hook(
|
||||
sess,
|
||||
turn_context,
|
||||
preview_runs,
|
||||
hooks.run_user_prompt_submit(request),
|
||||
)
|
||||
.await
|
||||
for hook_outcome in hooks
|
||||
.dispatch(codex_hooks::HookPayload {
|
||||
session_id: sess.session_id().into(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
client: turn_context.app_server_client_name.clone(),
|
||||
triggered_at: chrono::Utc::now(),
|
||||
hook_event: codex_hooks::HookEvent::AfterAgent {
|
||||
event: codex_hooks::HookEventAfterAgent {
|
||||
thread_id: sess.conversation_id,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
input_messages,
|
||||
last_assistant_message,
|
||||
},
|
||||
},
|
||||
})
|
||||
.await
|
||||
{
|
||||
let hook_name = hook_outcome.hook_name;
|
||||
let (error, should_abort) = match hook_outcome.result {
|
||||
codex_hooks::HookResult::Success => continue,
|
||||
codex_hooks::HookResult::FailedContinue(error) => (error, false),
|
||||
codex_hooks::HookResult::FailedAbort(error) => (error, true),
|
||||
};
|
||||
let action = if should_abort {
|
||||
"aborting operation"
|
||||
} else {
|
||||
"continuing"
|
||||
};
|
||||
tracing::warn!(
|
||||
turn_id = %turn_context.sub_id,
|
||||
hook_name = %hook_name,
|
||||
error = %error,
|
||||
"after_agent hook failed; {action}"
|
||||
);
|
||||
if should_abort && abort_message.is_none() {
|
||||
abort_message = Some(format!(
|
||||
"after_agent hook '{hook_name}' failed and aborted turn completion: {error}"
|
||||
));
|
||||
}
|
||||
}
|
||||
let Some(message) = abort_message else {
|
||||
return false;
|
||||
};
|
||||
let event = EventMsg::Error(codex_protocol::protocol::ErrorEvent {
|
||||
message,
|
||||
codex_error_info: None,
|
||||
});
|
||||
sess.send_event(turn_context, event).await;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) async fn inspect_pending_input(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
pending_input_item: ResponseInputItem,
|
||||
) -> PendingInputHookDisposition {
|
||||
let response_item = ResponseItem::from(pending_input_item);
|
||||
if let Some(TurnItem::UserMessage(user_message)) = parse_turn_item(&response_item) {
|
||||
let user_prompt_submit_outcome =
|
||||
run_user_prompt_submit_hooks(sess, turn_context, user_message.message()).await;
|
||||
if user_prompt_submit_outcome.should_stop {
|
||||
PendingInputHookDisposition::Blocked {
|
||||
additional_contexts: user_prompt_submit_outcome.additional_contexts,
|
||||
}
|
||||
} else {
|
||||
PendingInputHookDisposition::Accepted(Box::new(PendingInputRecord::UserMessage {
|
||||
content: user_message.content,
|
||||
response_item,
|
||||
additional_contexts: user_prompt_submit_outcome.additional_contexts,
|
||||
}))
|
||||
pending_input_item: &TurnInput,
|
||||
) -> HookRuntimeOutcome {
|
||||
match pending_input_item {
|
||||
TurnInput::UserInput(content) => {
|
||||
let request = UserPromptSubmitRequest {
|
||||
session_id: sess.session_id().into(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.clone(),
|
||||
transcript_path: sess.hook_transcript_path().await,
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
permission_mode: hook_permission_mode(turn_context),
|
||||
prompt: UserMessageItem::new(content).message(),
|
||||
};
|
||||
let hooks = sess.hooks();
|
||||
let preview_runs = hooks.preview_user_prompt_submit(&request);
|
||||
run_context_injecting_hook(
|
||||
sess,
|
||||
turn_context,
|
||||
preview_runs,
|
||||
hooks.run_user_prompt_submit(request),
|
||||
)
|
||||
.await
|
||||
}
|
||||
} else {
|
||||
PendingInputHookDisposition::Accepted(Box::new(PendingInputRecord::ConversationItem {
|
||||
response_item,
|
||||
}))
|
||||
TurnInput::ResponseInputItem(_) => HookRuntimeOutcome {
|
||||
should_stop: false,
|
||||
additional_contexts: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn record_pending_input(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
pending_input: PendingInputRecord,
|
||||
pending_input: TurnInput,
|
||||
additional_contexts: Vec<String>,
|
||||
) {
|
||||
match pending_input {
|
||||
PendingInputRecord::UserMessage {
|
||||
content,
|
||||
response_item,
|
||||
additional_contexts,
|
||||
} => {
|
||||
sess.record_user_prompt_and_emit_turn_item(
|
||||
turn_context.as_ref(),
|
||||
content.as_slice(),
|
||||
response_item,
|
||||
)
|
||||
.await;
|
||||
record_additional_contexts(sess, turn_context, additional_contexts).await;
|
||||
TurnInput::UserInput(content) => {
|
||||
sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), content.as_slice())
|
||||
.await;
|
||||
}
|
||||
PendingInputRecord::ConversationItem { response_item } => {
|
||||
TurnInput::ResponseInputItem(input) => {
|
||||
let response_item = ResponseItem::from(input);
|
||||
sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
record_additional_contexts(sess, turn_context, additional_contexts).await;
|
||||
}
|
||||
|
||||
async fn run_context_injecting_hook<Fut, Outcome>(
|
||||
|
||||
@@ -20,7 +20,7 @@ mod compact_remote;
|
||||
mod compact_remote_v2;
|
||||
mod config_lock;
|
||||
pub use codex_thread::CodexThread;
|
||||
pub use codex_thread::CodexThreadTurnContextOverrides;
|
||||
pub use codex_thread::CodexThreadSettingsOverrides;
|
||||
pub use codex_thread::ThreadConfigSnapshot;
|
||||
pub use session::turn_context::TurnContext;
|
||||
mod agent;
|
||||
@@ -90,13 +90,11 @@ pub(crate) use skills::SkillsManager;
|
||||
pub(crate) use skills::build_available_skills;
|
||||
pub(crate) use skills::build_skill_injections;
|
||||
pub(crate) use skills::build_skill_name_counts;
|
||||
pub(crate) use skills::collect_env_var_dependencies;
|
||||
pub(crate) use skills::collect_explicit_skill_mentions;
|
||||
pub(crate) use skills::default_skill_metadata_budget;
|
||||
pub(crate) use skills::injection;
|
||||
pub(crate) use skills::manager;
|
||||
pub(crate) use skills::maybe_emit_implicit_skill_invocation;
|
||||
pub(crate) use skills::resolve_skill_dependencies_for_turn;
|
||||
pub(crate) use skills::skills_load_input_from_config;
|
||||
mod stream_events_utils;
|
||||
pub mod test_support;
|
||||
|
||||
@@ -974,6 +974,7 @@ pub(crate) struct McpToolApprovalMetadata {
|
||||
connector_id: Option<String>,
|
||||
connector_name: Option<String>,
|
||||
connector_description: Option<String>,
|
||||
plugin_id: Option<String>,
|
||||
tool_title: Option<String>,
|
||||
tool_description: Option<String>,
|
||||
mcp_app_resource_uri: Option<String>,
|
||||
@@ -983,6 +984,7 @@ pub(crate) struct McpToolApprovalMetadata {
|
||||
|
||||
const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate";
|
||||
const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri";
|
||||
const MCP_TOOL_PLUGIN_ID_META_KEY: &str = "plugin_id";
|
||||
const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId";
|
||||
|
||||
async fn custom_mcp_tool_approval_mode(
|
||||
@@ -1068,6 +1070,12 @@ fn build_mcp_tool_call_request_meta(
|
||||
serde_json::Value::Object(codex_apps_meta),
|
||||
);
|
||||
}
|
||||
if let Some(plugin_id) = metadata.and_then(|metadata| metadata.plugin_id.as_ref()) {
|
||||
request_meta.insert(
|
||||
MCP_TOOL_PLUGIN_ID_META_KEY.to_string(),
|
||||
serde_json::Value::String(plugin_id.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
(!request_meta.is_empty()).then_some(serde_json::Value::Object(request_meta))
|
||||
}
|
||||
@@ -1480,13 +1488,11 @@ pub(crate) async fn lookup_mcp_tool_metadata(
|
||||
server: &str,
|
||||
tool_name: &str,
|
||||
) -> Option<McpToolApprovalMetadata> {
|
||||
let tools = sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.list_all_tools()
|
||||
.await;
|
||||
let manager = sess.services.mcp_connection_manager.read().await;
|
||||
let plugin_id = manager
|
||||
.plugin_id_for_mcp_server_name(server)
|
||||
.map(str::to_string);
|
||||
let tools = manager.list_all_tools().await;
|
||||
let tool_info = tools
|
||||
.into_iter()
|
||||
.find(|tool_info| tool_info.server_name == server && tool_info.tool.name == tool_name)?;
|
||||
@@ -1519,6 +1525,7 @@ pub(crate) async fn lookup_mcp_tool_metadata(
|
||||
connector_id: tool_info.connector_id,
|
||||
connector_name: tool_info.connector_name,
|
||||
connector_description,
|
||||
plugin_id,
|
||||
tool_title: tool_info.tool.title,
|
||||
tool_description: tool_info.tool.description.map(std::borrow::Cow::into_owned),
|
||||
mcp_app_resource_uri: get_mcp_app_resource_uri(tool_info.tool.meta.as_deref()),
|
||||
|
||||
@@ -76,6 +76,7 @@ fn approval_metadata(
|
||||
connector_id: connector_id.map(str::to_string),
|
||||
connector_name: connector_name.map(str::to_string),
|
||||
connector_description: connector_description.map(str::to_string),
|
||||
plugin_id: None,
|
||||
tool_title: tool_title.map(str::to_string),
|
||||
tool_description: tool_description.map(str::to_string),
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -1099,6 +1100,29 @@ async fn mcp_tool_call_request_meta_includes_turn_started_at_unix_ms() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_mcp_tool_call_request_meta_includes_plugin_id() {
|
||||
let (_, turn_context) = make_session_and_context().await;
|
||||
let expected_turn_metadata = turn_context
|
||||
.turn_metadata_state
|
||||
.current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context))
|
||||
.expect("turn metadata");
|
||||
let mut metadata = approval_metadata(
|
||||
/*connector_id*/ None, /*connector_name*/ None,
|
||||
/*connector_description*/ None, /*tool_title*/ None,
|
||||
/*tool_description*/ None,
|
||||
);
|
||||
metadata.plugin_id = Some("sample@test".to_string());
|
||||
|
||||
assert_eq!(
|
||||
build_mcp_tool_call_request_meta(&turn_context, "sample", "call-plugin", Some(&metadata),),
|
||||
Some(serde_json::json!({
|
||||
crate::X_CODEX_TURN_METADATA_HEADER: expected_turn_metadata,
|
||||
MCP_TOOL_PLUGIN_ID_META_KEY: "sample@test",
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps_meta() {
|
||||
let (_, turn_context) = make_session_and_context().await;
|
||||
@@ -1111,6 +1135,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
connector_description: Some("Manage events".to_string()),
|
||||
plugin_id: None,
|
||||
tool_title: Some("Create Event".to_string()),
|
||||
tool_description: Some("Create a calendar event.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -1210,8 +1235,8 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context:
|
||||
let environment = session
|
||||
.services
|
||||
.environment_manager
|
||||
.default_environment()
|
||||
.unwrap_or_else(|| session.services.environment_manager.local_environment());
|
||||
.default_or_local_environment()
|
||||
.expect("test session should have an MCP runtime environment");
|
||||
let (manager, _cancel_token) = codex_mcp::McpConnectionManager::new(
|
||||
&HashMap::new(),
|
||||
turn_context.config.mcp_oauth_credentials_store_mode,
|
||||
@@ -1220,10 +1245,14 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context:
|
||||
turn_context.sub_id.clone(),
|
||||
session.get_tx_event(),
|
||||
turn_context.permission_profile(),
|
||||
codex_mcp::McpRuntimeEnvironment::new(environment, {
|
||||
#[allow(deprecated)]
|
||||
turn_context.cwd.to_path_buf()
|
||||
}),
|
||||
codex_mcp::McpRuntimeEnvironment::new(
|
||||
Some(environment),
|
||||
session.services.environment_manager.try_local_environment(),
|
||||
{
|
||||
#[allow(deprecated)]
|
||||
turn_context.cwd.to_path_buf()
|
||||
},
|
||||
),
|
||||
turn_context.config.codex_home.to_path_buf(),
|
||||
codex_mcp::codex_apps_tools_cache_key(auth.as_ref()),
|
||||
/*host_owned_codex_apps_enabled*/ true,
|
||||
@@ -1573,6 +1602,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() {
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
connector_description: None,
|
||||
plugin_id: None,
|
||||
tool_title: None,
|
||||
tool_description: None,
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -2290,6 +2320,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
connector_description: None,
|
||||
plugin_id: None,
|
||||
tool_title: Some("Read Only Tool".to_string()),
|
||||
tool_description: None,
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -2363,6 +2394,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
connector_description: None,
|
||||
plugin_id: None,
|
||||
tool_title: Some("Read Only Tool".to_string()),
|
||||
tool_description: None,
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -2419,6 +2451,7 @@ async fn permission_request_hook_allows_mcp_tool_call() {
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
connector_description: None,
|
||||
plugin_id: None,
|
||||
tool_title: Some("Create entities".to_string()),
|
||||
tool_description: None,
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -2554,6 +2587,7 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() {
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
connector_description: None,
|
||||
plugin_id: None,
|
||||
tool_title: Some("Create entities".to_string()),
|
||||
tool_description: None,
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -2640,6 +2674,7 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() {
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
connector_description: None,
|
||||
plugin_id: None,
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Reads calendar data.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -2693,6 +2728,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
connector_description: None,
|
||||
plugin_id: None,
|
||||
tool_title: Some("Read Only Tool".to_string()),
|
||||
tool_description: None,
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -2772,6 +2808,7 @@ async fn approve_mode_skips_arc_interrupt_for_model() {
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
connector_description: Some("Manage events".to_string()),
|
||||
plugin_id: None,
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -2839,6 +2876,7 @@ async fn custom_approve_mode_skips_arc_interrupt_for_model() {
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
connector_description: None,
|
||||
plugin_id: None,
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -2906,6 +2944,7 @@ async fn approve_mode_skips_arc_interrupt_without_annotations() {
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
connector_description: Some("Manage events".to_string()),
|
||||
plugin_id: None,
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -2978,6 +3017,7 @@ async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() {
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
connector_description: Some("Manage events".to_string()),
|
||||
plugin_id: None,
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
@@ -3046,6 +3086,7 @@ async fn approve_mode_skips_arc_and_guardian_in_every_permission_mode() {
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
connector_description: Some("Manage events".to_string()),
|
||||
plugin_id: None,
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
|
||||
@@ -3,7 +3,6 @@ use std::collections::HashSet;
|
||||
use codex_features::Feature;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::ToolInfo as McpToolInfo;
|
||||
use codex_tools::ToolsConfig;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::connectors;
|
||||
@@ -18,9 +17,8 @@ pub(crate) struct McpToolExposure {
|
||||
pub(crate) fn build_mcp_tool_exposure(
|
||||
all_mcp_tools: &[McpToolInfo],
|
||||
connectors: Option<&[connectors::AppInfo]>,
|
||||
explicitly_enabled_connectors: &[connectors::AppInfo],
|
||||
config: &Config,
|
||||
tools_config: &ToolsConfig,
|
||||
search_tool_enabled: bool,
|
||||
) -> McpToolExposure {
|
||||
let mut deferred_tools = filter_non_codex_apps_mcp_tools_only(all_mcp_tools);
|
||||
if let Some(connectors) = connectors {
|
||||
@@ -31,7 +29,7 @@ pub(crate) fn build_mcp_tool_exposure(
|
||||
));
|
||||
}
|
||||
|
||||
let should_defer = tools_config.search_tool
|
||||
let should_defer = search_tool_enabled
|
||||
&& (config
|
||||
.features
|
||||
.enabled(Feature::ToolSearchAlwaysDeferMcpTools)
|
||||
@@ -44,16 +42,8 @@ pub(crate) fn build_mcp_tool_exposure(
|
||||
};
|
||||
}
|
||||
|
||||
let direct_tools =
|
||||
filter_codex_apps_mcp_tools(all_mcp_tools, explicitly_enabled_connectors, config);
|
||||
let direct_tool_names = direct_tools
|
||||
.iter()
|
||||
.map(McpToolInfo::canonical_tool_name)
|
||||
.collect::<HashSet<_>>();
|
||||
deferred_tools.retain(|tool| !direct_tool_names.contains(&tool.canonical_tool_name()));
|
||||
|
||||
McpToolExposure {
|
||||
direct_tools,
|
||||
direct_tools: Vec::new(),
|
||||
deferred_tools: (!deferred_tools.is_empty()).then_some(deferred_tools),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,9 @@ use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_features::Feature;
|
||||
use codex_features::Features;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::ToolInfo;
|
||||
use codex_models_manager::test_support::construct_model_info_offline_for_tests;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolsConfig;
|
||||
use codex_tools::ToolsConfigParams;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::Tool;
|
||||
@@ -94,38 +86,13 @@ fn tool_names(tools: &[ToolInfo]) -> HashSet<ToolName> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn tools_config_for_mcp_tool_exposure(search_tool: bool) -> ToolsConfig {
|
||||
let config = test_config().await;
|
||||
let model_info =
|
||||
construct_model_info_offline_for_tests("gpt-5.4", &config.to_models_manager_config());
|
||||
let features = Features::with_defaults();
|
||||
let available_models = Vec::new();
|
||||
let mut tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
permission_profile: &PermissionProfile::Disabled,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
tools_config.search_tool = search_tool;
|
||||
tools_config
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn directly_exposes_small_effective_tool_sets() {
|
||||
let config = test_config().await;
|
||||
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
|
||||
let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1);
|
||||
|
||||
let exposure = build_mcp_tool_exposure(
|
||||
&mcp_tools,
|
||||
/*connectors*/ None,
|
||||
&[],
|
||||
&config,
|
||||
&tools_config,
|
||||
&mcp_tools, /*connectors*/ None, &config, /*search_tool_enabled*/ true,
|
||||
);
|
||||
|
||||
assert_eq!(tool_names(&exposure.direct_tools), tool_names(&mcp_tools));
|
||||
@@ -135,15 +102,10 @@ async fn directly_exposes_small_effective_tool_sets() {
|
||||
#[tokio::test]
|
||||
async fn searches_large_effective_tool_sets() {
|
||||
let config = test_config().await;
|
||||
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
|
||||
let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD);
|
||||
|
||||
let exposure = build_mcp_tool_exposure(
|
||||
&mcp_tools,
|
||||
/*connectors*/ None,
|
||||
&[],
|
||||
&config,
|
||||
&tools_config,
|
||||
&mcp_tools, /*connectors*/ None, &config, /*search_tool_enabled*/ true,
|
||||
);
|
||||
|
||||
assert!(exposure.direct_tools.is_empty());
|
||||
@@ -155,64 +117,12 @@ async fn searches_large_effective_tool_sets() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn directly_exposes_explicit_apps_without_deferred_overlap() {
|
||||
let config = test_config().await;
|
||||
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
|
||||
let mut mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1);
|
||||
mcp_tools.push(make_mcp_tool(
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"calendar_create_event",
|
||||
"mcp__codex_apps__calendar",
|
||||
"_create_event",
|
||||
Some("calendar"),
|
||||
Some("Calendar"),
|
||||
));
|
||||
let connectors = vec![make_connector("calendar", "Calendar")];
|
||||
|
||||
let exposure = build_mcp_tool_exposure(
|
||||
&mcp_tools,
|
||||
Some(connectors.as_slice()),
|
||||
connectors.as_slice(),
|
||||
&config,
|
||||
&tools_config,
|
||||
);
|
||||
|
||||
let direct_tool_names = tool_names(&exposure.direct_tools);
|
||||
assert_eq!(
|
||||
direct_tool_names,
|
||||
HashSet::from([ToolName::namespaced(
|
||||
"mcp__codex_apps__calendar",
|
||||
"_create_event"
|
||||
)])
|
||||
);
|
||||
assert_eq!(
|
||||
exposure.deferred_tools.as_ref().map(Vec::len),
|
||||
Some(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1)
|
||||
);
|
||||
let deferred_tools = exposure
|
||||
.deferred_tools
|
||||
.as_ref()
|
||||
.expect("large tool sets should be discoverable through tool_search");
|
||||
let deferred_tool_names = tool_names(deferred_tools);
|
||||
assert!(
|
||||
direct_tool_names.is_disjoint(&deferred_tool_names),
|
||||
"direct tools should not also be deferred: {direct_tool_names:?}"
|
||||
);
|
||||
assert!(!deferred_tool_names.contains(&ToolName::namespaced(
|
||||
"mcp__codex_apps__calendar",
|
||||
"_create_event"
|
||||
)));
|
||||
assert!(deferred_tool_names.contains(&ToolName::namespaced("mcp__rmcp__", "tool_0")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn always_defer_feature_preserves_explicit_apps() {
|
||||
async fn always_defer_feature_defers_apps_too() {
|
||||
let mut config = test_config().await;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::ToolSearchAlwaysDeferMcpTools)
|
||||
.expect("test config should allow feature update");
|
||||
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
|
||||
let mcp_tools = vec![
|
||||
make_mcp_tool(
|
||||
"rmcp",
|
||||
@@ -236,26 +146,18 @@ async fn always_defer_feature_preserves_explicit_apps() {
|
||||
let exposure = build_mcp_tool_exposure(
|
||||
&mcp_tools,
|
||||
Some(connectors.as_slice()),
|
||||
connectors.as_slice(),
|
||||
&config,
|
||||
&tools_config,
|
||||
/*search_tool_enabled*/ true,
|
||||
);
|
||||
|
||||
let direct_tool_names = tool_names(&exposure.direct_tools);
|
||||
assert_eq!(
|
||||
direct_tool_names,
|
||||
HashSet::from([ToolName::namespaced(
|
||||
"mcp__codex_apps__calendar",
|
||||
"_create_event"
|
||||
)])
|
||||
);
|
||||
assert!(exposure.direct_tools.is_empty());
|
||||
let deferred_tools = exposure
|
||||
.deferred_tools
|
||||
.as_ref()
|
||||
.expect("MCP tools should be discoverable through tool_search");
|
||||
let deferred_tool_names = tool_names(deferred_tools);
|
||||
assert!(deferred_tool_names.contains(&ToolName::namespaced("mcp__rmcp__", "tool")));
|
||||
assert!(!deferred_tool_names.contains(&ToolName::namespaced(
|
||||
assert!(deferred_tool_names.contains(&ToolName::namespaced(
|
||||
"mcp__codex_apps__calendar",
|
||||
"_create_event"
|
||||
)));
|
||||
|
||||
@@ -43,14 +43,16 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec<Stri
|
||||
(None, "shell_command") => serde_json::from_str::<ShellCommandToolCallParams>(arguments)
|
||||
.ok()
|
||||
.map(|params| {
|
||||
if !invocation.turn.tools_config.allow_login_shell && params.login == Some(true) {
|
||||
if !invocation.turn.config.permissions.allow_login_shell
|
||||
&& params.login == Some(true)
|
||||
{
|
||||
#[allow(deprecated)]
|
||||
let cwd = invocation.turn.resolve_path(params.workdir).to_path_buf();
|
||||
return (Vec::new(), cwd);
|
||||
}
|
||||
let use_login_shell = params
|
||||
.login
|
||||
.unwrap_or(invocation.turn.tools_config.allow_login_shell);
|
||||
.unwrap_or(invocation.turn.config.permissions.allow_login_shell);
|
||||
let command = invocation
|
||||
.session
|
||||
.user_shell()
|
||||
@@ -65,8 +67,8 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec<Stri
|
||||
let command = crate::tools::handlers::unified_exec::get_command(
|
||||
¶ms,
|
||||
invocation.session.user_shell(),
|
||||
&invocation.turn.tools_config.unified_exec_shell_mode,
|
||||
invocation.turn.tools_config.allow_login_shell,
|
||||
&invocation.turn.unified_exec_shell_mode,
|
||||
invocation.turn.config.permissions.allow_login_shell,
|
||||
)
|
||||
.ok()?;
|
||||
#[allow(deprecated)]
|
||||
|
||||
@@ -72,6 +72,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
@@ -46,9 +45,12 @@ pub async fn build_prompt_input(
|
||||
Arc::clone(&auth_manager),
|
||||
SessionSource::Exec,
|
||||
Arc::new(
|
||||
EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths)
|
||||
.await
|
||||
.map_err(|err| CodexErr::Fatal(err.to_string()))?,
|
||||
EnvironmentManager::from_codex_home(
|
||||
config.codex_home.clone(),
|
||||
Some(local_runtime_paths),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| CodexErr::Fatal(err.to_string()))?,
|
||||
),
|
||||
empty_extension_registry(),
|
||||
/*analytics_events_client*/ None,
|
||||
@@ -86,15 +88,7 @@ pub(crate) async fn build_prompt_input_from_session(
|
||||
.clone_history()
|
||||
.await
|
||||
.for_prompt(&turn_context.model_info.input_modalities);
|
||||
let router = built_tools(
|
||||
sess,
|
||||
turn_context.as_ref(),
|
||||
&prompt_input,
|
||||
&HashSet::new(),
|
||||
Some(turn_context.turn_skills.outcome.as_ref()),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await?;
|
||||
let router = built_tools(sess, turn_context.as_ref(), &CancellationToken::new()).await?;
|
||||
let base_instructions = sess.get_base_instructions().await;
|
||||
let prompt = build_prompt(
|
||||
prompt_input,
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
use super::*;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::FileSystemAccessMode;
|
||||
use codex_protocol::protocol::FileSystemPath;
|
||||
use codex_protocol::protocol::FileSystemSandboxEntry;
|
||||
use codex_protocol::protocol::FileSystemSpecialPath;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::PathExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn permission_profile_for_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile {
|
||||
PermissionProfile::from_legacy_sandbox_policy(sandbox_policy)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_writable_roots_constraint() {
|
||||
// Use a temporary directory as our workspace to avoid touching
|
||||
@@ -32,36 +28,34 @@ fn test_writable_roots_constraint() {
|
||||
|
||||
// Policy limited to the workspace only; exclude system temp roots so
|
||||
// only `cwd` is writable by default.
|
||||
let policy_workspace_only = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
let workspace_only_file_system_policy = FileSystemSandboxPolicy::workspace_write(
|
||||
&[],
|
||||
/*exclude_tmpdir_env_var*/ true,
|
||||
/*exclude_slash_tmp*/ true,
|
||||
);
|
||||
|
||||
assert!(is_write_patch_constrained_to_writable_paths(
|
||||
&add_inside,
|
||||
&FileSystemSandboxPolicy::from(&policy_workspace_only),
|
||||
&workspace_only_file_system_policy,
|
||||
&cwd,
|
||||
));
|
||||
|
||||
assert!(!is_write_patch_constrained_to_writable_paths(
|
||||
&add_outside,
|
||||
&FileSystemSandboxPolicy::from(&policy_workspace_only),
|
||||
&workspace_only_file_system_policy,
|
||||
&cwd,
|
||||
));
|
||||
|
||||
// With the parent dir explicitly added as a writable root, the
|
||||
// outside write should be permitted.
|
||||
let policy_with_parent = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![parent],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
let file_system_policy_with_parent = FileSystemSandboxPolicy::workspace_write(
|
||||
std::slice::from_ref(&parent),
|
||||
/*exclude_tmpdir_env_var*/ true,
|
||||
/*exclude_slash_tmp*/ true,
|
||||
);
|
||||
assert!(is_write_patch_constrained_to_writable_paths(
|
||||
&add_outside,
|
||||
&FileSystemSandboxPolicy::from(&policy_with_parent),
|
||||
&file_system_policy_with_parent,
|
||||
&cwd,
|
||||
));
|
||||
}
|
||||
@@ -73,16 +67,17 @@ fn external_sandbox_auto_approves_in_on_request() {
|
||||
let add_inside_path = cwd.join("inner.txt");
|
||||
let add_inside = ApplyPatchAction::new_add_for_test(&add_inside_path, "".to_string());
|
||||
|
||||
let policy = SandboxPolicy::ExternalSandbox {
|
||||
network_access: codex_protocol::protocol::NetworkAccess::Enabled,
|
||||
let permission_profile = PermissionProfile::External {
|
||||
network: NetworkSandboxPolicy::Enabled,
|
||||
};
|
||||
let file_system_sandbox_policy = FileSystemSandboxPolicy::external_sandbox();
|
||||
|
||||
assert_eq!(
|
||||
assess_patch_safety(
|
||||
&add_inside,
|
||||
AskForApproval::OnRequest,
|
||||
&permission_profile_for_policy(&policy),
|
||||
&FileSystemSandboxPolicy::from(&policy),
|
||||
&permission_profile,
|
||||
&file_system_sandbox_policy,
|
||||
&cwd,
|
||||
WindowsSandboxLevel::Disabled
|
||||
),
|
||||
@@ -100,19 +95,20 @@ fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() {
|
||||
let parent = cwd.parent().unwrap();
|
||||
let outside_path = parent.join("outside.txt");
|
||||
let add_outside = ApplyPatchAction::new_add_for_test(&outside_path, "".to_string());
|
||||
let policy_workspace_only = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
let permission_profile = PermissionProfile::workspace_write_with(
|
||||
&[],
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
/*exclude_tmpdir_env_var*/ true,
|
||||
/*exclude_slash_tmp*/ true,
|
||||
);
|
||||
let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy();
|
||||
|
||||
assert_eq!(
|
||||
assess_patch_safety(
|
||||
&add_outside,
|
||||
AskForApproval::OnRequest,
|
||||
&permission_profile_for_policy(&policy_workspace_only),
|
||||
&FileSystemSandboxPolicy::from(&policy_workspace_only),
|
||||
&permission_profile,
|
||||
&file_system_sandbox_policy,
|
||||
&cwd,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
),
|
||||
@@ -128,8 +124,8 @@ fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() {
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
}),
|
||||
&permission_profile_for_policy(&policy_workspace_only),
|
||||
&FileSystemSandboxPolicy::from(&policy_workspace_only),
|
||||
&permission_profile,
|
||||
&file_system_sandbox_policy,
|
||||
&cwd,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
),
|
||||
@@ -144,12 +140,13 @@ fn granular_sandbox_approval_false_rejects_out_of_root_patch() {
|
||||
let parent = cwd.parent().unwrap();
|
||||
let outside_path = parent.join("outside.txt");
|
||||
let add_outside = ApplyPatchAction::new_add_for_test(&outside_path, "".to_string());
|
||||
let policy_workspace_only = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
let permission_profile = PermissionProfile::workspace_write_with(
|
||||
&[],
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
/*exclude_tmpdir_env_var*/ true,
|
||||
/*exclude_slash_tmp*/ true,
|
||||
);
|
||||
let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy();
|
||||
|
||||
assert_eq!(
|
||||
assess_patch_safety(
|
||||
@@ -161,8 +158,8 @@ fn granular_sandbox_approval_false_rejects_out_of_root_patch() {
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
}),
|
||||
&permission_profile_for_policy(&policy_workspace_only),
|
||||
&FileSystemSandboxPolicy::from(&policy_workspace_only),
|
||||
&permission_profile,
|
||||
&file_system_sandbox_policy,
|
||||
&cwd,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
),
|
||||
@@ -178,9 +175,8 @@ fn read_only_policy_rejects_patch_with_read_only_reason() {
|
||||
let cwd = tmp.path().abs();
|
||||
let inside_path = cwd.join("inside.txt");
|
||||
let action = ApplyPatchAction::new_add_for_test(&inside_path, "".to_string());
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
let file_system_sandbox_policy =
|
||||
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, &cwd);
|
||||
let permission_profile = PermissionProfile::read_only();
|
||||
let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy();
|
||||
|
||||
assert!(!is_write_patch_constrained_to_writable_paths(
|
||||
&action,
|
||||
@@ -191,7 +187,7 @@ fn read_only_policy_rejects_patch_with_read_only_reason() {
|
||||
assess_patch_safety(
|
||||
&action,
|
||||
AskForApproval::Never,
|
||||
&permission_profile_for_policy(&sandbox_policy),
|
||||
&permission_profile,
|
||||
&file_system_sandbox_policy,
|
||||
&cwd,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
@@ -208,8 +204,8 @@ fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() {
|
||||
let blocked_path = cwd.join("blocked.txt");
|
||||
let blocked_absolute = blocked_path;
|
||||
let action = ApplyPatchAction::new_add_for_test(&blocked_absolute, "".to_string());
|
||||
let sandbox_policy = SandboxPolicy::ExternalSandbox {
|
||||
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
|
||||
let permission_profile = PermissionProfile::External {
|
||||
network: NetworkSandboxPolicy::Restricted,
|
||||
};
|
||||
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
@@ -222,7 +218,7 @@ fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() {
|
||||
path: FileSystemPath::Path {
|
||||
path: blocked_absolute,
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -235,7 +231,7 @@ fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() {
|
||||
assess_patch_safety(
|
||||
&action,
|
||||
AskForApproval::OnRequest,
|
||||
&permission_profile_for_policy(&sandbox_policy),
|
||||
&permission_profile,
|
||||
&file_system_sandbox_policy,
|
||||
&cwd,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
@@ -252,8 +248,8 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() {
|
||||
let blocked_absolute = blocked_path;
|
||||
let docs_absolute = AbsolutePathBuf::resolve_path_against_base("docs", &cwd);
|
||||
let action = ApplyPatchAction::new_add_for_test(&blocked_absolute, "".to_string());
|
||||
let sandbox_policy = SandboxPolicy::ExternalSandbox {
|
||||
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
|
||||
let permission_profile = PermissionProfile::External {
|
||||
network: NetworkSandboxPolicy::Restricted,
|
||||
};
|
||||
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
@@ -279,7 +275,7 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() {
|
||||
assess_patch_safety(
|
||||
&action,
|
||||
AskForApproval::OnRequest,
|
||||
&permission_profile_for_policy(&sandbox_policy),
|
||||
&permission_profile,
|
||||
&file_system_sandbox_policy,
|
||||
&cwd,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
@@ -294,14 +290,21 @@ fn missing_project_dot_codex_config_requires_approval() {
|
||||
let cwd = tmp.path().abs();
|
||||
let config_path = cwd.join(".codex").join("config.toml");
|
||||
let action = ApplyPatchAction::new_add_for_test(&config_path, "".to_string());
|
||||
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
let file_system_sandbox_policy =
|
||||
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, &cwd);
|
||||
let permission_profile = PermissionProfile::workspace_write_with(
|
||||
&[],
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
/*exclude_tmpdir_env_var*/ true,
|
||||
/*exclude_slash_tmp*/ true,
|
||||
);
|
||||
let mut file_system_sandbox_policy = permission_profile.file_system_sandbox_policy();
|
||||
file_system_sandbox_policy
|
||||
.entries
|
||||
.push(FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: cwd.join(".codex"),
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
});
|
||||
|
||||
assert!(!is_write_patch_constrained_to_writable_paths(
|
||||
&action,
|
||||
@@ -312,7 +315,7 @@ fn missing_project_dot_codex_config_requires_approval() {
|
||||
assess_patch_safety(
|
||||
&action,
|
||||
AskForApproval::OnRequest,
|
||||
&permission_profile_for_policy(&sandbox_policy),
|
||||
&permission_profile,
|
||||
&file_system_sandbox_policy,
|
||||
&cwd,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use codex_sandboxing::get_platform_sandbox;
|
||||
use codex_sandboxing::policy_transforms::should_require_platform_sandbox;
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn sandbox_tag(
|
||||
policy: &SandboxPolicy,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
) -> &'static str {
|
||||
permission_profile_sandbox_tag(
|
||||
&PermissionProfile::from_legacy_sandbox_policy(policy),
|
||||
windows_sandbox_level,
|
||||
/*enforce_managed_network*/ false,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn permission_profile_sandbox_tag(
|
||||
profile: &PermissionProfile,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use super::permission_profile_policy_tag;
|
||||
use super::permission_profile_sandbox_tag;
|
||||
use super::sandbox_tag;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::ManagedFileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
@@ -10,8 +9,6 @@ use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::permissions::FileSystemSandboxKind;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::NetworkAccess;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use codex_sandboxing::get_platform_sandbox;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -20,29 +17,32 @@ use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn danger_full_access_is_untagged_even_when_linux_sandbox_defaults_apply() {
|
||||
let actual = sandbox_tag(
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
let actual = permission_profile_sandbox_tag(
|
||||
&PermissionProfile::Disabled,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
/*enforce_managed_network*/ false,
|
||||
);
|
||||
assert_eq!(actual, "none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_sandbox_keeps_external_tag_when_linux_sandbox_defaults_apply() {
|
||||
let actual = sandbox_tag(
|
||||
&SandboxPolicy::ExternalSandbox {
|
||||
network_access: NetworkAccess::Enabled,
|
||||
let actual = permission_profile_sandbox_tag(
|
||||
&PermissionProfile::External {
|
||||
network: NetworkSandboxPolicy::Enabled,
|
||||
},
|
||||
WindowsSandboxLevel::Disabled,
|
||||
/*enforce_managed_network*/ false,
|
||||
);
|
||||
assert_eq!(actual, "external");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_linux_sandbox_uses_platform_sandbox_tag() {
|
||||
let actual = sandbox_tag(
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
let actual = permission_profile_sandbox_tag(
|
||||
&PermissionProfile::read_only(),
|
||||
WindowsSandboxLevel::Disabled,
|
||||
/*enforce_managed_network*/ false,
|
||||
);
|
||||
let expected = get_platform_sandbox(/*windows_sandbox_enabled*/ false)
|
||||
.map(SandboxType::as_metric_tag)
|
||||
|
||||
@@ -42,15 +42,15 @@ use codex_protocol::protocol::ReviewRequest;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::ThreadMemoryMode;
|
||||
use codex_protocol::protocol::ThreadRolledBackEvent;
|
||||
use codex_protocol::protocol::ThreadSettingsAppliedEvent;
|
||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||
use codex_protocol::protocol::ThreadSettingsSnapshot;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
|
||||
use crate::context_manager::is_user_turn_boundary;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::items::UserMessageItem;
|
||||
use codex_protocol::mcp::RequestId as ProtocolRequestId;
|
||||
@@ -83,19 +83,6 @@ pub async fn realtime_conversation_list_voices(sess: &Session, sub_id: String) {
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn override_turn_context(sess: &Session, sub_id: String, updates: SessionSettingsUpdate) {
|
||||
if let Err(err) = sess.update_settings(updates).await {
|
||||
sess.send_event_raw(Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: err.to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn user_input_or_turn(sess: &Arc<Session>, sub_id: String, op: Op) {
|
||||
user_input_or_turn_inner(
|
||||
sess,
|
||||
@@ -106,134 +93,132 @@ pub async fn user_input_or_turn(sess: &Arc<Session>, sub_id: String, op: Op) {
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn update_thread_settings(
|
||||
sess: &Arc<Session>,
|
||||
sub_id: String,
|
||||
thread_settings: ThreadSettingsOverrides,
|
||||
) {
|
||||
let updates = thread_settings_update(sess, thread_settings).await;
|
||||
let msg = match sess.update_settings(updates).await {
|
||||
Ok(()) => thread_settings_applied_event(sess).await,
|
||||
Err(err) => EventMsg::Error(ErrorEvent {
|
||||
message: format!("invalid thread settings override: {err}"),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
}),
|
||||
};
|
||||
sess.send_event_raw(Event { id: sub_id, msg }).await;
|
||||
}
|
||||
|
||||
async fn thread_settings_update(
|
||||
sess: &Session,
|
||||
thread_settings: ThreadSettingsOverrides,
|
||||
) -> SessionSettingsUpdate {
|
||||
let ThreadSettingsOverrides {
|
||||
cwd,
|
||||
workspace_roots,
|
||||
profile_workspace_roots,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
active_permission_profile,
|
||||
windows_sandbox_level,
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
} = thread_settings;
|
||||
let collaboration_mode = match collaboration_mode {
|
||||
Some(collaboration_mode) => collaboration_mode,
|
||||
None => {
|
||||
let state = sess.state.lock().await;
|
||||
// Model and reasoning effort live in CollaborationMode settings today, so
|
||||
// partial thread-settings updates refresh those fields on the active mode.
|
||||
state
|
||||
.session_configuration
|
||||
.collaboration_mode
|
||||
.with_updates(model, effort, /*developer_instructions*/ None)
|
||||
}
|
||||
};
|
||||
SessionSettingsUpdate {
|
||||
cwd,
|
||||
workspace_roots,
|
||||
profile_workspace_roots,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
active_permission_profile,
|
||||
windows_sandbox_level,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
reasoning_summary: summary,
|
||||
service_tier,
|
||||
personality,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn thread_settings_applied_event(sess: &Session) -> EventMsg {
|
||||
let snapshot = {
|
||||
let state = sess.state.lock().await;
|
||||
state.session_configuration.thread_config_snapshot()
|
||||
};
|
||||
EventMsg::ThreadSettingsApplied(ThreadSettingsAppliedEvent {
|
||||
thread_settings: ThreadSettingsSnapshot {
|
||||
model: snapshot.model,
|
||||
model_provider_id: snapshot.model_provider_id,
|
||||
service_tier: snapshot.service_tier,
|
||||
approval_policy: snapshot.approval_policy,
|
||||
approvals_reviewer: snapshot.approvals_reviewer,
|
||||
permission_profile: snapshot.permission_profile,
|
||||
active_permission_profile: snapshot.active_permission_profile,
|
||||
cwd: snapshot.cwd,
|
||||
reasoning_effort: snapshot.reasoning_effort,
|
||||
reasoning_summary: snapshot.reasoning_summary,
|
||||
personality: snapshot.personality,
|
||||
collaboration_mode: snapshot.collaboration_mode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn user_input_or_turn_inner(
|
||||
sess: &Arc<Session>,
|
||||
sub_id: String,
|
||||
op: Op,
|
||||
mirror_user_text_to_realtime: Option<()>,
|
||||
) {
|
||||
let (items, updates, responsesapi_client_metadata) = match op {
|
||||
Op::UserTurn {
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
final_output_json_schema,
|
||||
items,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
environments,
|
||||
} => {
|
||||
let collaboration_mode = collaboration_mode.or_else(|| {
|
||||
Some(CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
settings: Settings {
|
||||
model: model.clone(),
|
||||
reasoning_effort: effort,
|
||||
developer_instructions: None,
|
||||
},
|
||||
})
|
||||
});
|
||||
(
|
||||
items,
|
||||
SessionSettingsUpdate {
|
||||
cwd: Some(cwd),
|
||||
approval_policy: Some(approval_policy),
|
||||
approvals_reviewer,
|
||||
sandbox_policy: Some(sandbox_policy),
|
||||
permission_profile,
|
||||
active_permission_profile: None,
|
||||
windows_sandbox_level: None,
|
||||
collaboration_mode,
|
||||
reasoning_summary: summary,
|
||||
service_tier,
|
||||
final_output_json_schema: Some(final_output_json_schema),
|
||||
environments,
|
||||
personality,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
}
|
||||
Op::UserInputWithTurnContext {
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
active_permission_profile,
|
||||
windows_sandbox_level,
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
final_output_json_schema,
|
||||
items,
|
||||
responsesapi_client_metadata,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
environments,
|
||||
} => {
|
||||
let collaboration_mode = if let Some(collab_mode) = collaboration_mode {
|
||||
Some(collab_mode)
|
||||
} else {
|
||||
let state = sess.state.lock().await;
|
||||
Some(
|
||||
state
|
||||
.session_configuration
|
||||
.collaboration_mode
|
||||
.with_updates(model, effort, /*developer_instructions*/ None),
|
||||
)
|
||||
};
|
||||
(
|
||||
items,
|
||||
SessionSettingsUpdate {
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
active_permission_profile,
|
||||
windows_sandbox_level,
|
||||
collaboration_mode,
|
||||
reasoning_summary: summary,
|
||||
service_tier,
|
||||
final_output_json_schema: Some(final_output_json_schema),
|
||||
environments,
|
||||
personality,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
},
|
||||
responsesapi_client_metadata,
|
||||
)
|
||||
}
|
||||
Op::UserInput {
|
||||
items,
|
||||
environments,
|
||||
final_output_json_schema,
|
||||
responsesapi_client_metadata,
|
||||
} => (
|
||||
items,
|
||||
SessionSettingsUpdate {
|
||||
final_output_json_schema: Some(final_output_json_schema),
|
||||
environments,
|
||||
..Default::default()
|
||||
},
|
||||
responsesapi_client_metadata,
|
||||
),
|
||||
_ => unreachable!(),
|
||||
let Op::UserInput {
|
||||
items,
|
||||
environments,
|
||||
final_output_json_schema,
|
||||
responsesapi_client_metadata,
|
||||
thread_settings,
|
||||
} = op
|
||||
else {
|
||||
unreachable!();
|
||||
};
|
||||
let emit_thread_settings_applied = thread_settings != ThreadSettingsOverrides::default();
|
||||
let mut updates = if emit_thread_settings_applied {
|
||||
thread_settings_update(sess, thread_settings).await
|
||||
} else {
|
||||
SessionSettingsUpdate::default()
|
||||
};
|
||||
updates.final_output_json_schema = Some(final_output_json_schema);
|
||||
updates.environments = environments;
|
||||
|
||||
let Ok(current_context) = sess.new_turn_with_sub_id(sub_id.clone(), updates).await else {
|
||||
// new_turn_with_sub_id already emits the error event.
|
||||
return;
|
||||
};
|
||||
if emit_thread_settings_applied {
|
||||
sess.send_event_raw(Event {
|
||||
id: sub_id.clone(),
|
||||
msg: thread_settings_applied_event(sess).await,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref())
|
||||
.await;
|
||||
let accepted_items = match sess
|
||||
@@ -313,7 +298,9 @@ pub async fn inter_agent_communication(
|
||||
communication: InterAgentCommunication,
|
||||
) {
|
||||
let trigger_turn = communication.trigger_turn;
|
||||
sess.enqueue_mailbox_communication(communication);
|
||||
sess.input_queue
|
||||
.enqueue_mailbox_communication(communication)
|
||||
.await;
|
||||
if trigger_turn {
|
||||
sess.maybe_start_turn_for_pending_work_with_sub_id(sub_id)
|
||||
.await;
|
||||
@@ -632,12 +619,14 @@ async fn shutdown_session_runtime(sess: &Arc<Session>) {
|
||||
sess.guardian_review_session.shutdown().await;
|
||||
}
|
||||
|
||||
fn emit_thread_stop_lifecycle(sess: &Session) {
|
||||
async fn emit_thread_stop_lifecycle(sess: &Session) {
|
||||
for contributor in sess.services.extensions.thread_lifecycle_contributors() {
|
||||
contributor.on_thread_stop(codex_extension_api::ThreadStopInput {
|
||||
session_store: &sess.services.session_extension_data,
|
||||
thread_store: &sess.services.thread_extension_data,
|
||||
});
|
||||
contributor
|
||||
.on_thread_stop(codex_extension_api::ThreadStopInput {
|
||||
session_store: &sess.services.session_extension_data,
|
||||
thread_store: &sess.services.thread_extension_data,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,7 +645,7 @@ pub async fn shutdown(sess: &Arc<Session>, sub_id: String) -> bool {
|
||||
&[],
|
||||
);
|
||||
|
||||
emit_thread_stop_lifecycle(sess.as_ref());
|
||||
emit_thread_stop_lifecycle(sess.as_ref()).await;
|
||||
|
||||
// Gracefully flush and shutdown thread persistence on session end so tests
|
||||
// that inspect durable state do not race with the background writer.
|
||||
@@ -775,54 +764,12 @@ pub(super) async fn submission_loop(
|
||||
realtime_conversation_list_voices(&sess, sub.id.clone()).await;
|
||||
false
|
||||
}
|
||||
Op::OverrideTurnContext {
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
windows_sandbox_level,
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
} => {
|
||||
let collaboration_mode = if let Some(collab_mode) = collaboration_mode {
|
||||
collab_mode
|
||||
} else {
|
||||
let state = sess.state.lock().await;
|
||||
state.session_configuration.collaboration_mode.with_updates(
|
||||
model.clone(),
|
||||
effort,
|
||||
/*developer_instructions*/ None,
|
||||
)
|
||||
};
|
||||
override_turn_context(
|
||||
&sess,
|
||||
sub.id.clone(),
|
||||
SessionSettingsUpdate {
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
windows_sandbox_level,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
reasoning_summary: summary,
|
||||
service_tier,
|
||||
personality,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
Op::UserInput { .. } => {
|
||||
user_input_or_turn(&sess, sub.id.clone(), sub.op).await;
|
||||
false
|
||||
}
|
||||
Op::UserInput { .. }
|
||||
| Op::UserInputWithTurnContext { .. }
|
||||
| Op::UserTurn { .. } => {
|
||||
user_input_or_turn(&sess, sub.id.clone(), sub.op).await;
|
||||
Op::ThreadSettings { thread_settings } => {
|
||||
update_thread_settings(&sess, sub.id.clone(), thread_settings).await;
|
||||
false
|
||||
}
|
||||
Op::InterAgentCommunication { communication } => {
|
||||
@@ -911,7 +858,7 @@ pub(super) async fn submission_loop(
|
||||
// explicit shutdown op, still run session teardown.
|
||||
if !shutdown_received {
|
||||
shutdown_session_runtime(&sess).await;
|
||||
emit_thread_stop_lifecycle(sess.as_ref());
|
||||
emit_thread_stop_lifecycle(sess.as_ref()).await;
|
||||
}
|
||||
debug!("Agent loop exited");
|
||||
}
|
||||
@@ -953,7 +900,9 @@ Approved action:
|
||||
}];
|
||||
|
||||
if let Err(items) = sess.inject_response_items(items).await {
|
||||
sess.queue_response_items_for_next_turn(items).await;
|
||||
sess.input_queue
|
||||
.queue_response_items_for_next_turn(items)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::state::MailboxDeliveryPhase;
|
||||
use crate::state::TurnState;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::watch;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum TurnInput {
|
||||
UserInput(Vec<UserInput>),
|
||||
ResponseInputItem(ResponseInputItem),
|
||||
}
|
||||
|
||||
/// Turn-local pending input storage owned by the input queue flow.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TurnInputQueue {
|
||||
items: Vec<TurnInput>,
|
||||
}
|
||||
|
||||
/// Session-scoped pending input storage and active-turn mailbox delivery coordination.
|
||||
pub(crate) struct InputQueue {
|
||||
mailbox_tx: watch::Sender<()>,
|
||||
mailbox_pending_mails: Mutex<VecDeque<InterAgentCommunication>>,
|
||||
|
||||
idle_pending_input: Mutex<Vec<ResponseInputItem>>,
|
||||
}
|
||||
|
||||
impl InputQueue {
|
||||
pub(crate) fn new() -> Self {
|
||||
let (mailbox_tx, _) = watch::channel(());
|
||||
Self {
|
||||
mailbox_tx,
|
||||
mailbox_pending_mails: Mutex::new(VecDeque::new()),
|
||||
idle_pending_input: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn subscribe_mailbox(&self) -> watch::Receiver<()> {
|
||||
let mut mailbox_rx = self.mailbox_tx.subscribe();
|
||||
if self.has_pending_mailbox_items().await {
|
||||
mailbox_rx.mark_changed();
|
||||
}
|
||||
mailbox_rx
|
||||
}
|
||||
|
||||
pub(crate) async fn enqueue_mailbox_communication(
|
||||
&self,
|
||||
communication: InterAgentCommunication,
|
||||
) {
|
||||
self.mailbox_pending_mails
|
||||
.lock()
|
||||
.await
|
||||
.push_back(communication);
|
||||
self.mailbox_tx.send_replace(());
|
||||
}
|
||||
|
||||
pub(crate) async fn has_pending_mailbox_items(&self) -> bool {
|
||||
!self.mailbox_pending_mails.lock().await.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) async fn has_trigger_turn_mailbox_items(&self) -> bool {
|
||||
self.mailbox_pending_mails
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.any(|mail| mail.trigger_turn)
|
||||
}
|
||||
|
||||
pub(crate) async fn drain_mailbox_input_items(&self) -> Vec<ResponseInputItem> {
|
||||
self.mailbox_pending_mails
|
||||
.lock()
|
||||
.await
|
||||
.drain(..)
|
||||
.map(|mail| mail.to_response_input_item())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec<ResponseInputItem>) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.idle_pending_input.lock().await.extend(items);
|
||||
}
|
||||
|
||||
pub(crate) async fn take_queued_response_items_for_next_turn(&self) -> Vec<ResponseInputItem> {
|
||||
std::mem::take(&mut *self.idle_pending_input.lock().await)
|
||||
}
|
||||
|
||||
pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool {
|
||||
!self.idle_pending_input.lock().await.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) async fn turn_state_for_sub_id(
|
||||
&self,
|
||||
active_turn: &Mutex<Option<ActiveTurn>>,
|
||||
sub_id: &str,
|
||||
) -> Option<Arc<Mutex<TurnState>>> {
|
||||
let active = active_turn.lock().await;
|
||||
active.as_ref().and_then(|active_turn| {
|
||||
active_turn
|
||||
.tasks
|
||||
.contains_key(sub_id)
|
||||
.then(|| Arc::clone(&active_turn.turn_state))
|
||||
})
|
||||
}
|
||||
|
||||
/// Clear any pending waiters and input buffered for the current turn.
|
||||
pub(crate) async fn clear_pending(&self, active_turn: &ActiveTurn) {
|
||||
let mut turn_state = active_turn.turn_state.lock().await;
|
||||
turn_state.clear_pending_waiters();
|
||||
turn_state.pending_input.items.clear();
|
||||
}
|
||||
|
||||
pub(crate) async fn defer_mailbox_delivery_to_next_turn(
|
||||
&self,
|
||||
active_turn: &Mutex<Option<ActiveTurn>>,
|
||||
sub_id: &str,
|
||||
) {
|
||||
let turn_state = self.turn_state_for_sub_id(active_turn, sub_id).await;
|
||||
let Some(turn_state) = turn_state else {
|
||||
return;
|
||||
};
|
||||
let mut turn_state = turn_state.lock().await;
|
||||
if !turn_state.pending_input.items.is_empty() {
|
||||
return;
|
||||
}
|
||||
turn_state.set_mailbox_delivery_phase(MailboxDeliveryPhase::NextTurn);
|
||||
}
|
||||
|
||||
pub(crate) async fn accept_mailbox_delivery_for_current_turn(
|
||||
&self,
|
||||
active_turn: &Mutex<Option<ActiveTurn>>,
|
||||
sub_id: &str,
|
||||
) {
|
||||
let turn_state = self.turn_state_for_sub_id(active_turn, sub_id).await;
|
||||
let Some(turn_state) = turn_state else {
|
||||
return;
|
||||
};
|
||||
self.accept_mailbox_delivery_for_turn_state(turn_state.as_ref())
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(super) async fn accept_mailbox_delivery_for_turn_state(
|
||||
&self,
|
||||
turn_state: &Mutex<TurnState>,
|
||||
) {
|
||||
turn_state
|
||||
.lock()
|
||||
.await
|
||||
.accept_mailbox_delivery_for_current_turn();
|
||||
}
|
||||
|
||||
pub(super) async fn push_pending_input_and_accept_mailbox_delivery_for_turn_state(
|
||||
&self,
|
||||
turn_state: &Mutex<TurnState>,
|
||||
input: TurnInput,
|
||||
) {
|
||||
let mut turn_state = turn_state.lock().await;
|
||||
turn_state.pending_input.items.push(input);
|
||||
turn_state.accept_mailbox_delivery_for_current_turn();
|
||||
}
|
||||
|
||||
pub(crate) async fn extend_pending_input_for_turn_state(
|
||||
&self,
|
||||
turn_state: &Mutex<TurnState>,
|
||||
input: Vec<TurnInput>,
|
||||
) {
|
||||
turn_state.lock().await.pending_input.items.extend(input);
|
||||
}
|
||||
|
||||
pub(crate) async fn take_pending_input_for_turn_state(
|
||||
&self,
|
||||
turn_state: &Mutex<TurnState>,
|
||||
) -> Vec<TurnInput> {
|
||||
turn_state.lock().await.pending_input.items.split_off(0)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state updates must remain atomic"
|
||||
)]
|
||||
pub(crate) async fn inject_response_items(
|
||||
&self,
|
||||
active_turn: &Mutex<Option<ActiveTurn>>,
|
||||
input: Vec<ResponseInputItem>,
|
||||
) -> Result<(), Vec<ResponseInputItem>> {
|
||||
let mut active = active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(active_turn) => {
|
||||
self.extend_pending_input_for_turn_state(
|
||||
active_turn.turn_state.as_ref(),
|
||||
input
|
||||
.into_iter()
|
||||
.map(TurnInput::ResponseInputItem)
|
||||
.collect(),
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
None => Err(input),
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state updates must remain atomic"
|
||||
)]
|
||||
pub(crate) async fn get_pending_input(
|
||||
&self,
|
||||
active_turn: &Mutex<Option<ActiveTurn>>,
|
||||
) -> Vec<TurnInput> {
|
||||
let (pending_input, accepts_mailbox_delivery) = {
|
||||
let mut active = active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(active_turn) => {
|
||||
let mut turn_state = active_turn.turn_state.lock().await;
|
||||
(
|
||||
turn_state.pending_input.items.split_off(0),
|
||||
turn_state.accepts_mailbox_delivery_for_current_turn(),
|
||||
)
|
||||
}
|
||||
None => (Vec::new(), true),
|
||||
}
|
||||
};
|
||||
if !accepts_mailbox_delivery {
|
||||
return pending_input;
|
||||
}
|
||||
let mailbox_items = self
|
||||
.drain_mailbox_input_items()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(TurnInput::ResponseInputItem);
|
||||
if pending_input.is_empty() {
|
||||
mailbox_items.collect()
|
||||
} else {
|
||||
let mut pending_input = pending_input;
|
||||
pending_input.extend(mailbox_items);
|
||||
pending_input
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state reads must remain atomic"
|
||||
)]
|
||||
pub(crate) async fn has_pending_input(&self, active_turn: &Mutex<Option<ActiveTurn>>) -> bool {
|
||||
let (has_turn_pending_input, accepts_mailbox_delivery) = {
|
||||
let active = active_turn.lock().await;
|
||||
match active.as_ref() {
|
||||
Some(active_turn) => {
|
||||
let turn_state = active_turn.turn_state.lock().await;
|
||||
(
|
||||
!turn_state.pending_input.items.is_empty(),
|
||||
turn_state.accepts_mailbox_delivery_for_current_turn(),
|
||||
)
|
||||
}
|
||||
None => (false, true),
|
||||
}
|
||||
};
|
||||
if has_turn_pending_input {
|
||||
return true;
|
||||
}
|
||||
if !accepts_mailbox_delivery {
|
||||
return false;
|
||||
}
|
||||
self.has_pending_mailbox_items().await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::AgentPath;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn make_mail(
|
||||
author: AgentPath,
|
||||
recipient: AgentPath,
|
||||
content: &str,
|
||||
trigger_turn: bool,
|
||||
) -> InterAgentCommunication {
|
||||
InterAgentCommunication::new(
|
||||
author,
|
||||
recipient,
|
||||
Vec::new(),
|
||||
content.to_string(),
|
||||
trigger_turn,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn input_queue_notifies_mailbox_subscribers() {
|
||||
let input_queue = InputQueue::new();
|
||||
let mut mailbox_rx = input_queue.subscribe_mailbox().await;
|
||||
|
||||
input_queue
|
||||
.enqueue_mailbox_communication(make_mail(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
"one",
|
||||
/*trigger_turn*/ false,
|
||||
))
|
||||
.await;
|
||||
input_queue
|
||||
.enqueue_mailbox_communication(make_mail(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
"two",
|
||||
/*trigger_turn*/ false,
|
||||
))
|
||||
.await;
|
||||
|
||||
mailbox_rx.changed().await.expect("mailbox update");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn input_queue_drains_mailbox_in_delivery_order() {
|
||||
let input_queue = InputQueue::new();
|
||||
let mail_one = make_mail(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
"one",
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
let mail_two = make_mail(
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
AgentPath::root(),
|
||||
"two",
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
|
||||
input_queue
|
||||
.enqueue_mailbox_communication(mail_one.clone())
|
||||
.await;
|
||||
input_queue
|
||||
.enqueue_mailbox_communication(mail_two.clone())
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
input_queue.drain_mailbox_input_items().await,
|
||||
vec![
|
||||
mail_one.to_response_input_item(),
|
||||
mail_two.to_response_input_item()
|
||||
]
|
||||
);
|
||||
assert!(!input_queue.has_pending_mailbox_items().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn input_queue_tracks_pending_trigger_turn_mail() {
|
||||
let input_queue = InputQueue::new();
|
||||
|
||||
input_queue
|
||||
.enqueue_mailbox_communication(make_mail(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
"queued",
|
||||
/*trigger_turn*/ false,
|
||||
))
|
||||
.await;
|
||||
assert!(!input_queue.has_trigger_turn_mailbox_items().await);
|
||||
|
||||
input_queue
|
||||
.enqueue_mailbox_communication(make_mail(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
"wake",
|
||||
/*trigger_turn*/ true,
|
||||
))
|
||||
.await;
|
||||
assert!(input_queue.has_trigger_turn_mailbox_items().await);
|
||||
}
|
||||
}
|
||||
@@ -291,14 +291,15 @@ impl Session {
|
||||
compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await;
|
||||
let mcp_runtime_environment = match turn_context.environments.primary() {
|
||||
Some(turn_environment) => McpRuntimeEnvironment::new(
|
||||
Arc::clone(&turn_environment.environment),
|
||||
Some(Arc::clone(&turn_environment.environment)),
|
||||
self.services.environment_manager.try_local_environment(),
|
||||
turn_environment.cwd.to_path_buf(),
|
||||
),
|
||||
None => McpRuntimeEnvironment::new(
|
||||
self.services
|
||||
.environment_manager
|
||||
.default_environment()
|
||||
.unwrap_or_else(|| self.services.environment_manager.local_environment()),
|
||||
.default_or_local_environment(),
|
||||
self.services.environment_manager.try_local_environment(),
|
||||
#[allow(deprecated)]
|
||||
turn_context.cwd.to_path_buf(),
|
||||
),
|
||||
|
||||
@@ -10,8 +10,6 @@ use std::time::UNIX_EPOCH;
|
||||
|
||||
use crate::agent::AgentControl;
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::Mailbox;
|
||||
use crate::agent::MailboxReceiver;
|
||||
use crate::agent::agent_status_from_event;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::attestation::AttestationProvider;
|
||||
@@ -80,6 +78,7 @@ use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::config_types::AutoCompactTokenLimitScope;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
@@ -95,6 +94,7 @@ use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::SandboxEnforcement;
|
||||
use codex_protocol::models::format_allow_prefixes;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::FileChange;
|
||||
@@ -173,6 +173,7 @@ use crate::compact::collect_user_messages;
|
||||
use crate::config::Config;
|
||||
use crate::config::Constrained;
|
||||
use crate::config::ConstraintResult;
|
||||
use crate::config::PermissionProfileSnapshot;
|
||||
use crate::config::PermissionProfileState;
|
||||
use crate::config::StartedNetworkProxy;
|
||||
use crate::config::resolve_web_search_mode_for_turn;
|
||||
@@ -192,6 +193,7 @@ use codex_protocol::exec_output::StreamOutput;
|
||||
|
||||
mod config_lock;
|
||||
mod handlers;
|
||||
mod input_queue;
|
||||
mod mcp;
|
||||
mod multi_agents;
|
||||
mod review;
|
||||
@@ -205,6 +207,8 @@ use self::config_lock::validate_config_lock_if_configured;
|
||||
#[cfg(test)]
|
||||
use self::handlers::submission_dispatch_span;
|
||||
use self::handlers::submission_loop;
|
||||
pub(crate) use self::input_queue::TurnInput;
|
||||
pub(crate) use self::input_queue::TurnInputQueue;
|
||||
use self::review::spawn_review_thread;
|
||||
use self::session::AppServerClientMetadata;
|
||||
use self::session::Session;
|
||||
@@ -214,8 +218,6 @@ pub(crate) use self::session::SessionSettingsUpdate;
|
||||
use self::turn::AssistantMessageStreamParsers;
|
||||
#[cfg(test)]
|
||||
use self::turn::collect_explicit_app_ids_from_skill_items;
|
||||
#[cfg(test)]
|
||||
use self::turn::filter_connectors_for_input;
|
||||
use self::turn::realtime_text_for_event;
|
||||
use self::turn_context::TurnContext;
|
||||
use self::turn_context::TurnSkillsContext;
|
||||
@@ -288,8 +290,7 @@ use crate::rollout::map_session_init_error;
|
||||
use crate::session_startup_prewarm::SessionStartupPrewarmHandle;
|
||||
use crate::shell;
|
||||
use crate::shell_snapshot::ShellSnapshot;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::state::MailboxDeliveryPhase;
|
||||
use crate::state::AutoCompactWindowSnapshot;
|
||||
use crate::state::PendingRequestPermissions;
|
||||
use crate::state::SessionServices;
|
||||
use crate::state::SessionState;
|
||||
@@ -358,8 +359,8 @@ use codex_protocol::protocol::TokenUsageInfo;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_tools::ToolEnvironmentMode;
|
||||
use codex_tools::ToolsConfig;
|
||||
use codex_tools::ToolsConfigParams;
|
||||
use codex_tools::UnifiedExecShellMode;
|
||||
use codex_tools::shell_command_backend_for_features;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
#[cfg(test)]
|
||||
use codex_utils_stream_parser::ProposedPlanSegment;
|
||||
@@ -621,6 +622,7 @@ impl Codex {
|
||||
permission_profile_state: session_permission_profile_state_from_config(&config)?,
|
||||
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
|
||||
cwd: config.cwd.clone(),
|
||||
workspace_roots: config.workspace_roots.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
thread_name: None,
|
||||
environments: environment_selections.to_selections(),
|
||||
@@ -821,15 +823,7 @@ fn get_service_tier(
|
||||
fn session_permission_profile_state_from_config(
|
||||
config: &Config,
|
||||
) -> CodexResult<PermissionProfileState> {
|
||||
config
|
||||
.permissions
|
||||
.permission_profile_state()
|
||||
.clone_with_permission_profile(config.permissions.effective_permission_profile())
|
||||
.map_err(|err| {
|
||||
CodexErr::Fatal(format!(
|
||||
"failed to materialize workspace roots for session permissions: {err}"
|
||||
))
|
||||
})
|
||||
Ok(config.permissions.permission_profile_state().clone())
|
||||
}
|
||||
|
||||
fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool {
|
||||
@@ -894,22 +888,6 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn configured_multi_agent_v2_usage_hint_texts(&self) -> Vec<String> {
|
||||
if !self.features.enabled(Feature::MultiAgentV2) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let state = self.state.lock().await;
|
||||
let config = &state.session_configuration.original_config_do_not_use;
|
||||
[
|
||||
config.multi_agent_v2.root_agent_usage_hint_text.clone(),
|
||||
config.multi_agent_v2.subagent_usage_hint_text.clone(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn managed_network_proxy_active_for_permission_profile(
|
||||
permission_profile: &PermissionProfile,
|
||||
) -> bool {
|
||||
@@ -1102,6 +1080,7 @@ impl Session {
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
thread_settings: Default::default(),
|
||||
},
|
||||
/*mirror_user_text_to_realtime*/ None,
|
||||
)
|
||||
@@ -1113,6 +1092,11 @@ impl Session {
|
||||
state.get_total_token_usage(state.server_reasoning_included())
|
||||
}
|
||||
|
||||
pub(crate) async fn auto_compact_window_snapshot(&self) -> AutoCompactWindowSnapshot {
|
||||
let state = self.state.lock().await;
|
||||
state.auto_compact_window_snapshot()
|
||||
}
|
||||
|
||||
pub(crate) async fn get_total_token_usage_breakdown(&self) -> TotalTokenUsageBreakdown {
|
||||
let state = self.state.lock().await;
|
||||
state.history.get_total_token_usage_breakdown()
|
||||
@@ -1271,11 +1255,41 @@ impl Session {
|
||||
reconstructed_rollout.reference_context_item,
|
||||
)
|
||||
.await;
|
||||
let prefix_tokens = if matches!(
|
||||
turn_context.config.model_auto_compact_token_limit_scope,
|
||||
AutoCompactTokenLimitScope::BodyAfterPrefix
|
||||
) {
|
||||
let history = self.clone_history().await;
|
||||
let base_instructions = self.get_base_instructions().await;
|
||||
history.estimate_token_count_with_base_instructions(&base_instructions)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(prefix_tokens) = prefix_tokens {
|
||||
self.set_auto_compact_window_estimated_prefill_for_scope(turn_context, prefix_tokens)
|
||||
.await;
|
||||
}
|
||||
self.set_previous_turn_settings(previous_turn_settings.clone())
|
||||
.await;
|
||||
previous_turn_settings
|
||||
}
|
||||
|
||||
async fn set_auto_compact_window_estimated_prefill_for_scope(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
tokens: i64,
|
||||
) {
|
||||
if !matches!(
|
||||
turn_context.config.model_auto_compact_token_limit_scope,
|
||||
AutoCompactTokenLimitScope::BodyAfterPrefix
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut state = self.state.lock().await;
|
||||
state.set_auto_compact_window_estimated_prefill(tokens);
|
||||
}
|
||||
|
||||
fn last_token_info_from_rollout(rollout_items: &[RolloutItem]) -> Option<TokenUsageInfo> {
|
||||
rollout_items.iter().rev().find_map(|item| match item {
|
||||
RolloutItem::EventMsg(EventMsg::TokenCount(ev)) => ev.info.clone(),
|
||||
@@ -1391,12 +1405,15 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn validate_settings(
|
||||
pub(crate) async fn preview_settings(
|
||||
&self,
|
||||
updates: &SessionSettingsUpdate,
|
||||
) -> ConstraintResult<()> {
|
||||
) -> ConstraintResult<ThreadConfigSnapshot> {
|
||||
let state = self.state.lock().await;
|
||||
state.session_configuration.apply(updates).map(|_| ())
|
||||
state
|
||||
.session_configuration
|
||||
.apply(updates)
|
||||
.map(|configuration| configuration.thread_config_snapshot())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_session_startup_prewarm(
|
||||
@@ -2589,8 +2606,11 @@ impl Session {
|
||||
reference_context_item: Option<TurnContextItem>,
|
||||
compacted_item: CompactedItem,
|
||||
) {
|
||||
self.replace_history(items, reference_context_item.clone())
|
||||
.await;
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
state.replace_history(items, reference_context_item.clone());
|
||||
state.start_next_auto_compact_window();
|
||||
}
|
||||
|
||||
self.persist_rollout_items(&[RolloutItem::Compacted(compacted_item)])
|
||||
.await;
|
||||
@@ -2697,14 +2717,6 @@ impl Session {
|
||||
{
|
||||
developer_sections.push(developer_instructions.to_string());
|
||||
}
|
||||
// Add developer instructions for memories.
|
||||
if turn_context.features.enabled(Feature::MemoryTool)
|
||||
&& turn_context.config.memories.use_memories
|
||||
&& let Some(memory_prompt) =
|
||||
build_memory_tool_developer_instructions(&turn_context.config.codex_home).await
|
||||
{
|
||||
developer_sections.push(memory_prompt);
|
||||
}
|
||||
// Add developer instructions from collaboration_mode if they exist and are non-empty
|
||||
if turn_context.config.include_collaboration_mode_instructions
|
||||
&& let Some(collab_instructions) =
|
||||
@@ -2956,16 +2968,24 @@ impl Session {
|
||||
let mut state = self.state.lock().await;
|
||||
state
|
||||
.update_token_info_from_usage(token_usage, turn_context.model_context_window());
|
||||
if matches!(
|
||||
turn_context.config.model_auto_compact_token_limit_scope,
|
||||
AutoCompactTokenLimitScope::BodyAfterPrefix
|
||||
) {
|
||||
state.ensure_auto_compact_window_server_prefill_from_usage(token_usage);
|
||||
}
|
||||
state.token_info()
|
||||
};
|
||||
if let Some(token_info) = token_info.as_ref() {
|
||||
for contributor in self.services.extensions.token_usage_contributors() {
|
||||
contributor.on_token_usage(
|
||||
&self.services.session_extension_data,
|
||||
&self.services.thread_extension_data,
|
||||
turn_context.extension_data.as_ref(),
|
||||
token_info,
|
||||
);
|
||||
contributor
|
||||
.on_token_usage(
|
||||
&self.services.session_extension_data,
|
||||
&self.services.thread_extension_data,
|
||||
turn_context.extension_data.as_ref(),
|
||||
token_info,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3001,6 +3021,11 @@ impl Session {
|
||||
|
||||
state.set_token_info(Some(info));
|
||||
}
|
||||
self.set_auto_compact_window_estimated_prefill_for_scope(
|
||||
turn_context,
|
||||
estimated_total_tokens,
|
||||
)
|
||||
.await;
|
||||
self.send_token_count_event(turn_context).await;
|
||||
}
|
||||
|
||||
@@ -3033,16 +3058,6 @@ impl Session {
|
||||
state.record_mcp_dependency_prompted(names);
|
||||
}
|
||||
|
||||
pub async fn dependency_env(&self) -> HashMap<String, String> {
|
||||
let state = self.state.lock().await;
|
||||
state.dependency_env()
|
||||
}
|
||||
|
||||
pub async fn set_dependency_env(&self, values: HashMap<String, String>) {
|
||||
let mut state = self.state.lock().await;
|
||||
state.set_dependency_env(values);
|
||||
}
|
||||
|
||||
pub(crate) async fn set_server_reasoning_included(&self, included: bool) {
|
||||
let mut state = self.state.lock().await;
|
||||
state.set_server_reasoning_included(included);
|
||||
@@ -3085,11 +3100,11 @@ impl Session {
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
input: &[UserInput],
|
||||
response_item: ResponseItem,
|
||||
) {
|
||||
// Persist the user message to history, but emit the turn item from `UserInput` so
|
||||
// UI-only `text_elements` are preserved. `ResponseItem::Message` does not carry
|
||||
// those spans, and `record_response_item_and_emit_turn_item` would drop them.
|
||||
let response_item = ResponseItem::from(ResponseInputItem::from(input.to_vec()));
|
||||
self.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
|
||||
.await;
|
||||
let turn_item = TurnItem::UserMessage(UserMessageItem::new(input));
|
||||
@@ -3129,10 +3144,6 @@ impl Session {
|
||||
expected_turn_id: Option<&str>,
|
||||
responsesapi_client_metadata: Option<HashMap<String, String>>,
|
||||
) -> Result<String, SteerInputError> {
|
||||
if input.is_empty() {
|
||||
return Err(SteerInputError::EmptyInput);
|
||||
}
|
||||
|
||||
let mut active = self.active_turn.lock().await;
|
||||
let Some(active_turn) = active.as_mut() else {
|
||||
return Err(SteerInputError::NoActiveTurn(input));
|
||||
@@ -3166,6 +3177,10 @@ impl Session {
|
||||
None => return Err(SteerInputError::NoActiveTurn(input)),
|
||||
}
|
||||
|
||||
if input.is_empty() {
|
||||
return Err(SteerInputError::EmptyInput);
|
||||
}
|
||||
|
||||
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata
|
||||
&& let Some((_, active_task)) = active_turn.tasks.first()
|
||||
{
|
||||
@@ -3175,199 +3190,39 @@ impl Session {
|
||||
.set_responsesapi_client_metadata(responsesapi_client_metadata);
|
||||
}
|
||||
|
||||
let mut turn_state = active_turn.turn_state.lock().await;
|
||||
turn_state.push_pending_input(input.into());
|
||||
turn_state.accept_mailbox_delivery_for_current_turn();
|
||||
self.input_queue
|
||||
.push_pending_input_and_accept_mailbox_delivery_for_turn_state(
|
||||
active_turn.turn_state.as_ref(),
|
||||
TurnInput::UserInput(input),
|
||||
)
|
||||
.await;
|
||||
Ok(active_turn_id.clone())
|
||||
}
|
||||
|
||||
/// Returns the input if there was no task running to inject into.
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state updates must remain atomic"
|
||||
)]
|
||||
pub async fn inject_response_items(
|
||||
&self,
|
||||
input: Vec<ResponseInputItem>,
|
||||
) -> Result<(), Vec<ResponseInputItem>> {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
for item in input {
|
||||
ts.push_pending_input(item);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => Err(input),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn defer_mailbox_delivery_to_next_turn(&self, sub_id: &str) {
|
||||
let turn_state = self.turn_state_for_sub_id(sub_id).await;
|
||||
let Some(turn_state) = turn_state else {
|
||||
return;
|
||||
};
|
||||
let mut turn_state = turn_state.lock().await;
|
||||
if turn_state.has_pending_input() {
|
||||
return;
|
||||
}
|
||||
turn_state.set_mailbox_delivery_phase(MailboxDeliveryPhase::NextTurn);
|
||||
}
|
||||
|
||||
pub(crate) async fn accept_mailbox_delivery_for_current_turn(&self, sub_id: &str) {
|
||||
let turn_state = self.turn_state_for_sub_id(sub_id).await;
|
||||
let Some(turn_state) = turn_state else {
|
||||
return;
|
||||
};
|
||||
turn_state
|
||||
.lock()
|
||||
self.input_queue
|
||||
.inject_response_items(&self.active_turn, input)
|
||||
.await
|
||||
.set_mailbox_delivery_phase(MailboxDeliveryPhase::CurrentTurn);
|
||||
}
|
||||
|
||||
pub(crate) async fn record_memory_citation_for_turn(&self, sub_id: &str) {
|
||||
let turn_state = self.turn_state_for_sub_id(sub_id).await;
|
||||
let turn_state = self
|
||||
.input_queue
|
||||
.turn_state_for_sub_id(&self.active_turn, sub_id)
|
||||
.await;
|
||||
let Some(turn_state) = turn_state else {
|
||||
return;
|
||||
};
|
||||
turn_state.lock().await.has_memory_citation = true;
|
||||
}
|
||||
|
||||
async fn turn_state_for_sub_id(
|
||||
&self,
|
||||
sub_id: &str,
|
||||
) -> Option<Arc<tokio::sync::Mutex<crate::state::TurnState>>> {
|
||||
let active = self.active_turn.lock().await;
|
||||
active.as_ref().and_then(|active_turn| {
|
||||
active_turn
|
||||
.tasks
|
||||
.contains_key(sub_id)
|
||||
.then(|| Arc::clone(&active_turn.turn_state))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe_mailbox_seq(&self) -> watch::Receiver<u64> {
|
||||
self.mailbox.subscribe()
|
||||
}
|
||||
|
||||
pub(crate) fn enqueue_mailbox_communication(&self, communication: InterAgentCommunication) {
|
||||
self.mailbox.send(communication);
|
||||
}
|
||||
|
||||
pub(crate) async fn has_trigger_turn_mailbox_items(&self) -> bool {
|
||||
self.mailbox_rx.lock().await.has_pending_trigger_turn()
|
||||
}
|
||||
|
||||
pub(crate) async fn has_pending_mailbox_items(&self) -> bool {
|
||||
self.mailbox_rx.lock().await.has_pending()
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state updates must remain atomic"
|
||||
)]
|
||||
pub async fn prepend_pending_input(&self, input: Vec<ResponseInputItem>) -> Result<(), ()> {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
ts.prepend_pending_input(input);
|
||||
Ok(())
|
||||
}
|
||||
None => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state updates must remain atomic"
|
||||
)]
|
||||
pub async fn get_pending_input(&self) -> Vec<ResponseInputItem> {
|
||||
let (pending_input, accepts_mailbox_delivery) = {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
(
|
||||
ts.take_pending_input(),
|
||||
ts.accepts_mailbox_delivery_for_current_turn(),
|
||||
)
|
||||
}
|
||||
None => (Vec::new(), true),
|
||||
}
|
||||
};
|
||||
if !accepts_mailbox_delivery {
|
||||
return pending_input;
|
||||
}
|
||||
let mailbox_items = {
|
||||
let mut mailbox_rx = self.mailbox_rx.lock().await;
|
||||
mailbox_rx
|
||||
.drain()
|
||||
.into_iter()
|
||||
.map(|mail| mail.to_response_input_item())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
if pending_input.is_empty() {
|
||||
mailbox_items
|
||||
} else if mailbox_items.is_empty() {
|
||||
pending_input
|
||||
} else {
|
||||
let mut pending_input = pending_input;
|
||||
pending_input.extend(mailbox_items);
|
||||
pending_input
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue response items to be injected into the next active turn created for this session.
|
||||
pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec<ResponseInputItem>) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut idle_pending_input = self.idle_pending_input.lock().await;
|
||||
idle_pending_input.extend(items);
|
||||
}
|
||||
|
||||
pub(crate) async fn take_queued_response_items_for_next_turn(&self) -> Vec<ResponseInputItem> {
|
||||
std::mem::take(&mut *self.idle_pending_input.lock().await)
|
||||
}
|
||||
|
||||
pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool {
|
||||
!self.idle_pending_input.lock().await.is_empty()
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state reads must remain atomic"
|
||||
)]
|
||||
pub async fn has_pending_input(&self) -> bool {
|
||||
let (has_turn_pending_input, accepts_mailbox_delivery) = {
|
||||
let active = self.active_turn.lock().await;
|
||||
match active.as_ref() {
|
||||
Some(at) => {
|
||||
let ts = at.turn_state.lock().await;
|
||||
(
|
||||
ts.has_pending_input(),
|
||||
ts.accepts_mailbox_delivery_for_current_turn(),
|
||||
)
|
||||
}
|
||||
None => (false, true),
|
||||
}
|
||||
};
|
||||
if has_turn_pending_input {
|
||||
return true;
|
||||
}
|
||||
if !accepts_mailbox_delivery {
|
||||
return false;
|
||||
}
|
||||
self.has_pending_mailbox_items().await
|
||||
}
|
||||
|
||||
pub async fn interrupt_task(self: &Arc<Self>) {
|
||||
info!("interrupt received: abort current task, if any");
|
||||
let had_active_turn = self.active_turn.lock().await.is_some();
|
||||
// Even without an active task, interrupt handling pauses any active goal.
|
||||
self.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
||||
if !had_active_turn {
|
||||
self.cancel_mcp_startup().await;
|
||||
@@ -3445,8 +3300,6 @@ pub(crate) fn emit_subagent_session_started(
|
||||
});
|
||||
}
|
||||
|
||||
use codex_memories_read::build_memory_tool_developer_instructions;
|
||||
|
||||
/// Builds the hook engine for one config snapshot, including any enabled plugin hooks.
|
||||
async fn build_hooks_for_config(
|
||||
config: &Config,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use super::turn_context::image_generation_tool_auth_allowed;
|
||||
use super::*;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
@@ -23,60 +22,21 @@ pub(super) async fn spawn_review_thread(
|
||||
let mut review_features = sess.features.clone();
|
||||
let _ = review_features.disable(Feature::WebSearchRequest);
|
||||
let _ = review_features.disable(Feature::WebSearchCached);
|
||||
let _ = review_features.disable(Feature::Goals);
|
||||
let review_web_search_mode = WebSearchMode::Disabled;
|
||||
let goal_tools_supported = !config.ephemeral && parent_turn_context.tools_config.goal_tools;
|
||||
let provider_capabilities = parent_turn_context.provider.capabilities();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &review_model_info,
|
||||
available_models: &sess
|
||||
.services
|
||||
.models_manager
|
||||
.list_models(RefreshStrategy::OnlineIfUncached)
|
||||
.await,
|
||||
features: &review_features,
|
||||
image_generation_tool_auth_allowed: image_generation_tool_auth_allowed(Some(
|
||||
sess.services.auth_manager.as_ref(),
|
||||
)),
|
||||
web_search_mode: Some(review_web_search_mode),
|
||||
session_source: parent_turn_context.session_source.clone(),
|
||||
permission_profile: &parent_turn_context.permission_profile,
|
||||
windows_sandbox_level: parent_turn_context.windows_sandbox_level,
|
||||
})
|
||||
.with_namespace_tools_capability(provider_capabilities.namespace_tools)
|
||||
.with_image_generation_capability(provider_capabilities.image_generation)
|
||||
.with_web_search_capability(provider_capabilities.web_search)
|
||||
.with_unified_exec_shell_mode_for_session(
|
||||
crate::tools::spec::tool_user_shell_type(sess.services.user_shell.as_ref()),
|
||||
let goal_tools_supported = !config.ephemeral && parent_turn_context.goal_tools_enabled();
|
||||
let available_models = sess
|
||||
.services
|
||||
.models_manager
|
||||
.list_models(RefreshStrategy::OnlineIfUncached)
|
||||
.await;
|
||||
let shell_command_backend = shell_command_backend_for_features(review_features.get());
|
||||
let unified_exec_shell_mode = UnifiedExecShellMode::for_session(
|
||||
shell_command_backend,
|
||||
crate::tools::tool_user_shell_type(sess.services.user_shell.as_ref()),
|
||||
sess.services.shell_zsh_path.as_ref(),
|
||||
sess.services.main_execve_wrapper_exe.as_ref(),
|
||||
)
|
||||
.with_web_search_config(/*web_search_config*/ None)
|
||||
.with_allow_login_shell(config.permissions.allow_login_shell)
|
||||
.with_environment_mode(parent_turn_context.tools_config.environment_mode)
|
||||
.with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled)
|
||||
.with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone())
|
||||
.with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata)
|
||||
.with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only)
|
||||
.with_goal_tools_allowed(goal_tools_supported)
|
||||
.with_max_concurrent_threads_per_session(config.agent_max_threads)
|
||||
.with_wait_agent_min_timeout_ms(
|
||||
review_features
|
||||
.enabled(Feature::MultiAgentV2)
|
||||
.then_some(config.multi_agent_v2.min_wait_timeout_ms),
|
||||
)
|
||||
.with_wait_agent_max_timeout_ms(
|
||||
review_features
|
||||
.enabled(Feature::MultiAgentV2)
|
||||
.then_some(config.multi_agent_v2.max_wait_timeout_ms),
|
||||
)
|
||||
.with_wait_agent_default_timeout_ms(
|
||||
review_features
|
||||
.enabled(Feature::MultiAgentV2)
|
||||
.then_some(config.multi_agent_v2.default_wait_timeout_ms),
|
||||
)
|
||||
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
|
||||
&config.agent_roles,
|
||||
));
|
||||
);
|
||||
|
||||
let review_prompt = resolved.prompt.clone();
|
||||
let provider = parent_turn_context.provider.clone();
|
||||
@@ -138,8 +98,10 @@ pub(super) async fn spawn_review_thread(
|
||||
session_source,
|
||||
thread_source: parent_turn_context.thread_source,
|
||||
environments: parent_turn_context.environments.clone(),
|
||||
tools_config,
|
||||
features: parent_turn_context.features.clone(),
|
||||
available_models,
|
||||
unified_exec_shell_mode,
|
||||
goal_tools_supported,
|
||||
features: review_features,
|
||||
ghost_snapshot: parent_turn_context.ghost_snapshot.clone(),
|
||||
current_date: parent_turn_context.current_date.clone(),
|
||||
timezone: parent_turn_context.timezone.clone(),
|
||||
|
||||
@@ -59,7 +59,6 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
@@ -74,11 +73,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
effort: turn_context.reasoning_effort,
|
||||
summary: turn_context.reasoning_summary,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
};
|
||||
let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)];
|
||||
|
||||
@@ -101,7 +96,6 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
let previous_model = "previous-rollout-model";
|
||||
let mut previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
@@ -116,11 +110,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
effort: turn_context.reasoning_effort,
|
||||
summary: turn_context.reasoning_summary,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
};
|
||||
let turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -143,6 +133,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
@@ -209,6 +200,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(first_context_item.clone()),
|
||||
@@ -237,6 +229,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(rolled_back_context_item),
|
||||
@@ -307,6 +300,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(first_context_item.clone()),
|
||||
@@ -335,6 +329,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::ResponseItem(turn_two_user),
|
||||
@@ -397,6 +392,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(first_context_item.clone()),
|
||||
@@ -425,6 +421,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::ResponseItem(turn_two_user),
|
||||
@@ -515,6 +512,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(first_context_item.clone()),
|
||||
@@ -603,6 +601,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(only_context_item),
|
||||
@@ -655,6 +654,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() {
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
@@ -727,6 +727,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(previous_context_item.clone()),
|
||||
@@ -753,6 +754,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
@@ -884,6 +886,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(current_context_item),
|
||||
@@ -912,7 +915,6 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
@@ -927,11 +929,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
effort: turn_context.reasoning_effort,
|
||||
summary: turn_context.reasoning_summary,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
};
|
||||
let previous_turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -952,6 +950,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
// Compaction clears baseline until a later TurnContextItem re-establishes it.
|
||||
@@ -991,7 +990,6 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
.expect("serialize seeded reference context item"),
|
||||
serde_json::to_value(Some(TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
@@ -1006,11 +1004,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
effort: turn_context.reasoning_effort,
|
||||
summary: turn_context.reasoning_summary,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
}))
|
||||
.expect("serialize expected reference context item")
|
||||
);
|
||||
@@ -1023,7 +1017,6 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
@@ -1038,11 +1031,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
effort: turn_context.reasoning_effort,
|
||||
summary: turn_context.reasoning_summary,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
};
|
||||
let previous_turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -1065,6 +1054,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
@@ -1091,6 +1081,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::TurnAborted(
|
||||
@@ -1139,7 +1130,6 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
let unmatched_abort_turn_id = "other-turn".to_string();
|
||||
let current_context_item = TurnContextItem {
|
||||
turn_id: Some(current_turn_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
@@ -1154,11 +1144,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
effort: turn_context.reasoning_effort,
|
||||
summary: turn_context.reasoning_summary,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
};
|
||||
|
||||
let rollout_items = vec![
|
||||
@@ -1176,6 +1162,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
@@ -1202,6 +1189,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::TurnAborted(
|
||||
@@ -1254,7 +1242,6 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
@@ -1269,11 +1256,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
effort: turn_context.reasoning_effort,
|
||||
summary: turn_context.reasoning_summary,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
};
|
||||
let previous_turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -1296,6 +1279,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
@@ -1322,6 +1306,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
@@ -1372,6 +1357,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(current_context_item.clone()),
|
||||
@@ -1407,7 +1393,6 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
let previous_model = "previous-rollout-model";
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
@@ -1422,11 +1407,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
effort: turn_context.reasoning_effort,
|
||||
summary: turn_context.reasoning_summary,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
};
|
||||
let previous_turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -1450,6 +1431,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
@@ -1476,6 +1458,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::input_queue::InputQueue;
|
||||
use super::*;
|
||||
use crate::goals::GoalRuntimeState;
|
||||
use crate::state::ActiveTurn;
|
||||
use codex_protocol::SessionId;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
@@ -27,9 +29,7 @@ pub(crate) struct Session {
|
||||
pub(super) pending_mcp_server_refresh_config: Mutex<Option<McpServerRefreshConfig>>,
|
||||
pub(crate) conversation: Arc<RealtimeConversationManager>,
|
||||
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
|
||||
pub(super) mailbox: Mailbox,
|
||||
pub(super) mailbox_rx: Mutex<MailboxReceiver>,
|
||||
pub(super) idle_pending_input: Mutex<Vec<ResponseInputItem>>, // TODO (jif) merge with mailbox!
|
||||
pub(crate) input_queue: InputQueue,
|
||||
pub(crate) goal_runtime: GoalRuntimeState,
|
||||
pub(crate) guardian_review_session: GuardianReviewSessionManager,
|
||||
pub(crate) services: SessionServices,
|
||||
@@ -63,9 +63,9 @@ pub(crate) struct SessionConfiguration {
|
||||
/// When to escalate for approval for execution
|
||||
pub(super) approval_policy: Constrained<AskForApproval>,
|
||||
pub(super) approvals_reviewer: ApprovalsReviewer,
|
||||
/// Permission profile state for the session. Keep the constrained profile
|
||||
/// and selected profile id in sync by using the methods below instead of
|
||||
/// mutating the fields independently.
|
||||
/// Permission profile state for the session. Keep the constrained profile,
|
||||
/// active profile id, and profile-defined workspace roots in sync by using
|
||||
/// the methods below instead of mutating the fields independently.
|
||||
pub(super) permission_profile_state: PermissionProfileState,
|
||||
pub(super) windows_sandbox_level: WindowsSandboxLevel,
|
||||
|
||||
@@ -74,6 +74,9 @@ pub(crate) struct SessionConfiguration {
|
||||
/// execution sandbox are resolved against this directory **instead** of
|
||||
/// the process-wide current working directory.
|
||||
pub(super) cwd: AbsolutePathBuf,
|
||||
/// Thread-scoped runtime workspace roots for materializing symbolic
|
||||
/// workspace permissions at session runtime.
|
||||
pub(super) workspace_roots: Vec<AbsolutePathBuf>,
|
||||
/// Directory containing all Codex state for this session.
|
||||
pub(super) codex_home: AbsolutePathBuf,
|
||||
/// Optional user-facing name for the thread, updated during the session.
|
||||
@@ -107,13 +110,20 @@ impl SessionConfiguration {
|
||||
}
|
||||
|
||||
pub(super) fn permission_profile(&self) -> PermissionProfile {
|
||||
self.permission_profile_state.permission_profile().clone()
|
||||
self.permission_profile_state
|
||||
.permission_profile()
|
||||
.clone()
|
||||
.materialize_project_roots_with_workspace_roots(&self.workspace_roots)
|
||||
}
|
||||
|
||||
pub(super) fn active_permission_profile(&self) -> Option<ActivePermissionProfile> {
|
||||
self.permission_profile_state.active_permission_profile()
|
||||
}
|
||||
|
||||
pub(super) fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] {
|
||||
self.permission_profile_state.profile_workspace_roots()
|
||||
}
|
||||
|
||||
pub(super) fn apply_permission_profile_to_permissions(
|
||||
&self,
|
||||
permissions: &mut crate::config::Permissions,
|
||||
@@ -164,9 +174,13 @@ impl SessionConfiguration {
|
||||
permission_profile: self.permission_profile(),
|
||||
active_permission_profile: self.active_permission_profile(),
|
||||
cwd: self.cwd.clone(),
|
||||
workspace_roots: self.workspace_roots.clone(),
|
||||
profile_workspace_roots: self.profile_workspace_roots().to_vec(),
|
||||
ephemeral: self.original_config_do_not_use.ephemeral,
|
||||
reasoning_effort: self.collaboration_mode.reasoning_effort(),
|
||||
reasoning_summary: self.model_reasoning_summary,
|
||||
personality: self.personality,
|
||||
collaboration_mode: self.collaboration_mode.clone(),
|
||||
session_source: self.session_source.clone(),
|
||||
thread_source: self.thread_source,
|
||||
}
|
||||
@@ -243,6 +257,23 @@ impl SessionConfiguration {
|
||||
|
||||
let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path();
|
||||
next_configuration.cwd = absolute_cwd;
|
||||
if let Some(workspace_roots) = updates.workspace_roots.clone() {
|
||||
next_configuration.workspace_roots = workspace_roots;
|
||||
} else if cwd_changed && self.workspace_roots.contains(&self.cwd) {
|
||||
let mut retargeted_workspace_roots =
|
||||
Vec::with_capacity(next_configuration.workspace_roots.len());
|
||||
for root in &self.workspace_roots {
|
||||
let root = if root == &self.cwd {
|
||||
next_configuration.cwd.clone()
|
||||
} else {
|
||||
root.clone()
|
||||
};
|
||||
if !retargeted_workspace_roots.contains(&root) {
|
||||
retargeted_workspace_roots.push(root);
|
||||
}
|
||||
}
|
||||
next_configuration.workspace_roots = retargeted_workspace_roots;
|
||||
}
|
||||
|
||||
if let Some(permission_profile) = updates.permission_profile.clone() {
|
||||
let active_permission_profile =
|
||||
@@ -256,6 +287,7 @@ impl SessionConfiguration {
|
||||
next_configuration.set_permission_profile_projection(
|
||||
permission_profile,
|
||||
active_permission_profile,
|
||||
updates.profile_workspace_roots.clone().unwrap_or_default(),
|
||||
Some(¤t_file_system_sandbox_policy),
|
||||
)?;
|
||||
} else if let Some(sandbox_policy) = updates.sandbox_policy.clone() {
|
||||
@@ -311,6 +343,7 @@ impl SessionConfiguration {
|
||||
&mut self,
|
||||
permission_profile: PermissionProfile,
|
||||
active_permission_profile: Option<ActivePermissionProfile>,
|
||||
profile_workspace_roots: Vec<AbsolutePathBuf>,
|
||||
preserve_deny_reads_from: Option<&FileSystemSandboxPolicy>,
|
||||
) -> ConstraintResult<()> {
|
||||
let enforcement = permission_profile.enforcement();
|
||||
@@ -326,17 +359,28 @@ impl SessionConfiguration {
|
||||
&file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
);
|
||||
self.permission_profile_state.set_active_permission_profile(
|
||||
effective_permission_profile,
|
||||
active_permission_profile,
|
||||
Vec::new(),
|
||||
)
|
||||
|
||||
let permission_snapshot = match active_permission_profile {
|
||||
Some(active_permission_profile) => {
|
||||
PermissionProfileSnapshot::active_with_profile_workspace_roots(
|
||||
effective_permission_profile,
|
||||
active_permission_profile,
|
||||
profile_workspace_roots,
|
||||
)
|
||||
}
|
||||
None => PermissionProfileSnapshot::legacy(effective_permission_profile),
|
||||
};
|
||||
|
||||
self.permission_profile_state
|
||||
.set_permission_profile_snapshot(permission_snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub(crate) struct SessionSettingsUpdate {
|
||||
pub(crate) cwd: Option<PathBuf>,
|
||||
pub(crate) workspace_roots: Option<Vec<AbsolutePathBuf>>,
|
||||
pub(crate) profile_workspace_roots: Option<Vec<AbsolutePathBuf>>,
|
||||
pub(crate) approval_policy: Option<AskForApproval>,
|
||||
pub(crate) approvals_reviewer: Option<ApprovalsReviewer>,
|
||||
pub(crate) sandbox_policy: Option<SandboxPolicy>,
|
||||
@@ -835,7 +879,7 @@ impl Session {
|
||||
config: config.as_ref(),
|
||||
session_store: &session_extension_data,
|
||||
thread_store: &thread_extension_data,
|
||||
});
|
||||
}).await;
|
||||
}
|
||||
|
||||
let services = SessionServices {
|
||||
@@ -908,7 +952,6 @@ impl Session {
|
||||
let (out_of_band_elicitation_paused, _out_of_band_elicitation_paused_rx) =
|
||||
watch::channel(false);
|
||||
|
||||
let (mailbox, mailbox_rx) = Mailbox::new();
|
||||
let sess = Arc::new(Session {
|
||||
conversation_id: thread_id,
|
||||
installation_id,
|
||||
@@ -921,9 +964,7 @@ impl Session {
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
conversation: Arc::new(RealtimeConversationManager::new()),
|
||||
active_turn: Mutex::new(None),
|
||||
mailbox,
|
||||
mailbox_rx: Mutex::new(mailbox_rx),
|
||||
idle_pending_input: Mutex::new(Vec::new()),
|
||||
input_queue: InputQueue::new(),
|
||||
goal_runtime: GoalRuntimeState::new(),
|
||||
guardian_review_session: GuardianReviewSessionManager::default(),
|
||||
services,
|
||||
@@ -1009,14 +1050,13 @@ impl Session {
|
||||
.cloned();
|
||||
let mcp_runtime_environment = match turn_environment {
|
||||
Some(turn_environment) => McpRuntimeEnvironment::new(
|
||||
Arc::clone(&turn_environment.environment),
|
||||
Some(Arc::clone(&turn_environment.environment)),
|
||||
sess.services.environment_manager.try_local_environment(),
|
||||
turn_environment.cwd.to_path_buf(),
|
||||
),
|
||||
None => McpRuntimeEnvironment::new(
|
||||
sess.services
|
||||
.environment_manager
|
||||
.default_environment()
|
||||
.unwrap_or_else(|| sess.services.environment_manager.local_environment()),
|
||||
sess.services.environment_manager.default_or_local_environment(),
|
||||
sess.services.environment_manager.try_local_environment(),
|
||||
session_configuration.cwd.to_path_buf(),
|
||||
),
|
||||
};
|
||||
@@ -1088,7 +1128,7 @@ impl Session {
|
||||
};
|
||||
|
||||
// record_initial_history can emit events. We record only after the SessionConfiguredEvent is emitted.
|
||||
sess.record_initial_history(initial_history).await;
|
||||
Box::pin(sess.record_initial_history(initial_history)).await;
|
||||
{
|
||||
let mut state = sess.state.lock().await;
|
||||
state.set_pending_session_start_source(Some(session_start_source));
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: core/src/session/tests.rs
|
||||
assertion_line: 1619
|
||||
expression: snapshot
|
||||
---
|
||||
Scenario: First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode.
|
||||
|
||||
+634
-411
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,9 @@ use crate::exec_policy::ExecPolicyManager;
|
||||
use crate::guardian::GUARDIAN_REVIEWER_NAME;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::test_support::models_manager_with_provider;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolCallSource;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_config::ConfigLayerEntry;
|
||||
@@ -22,8 +23,8 @@ use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::models::AdditionalPermissionProfile as PermissionProfile;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::NetworkPermissions;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::models::function_call_output_content_items_to_text;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::request_permissions::PermissionGrantScope;
|
||||
use codex_protocol::request_permissions::RequestPermissionProfile;
|
||||
@@ -48,8 +49,23 @@ use tempfile::tempdir;
|
||||
use tokio::time::timeout;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn expect_text_output(output: &FunctionToolOutput) -> String {
|
||||
function_call_output_content_items_to_text(&output.body).unwrap_or_default()
|
||||
fn expect_text_output<T>(output: &T) -> String
|
||||
where
|
||||
T: ToolOutput + ?Sized,
|
||||
{
|
||||
let response = output.to_response_item(
|
||||
"call-guardian",
|
||||
&ToolPayload::Function {
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
);
|
||||
match response {
|
||||
ResponseInputItem::FunctionCallOutput { output, .. }
|
||||
| ResponseInputItem::CustomToolCallOutput { output, .. } => {
|
||||
output.body.to_text().unwrap_or_default()
|
||||
}
|
||||
other => panic!("expected function output, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+343
-443
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,6 @@ use codex_sandboxing::policy_transforms::effective_network_sandbox_policy;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
pub(super) fn image_generation_tool_auth_allowed(auth_manager: Option<&AuthManager>) -> bool {
|
||||
auth_manager.is_some_and(AuthManager::current_auth_uses_codex_backend)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct TurnSkillsContext {
|
||||
pub(crate) outcome: Arc<SkillLoadOutcome>,
|
||||
@@ -84,7 +80,9 @@ pub struct TurnContext {
|
||||
pub(crate) network: Option<NetworkProxy>,
|
||||
pub(crate) windows_sandbox_level: WindowsSandboxLevel,
|
||||
pub(crate) shell_environment_policy: ShellEnvironmentPolicy,
|
||||
pub(crate) tools_config: ToolsConfig,
|
||||
pub(crate) available_models: Vec<ModelPreset>,
|
||||
pub(crate) unified_exec_shell_mode: UnifiedExecShellMode,
|
||||
pub(crate) goal_tools_supported: bool,
|
||||
pub features: ManagedFeatures,
|
||||
pub(crate) ghost_snapshot: GhostSnapshotConfig,
|
||||
pub(crate) final_output_json_schema: Option<Value>,
|
||||
@@ -156,6 +154,14 @@ impl TurnContext {
|
||||
self.features.apps_enabled_for_auth(uses_codex_backend)
|
||||
}
|
||||
|
||||
pub(crate) fn tool_environment_mode(&self) -> ToolEnvironmentMode {
|
||||
ToolEnvironmentMode::from_count(self.environments.turn_environments.len())
|
||||
}
|
||||
|
||||
pub(crate) fn goal_tools_enabled(&self) -> bool {
|
||||
self.goal_tools_supported && self.features.get().enabled(Feature::Goals)
|
||||
}
|
||||
|
||||
pub(crate) async fn with_model(
|
||||
&self,
|
||||
model: String,
|
||||
@@ -195,60 +201,9 @@ impl TurnContext {
|
||||
/*developer_instructions*/ None,
|
||||
);
|
||||
let features = self.features.clone();
|
||||
let provider_capabilities = self.provider.capabilities();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &models_manager
|
||||
.list_models(RefreshStrategy::OnlineIfUncached)
|
||||
.await,
|
||||
features: &features,
|
||||
image_generation_tool_auth_allowed: image_generation_tool_auth_allowed(
|
||||
self.auth_manager.as_deref(),
|
||||
),
|
||||
web_search_mode: self.tools_config.web_search_mode,
|
||||
session_source: self.session_source.clone(),
|
||||
permission_profile: &self.permission_profile,
|
||||
windows_sandbox_level: self.windows_sandbox_level,
|
||||
})
|
||||
.with_namespace_tools_capability(provider_capabilities.namespace_tools)
|
||||
.with_image_generation_capability(provider_capabilities.image_generation)
|
||||
.with_web_search_capability(provider_capabilities.web_search)
|
||||
.with_unified_exec_shell_mode(self.tools_config.unified_exec_shell_mode.clone())
|
||||
.with_web_search_config(self.tools_config.web_search_config.clone())
|
||||
.with_allow_login_shell(self.tools_config.allow_login_shell)
|
||||
.with_environment_mode(self.tools_config.environment_mode)
|
||||
.with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled)
|
||||
.with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone())
|
||||
.with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata)
|
||||
.with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only)
|
||||
.with_goal_tools_allowed(self.tools_config.goal_tools)
|
||||
.with_max_concurrent_threads_per_session(
|
||||
config
|
||||
.features
|
||||
.enabled(Feature::MultiAgentV2)
|
||||
.then_some(config.multi_agent_v2.max_concurrent_threads_per_session),
|
||||
)
|
||||
.with_wait_agent_min_timeout_ms(
|
||||
config
|
||||
.features
|
||||
.enabled(Feature::MultiAgentV2)
|
||||
.then_some(config.multi_agent_v2.min_wait_timeout_ms),
|
||||
)
|
||||
.with_wait_agent_max_timeout_ms(
|
||||
config
|
||||
.features
|
||||
.enabled(Feature::MultiAgentV2)
|
||||
.then_some(config.multi_agent_v2.max_wait_timeout_ms),
|
||||
)
|
||||
.with_wait_agent_default_timeout_ms(
|
||||
config
|
||||
.features
|
||||
.enabled(Feature::MultiAgentV2)
|
||||
.then_some(config.multi_agent_v2.default_wait_timeout_ms),
|
||||
)
|
||||
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
|
||||
&config.agent_roles,
|
||||
));
|
||||
let available_models = models_manager
|
||||
.list_models(RefreshStrategy::OnlineIfUncached)
|
||||
.await;
|
||||
|
||||
Self {
|
||||
sub_id: self.sub_id.clone(),
|
||||
@@ -282,7 +237,9 @@ impl TurnContext {
|
||||
network: self.network.clone(),
|
||||
windows_sandbox_level: self.windows_sandbox_level,
|
||||
shell_environment_policy: self.shell_environment_policy.clone(),
|
||||
tools_config,
|
||||
available_models,
|
||||
unified_exec_shell_mode: self.unified_exec_shell_mode.clone(),
|
||||
goal_tools_supported: self.goal_tools_supported,
|
||||
features,
|
||||
ghost_snapshot: self.ghost_snapshot.clone(),
|
||||
final_output_json_schema: self.final_output_json_schema.clone(),
|
||||
@@ -367,7 +324,6 @@ impl TurnContext {
|
||||
pub(crate) fn to_turn_context_item(&self) -> TurnContextItem {
|
||||
TurnContextItem {
|
||||
turn_id: Some(self.sub_id.clone()),
|
||||
trace_id: self.trace_id.clone(),
|
||||
#[allow(deprecated)]
|
||||
cwd: self.cwd.to_path_buf(),
|
||||
current_date: self.current_date.clone(),
|
||||
@@ -382,11 +338,7 @@ impl TurnContext {
|
||||
collaboration_mode: Some(self.collaboration_mode.clone()),
|
||||
realtime_active: Some(self.realtime_active),
|
||||
effort: self.reasoning_effort,
|
||||
summary: self.reasoning_summary,
|
||||
user_instructions: self.user_instructions.clone(),
|
||||
developer_instructions: self.developer_instructions.clone(),
|
||||
final_output_json_schema: self.final_output_json_schema.clone(),
|
||||
truncation_policy: Some(self.truncation_policy),
|
||||
summary: ReasoningSummaryConfig::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +384,10 @@ impl Session {
|
||||
let config = session_configuration.original_config_do_not_use.clone();
|
||||
let mut per_turn_config = (*config).clone();
|
||||
per_turn_config.cwd = cwd;
|
||||
per_turn_config.workspace_roots = session_configuration.workspace_roots.clone();
|
||||
per_turn_config
|
||||
.permissions
|
||||
.set_workspace_roots(session_configuration.workspace_roots.clone());
|
||||
per_turn_config.model_reasoning_effort =
|
||||
session_configuration.collaboration_mode.reasoning_effort();
|
||||
per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary;
|
||||
@@ -466,6 +422,10 @@ impl Session {
|
||||
Self::build_per_turn_config(session_configuration, session_configuration.cwd.clone());
|
||||
config.model = Some(session_configuration.collaboration_mode.model().to_string());
|
||||
config.permissions.approval_policy = session_configuration.approval_policy.clone();
|
||||
config.workspace_roots = session_configuration.workspace_roots.clone();
|
||||
config
|
||||
.permissions
|
||||
.set_workspace_roots(session_configuration.workspace_roots.clone());
|
||||
config
|
||||
}
|
||||
|
||||
@@ -499,71 +459,18 @@ impl Session {
|
||||
model_info.slug.as_str(),
|
||||
);
|
||||
let session_source = session_configuration.session_source.clone();
|
||||
let image_generation_tool_auth_allowed =
|
||||
image_generation_tool_auth_allowed(auth_manager.as_deref());
|
||||
let auth_manager_for_context = auth_manager.clone();
|
||||
let provider_for_context = create_model_provider(provider, auth_manager);
|
||||
let provider_capabilities = provider_for_context.capabilities();
|
||||
let session_telemetry_for_context = session_telemetry;
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &models_manager.try_list_models().unwrap_or_default(),
|
||||
features: &per_turn_config.features,
|
||||
image_generation_tool_auth_allowed,
|
||||
web_search_mode: Some(per_turn_config.web_search_mode.value()),
|
||||
session_source: session_source.clone(),
|
||||
permission_profile: &session_configuration.permission_profile(),
|
||||
windows_sandbox_level: session_configuration.windows_sandbox_level,
|
||||
})
|
||||
.with_namespace_tools_capability(provider_capabilities.namespace_tools)
|
||||
.with_image_generation_capability(provider_capabilities.image_generation)
|
||||
.with_web_search_capability(provider_capabilities.web_search)
|
||||
.with_unified_exec_shell_mode_for_session(
|
||||
crate::tools::spec::tool_user_shell_type(user_shell),
|
||||
let available_models = models_manager.try_list_models().unwrap_or_default();
|
||||
let shell_command_backend =
|
||||
shell_command_backend_for_features(per_turn_config.features.get());
|
||||
let unified_exec_shell_mode = UnifiedExecShellMode::for_session(
|
||||
shell_command_backend,
|
||||
crate::tools::tool_user_shell_type(user_shell),
|
||||
shell_zsh_path,
|
||||
main_execve_wrapper_exe,
|
||||
)
|
||||
.with_web_search_config(per_turn_config.web_search_config.clone())
|
||||
.with_allow_login_shell(per_turn_config.permissions.allow_login_shell)
|
||||
.with_environment_mode(ToolEnvironmentMode::from_count(
|
||||
environments.turn_environments.len(),
|
||||
))
|
||||
.with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled)
|
||||
.with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone())
|
||||
.with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata)
|
||||
.with_multi_agent_v2_non_code_mode_only(per_turn_config.multi_agent_v2.non_code_mode_only)
|
||||
.with_goal_tools_allowed(goal_tools_supported)
|
||||
.with_max_concurrent_threads_per_session(
|
||||
per_turn_config
|
||||
.features
|
||||
.enabled(Feature::MultiAgentV2)
|
||||
.then_some(
|
||||
per_turn_config
|
||||
.multi_agent_v2
|
||||
.max_concurrent_threads_per_session,
|
||||
),
|
||||
)
|
||||
.with_wait_agent_min_timeout_ms(
|
||||
per_turn_config
|
||||
.features
|
||||
.enabled(Feature::MultiAgentV2)
|
||||
.then_some(per_turn_config.multi_agent_v2.min_wait_timeout_ms),
|
||||
)
|
||||
.with_wait_agent_max_timeout_ms(
|
||||
per_turn_config
|
||||
.features
|
||||
.enabled(Feature::MultiAgentV2)
|
||||
.then_some(per_turn_config.multi_agent_v2.max_wait_timeout_ms),
|
||||
)
|
||||
.with_wait_agent_default_timeout_ms(
|
||||
per_turn_config
|
||||
.features
|
||||
.enabled(Feature::MultiAgentV2)
|
||||
.then_some(per_turn_config.multi_agent_v2.default_wait_timeout_ms),
|
||||
)
|
||||
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
|
||||
&per_turn_config.agent_roles,
|
||||
));
|
||||
);
|
||||
|
||||
let mut per_turn_config = per_turn_config;
|
||||
per_turn_config.service_tier = per_turn_config
|
||||
@@ -611,7 +518,9 @@ impl Session {
|
||||
network,
|
||||
windows_sandbox_level: session_configuration.windows_sandbox_level,
|
||||
shell_environment_policy: per_turn_config.permissions.shell_environment_policy.clone(),
|
||||
tools_config,
|
||||
available_models,
|
||||
unified_exec_shell_mode,
|
||||
goal_tools_supported,
|
||||
features: per_turn_config.features.clone(),
|
||||
ghost_snapshot: per_turn_config.ghost_snapshot.clone(),
|
||||
final_output_json_schema: None,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
@@ -235,9 +234,6 @@ async fn schedule_startup_prewarm_inner(
|
||||
let startup_router = built_tools(
|
||||
session.as_ref(),
|
||||
startup_turn_context.as_ref(),
|
||||
&[],
|
||||
&HashSet::new(),
|
||||
/*skills_outcome*/ None,
|
||||
&startup_cancellation_token,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
@@ -10,14 +5,9 @@ use codex_analytics::InvocationType;
|
||||
use codex_analytics::SkillInvocation;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_protocol::request_user_input::RequestUserInputArgs;
|
||||
use codex_protocol::request_user_input::RequestUserInputQuestion;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_plugins::PluginSkillRoot;
|
||||
use tracing::warn;
|
||||
|
||||
pub use codex_core_skills::SkillDependencyInfo;
|
||||
pub use codex_core_skills::SkillError;
|
||||
pub use codex_core_skills::SkillLoadOutcome;
|
||||
pub use codex_core_skills::SkillMetadata;
|
||||
@@ -27,7 +17,6 @@ pub use codex_core_skills::SkillsLoadInput;
|
||||
pub use codex_core_skills::SkillsManager;
|
||||
pub use codex_core_skills::build_available_skills;
|
||||
pub use codex_core_skills::build_skill_name_counts;
|
||||
pub use codex_core_skills::collect_env_var_dependencies;
|
||||
pub use codex_core_skills::config_rules;
|
||||
pub use codex_core_skills::default_skill_metadata_budget;
|
||||
pub use codex_core_skills::detect_implicit_skill_invocation_for_command;
|
||||
@@ -56,121 +45,6 @@ pub(crate) fn skills_load_input_from_config(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_skill_dependencies_for_turn(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
dependencies: &[SkillDependencyInfo],
|
||||
) {
|
||||
if dependencies.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let existing_env = sess.dependency_env().await;
|
||||
let mut loaded_values = HashMap::new();
|
||||
let mut missing = Vec::new();
|
||||
let mut seen_names = HashSet::new();
|
||||
|
||||
for dependency in dependencies {
|
||||
let name = dependency.name.clone();
|
||||
if !seen_names.insert(name.clone()) || existing_env.contains_key(&name) {
|
||||
continue;
|
||||
}
|
||||
match env::var(&name) {
|
||||
Ok(value) => {
|
||||
loaded_values.insert(name.clone(), value);
|
||||
}
|
||||
Err(env::VarError::NotPresent) => {
|
||||
missing.push(dependency.clone());
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("failed to read env var {name}: {err}");
|
||||
missing.push(dependency.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !loaded_values.is_empty() {
|
||||
sess.set_dependency_env(loaded_values).await;
|
||||
}
|
||||
|
||||
if !missing.is_empty() {
|
||||
request_skill_dependencies(sess, turn_context, &missing).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_skill_dependencies(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
dependencies: &[SkillDependencyInfo],
|
||||
) {
|
||||
let questions = dependencies
|
||||
.iter()
|
||||
.map(|dependency| {
|
||||
let requirement = dependency.description.as_ref().map_or_else(
|
||||
|| {
|
||||
format!(
|
||||
"The skill \"{}\" requires \"{}\" to be set.",
|
||||
dependency.skill_name, dependency.name
|
||||
)
|
||||
},
|
||||
|description| {
|
||||
format!(
|
||||
"The skill \"{}\" requires \"{}\" to be set ({}).",
|
||||
dependency.skill_name, dependency.name, description
|
||||
)
|
||||
},
|
||||
);
|
||||
RequestUserInputQuestion {
|
||||
id: dependency.name.clone(),
|
||||
header: "Skill requires environment variable".to_string(),
|
||||
question: format!(
|
||||
"{requirement} This is an experimental internal feature. The value is stored in memory for this session only."
|
||||
),
|
||||
is_other: false,
|
||||
is_secret: true,
|
||||
options: None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if questions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let response = sess
|
||||
.request_user_input(
|
||||
turn_context,
|
||||
format!("skill-deps-{}", turn_context.sub_id),
|
||||
RequestUserInputArgs { questions },
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| RequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
});
|
||||
if response.answers.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut values = HashMap::new();
|
||||
for (name, answer) in response.answers {
|
||||
let mut user_note = None;
|
||||
for entry in &answer.answers {
|
||||
if let Some(note) = entry.strip_prefix("user_note: ")
|
||||
&& !note.trim().is_empty()
|
||||
{
|
||||
user_note = Some(note.trim().to_string());
|
||||
}
|
||||
}
|
||||
if let Some(value) = user_note {
|
||||
values.insert(name, value);
|
||||
}
|
||||
}
|
||||
if values.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
sess.set_dependency_env(values).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_emit_implicit_skill_invocation(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct AutoCompactWindowSnapshot {
|
||||
pub(crate) ordinal: u64,
|
||||
pub(crate) prefill_input_tokens: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum AutoCompactWindowPrefill {
|
||||
ServerObserved(i64),
|
||||
Estimated(i64),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct AutoCompactWindow {
|
||||
ordinal: u64,
|
||||
/// Absolute input-token baseline for the current compaction window.
|
||||
///
|
||||
/// `body_after_prefix` subtracts this from later active-context usage. It is
|
||||
/// not the growth itself; server-observed usage replaces estimated
|
||||
/// resume/recompute baselines when available.
|
||||
prefill_input_tokens: Option<AutoCompactWindowPrefill>,
|
||||
}
|
||||
|
||||
impl AutoCompactWindow {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
ordinal: 1,
|
||||
prefill_input_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn clear_prefill(&mut self) {
|
||||
self.prefill_input_tokens = None;
|
||||
}
|
||||
|
||||
pub(super) fn start_next(&mut self) {
|
||||
self.ordinal = self.ordinal.saturating_add(1);
|
||||
self.clear_prefill();
|
||||
}
|
||||
|
||||
/// Records the request-input side of the first server usage sample. The
|
||||
/// sampled output from that response is body growth and should remain
|
||||
/// counted against the scoped auto-compact budget.
|
||||
pub(super) fn ensure_server_observed_prefill_from_usage(&mut self, usage: &TokenUsage) {
|
||||
if matches!(
|
||||
self.prefill_input_tokens,
|
||||
Some(AutoCompactWindowPrefill::ServerObserved(_))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.prefill_input_tokens = Some(AutoCompactWindowPrefill::ServerObserved(
|
||||
usage.input_tokens.max(0),
|
||||
));
|
||||
}
|
||||
|
||||
pub(super) fn set_estimated_prefill(&mut self, tokens: i64) {
|
||||
if matches!(
|
||||
self.prefill_input_tokens,
|
||||
Some(AutoCompactWindowPrefill::ServerObserved(_))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.prefill_input_tokens = Some(AutoCompactWindowPrefill::Estimated(tokens.max(0)));
|
||||
}
|
||||
|
||||
pub(super) fn snapshot(&self) -> AutoCompactWindowSnapshot {
|
||||
let prefill_input_tokens = match self.prefill_input_tokens {
|
||||
Some(AutoCompactWindowPrefill::ServerObserved(tokens))
|
||||
| Some(AutoCompactWindowPrefill::Estimated(tokens)) => Some(tokens),
|
||||
None => None,
|
||||
};
|
||||
AutoCompactWindowSnapshot {
|
||||
ordinal: self.ordinal,
|
||||
prefill_input_tokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn tracks_prefill_and_window_boundaries() {
|
||||
let mut window = AutoCompactWindow::new();
|
||||
|
||||
assert_eq!(
|
||||
window.snapshot(),
|
||||
AutoCompactWindowSnapshot {
|
||||
ordinal: 1,
|
||||
prefill_input_tokens: None,
|
||||
}
|
||||
);
|
||||
|
||||
window.set_estimated_prefill(/*tokens*/ 150);
|
||||
assert_eq!(
|
||||
window.snapshot(),
|
||||
AutoCompactWindowSnapshot {
|
||||
ordinal: 1,
|
||||
prefill_input_tokens: Some(150),
|
||||
}
|
||||
);
|
||||
|
||||
window.ensure_server_observed_prefill_from_usage(&TokenUsage {
|
||||
input_tokens: 120,
|
||||
total_tokens: 170,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
window.snapshot(),
|
||||
AutoCompactWindowSnapshot {
|
||||
ordinal: 1,
|
||||
prefill_input_tokens: Some(120),
|
||||
}
|
||||
);
|
||||
|
||||
window.ensure_server_observed_prefill_from_usage(&TokenUsage {
|
||||
input_tokens: 130,
|
||||
total_tokens: 180,
|
||||
..Default::default()
|
||||
});
|
||||
window.set_estimated_prefill(/*tokens*/ 90);
|
||||
assert_eq!(
|
||||
window.snapshot(),
|
||||
AutoCompactWindowSnapshot {
|
||||
ordinal: 1,
|
||||
prefill_input_tokens: Some(120),
|
||||
}
|
||||
);
|
||||
|
||||
window.start_next();
|
||||
assert_eq!(
|
||||
window.snapshot(),
|
||||
AutoCompactWindowSnapshot {
|
||||
ordinal: 2,
|
||||
prefill_input_tokens: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
mod auto_compact_window;
|
||||
mod service;
|
||||
mod session;
|
||||
mod turn;
|
||||
|
||||
pub(crate) use auto_compact_window::AutoCompactWindowSnapshot;
|
||||
pub(crate) use service::SessionServices;
|
||||
pub(crate) use session::SessionState;
|
||||
pub(crate) use turn::ActiveTurn;
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_sandboxing::policy_transforms::merge_permission_profiles;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::auto_compact_window::AutoCompactWindow;
|
||||
use super::auto_compact_window::AutoCompactWindowSnapshot;
|
||||
use crate::context_manager::ContextManager;
|
||||
use crate::session::PreviousTurnSettings;
|
||||
use crate::session::session::SessionConfiguration;
|
||||
@@ -22,12 +23,13 @@ pub(crate) struct SessionState {
|
||||
pub(crate) history: ContextManager,
|
||||
pub(crate) latest_rate_limits: Option<RateLimitSnapshot>,
|
||||
pub(crate) server_reasoning_included: bool,
|
||||
pub(crate) dependency_env: HashMap<String, String>,
|
||||
pub(crate) mcp_dependency_prompted: HashSet<String>,
|
||||
/// Settings used by the latest regular user turn, used for turn-to-turn
|
||||
/// model/realtime handling on subsequent regular turns (including full-context
|
||||
/// reinjection after resume or `/compact`).
|
||||
previous_turn_settings: Option<PreviousTurnSettings>,
|
||||
/// Runtime accounting state for the active auto-compaction window.
|
||||
auto_compact_window: AutoCompactWindow,
|
||||
/// Startup prewarmed session prepared during session initialization.
|
||||
pub(crate) startup_prewarm: Option<SessionStartupPrewarmHandle>,
|
||||
pub(crate) active_connector_selection: HashSet<String>,
|
||||
@@ -45,9 +47,9 @@ impl SessionState {
|
||||
history,
|
||||
latest_rate_limits: None,
|
||||
server_reasoning_included: false,
|
||||
dependency_env: HashMap::new(),
|
||||
mcp_dependency_prompted: HashSet::new(),
|
||||
previous_turn_settings: None,
|
||||
auto_compact_window: AutoCompactWindow::new(),
|
||||
startup_prewarm: None,
|
||||
active_connector_selection: HashSet::new(),
|
||||
pending_session_start_source: None,
|
||||
@@ -97,6 +99,7 @@ impl SessionState {
|
||||
self.history.replace(items);
|
||||
self.history
|
||||
.set_reference_context_item(reference_context_item);
|
||||
self.auto_compact_window.clear_prefill();
|
||||
}
|
||||
|
||||
pub(crate) fn set_token_info(&mut self, info: Option<TokenUsageInfo>) {
|
||||
@@ -120,6 +123,26 @@ impl SessionState {
|
||||
self.history.update_token_info(usage, model_context_window);
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_auto_compact_window_server_prefill_from_usage(
|
||||
&mut self,
|
||||
usage: &TokenUsage,
|
||||
) {
|
||||
self.auto_compact_window
|
||||
.ensure_server_observed_prefill_from_usage(usage);
|
||||
}
|
||||
|
||||
pub(crate) fn set_auto_compact_window_estimated_prefill(&mut self, tokens: i64) {
|
||||
self.auto_compact_window.set_estimated_prefill(tokens);
|
||||
}
|
||||
|
||||
pub(crate) fn start_next_auto_compact_window(&mut self) {
|
||||
self.auto_compact_window.start_next();
|
||||
}
|
||||
|
||||
pub(crate) fn auto_compact_window_snapshot(&self) -> AutoCompactWindowSnapshot {
|
||||
self.auto_compact_window.snapshot()
|
||||
}
|
||||
|
||||
pub(crate) fn token_info(&self) -> Option<TokenUsageInfo> {
|
||||
self.history.token_info()
|
||||
}
|
||||
@@ -165,16 +188,6 @@ impl SessionState {
|
||||
self.mcp_dependency_prompted.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_dependency_env(&mut self, values: HashMap<String, String>) {
|
||||
for (key, value) in values {
|
||||
self.dependency_env.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn dependency_env(&self) -> HashMap<String, String> {
|
||||
self.dependency_env.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_session_startup_prewarm(
|
||||
&mut self,
|
||||
startup_prewarm: SessionStartupPrewarmHandle,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use crate::session::tests::make_session_configuration_for_tests;
|
||||
use crate::state::AutoCompactWindowSnapshot;
|
||||
use codex_protocol::protocol::CreditsSnapshot;
|
||||
use codex_protocol::protocol::RateLimitWindow;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -61,6 +62,24 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_history_clears_auto_compact_window_prefill_without_advancing() {
|
||||
let session_configuration = make_session_configuration_for_tests().await;
|
||||
let mut state = SessionState::new(session_configuration);
|
||||
|
||||
state.start_next_auto_compact_window();
|
||||
state.set_auto_compact_window_estimated_prefill(/*tokens*/ 100);
|
||||
state.replace_history(Vec::new(), /*reference_context_item*/ None);
|
||||
|
||||
assert_eq!(
|
||||
state.auto_compact_window_snapshot(),
|
||||
AutoCompactWindowSnapshot {
|
||||
ordinal: 2,
|
||||
prefill_input_tokens: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_bucket() {
|
||||
let session_configuration = make_session_configuration_for_tests().await;
|
||||
|
||||
@@ -11,7 +11,6 @@ use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
use codex_extension_api::ExtensionData;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::request_permissions::RequestPermissionProfile;
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
@@ -20,6 +19,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use rmcp::model::RequestId;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::session::TurnInputQueue;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::tasks::AnySessionTask;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
@@ -115,7 +115,7 @@ pub(crate) struct TurnState {
|
||||
pending_user_input: HashMap<String, oneshot::Sender<RequestUserInputResponse>>,
|
||||
pending_elicitations: HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>,
|
||||
pending_dynamic_tools: HashMap<String, oneshot::Sender<DynamicToolResponse>>,
|
||||
pending_input: Vec<ResponseInputItem>,
|
||||
pub(crate) pending_input: TurnInputQueue,
|
||||
mailbox_delivery_phase: MailboxDeliveryPhase,
|
||||
granted_permissions: Option<AdditionalPermissionProfile>,
|
||||
strict_auto_review_enabled: bool,
|
||||
@@ -146,13 +146,12 @@ impl TurnState {
|
||||
self.pending_approvals.remove(key)
|
||||
}
|
||||
|
||||
pub(crate) fn clear_pending(&mut self) {
|
||||
pub(crate) fn clear_pending_waiters(&mut self) {
|
||||
self.pending_approvals.clear();
|
||||
self.pending_request_permissions.clear();
|
||||
self.pending_user_input.clear();
|
||||
self.pending_elicitations.clear();
|
||||
self.pending_dynamic_tools.clear();
|
||||
self.pending_input.clear();
|
||||
}
|
||||
|
||||
pub(crate) fn insert_pending_request_permissions(
|
||||
@@ -220,33 +219,6 @@ impl TurnState {
|
||||
self.pending_dynamic_tools.remove(key)
|
||||
}
|
||||
|
||||
pub(crate) fn push_pending_input(&mut self, input: ResponseInputItem) {
|
||||
self.pending_input.push(input);
|
||||
}
|
||||
|
||||
pub(crate) fn prepend_pending_input(&mut self, mut input: Vec<ResponseInputItem>) {
|
||||
if input.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
input.append(&mut self.pending_input);
|
||||
self.pending_input = input;
|
||||
}
|
||||
|
||||
pub(crate) fn take_pending_input(&mut self) -> Vec<ResponseInputItem> {
|
||||
if self.pending_input.is_empty() {
|
||||
Vec::with_capacity(0)
|
||||
} else {
|
||||
let mut ret = Vec::new();
|
||||
std::mem::swap(&mut ret, &mut self.pending_input);
|
||||
ret
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn has_pending_input(&self) -> bool {
|
||||
!self.pending_input.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn accept_mailbox_delivery_for_current_turn(&mut self) {
|
||||
self.set_mailbox_delivery_phase(MailboxDeliveryPhase::CurrentTurn);
|
||||
}
|
||||
@@ -276,11 +248,3 @@ impl TurnState {
|
||||
self.strict_auto_review_enabled
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveTurn {
|
||||
/// Clear any pending approvals and input buffered for the current turn.
|
||||
pub(crate) async fn clear_pending(&self) {
|
||||
let mut ts = self.turn_state.lock().await;
|
||||
ts.clear_pending();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,8 @@ pub(crate) async fn record_completed_response_item_with_finalized_facts(
|
||||
|facts| facts.defers_mailbox_delivery_to_next_turn,
|
||||
);
|
||||
if defers_mailbox_delivery {
|
||||
sess.defer_mailbox_delivery_to_next_turn(&turn_context.sub_id)
|
||||
sess.input_queue
|
||||
.defer_mailbox_delivery_to_next_turn(&sess.active_turn, &turn_context.sub_id)
|
||||
.await;
|
||||
}
|
||||
mark_thread_memory_mode_polluted_if_external_context(sess, turn_context, item).await;
|
||||
@@ -351,7 +352,11 @@ pub(crate) async fn handle_output_item_done(
|
||||
// The model emitted a tool call; log it, persist the item immediately, and queue the tool execution.
|
||||
Ok(Some(call)) => {
|
||||
ctx.sess
|
||||
.accept_mailbox_delivery_for_current_turn(&ctx.turn_context.sub_id)
|
||||
.input_queue
|
||||
.accept_mailbox_delivery_for_current_turn(
|
||||
&ctx.sess.active_turn,
|
||||
&ctx.turn_context.sub_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
let payload_preview = call.payload.log_payload().into_owned();
|
||||
|
||||
@@ -268,8 +268,8 @@ async fn handle_output_item_done_returns_contributed_last_agent_message() {
|
||||
session.services.extensions = Arc::new(builder.build());
|
||||
let session = Arc::new(session);
|
||||
let turn_context = Arc::new(turn_context);
|
||||
let router = Arc::new(ToolRouter::from_config(
|
||||
&turn_context.tools_config,
|
||||
let router = Arc::new(ToolRouter::from_turn_context(
|
||||
&turn_context,
|
||||
crate::tools::router::ToolRouterParams {
|
||||
mcp_tools: None,
|
||||
deferred_mcp_tools: None,
|
||||
|
||||
@@ -4,38 +4,44 @@ use codex_protocol::protocol::TurnAbortReason;
|
||||
use crate::session::session::Session;
|
||||
|
||||
impl Session {
|
||||
pub(super) fn emit_turn_start_lifecycle(&self, turn_store: &ExtensionData) {
|
||||
pub(super) async fn emit_turn_start_lifecycle(&self, turn_store: &ExtensionData) {
|
||||
for contributor in self.services.extensions.turn_lifecycle_contributors() {
|
||||
contributor.on_turn_start(codex_extension_api::TurnStartInput {
|
||||
session_store: &self.services.session_extension_data,
|
||||
thread_store: &self.services.thread_extension_data,
|
||||
turn_store,
|
||||
});
|
||||
contributor
|
||||
.on_turn_start(codex_extension_api::TurnStartInput {
|
||||
session_store: &self.services.session_extension_data,
|
||||
thread_store: &self.services.thread_extension_data,
|
||||
turn_store,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn emit_turn_stop_lifecycle(&self, turn_store: &ExtensionData) {
|
||||
pub(super) async fn emit_turn_stop_lifecycle(&self, turn_store: &ExtensionData) {
|
||||
for contributor in self.services.extensions.turn_lifecycle_contributors() {
|
||||
contributor.on_turn_stop(codex_extension_api::TurnStopInput {
|
||||
session_store: &self.services.session_extension_data,
|
||||
thread_store: &self.services.thread_extension_data,
|
||||
turn_store,
|
||||
});
|
||||
contributor
|
||||
.on_turn_stop(codex_extension_api::TurnStopInput {
|
||||
session_store: &self.services.session_extension_data,
|
||||
thread_store: &self.services.thread_extension_data,
|
||||
turn_store,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn emit_turn_abort_lifecycle(
|
||||
pub(super) async fn emit_turn_abort_lifecycle(
|
||||
&self,
|
||||
reason: TurnAbortReason,
|
||||
turn_store: &ExtensionData,
|
||||
) {
|
||||
for contributor in self.services.extensions.turn_lifecycle_contributors() {
|
||||
contributor.on_turn_abort(codex_extension_api::TurnAbortInput {
|
||||
reason: reason.clone(),
|
||||
session_store: &self.services.session_extension_data,
|
||||
thread_store: &self.services.thread_extension_data,
|
||||
turn_store,
|
||||
});
|
||||
contributor
|
||||
.on_turn_abort(codex_extension_api::TurnAbortInput {
|
||||
reason: reason.clone(),
|
||||
session_store: &self.services.session_extension_data,
|
||||
thread_store: &self.services.thread_extension_data,
|
||||
turn_store,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,10 @@ use tracing::warn;
|
||||
use crate::config::Config;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::goals::GoalRuntimeEvent;
|
||||
use crate::hook_runtime::PendingInputHookDisposition;
|
||||
use crate::hook_runtime::inspect_pending_input;
|
||||
use crate::hook_runtime::record_additional_contexts;
|
||||
use crate::hook_runtime::record_pending_input;
|
||||
use crate::session::TurnInput;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::state::ActiveTurn;
|
||||
@@ -42,7 +42,6 @@ use codex_otel::TURN_MEMORY_METRIC;
|
||||
use codex_otel::TURN_NETWORK_PROXY_METRIC;
|
||||
use codex_otel::TURN_TOKEN_USAGE_METRIC;
|
||||
use codex_otel::TURN_TOOL_CALL_METRIC;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
@@ -347,25 +346,28 @@ impl Session {
|
||||
{
|
||||
warn!("failed to apply goal runtime turn-start event: {err}");
|
||||
}
|
||||
let queued_response_items = self.take_queued_response_items_for_next_turn().await;
|
||||
let mailbox_items = self.get_pending_input().await;
|
||||
let queued_response_items = self
|
||||
.input_queue
|
||||
.take_queued_response_items_for_next_turn()
|
||||
.await;
|
||||
let mailbox_items = self.input_queue.get_pending_input(&self.active_turn).await;
|
||||
let turn_state = {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
let turn = active.get_or_insert_with(ActiveTurn::default);
|
||||
debug_assert!(turn.tasks.is_empty());
|
||||
Arc::clone(&turn.turn_state)
|
||||
};
|
||||
{
|
||||
let mut turn_state = turn_state.lock().await;
|
||||
turn_state.token_usage_at_turn_start = token_usage_at_turn_start;
|
||||
for item in queued_response_items {
|
||||
turn_state.push_pending_input(item);
|
||||
}
|
||||
for item in mailbox_items {
|
||||
turn_state.push_pending_input(item);
|
||||
}
|
||||
}
|
||||
self.emit_turn_start_lifecycle(turn_context.extension_data.as_ref());
|
||||
turn_state.lock().await.token_usage_at_turn_start = token_usage_at_turn_start;
|
||||
let mut pending_items = queued_response_items
|
||||
.into_iter()
|
||||
.map(TurnInput::ResponseInputItem)
|
||||
.collect::<Vec<_>>();
|
||||
pending_items.extend(mailbox_items);
|
||||
self.input_queue
|
||||
.extend_pending_input_for_turn_state(turn_state.as_ref(), pending_items)
|
||||
.await;
|
||||
self.emit_turn_start_lifecycle(turn_context.extension_data.as_ref())
|
||||
.await;
|
||||
|
||||
let turn_extension_data = Arc::clone(&turn_context.extension_data);
|
||||
let mut active = self.active_turn.lock().await;
|
||||
@@ -467,8 +469,11 @@ impl Session {
|
||||
self: &Arc<Self>,
|
||||
sub_id: String,
|
||||
) {
|
||||
if !self.has_queued_response_items_for_next_turn().await
|
||||
&& !self.has_trigger_turn_mailbox_items().await
|
||||
if !self
|
||||
.input_queue
|
||||
.has_queued_response_items_for_next_turn()
|
||||
.await
|
||||
&& !self.input_queue.has_trigger_turn_mailbox_items().await
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -505,13 +510,13 @@ impl Session {
|
||||
}
|
||||
|
||||
if let Some(turn_context) = turn_context.as_deref() {
|
||||
self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref());
|
||||
self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref())
|
||||
.await;
|
||||
}
|
||||
if (aborted_turn || reason == TurnAbortReason::Interrupted)
|
||||
&& let Err(err) = self
|
||||
.goal_runtime_apply(GoalRuntimeEvent::TaskAborted {
|
||||
turn_context: turn_context.as_deref(),
|
||||
reason: reason.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -520,7 +525,7 @@ impl Session {
|
||||
if let Some(active_turn) = active_turn_to_clear {
|
||||
// Let interrupted tasks observe cancellation before dropping pending approvals, or an
|
||||
// in-flight approval wait can surface as a model-visible rejection before TurnAborted.
|
||||
active_turn.clear_pending().await;
|
||||
self.input_queue.clear_pending(&active_turn).await;
|
||||
}
|
||||
if reason == TurnAbortReason::Interrupted && aborted_turn {
|
||||
self.maybe_start_turn_for_pending_work().await;
|
||||
@@ -553,12 +558,12 @@ impl Session {
|
||||
self.handle_task_abort(task, reason.clone()).await;
|
||||
}
|
||||
if let Some(turn_context) = turn_context.as_deref() {
|
||||
self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref());
|
||||
self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref())
|
||||
.await;
|
||||
}
|
||||
if let Err(err) = self
|
||||
.goal_runtime_apply(GoalRuntimeEvent::TaskAborted {
|
||||
turn_context: turn_context.as_deref(),
|
||||
reason: reason.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -566,7 +571,7 @@ impl Session {
|
||||
}
|
||||
// Let interrupted tasks observe cancellation before dropping pending approvals, or an
|
||||
// in-flight approval wait can surface as a model-visible rejection before TurnAborted.
|
||||
active_turn.clear_pending().await;
|
||||
self.input_queue.clear_pending(&active_turn).await;
|
||||
|
||||
if reason == TurnAbortReason::Interrupted {
|
||||
self.maybe_start_turn_for_pending_work().await;
|
||||
@@ -584,7 +589,7 @@ impl Session {
|
||||
.turn_metadata_state
|
||||
.cancel_git_enrichment_task();
|
||||
|
||||
let mut pending_input = Vec::<ResponseInputItem>::new();
|
||||
let mut pending_input = Vec::<TurnInput>::new();
|
||||
let mut should_clear_active_turn = false;
|
||||
let mut token_usage_at_turn_start = None;
|
||||
let mut turn_had_memory_citation = false;
|
||||
@@ -608,23 +613,34 @@ impl Session {
|
||||
}
|
||||
};
|
||||
if let Some(turn_state) = turn_state.as_ref() {
|
||||
let mut ts = turn_state.lock().await;
|
||||
pending_input = ts.take_pending_input();
|
||||
pending_input = self
|
||||
.input_queue
|
||||
.take_pending_input_for_turn_state(turn_state.as_ref())
|
||||
.await;
|
||||
let ts = turn_state.lock().await;
|
||||
turn_had_memory_citation = ts.has_memory_citation;
|
||||
turn_tool_calls = ts.tool_calls;
|
||||
token_usage_at_turn_start = Some(ts.token_usage_at_turn_start.clone());
|
||||
}
|
||||
if !pending_input.is_empty() {
|
||||
for pending_input_item in pending_input {
|
||||
match inspect_pending_input(self, &turn_context, pending_input_item).await {
|
||||
PendingInputHookDisposition::Accepted(pending_input) => {
|
||||
record_pending_input(self, &turn_context, *pending_input).await;
|
||||
}
|
||||
PendingInputHookDisposition::Blocked {
|
||||
additional_contexts,
|
||||
} => {
|
||||
record_additional_contexts(self, &turn_context, additional_contexts).await;
|
||||
}
|
||||
let hook_outcome =
|
||||
inspect_pending_input(self, &turn_context, &pending_input_item).await;
|
||||
if hook_outcome.should_stop {
|
||||
record_additional_contexts(
|
||||
self,
|
||||
&turn_context,
|
||||
hook_outcome.additional_contexts,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
record_pending_input(
|
||||
self,
|
||||
&turn_context,
|
||||
pending_input_item,
|
||||
hook_outcome.additional_contexts,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -756,7 +772,8 @@ impl Session {
|
||||
.time_to_first_token_ms()
|
||||
.await;
|
||||
if should_clear_active_turn {
|
||||
self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref());
|
||||
self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref())
|
||||
.await;
|
||||
}
|
||||
if let Err(err) = self
|
||||
.goal_runtime_apply(GoalRuntimeEvent::TurnFinished {
|
||||
|
||||
@@ -80,7 +80,7 @@ impl SessionTask for RegularTask {
|
||||
)
|
||||
.instrument(run_turn_span.clone())
|
||||
.await;
|
||||
if !sess.has_pending_input().await {
|
||||
if !sess.input_queue.has_pending_input(&sess.active_turn).await {
|
||||
return last_agent_message;
|
||||
}
|
||||
next_input = Vec::new();
|
||||
|
||||
@@ -1228,7 +1228,7 @@ impl ThreadManagerState {
|
||||
.finalize_thread_spawn(codex, thread_id, tracked_session_source)
|
||||
.await?;
|
||||
if is_resumed_thread {
|
||||
new_thread.thread.emit_thread_resume_lifecycle();
|
||||
new_thread.thread.emit_thread_resume_lifecycle().await;
|
||||
if let Err(err) = new_thread.thread.apply_goal_resume_runtime_effects().await {
|
||||
warn!("failed to apply goal resume runtime effects: {err}");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user