mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Merge remote-tracking branch 'origin/main' into pr-15478
This commit is contained in:
@@ -1180,6 +1180,7 @@
|
||||
},
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
"stop"
|
||||
|
||||
@@ -7994,6 +7994,7 @@
|
||||
},
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
"stop"
|
||||
|
||||
@@ -4698,6 +4698,7 @@
|
||||
},
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
"stop"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"definitions": {
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
"stop"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"definitions": {
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
"stop"
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type HookEventName = "sessionStart" | "userPromptSubmit" | "stop";
|
||||
export type HookEventName = "preToolUse" | "sessionStart" | "userPromptSubmit" | "stop";
|
||||
|
||||
@@ -377,7 +377,7 @@ v2_enum_from_core!(
|
||||
|
||||
v2_enum_from_core!(
|
||||
pub enum HookEventName from CoreHookEventName {
|
||||
SessionStart, UserPromptSubmit, Stop
|
||||
PreToolUse, SessionStart, UserPromptSubmit, Stop
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::inter_agent_instruction::InterAgentDelivery;
|
||||
use crate::agent::inter_agent_instruction::InterAgentInstruction;
|
||||
use crate::agent::registry::AgentMetadata;
|
||||
use crate::agent::registry::AgentRegistry;
|
||||
use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
@@ -23,6 +21,7 @@ use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
@@ -267,6 +266,7 @@ impl AgentControl {
|
||||
new_thread.thread_id,
|
||||
notification_source,
|
||||
child_reference,
|
||||
agent_metadata.agent_path.clone(),
|
||||
);
|
||||
|
||||
Ok(LiveAgent {
|
||||
@@ -438,6 +438,7 @@ impl AgentControl {
|
||||
resumed_thread.thread_id,
|
||||
Some(notification_source.clone()),
|
||||
child_reference,
|
||||
agent_metadata.agent_path.clone(),
|
||||
);
|
||||
self.persist_thread_spawn_edge_for_source(
|
||||
resumed_thread.thread.as_ref(),
|
||||
@@ -488,18 +489,17 @@ impl AgentControl {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn deliver_inter_agent_instruction(
|
||||
pub(crate) async fn send_inter_agent_communication(
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
instruction: InterAgentInstruction,
|
||||
delivery: InterAgentDelivery,
|
||||
communication: InterAgentCommunication,
|
||||
) -> CodexResult<String> {
|
||||
let state = self.upgrade()?;
|
||||
self.handle_thread_request_result(
|
||||
agent_id,
|
||||
&state,
|
||||
state
|
||||
.deliver_inter_agent_instruction(agent_id, instruction, delivery)
|
||||
.send_op(agent_id, Op::InterAgentCommunication { communication })
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
@@ -689,6 +689,7 @@ impl AgentControl {
|
||||
child_thread_id: ThreadId,
|
||||
session_source: Option<SessionSource>,
|
||||
child_reference: String,
|
||||
child_agent_path: Option<AgentPath>,
|
||||
) {
|
||||
let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
@@ -719,6 +720,39 @@ impl AgentControl {
|
||||
let Ok(state) = control.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let child_thread = state.get_thread(child_thread_id).await.ok();
|
||||
if let Some(child_agent_path) = child_agent_path
|
||||
&& child_thread
|
||||
.as_ref()
|
||||
.map(|thread| thread.enabled(Feature::MultiAgentV2))
|
||||
.unwrap_or(true)
|
||||
{
|
||||
let AgentStatus::Completed(Some(content)) = &status else {
|
||||
return;
|
||||
};
|
||||
let Some((parent_path, _)) = child_agent_path.as_str().rsplit_once('/') else {
|
||||
return;
|
||||
};
|
||||
let Ok(parent_agent_path) = AgentPath::try_from(parent_path) else {
|
||||
return;
|
||||
};
|
||||
let Some(parent_thread_id) = control.state.agent_id_for_path(&parent_agent_path)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let _ = control
|
||||
.send_inter_agent_communication(
|
||||
parent_thread_id,
|
||||
InterAgentCommunication::new(
|
||||
child_agent_path,
|
||||
parent_agent_path,
|
||||
Vec::new(),
|
||||
content.clone(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let Ok(parent_thread) = state.get_thread(parent_thread_id).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -11,11 +11,13 @@ use crate::contextual_user_message::SUBAGENT_NOTIFICATION_OPEN_TAG;
|
||||
use assert_matches::assert_matches;
|
||||
use chrono::Utc;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
@@ -125,6 +127,29 @@ fn history_contains_text(history_items: &[ResponseItem], needle: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn history_contains_assistant_inter_agent_communication(
|
||||
history_items: &[ResponseItem],
|
||||
expected: &InterAgentCommunication,
|
||||
) -> bool {
|
||||
history_items.iter().any(|item| {
|
||||
let ResponseItem::Message { role, content, .. } = item else {
|
||||
return false;
|
||||
};
|
||||
if role != "assistant" {
|
||||
return false;
|
||||
}
|
||||
content.iter().any(|content_item| match content_item {
|
||||
ContentItem::OutputText { text } => {
|
||||
serde_json::from_str::<InterAgentCommunication>(text)
|
||||
.ok()
|
||||
.as_ref()
|
||||
== Some(expected)
|
||||
}
|
||||
ContentItem::InputText { .. } | ContentItem::InputImage { .. } => false,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_for_subagent_notification(parent_thread: &Arc<CodexThread>) -> bool {
|
||||
let wait = async {
|
||||
loop {
|
||||
@@ -937,6 +962,223 @@ async fn spawn_child_completion_notifies_parent_history() {
|
||||
assert_eq!(wait_for_subagent_notification(&parent_thread).await, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_completion_sends_inter_agent_message_to_direct_parent() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (root_thread_id, _) = harness.start_thread().await;
|
||||
let mut config = harness.config.clone();
|
||||
let _ = config.features.enable(Feature::MultiAgentV2);
|
||||
let worker_path = AgentPath::root().join("worker_a").expect("worker path");
|
||||
let worker_thread_id = harness
|
||||
.control
|
||||
.spawn_agent(
|
||||
config.clone(),
|
||||
text_input("hello worker"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root_thread_id,
|
||||
depth: 1,
|
||||
agent_path: Some(worker_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("worker spawn should succeed");
|
||||
let tester_path = worker_path.join("tester").expect("tester path");
|
||||
let tester_thread_id = harness
|
||||
.control
|
||||
.spawn_agent(
|
||||
config,
|
||||
text_input("hello tester"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: worker_thread_id,
|
||||
depth: 2,
|
||||
agent_path: Some(tester_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("tester spawn should succeed");
|
||||
|
||||
let tester_thread = harness
|
||||
.manager
|
||||
.get_thread(tester_thread_id)
|
||||
.await
|
||||
.expect("tester thread should exist");
|
||||
let tester_turn = tester_thread.codex.session.new_default_turn().await;
|
||||
tester_thread
|
||||
.codex
|
||||
.session
|
||||
.send_event(
|
||||
tester_turn.as_ref(),
|
||||
EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: tester_turn.sub_id.clone(),
|
||||
last_agent_message: Some("done".to_string()),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let delivered = harness
|
||||
.manager
|
||||
.captured_ops()
|
||||
.into_iter()
|
||||
.any(|(thread_id, op)| {
|
||||
thread_id == worker_thread_id
|
||||
&& matches!(
|
||||
op,
|
||||
Op::InterAgentCommunication { communication }
|
||||
if communication.author == tester_path
|
||||
&& communication.recipient == worker_path
|
||||
&& communication.other_recipients.is_empty()
|
||||
&& communication.content == "done"
|
||||
)
|
||||
});
|
||||
if delivered {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("completion watcher should send inter-agent communication");
|
||||
|
||||
let worker_thread = harness
|
||||
.manager
|
||||
.get_thread(worker_thread_id)
|
||||
.await
|
||||
.expect("worker thread should exist");
|
||||
let expected_message = InterAgentCommunication::new(
|
||||
tester_path.clone(),
|
||||
worker_path.clone(),
|
||||
Vec::new(),
|
||||
"done".to_string(),
|
||||
);
|
||||
timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let history_items = worker_thread
|
||||
.codex
|
||||
.session
|
||||
.clone_history()
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
if history_contains_assistant_inter_agent_communication(
|
||||
&history_items,
|
||||
&expected_message,
|
||||
) && !has_subagent_notification(&history_items)
|
||||
{
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("worker should record assistant inter-agent message");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_completion_ignores_dead_direct_parent() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (root_thread_id, root_thread) = harness.start_thread().await;
|
||||
let mut config = harness.config.clone();
|
||||
let _ = config.features.enable(Feature::MultiAgentV2);
|
||||
let worker_path = AgentPath::root().join("worker_a").expect("worker path");
|
||||
let worker_thread_id = harness
|
||||
.control
|
||||
.spawn_agent(
|
||||
config.clone(),
|
||||
text_input("hello worker"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root_thread_id,
|
||||
depth: 1,
|
||||
agent_path: Some(worker_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("worker spawn should succeed");
|
||||
let tester_path = worker_path.join("tester").expect("tester path");
|
||||
let tester_thread_id = harness
|
||||
.control
|
||||
.spawn_agent(
|
||||
config,
|
||||
text_input("hello tester"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: worker_thread_id,
|
||||
depth: 2,
|
||||
agent_path: Some(tester_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("tester spawn should succeed");
|
||||
harness
|
||||
.control
|
||||
.shutdown_live_agent(worker_thread_id)
|
||||
.await
|
||||
.expect("worker shutdown should succeed");
|
||||
|
||||
let tester_thread = harness
|
||||
.manager
|
||||
.get_thread(tester_thread_id)
|
||||
.await
|
||||
.expect("tester thread should exist");
|
||||
let tester_turn = tester_thread.codex.session.new_default_turn().await;
|
||||
tester_thread
|
||||
.codex
|
||||
.session
|
||||
.send_event(
|
||||
tester_turn.as_ref(),
|
||||
EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: tester_turn.sub_id.clone(),
|
||||
last_agent_message: Some("done".to_string()),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
|
||||
assert!(
|
||||
!harness
|
||||
.manager
|
||||
.captured_ops()
|
||||
.into_iter()
|
||||
.any(|(thread_id, op)| {
|
||||
thread_id == worker_thread_id
|
||||
&& matches!(
|
||||
op,
|
||||
Op::InterAgentCommunication { communication }
|
||||
if communication.author == tester_path
|
||||
&& communication.recipient == worker_path
|
||||
&& communication.content == "done"
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
let root_history_items = root_thread
|
||||
.codex
|
||||
.session
|
||||
.clone_history()
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
assert!(!history_contains_assistant_inter_agent_communication(
|
||||
&root_history_items,
|
||||
&InterAgentCommunication::new(
|
||||
tester_path,
|
||||
AgentPath::root(),
|
||||
Vec::new(),
|
||||
"done".to_string(),
|
||||
)
|
||||
));
|
||||
assert!(!has_subagent_notification(&root_history_items));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_watcher_notifies_parent_when_child_is_missing() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
@@ -953,6 +1195,7 @@ async fn completion_watcher_notifies_parent_when_child_is_missing() {
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
child_thread_id.to_string(),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(wait_for_subagent_notification(&parent_thread).await, true);
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum InterAgentDelivery {
|
||||
CurrentTurn,
|
||||
NextTurn,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct InterAgentInstruction {
|
||||
author: AgentPath,
|
||||
recipient: AgentPath,
|
||||
other_recipients: Vec<AgentPath>,
|
||||
content: String,
|
||||
}
|
||||
|
||||
impl InterAgentInstruction {
|
||||
pub(crate) fn new(
|
||||
author: AgentPath,
|
||||
recipient: AgentPath,
|
||||
other_recipients: Vec<AgentPath>,
|
||||
content: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
author,
|
||||
recipient,
|
||||
other_recipients,
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_response_item(&self) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: self.as_text(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_message_content(content: &[ContentItem]) -> bool {
|
||||
content.iter().any(|content_item| match content_item {
|
||||
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
|
||||
Self::is_instruction_text(text)
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn as_text(&self) -> String {
|
||||
let other_recipients = self
|
||||
.other_recipients
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!(
|
||||
"author: {}\nrecipient: {}\nother_recipients: [{other_recipients}]\nContent: {}",
|
||||
self.author, self.recipient, self.content
|
||||
)
|
||||
}
|
||||
|
||||
fn is_instruction_text(text: &str) -> bool {
|
||||
text.starts_with("author: ")
|
||||
&& text.contains("\nrecipient: ")
|
||||
&& text.contains("\nother_recipients: [")
|
||||
&& text.contains("]\nContent: ")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
pub(crate) mod agent_resolver;
|
||||
pub(crate) mod control;
|
||||
pub(crate) mod inter_agent_instruction;
|
||||
mod registry;
|
||||
pub(crate) mod role;
|
||||
pub(crate) mod status;
|
||||
|
||||
@@ -4306,6 +4306,10 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await;
|
||||
false
|
||||
}
|
||||
Op::InterAgentCommunication { communication } => {
|
||||
handlers::inter_agent_communication(&sess, sub.id.clone(), communication).await;
|
||||
false
|
||||
}
|
||||
Op::ExecApproval {
|
||||
id: approval_id,
|
||||
turn_id,
|
||||
@@ -4485,6 +4489,7 @@ mod handlers {
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::ListCustomPromptsResponseEvent;
|
||||
use codex_protocol::protocol::ListSkillsResponseEvent;
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
@@ -4545,6 +4550,7 @@ mod handlers {
|
||||
Op::UserTurn {
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
model,
|
||||
effort,
|
||||
@@ -4570,7 +4576,7 @@ mod handlers {
|
||||
SessionSettingsUpdate {
|
||||
cwd: Some(cwd),
|
||||
approval_policy: Some(approval_policy),
|
||||
approvals_reviewer: None,
|
||||
approvals_reviewer,
|
||||
sandbox_policy: Some(sandbox_policy),
|
||||
windows_sandbox_level: None,
|
||||
collaboration_mode,
|
||||
@@ -4627,6 +4633,29 @@ mod handlers {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn inter_agent_communication(
|
||||
sess: &Arc<Session>,
|
||||
sub_id: String,
|
||||
communication: InterAgentCommunication,
|
||||
) {
|
||||
let pending_item = communication.to_response_input_item();
|
||||
if sess
|
||||
.inject_response_items(vec![pending_item.clone()])
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
|
||||
sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref())
|
||||
.await;
|
||||
sess.queue_response_items_for_next_turn(vec![pending_item])
|
||||
.await;
|
||||
sess.spawn_task(turn_context, Vec::new(), crate::tasks::RegularTask::new())
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn run_user_shell_command(sess: &Arc<Session>, sub_id: String, command: String) {
|
||||
if let Some((turn_context, cancellation_token)) =
|
||||
sess.active_turn_context_and_cancellation_token().await
|
||||
|
||||
@@ -3,9 +3,11 @@ use super::*;
|
||||
use crate::protocol::CompactedItem;
|
||||
use crate::protocol::InitialHistory;
|
||||
use crate::protocol::ResumedHistory;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -34,11 +36,17 @@ fn assistant_message(text: &str) -> ResponseItem {
|
||||
}
|
||||
|
||||
fn inter_agent_assistant_message(text: &str) -> ResponseItem {
|
||||
let communication = InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
AgentPath::root().join("worker").unwrap(),
|
||||
Vec::new(),
|
||||
text.to_string(),
|
||||
);
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: text.to_string(),
|
||||
text: serde_json::to_string(&communication).unwrap(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
@@ -455,9 +463,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
|
||||
turn_id: Some(assistant_turn_id.clone()),
|
||||
..first_context_item.clone()
|
||||
};
|
||||
let assistant_instruction = inter_agent_assistant_message(
|
||||
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue",
|
||||
);
|
||||
let assistant_instruction = inter_agent_assistant_message("continue");
|
||||
let assistant_reply = assistant_message("worker reply");
|
||||
|
||||
let rollout_items = vec![
|
||||
|
||||
@@ -3013,6 +3013,41 @@ fn op_kind_distinguishes_turn_ops() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_turn_updates_approvals_reviewer() {
|
||||
let (session, turn_context, _rx) = make_session_and_context_with_rx().await;
|
||||
let config = session.get_config().await;
|
||||
|
||||
handlers::user_input_or_turn(
|
||||
&session,
|
||||
"sub-1".to_string(),
|
||||
Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: Some(crate::config::types::ApprovalsReviewer::GuardianSubagent),
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
effort: config.model_reasoning_effort,
|
||||
summary: config.model_reasoning_summary,
|
||||
service_tier: None,
|
||||
final_output_json_schema: None,
|
||||
collaboration_mode: None,
|
||||
personality: config.personality,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let state = session.state.lock().await;
|
||||
assert_eq!(
|
||||
state.session_configuration.approvals_reviewer,
|
||||
crate::config::types::ApprovalsReviewer::GuardianSubagent
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_task_turn_span_inherits_dispatch_trace_context() {
|
||||
struct TraceCaptureTask {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::inter_agent_instruction::InterAgentDelivery;
|
||||
use crate::agent::inter_agent_instruction::InterAgentInstruction;
|
||||
use crate::codex::Codex;
|
||||
use crate::codex::SteerInputError;
|
||||
use crate::config::ConstraintResult;
|
||||
@@ -158,6 +156,7 @@ impl CodexThread {
|
||||
/// If the thread already has an active turn, the message is queued as pending input for that
|
||||
/// turn. Otherwise it is queued at session scope and a regular turn is started so the agent
|
||||
/// can consume that pending input through the normal turn pipeline.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn append_message(&self, message: ResponseItem) -> CodexResult<String> {
|
||||
let submission_id = uuid::Uuid::new_v4().to_string();
|
||||
let pending_item = pending_message_input_item(&message)?;
|
||||
@@ -180,36 +179,6 @@ impl CodexThread {
|
||||
Ok(submission_id)
|
||||
}
|
||||
|
||||
pub(crate) async fn deliver_inter_agent_instruction(
|
||||
&self,
|
||||
instruction: InterAgentInstruction,
|
||||
delivery: InterAgentDelivery,
|
||||
) -> CodexResult<String> {
|
||||
let message = instruction.to_response_item();
|
||||
match delivery {
|
||||
InterAgentDelivery::CurrentTurn => self.append_message(message).await,
|
||||
InterAgentDelivery::NextTurn => self.queue_message_for_next_turn(message).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a prebuilt message so the next turn records it before any submitted user input.
|
||||
pub(crate) async fn queue_message_for_next_turn(
|
||||
&self,
|
||||
message: ResponseItem,
|
||||
) -> CodexResult<String> {
|
||||
let submission_id = uuid::Uuid::new_v4().to_string();
|
||||
let pending_item = pending_message_input_item(&message)?;
|
||||
self.codex
|
||||
.session
|
||||
.queue_response_items_for_next_turn(vec![pending_item])
|
||||
.await;
|
||||
self.codex
|
||||
.session
|
||||
.ensure_task_for_queued_response_items()
|
||||
.await;
|
||||
Ok(submission_id)
|
||||
}
|
||||
|
||||
pub fn rollout_path(&self) -> Option<PathBuf> {
|
||||
self.rollout_path.clone()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::agent::inter_agent_instruction::InterAgentInstruction;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::context_manager::normalize;
|
||||
use crate::event_mapping::is_contextual_user_message_content;
|
||||
@@ -18,6 +17,7 @@ use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ImageDetail;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::TokenUsageInfo;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
@@ -638,7 +638,7 @@ pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool {
|
||||
}
|
||||
|
||||
fn is_inter_agent_instruction_content(content: &[ContentItem]) -> bool {
|
||||
InterAgentInstruction::is_message_content(content)
|
||||
InterAgentCommunication::is_message_content(content)
|
||||
}
|
||||
|
||||
fn user_message_positions(items: &[ResponseItem]) -> Vec<usize> {
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::truncate::TruncationPolicy;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_git::GhostCommit;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
@@ -17,6 +18,7 @@ use codex_protocol::models::ReasoningItemContent;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::default_input_modalities;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use image::ImageBuffer;
|
||||
use image::ImageFormat;
|
||||
use image::Rgba;
|
||||
@@ -39,11 +41,17 @@ fn assistant_msg(text: &str) -> ResponseItem {
|
||||
}
|
||||
|
||||
fn inter_agent_assistant_msg(text: &str) -> ResponseItem {
|
||||
let communication = InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
AgentPath::root().join("worker").unwrap(),
|
||||
Vec::new(),
|
||||
text.to_string(),
|
||||
);
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: text.to_string(),
|
||||
text: serde_json::to_string(&communication).unwrap(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
@@ -239,9 +247,7 @@ fn items_after_last_model_generated_tokens_are_zero_without_model_generated_item
|
||||
|
||||
#[test]
|
||||
fn inter_agent_assistant_messages_are_turn_boundaries() {
|
||||
let item = inter_agent_assistant_msg(
|
||||
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue",
|
||||
);
|
||||
let item = inter_agent_assistant_msg("continue");
|
||||
|
||||
assert!(is_user_turn_boundary(&item));
|
||||
}
|
||||
@@ -250,9 +256,7 @@ fn inter_agent_assistant_messages_are_turn_boundaries() {
|
||||
fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_turns() {
|
||||
let first_turn = user_input_text_msg("first");
|
||||
let first_reply = assistant_msg("done");
|
||||
let inter_agent_turn = inter_agent_assistant_msg(
|
||||
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue",
|
||||
);
|
||||
let inter_agent_turn = inter_agent_assistant_msg("continue");
|
||||
let inter_agent_reply = assistant_msg("worker reply");
|
||||
let mut history = create_history_with_items(vec![
|
||||
first_turn.clone(),
|
||||
@@ -266,6 +270,15 @@ fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_t
|
||||
assert_eq!(history.raw_items(), &vec![first_turn, first_reply]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_inter_agent_assistant_messages_are_not_turn_boundaries() {
|
||||
let item = assistant_msg(
|
||||
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue",
|
||||
);
|
||||
|
||||
assert!(!is_user_turn_boundary(&item));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_token_usage_includes_all_items_after_last_model_generated_item() {
|
||||
let mut history = create_history_with_items(vec![assistant_msg("already counted by API")]);
|
||||
|
||||
@@ -514,6 +514,7 @@ async fn run_review_on_session(
|
||||
items: params.prompt_items.clone(),
|
||||
cwd: params.parent_turn.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: params.model.clone(),
|
||||
effort: params.reasoning_effort,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_hooks::PreToolUseOutcome;
|
||||
use codex_hooks::PreToolUseRequest;
|
||||
use codex_hooks::SessionStartOutcome;
|
||||
use codex_hooks::UserPromptSubmitOutcome;
|
||||
use codex_hooks::UserPromptSubmitRequest;
|
||||
@@ -109,6 +111,36 @@ pub(crate) async fn run_pending_session_start_hooks(
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_pre_tool_use_hooks(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
tool_use_id: String,
|
||||
command: String,
|
||||
) -> Option<String> {
|
||||
let request = PreToolUseRequest {
|
||||
session_id: sess.conversation_id,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
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),
|
||||
tool_name: "Bash".to_string(),
|
||||
tool_use_id,
|
||||
command,
|
||||
};
|
||||
let preview_runs = sess.hooks().preview_pre_tool_use(&request);
|
||||
emit_hook_started_events(sess, turn_context, preview_runs).await;
|
||||
|
||||
let PreToolUseOutcome {
|
||||
hook_events,
|
||||
should_block,
|
||||
block_reason,
|
||||
} = sess.hooks().run_pre_tool_use(request).await;
|
||||
emit_hook_completed_events(sess, turn_context, hook_events).await;
|
||||
|
||||
if should_block { block_reason } else { None }
|
||||
}
|
||||
|
||||
pub(crate) async fn run_user_prompt_submit_hooks(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
|
||||
@@ -405,6 +405,7 @@ impl Session {
|
||||
turn.add_task(task);
|
||||
*active = Some(turn);
|
||||
}
|
||||
|
||||
async fn take_active_turn(&self) -> Option<ActiveTurn> {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
active.take()
|
||||
|
||||
@@ -3,8 +3,6 @@ use crate::CodexAuth;
|
||||
use crate::ModelProviderInfo;
|
||||
use crate::OPENAI_PROVIDER_ID;
|
||||
use crate::agent::AgentControl;
|
||||
use crate::agent::inter_agent_instruction::InterAgentDelivery;
|
||||
use crate::agent::inter_agent_instruction::InterAgentInstruction;
|
||||
use crate::codex::Codex;
|
||||
use crate::codex::CodexSpawnArgs;
|
||||
use crate::codex::CodexSpawnOk;
|
||||
@@ -621,18 +619,6 @@ impl ThreadManagerState {
|
||||
thread.append_message(message).await
|
||||
}
|
||||
|
||||
pub(crate) async fn deliver_inter_agent_instruction(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
instruction: InterAgentInstruction,
|
||||
delivery: InterAgentDelivery,
|
||||
) -> CodexResult<String> {
|
||||
let thread = self.get_thread(thread_id).await?;
|
||||
thread
|
||||
.deliver_inter_agent_instruction(instruction, delivery)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Remove a thread from the manager by ID, returning it when present.
|
||||
pub(crate) async fn remove_thread(&self, thread_id: &ThreadId) -> Option<Arc<CodexThread>> {
|
||||
self.threads.write().await.remove(thread_id)
|
||||
|
||||
@@ -9,12 +9,14 @@ use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::protocol::AgentStatus;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::FileSystemSandboxPolicy;
|
||||
use crate::protocol::NetworkSandboxPolicy;
|
||||
use crate::protocol::Op;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::SessionSource;
|
||||
use crate::protocol::SubAgentSource;
|
||||
use crate::protocol::TurnCompleteEvent;
|
||||
use crate::state::TaskKind;
|
||||
use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
@@ -24,6 +26,7 @@ use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHand
|
||||
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
@@ -31,6 +34,7 @@ use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -78,6 +82,33 @@ fn thread_manager() -> ThreadManager {
|
||||
)
|
||||
}
|
||||
|
||||
fn history_contains_inter_agent_communication(
|
||||
history_items: &[ResponseItem],
|
||||
expected: &InterAgentCommunication,
|
||||
) -> bool {
|
||||
history_items.iter().any(|item| {
|
||||
let ResponseItem::Message { role, content, .. } = item else {
|
||||
return false;
|
||||
};
|
||||
if role != "assistant" {
|
||||
return false;
|
||||
}
|
||||
content.iter().any(|content_item| match content_item {
|
||||
ContentItem::OutputText { text } => {
|
||||
serde_json::from_str::<InterAgentCommunication>(text)
|
||||
.ok()
|
||||
.as_ref()
|
||||
== Some(expected)
|
||||
}
|
||||
ContentItem::InputText { .. } | ContentItem::InputImage { .. } => false,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn inter_agent_message_text(recipient: &str, content: &str) -> String {
|
||||
format!("author: /root\nrecipient: {recipient}\nother_recipients: []\nContent: {content}")
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct NeverEndingTask;
|
||||
|
||||
@@ -373,10 +404,28 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path(
|
||||
.await
|
||||
.expect("send_input should accept v2 path");
|
||||
|
||||
assert!(manager.captured_ops().iter().any(|(id, op)| {
|
||||
*id == child_thread_id
|
||||
&& matches!(
|
||||
op,
|
||||
Op::InterAgentCommunication { communication }
|
||||
if communication.author == AgentPath::root()
|
||||
&& communication.recipient.as_str() == "/root/test_process"
|
||||
&& communication.other_recipients.is_empty()
|
||||
&& communication.content == "continue"
|
||||
)
|
||||
}));
|
||||
|
||||
let child_thread = manager
|
||||
.get_thread(child_thread_id)
|
||||
.await
|
||||
.expect("child thread should exist");
|
||||
let expected_communication = InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/test_process").expect("agent path"),
|
||||
Vec::new(),
|
||||
"continue".to_string(),
|
||||
);
|
||||
timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let history_items = child_thread
|
||||
@@ -386,19 +435,8 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path(
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
let recorded = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "assistant"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::OutputText { text }
|
||||
if text
|
||||
== "author: /root\nrecipient: /root/test_process\nother_recipients: []\nContent: continue"
|
||||
))
|
||||
)
|
||||
});
|
||||
let recorded =
|
||||
history_contains_inter_agent_communication(&history_items, &expected_communication);
|
||||
let saw_user_message = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
@@ -495,6 +533,10 @@ async fn multi_agent_v2_send_input_accepts_structured_items() {
|
||||
.find(|(id, op)| *id == agent_id && *op == expected);
|
||||
assert_eq!(captured, Some((agent_id, expected)));
|
||||
|
||||
let expected_message = inter_agent_message_text(
|
||||
"/root/worker",
|
||||
"[mention:$drive](app://google_drive)\nread the folder",
|
||||
);
|
||||
timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let history_items = thread
|
||||
@@ -512,8 +554,7 @@ async fn multi_agent_v2_send_input_accepts_structured_items() {
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::OutputText { text }
|
||||
if text
|
||||
== "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: [mention:$drive](app://google_drive)\nread the folder"
|
||||
if text == &expected_message
|
||||
))
|
||||
)
|
||||
});
|
||||
@@ -618,6 +659,16 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message(
|
||||
.filter_map(|(id, op)| (*id == agent_id).then_some(op))
|
||||
.collect();
|
||||
assert!(ops_for_agent.iter().any(|op| matches!(op, Op::Interrupt)));
|
||||
assert!(ops_for_agent.iter().any(|op| {
|
||||
matches!(
|
||||
op,
|
||||
Op::InterAgentCommunication { communication }
|
||||
if communication.author == AgentPath::root()
|
||||
&& communication.recipient.as_str() == "/root/worker"
|
||||
&& communication.other_recipients.is_empty()
|
||||
&& communication.content == "continue"
|
||||
)
|
||||
}));
|
||||
assert!(!ops_for_agent.iter().any(|op| matches!(
|
||||
op,
|
||||
Op::UserInput { items, .. }
|
||||
@@ -636,19 +687,15 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message(
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
let saw_envelope = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "assistant"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::OutputText { text }
|
||||
if text
|
||||
== "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue"
|
||||
))
|
||||
)
|
||||
});
|
||||
let saw_envelope = history_contains_inter_agent_communication(
|
||||
&history_items,
|
||||
&InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
Vec::new(),
|
||||
"continue".to_string(),
|
||||
),
|
||||
);
|
||||
let saw_user_message = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
@@ -1369,7 +1416,7 @@ async fn multi_agent_v2_wait_agent_accepts_targets_argument() {
|
||||
assert_eq!(
|
||||
result,
|
||||
crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult {
|
||||
status: HashMap::from([(target, AgentStatus::NotFound)]),
|
||||
message: "Wait completed.".to_string(),
|
||||
timed_out: false,
|
||||
}
|
||||
);
|
||||
@@ -1537,12 +1584,7 @@ async fn wait_agent_returns_final_status_without_timeout() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentResult {
|
||||
task_name: String,
|
||||
}
|
||||
|
||||
async fn multi_agent_v2_wait_agent_returns_summary_for_named_targets() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
@@ -1572,9 +1614,7 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() {
|
||||
))
|
||||
.await
|
||||
.expect("spawn_agent should succeed");
|
||||
let (content, _) = expect_text_output(spawn_output);
|
||||
let spawn_result: SpawnAgentResult =
|
||||
serde_json::from_str(&content).expect("spawn result should parse");
|
||||
let _ = expect_text_output(spawn_output);
|
||||
|
||||
let agent_id = session
|
||||
.services
|
||||
@@ -1622,13 +1662,67 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() {
|
||||
assert_eq!(
|
||||
result,
|
||||
crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult {
|
||||
status: HashMap::from([(spawn_result.task_name, AgentStatus::Shutdown)]),
|
||||
message: "Wait completed.".to_string(),
|
||||
timed_out: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(success, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_wait_agent_does_not_return_completed_content() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
session.services.agent_control = manager.agent_control();
|
||||
let mut config = (*turn.config).clone();
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2)
|
||||
.expect("test config should allow feature update");
|
||||
turn.config = Arc::new(config.clone());
|
||||
|
||||
let thread = manager.start_thread(config).await.expect("start thread");
|
||||
let agent_id = thread.thread_id;
|
||||
let child_turn = thread.thread.codex.session.new_default_turn().await;
|
||||
thread
|
||||
.thread
|
||||
.codex
|
||||
.session
|
||||
.send_event(
|
||||
child_turn.as_ref(),
|
||||
EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: child_turn.sub_id.clone(),
|
||||
last_agent_message: Some("sensitive child output".to_string()),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let output = WaitAgentHandlerV2
|
||||
.handle(invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"wait_agent",
|
||||
function_payload(json!({
|
||||
"targets": [agent_id.to_string()],
|
||||
"timeout_ms": 1000
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("wait_agent should succeed");
|
||||
let (content, success) = expect_text_output(output);
|
||||
let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult =
|
||||
serde_json::from_str(&content).expect("wait_agent result should be json");
|
||||
assert_eq!(
|
||||
result,
|
||||
crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult {
|
||||
message: "Wait completed.".to_string(),
|
||||
timed_out: false,
|
||||
}
|
||||
);
|
||||
assert!(!content.contains("sensitive child output"));
|
||||
assert_eq!(success, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_agent_submits_shutdown_and_returns_previous_status() {
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use super::*;
|
||||
use crate::agent::inter_agent_instruction::InterAgentDelivery;
|
||||
use crate::agent::inter_agent_instruction::InterAgentInstruction;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
@@ -66,7 +65,7 @@ impl ToolHandler for Handler {
|
||||
"target agent is missing an agent_path".to_string(),
|
||||
)
|
||||
})?;
|
||||
let instruction = InterAgentInstruction::new(
|
||||
let communication = InterAgentCommunication::new(
|
||||
turn.session_source
|
||||
.get_agent_path()
|
||||
.unwrap_or_else(AgentPath::root),
|
||||
@@ -77,15 +76,7 @@ impl ToolHandler for Handler {
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.deliver_inter_agent_instruction(
|
||||
receiver_thread_id,
|
||||
instruction,
|
||||
if args.interrupt {
|
||||
InterAgentDelivery::NextTurn
|
||||
} else {
|
||||
InterAgentDelivery::CurrentTurn
|
||||
},
|
||||
)
|
||||
.send_inter_agent_communication(receiver_thread_id, communication)
|
||||
.await
|
||||
} else {
|
||||
session
|
||||
|
||||
@@ -35,21 +35,12 @@ impl ToolHandler for Handler {
|
||||
let args: WaitArgs = parse_arguments(&arguments)?;
|
||||
let receiver_thread_ids = resolve_agent_targets(&session, &turn, args.targets).await?;
|
||||
let mut receiver_agents = Vec::with_capacity(receiver_thread_ids.len());
|
||||
let mut target_by_thread_id = HashMap::with_capacity(receiver_thread_ids.len());
|
||||
for receiver_thread_id in &receiver_thread_ids {
|
||||
let agent_metadata = session
|
||||
.services
|
||||
.agent_control
|
||||
.get_agent_metadata(*receiver_thread_id)
|
||||
.unwrap_or_default();
|
||||
target_by_thread_id.insert(
|
||||
*receiver_thread_id,
|
||||
agent_metadata
|
||||
.agent_path
|
||||
.as_ref()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| receiver_thread_id.to_string()),
|
||||
);
|
||||
receiver_agents.push(CollabAgentRef {
|
||||
thread_id: *receiver_thread_id,
|
||||
agent_nickname: agent_metadata.agent_nickname,
|
||||
@@ -152,18 +143,7 @@ impl ToolHandler for Handler {
|
||||
let timed_out = statuses.is_empty();
|
||||
let statuses_by_id = statuses.clone().into_iter().collect::<HashMap<_, _>>();
|
||||
let agent_statuses = build_wait_agent_statuses(&statuses_by_id, &receiver_agents);
|
||||
let result = WaitAgentResult {
|
||||
status: statuses
|
||||
.into_iter()
|
||||
.filter_map(|(thread_id, status)| {
|
||||
target_by_thread_id
|
||||
.get(&thread_id)
|
||||
.cloned()
|
||||
.map(|target| (target, status))
|
||||
})
|
||||
.collect(),
|
||||
timed_out,
|
||||
};
|
||||
let result = WaitAgentResult::from_timed_out(timed_out);
|
||||
|
||||
session
|
||||
.send_event(
|
||||
@@ -191,10 +171,24 @@ struct WaitArgs {
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub(crate) struct WaitAgentResult {
|
||||
pub(crate) status: HashMap<String, AgentStatus>,
|
||||
pub(crate) message: String,
|
||||
pub(crate) timed_out: bool,
|
||||
}
|
||||
|
||||
impl WaitAgentResult {
|
||||
fn from_timed_out(timed_out: bool) -> Self {
|
||||
let message = if timed_out {
|
||||
"Wait timed out."
|
||||
} else {
|
||||
"Wait completed."
|
||||
};
|
||||
Self {
|
||||
message: message.to_string(),
|
||||
timed_out,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolOutput for WaitAgentResult {
|
||||
fn log_preview(&self) -> String {
|
||||
tool_output_json_text(self, "wait_agent")
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::time::Instant;
|
||||
|
||||
use crate::client_common::tools::ToolSpec;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::hook_runtime::run_pre_tool_use_hooks;
|
||||
use crate::memories::usage::emit_metric_for_tool_read;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::sandbox_tags::sandbox_tag;
|
||||
@@ -20,7 +21,10 @@ use codex_hooks::HookToolInput;
|
||||
use codex_hooks::HookToolInputLocalShell;
|
||||
use codex_hooks::HookToolKind;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ShellCommandToolCallParams;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use codex_utils_readiness::Readiness;
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -243,6 +247,20 @@ impl ToolRegistry {
|
||||
return Err(FunctionCallError::Fatal(message));
|
||||
}
|
||||
|
||||
if let Some(command) = pre_tool_use_command(tool_name.as_ref(), &invocation.payload)
|
||||
&& let Some(reason) = run_pre_tool_use_hooks(
|
||||
&invocation.session,
|
||||
&invocation.turn,
|
||||
invocation.call_id.clone(),
|
||||
command.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"Bash command blocked by hook: {reason}. Command: {command}"
|
||||
)));
|
||||
}
|
||||
|
||||
let is_mutating = handler.is_mutating(&invocation).await;
|
||||
let response_cell = tokio::sync::Mutex::new(None);
|
||||
let invocation_for_tool = invocation.clone();
|
||||
@@ -413,6 +431,35 @@ fn sandbox_policy_tag(policy: &SandboxPolicy) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PreToolUseExecCommandArgs {
|
||||
cmd: String,
|
||||
}
|
||||
|
||||
fn pre_tool_use_command(tool_name: &str, payload: &ToolPayload) -> Option<String> {
|
||||
match (tool_name, payload) {
|
||||
("shell" | "container.exec", ToolPayload::Function { arguments }) => {
|
||||
serde_json::from_str::<ShellToolCallParams>(arguments)
|
||||
.ok()
|
||||
.map(|params| codex_shell_command::parse_command::shlex_join(¶ms.command))
|
||||
}
|
||||
("local_shell", ToolPayload::LocalShell { params }) => Some(
|
||||
codex_shell_command::parse_command::shlex_join(¶ms.command),
|
||||
),
|
||||
("shell_command", ToolPayload::Function { arguments }) => {
|
||||
serde_json::from_str::<ShellCommandToolCallParams>(arguments)
|
||||
.ok()
|
||||
.map(|params| params.command)
|
||||
}
|
||||
("exec_command", ToolPayload::Function { arguments }) => {
|
||||
serde_json::from_str::<PreToolUseExecCommandArgs>(arguments)
|
||||
.ok()
|
||||
.map(|params| params.cmd)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Hooks use a separate wire-facing input type so hook payload JSON stays stable
|
||||
// and decoupled from core's internal tool runtime representation.
|
||||
impl From<&ToolPayload> for HookToolInput {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::*;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use async_trait::async_trait;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
struct TestHandler;
|
||||
@@ -48,3 +50,63 @@ fn handler_looks_up_namespaced_aliases_explicitly() {
|
||||
.is_some_and(|handler| Arc::ptr_eq(handler, &namespaced_handler))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_command_uses_raw_shell_command_input() {
|
||||
let payload = ToolPayload::Function {
|
||||
arguments: serde_json::json!({ "command": "printf shell command" }).to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
pre_tool_use_command("shell_command", &payload),
|
||||
Some("printf shell command".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_command_shell_joins_vector_input() {
|
||||
let payload = ToolPayload::LocalShell {
|
||||
params: ShellToolCallParams {
|
||||
command: vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"printf hi".to_string(),
|
||||
],
|
||||
workdir: None,
|
||||
timeout_ms: None,
|
||||
sandbox_permissions: None,
|
||||
prefix_rule: None,
|
||||
additional_permissions: None,
|
||||
justification: None,
|
||||
},
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
pre_tool_use_command("local_shell", &payload),
|
||||
Some("bash -lc 'printf hi'".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_command_uses_raw_exec_command_input() {
|
||||
let payload = ToolPayload::Function {
|
||||
arguments: serde_json::json!({ "cmd": "printf exec command" }).to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
pre_tool_use_command("exec_command", &payload),
|
||||
Some("printf exec command".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_command_skips_non_shell_tools() {
|
||||
let payload = ToolPayload::Function {
|
||||
arguments: serde_json::json!({
|
||||
"plan": [{ "step": "watch the tide", "status": "pending" }]
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(pre_tool_use_command("update_plan", &payload), None);
|
||||
}
|
||||
|
||||
@@ -178,23 +178,41 @@ fn resume_agent_output_schema() -> JsonValue {
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_output_schema() -> JsonValue {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "object",
|
||||
"description": "Final statuses keyed by canonical task name when available, otherwise by agent id.",
|
||||
"additionalProperties": agent_status_output_schema()
|
||||
fn wait_output_schema(multi_agent_v2: bool) -> JsonValue {
|
||||
if multi_agent_v2 {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Brief wait summary without the agent's final content."
|
||||
},
|
||||
"timed_out": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the wait call returned due to timeout before any agent reached a final status."
|
||||
}
|
||||
},
|
||||
"timed_out": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the wait call returned due to timeout before any agent reached a final status."
|
||||
}
|
||||
},
|
||||
"required": ["status", "timed_out"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
"required": ["message", "timed_out"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "object",
|
||||
"description": "Final statuses keyed by canonical task name when available, otherwise by agent id.",
|
||||
"additionalProperties": agent_status_output_schema()
|
||||
},
|
||||
"timed_out": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the wait call returned due to timeout before any agent reached a final status."
|
||||
}
|
||||
},
|
||||
"required": ["status", "timed_out"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn close_agent_output_schema() -> JsonValue {
|
||||
@@ -1422,7 +1440,7 @@ fn create_resume_agent_tool() -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_wait_agent_tool() -> ToolSpec {
|
||||
fn create_wait_agent_tool(multi_agent_v2: bool) -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"targets".to_string(),
|
||||
@@ -1445,8 +1463,13 @@ fn create_wait_agent_tool() -> ToolSpec {
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "wait_agent".to_string(),
|
||||
description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status."
|
||||
.to_string(),
|
||||
description: if multi_agent_v2 {
|
||||
"Wait for agents to reach a final status. Returns a brief wait summary instead of the agent's final content. Returns a timeout summary when no agent reaches a final status before the deadline."
|
||||
.to_string()
|
||||
} else {
|
||||
"Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status."
|
||||
.to_string()
|
||||
},
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
@@ -1454,7 +1477,7 @@ fn create_wait_agent_tool() -> ToolSpec {
|
||||
required: Some(vec!["targets".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(wait_output_schema()),
|
||||
output_schema: Some(wait_output_schema(multi_agent_v2)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3006,7 +3029,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
}
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_wait_agent_tool(),
|
||||
create_wait_agent_tool(config.multi_agent_v2),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
|
||||
@@ -469,7 +469,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
|
||||
create_view_image_tool(config.can_request_original_image_detail),
|
||||
create_spawn_agent_tool(&config),
|
||||
create_send_input_tool(),
|
||||
create_wait_agent_tool(),
|
||||
create_wait_agent_tool(config.multi_agent_v2),
|
||||
create_close_agent_tool(),
|
||||
] {
|
||||
expected.insert(tool_name(&spec).to_string(), spec);
|
||||
@@ -607,8 +607,8 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
|
||||
.as_ref()
|
||||
.expect("wait_agent should define output schema");
|
||||
assert_eq!(
|
||||
output_schema["properties"]["status"]["description"],
|
||||
json!("Final statuses keyed by canonical task name when available, otherwise by agent id.")
|
||||
output_schema["properties"]["message"]["description"],
|
||||
json!("Brief wait summary without the agent's final content.")
|
||||
);
|
||||
assert_lacks_tool_name(&tools, "resume_agent");
|
||||
}
|
||||
|
||||
@@ -723,6 +723,7 @@ impl TestCodex {
|
||||
final_output_json_schema: None,
|
||||
cwd: self.config.cwd.clone(),
|
||||
approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -310,6 +310,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff(
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
@@ -919,6 +920,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<(
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
@@ -1000,6 +1002,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() ->
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
@@ -1151,6 +1154,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff(
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
@@ -1215,6 +1219,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change(
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
@@ -1287,6 +1292,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
@@ -1359,6 +1365,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
|
||||
@@ -558,6 +558,7 @@ async fn submit_turn(
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -1310,6 +1310,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re
|
||||
}],
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
model: session_configured.model.clone(),
|
||||
effort: Some(ReasoningEffort::Low),
|
||||
@@ -1427,6 +1428,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default()
|
||||
}],
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
model: session_configured.model,
|
||||
effort: None,
|
||||
|
||||
@@ -2319,6 +2319,7 @@ text(
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
|
||||
@@ -178,6 +178,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> {
|
||||
}],
|
||||
cwd: test.config.cwd.clone(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: test.config.permissions.sandbox_policy.get().clone(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
@@ -293,6 +294,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu
|
||||
}],
|
||||
cwd: test.config.cwd.clone(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: test.config.permissions.sandbox_policy.get().clone(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
|
||||
@@ -1658,6 +1658,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() {
|
||||
final_output_json_schema: None,
|
||||
cwd: resumed.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: resumed.session_configured.model.clone(),
|
||||
effort: None,
|
||||
@@ -1748,6 +1749,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: previous_model.to_string(),
|
||||
effort: None,
|
||||
@@ -1772,6 +1774,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: next_model.to_string(),
|
||||
effort: None,
|
||||
@@ -1882,6 +1885,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() {
|
||||
final_output_json_schema: None,
|
||||
cwd: initial.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: previous_model.to_string(),
|
||||
effort: None,
|
||||
@@ -1930,6 +1934,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() {
|
||||
final_output_json_schema: None,
|
||||
cwd: resumed.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: next_model.to_string(),
|
||||
effort: None,
|
||||
@@ -3132,6 +3137,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: previous_model.to_string(),
|
||||
effort: None,
|
||||
@@ -3156,6 +3162,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: next_model.to_string(),
|
||||
effort: None,
|
||||
|
||||
@@ -16,6 +16,9 @@ use codex_core::ThreadManager;
|
||||
use codex_core::compact::SUMMARIZATION_PROMPT;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
@@ -502,6 +505,143 @@ async fn snapshot_rollback_past_compaction_replays_append_only_history() -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
/// Scenario: rolling back a turn that introduced persistent pre-turn context
|
||||
/// diffs currently duplicates those context updates on the next request.
|
||||
async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result<()> {
|
||||
if network_disabled() {
|
||||
println!("Skipping test because network is disabled in this sandbox");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
const MODEL: &str = "gpt-5.1-codex";
|
||||
const TURN_ONE_USER: &str = "turn 1 user";
|
||||
const TURN_TWO_USER: &str = "turn 2 user";
|
||||
const FOLLOWUP_USER: &str = "follow-up user";
|
||||
const ROLLED_BACK_DEV_INSTRUCTIONS: &str = "ROLLED_BACK_DEV_INSTRUCTIONS";
|
||||
const PRETURN_CONTEXT_DIFF_CWD: &str = "PRETURN_CONTEXT_DIFF_CWD";
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let request_log = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_assistant_message("m1", "turn 1 assistant"),
|
||||
ev_completed("r1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("m2", "turn 2 assistant"),
|
||||
ev_completed("r2"),
|
||||
]),
|
||||
sse(vec![ev_completed("r3")]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let (_home, config, _manager, conversation) =
|
||||
start_test_conversation(&server, Some(MODEL)).await;
|
||||
|
||||
user_turn(&conversation, TURN_ONE_USER).await;
|
||||
|
||||
let override_cwd = config.cwd.join(PRETURN_CONTEXT_DIFF_CWD);
|
||||
std::fs::create_dir_all(&override_cwd)?;
|
||||
conversation
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: Some(override_cwd),
|
||||
approval_policy: None,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: None,
|
||||
windows_sandbox_level: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
service_tier: None,
|
||||
collaboration_mode: Some(CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
settings: Settings {
|
||||
model: MODEL.to_string(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: Some(ROLLED_BACK_DEV_INSTRUCTIONS.to_string()),
|
||||
},
|
||||
}),
|
||||
personality: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
user_turn(&conversation, TURN_TWO_USER).await;
|
||||
|
||||
conversation
|
||||
.submit(Op::ThreadRollback { num_turns: 1 })
|
||||
.await?;
|
||||
let rollback_event = wait_for_event(&conversation, |ev| {
|
||||
matches!(ev, EventMsg::ThreadRolledBack(_))
|
||||
})
|
||||
.await;
|
||||
let EventMsg::ThreadRolledBack(rollback_event) = rollback_event else {
|
||||
panic!("expected thread rolled back event");
|
||||
};
|
||||
assert_eq!(rollback_event.num_turns, 1);
|
||||
|
||||
user_turn(&conversation, FOLLOWUP_USER).await;
|
||||
|
||||
let requests = request_log.requests();
|
||||
assert_eq!(requests.len(), 3);
|
||||
|
||||
assert_eq!(
|
||||
requests[1]
|
||||
.message_input_texts("developer")
|
||||
.iter()
|
||||
.filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
requests[1]
|
||||
.message_input_texts("user")
|
||||
.iter()
|
||||
.filter(|text| text.contains(PRETURN_CONTEXT_DIFF_CWD))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
requests[2]
|
||||
.message_input_texts("developer")
|
||||
.iter()
|
||||
.filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
|
||||
let after_rollback_user_texts = requests[2].message_input_texts("user");
|
||||
assert_eq!(
|
||||
after_rollback_user_texts
|
||||
.iter()
|
||||
.filter(|text| text.contains(PRETURN_CONTEXT_DIFF_CWD))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
after_rollback_user_texts.last().map(String::as_str),
|
||||
Some(FOLLOWUP_USER)
|
||||
);
|
||||
|
||||
insta::assert_snapshot!(
|
||||
"rollback_followup_turn_duplicates_context_updates",
|
||||
context_snapshot::format_labeled_requests_snapshot(
|
||||
"rollback currently duplicates pre-turn override context updates on the follow-up request",
|
||||
&[
|
||||
("rolled-back turn request", &requests[1]),
|
||||
("follow-up request after rollback", &requests[2]),
|
||||
],
|
||||
&ContextSnapshotOptions::default()
|
||||
.strip_capability_instructions()
|
||||
.render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 }),
|
||||
)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_line_endings(value: &mut Value) {
|
||||
match value {
|
||||
Value::String(text) => {
|
||||
|
||||
@@ -51,6 +51,7 @@ async fn submit_user_turn(
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -131,6 +132,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -174,6 +174,69 @@ if payload.get("prompt") == {blocked_prompt_json}:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_pre_tool_use_hook(
|
||||
home: &Path,
|
||||
matcher: Option<&str>,
|
||||
mode: &str,
|
||||
reason: &str,
|
||||
) -> Result<()> {
|
||||
let script_path = home.join("pre_tool_use_hook.py");
|
||||
let log_path = home.join("pre_tool_use_hook_log.jsonl");
|
||||
let mode_json = serde_json::to_string(mode).context("serialize pre tool use mode")?;
|
||||
let reason_json = serde_json::to_string(reason).context("serialize pre tool use reason")?;
|
||||
let script = format!(
|
||||
r#"import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
log_path = Path(r"{log_path}")
|
||||
mode = {mode_json}
|
||||
reason = {reason_json}
|
||||
|
||||
payload = json.load(sys.stdin)
|
||||
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(payload) + "\n")
|
||||
|
||||
if mode == "json_deny":
|
||||
print(json.dumps({{
|
||||
"hookSpecificOutput": {{
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": reason
|
||||
}}
|
||||
}}))
|
||||
elif mode == "exit_2":
|
||||
sys.stderr.write(reason + "\n")
|
||||
raise SystemExit(2)
|
||||
"#,
|
||||
log_path = log_path.display(),
|
||||
mode_json = mode_json,
|
||||
reason_json = reason_json,
|
||||
);
|
||||
|
||||
let mut group = serde_json::json!({
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": format!("python3 {}", script_path.display()),
|
||||
"statusMessage": "running pre tool use hook",
|
||||
}]
|
||||
});
|
||||
if let Some(matcher) = matcher {
|
||||
group["matcher"] = Value::String(matcher.to_string());
|
||||
}
|
||||
|
||||
let hooks = serde_json::json!({
|
||||
"hooks": {
|
||||
"PreToolUse": [group]
|
||||
}
|
||||
});
|
||||
|
||||
fs::write(&script_path, script).context("write pre tool use hook script")?;
|
||||
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_session_start_hook_recording_transcript(home: &Path) -> Result<()> {
|
||||
let script_path = home.join("session_start_hook.py");
|
||||
let log_path = home.join("session_start_hook_log.jsonl");
|
||||
@@ -253,6 +316,15 @@ fn read_stop_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_pre_tool_use_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
||||
fs::read_to_string(home.join("pre_tool_use_hook_log.jsonl"))
|
||||
.context("read pre tool use hook log")?
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| serde_json::from_str(line).context("parse pre tool use hook log line"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_session_start_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
||||
fs::read_to_string(home.join("session_start_hook_log.jsonl"))
|
||||
.context("read session start hook log")?
|
||||
@@ -849,3 +921,357 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu
|
||||
server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "pretooluse-shell-command";
|
||||
let marker = std::env::temp_dir().join("pretooluse-shell-command-marker");
|
||||
let command = format!("printf blocked > {}", marker.display());
|
||||
let args = serde_json::json!({ "command": command });
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_function_call(
|
||||
call_id,
|
||||
"shell_command",
|
||||
&serde_json::to_string(&args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "hook blocked it"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
if let Err(error) =
|
||||
write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked by pre hook")
|
||||
{
|
||||
panic!("failed to write pre tool use hook test fixture: {error}");
|
||||
}
|
||||
})
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
if marker.exists() {
|
||||
fs::remove_file(&marker).context("remove leftover pre tool use marker")?;
|
||||
}
|
||||
|
||||
test.submit_turn_with_policy(
|
||||
"run the blocked shell command",
|
||||
codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let output_item = requests[1].function_call_output(call_id);
|
||||
let output = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("shell command output string");
|
||||
assert!(
|
||||
output.contains("Bash command blocked by hook: blocked by pre hook"),
|
||||
"blocked tool output should surface the hook reason",
|
||||
);
|
||||
assert!(
|
||||
output.contains(&format!("Command: {command}")),
|
||||
"blocked tool output should surface the blocked command",
|
||||
);
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"blocked command should not create marker file"
|
||||
);
|
||||
|
||||
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
|
||||
assert_eq!(hook_inputs.len(), 1);
|
||||
assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse");
|
||||
assert_eq!(hook_inputs[0]["tool_name"], "Bash");
|
||||
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
||||
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
|
||||
let transcript_path = hook_inputs[0]["transcript_path"]
|
||||
.as_str()
|
||||
.expect("pre tool use hook transcript_path");
|
||||
assert!(
|
||||
!transcript_path.is_empty(),
|
||||
"pre tool use hook should receive a non-empty transcript_path",
|
||||
);
|
||||
assert!(
|
||||
Path::new(transcript_path).exists(),
|
||||
"pre tool use hook transcript_path should be materialized on disk",
|
||||
);
|
||||
assert!(
|
||||
hook_inputs[0]["turn_id"]
|
||||
.as_str()
|
||||
.is_some_and(|turn_id| !turn_id.is_empty())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "pretooluse-local-shell";
|
||||
let marker = std::env::temp_dir().join("pretooluse-local-shell-marker");
|
||||
let command = vec![
|
||||
"/bin/sh".to_string(),
|
||||
"-c".to_string(),
|
||||
format!("printf blocked > {}", marker.display()),
|
||||
];
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_local_shell_call(
|
||||
call_id,
|
||||
"completed",
|
||||
command.iter().map(String::as_str).collect(),
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "local shell blocked"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
if let Err(error) =
|
||||
write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked local shell")
|
||||
{
|
||||
panic!("failed to write pre tool use hook test fixture: {error}");
|
||||
}
|
||||
})
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
if marker.exists() {
|
||||
fs::remove_file(&marker).context("remove leftover local shell marker")?;
|
||||
}
|
||||
|
||||
test.submit_turn("run the blocked local shell command")
|
||||
.await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let output_item = requests[1].function_call_output(call_id);
|
||||
let output = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("local shell output string");
|
||||
assert!(
|
||||
output.contains("Bash command blocked by hook: blocked local shell"),
|
||||
"blocked local shell output should surface the hook reason",
|
||||
);
|
||||
assert!(
|
||||
output.contains(&format!(
|
||||
"Command: {}",
|
||||
codex_shell_command::parse_command::shlex_join(&command)
|
||||
)),
|
||||
"blocked local shell output should surface the blocked command",
|
||||
);
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"blocked local shell command should not execute"
|
||||
);
|
||||
|
||||
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
|
||||
assert_eq!(hook_inputs.len(), 1);
|
||||
assert_eq!(
|
||||
hook_inputs[0]["tool_input"]["command"],
|
||||
codex_shell_command::parse_command::shlex_join(&command),
|
||||
);
|
||||
assert!(
|
||||
hook_inputs[0]["turn_id"]
|
||||
.as_str()
|
||||
.is_some_and(|turn_id| !turn_id.is_empty())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn pre_tool_use_blocks_exec_command_before_execution() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "pretooluse-exec-command";
|
||||
let marker = std::env::temp_dir().join("pretooluse-exec-command-marker");
|
||||
let command = format!("printf blocked > {}", marker.display());
|
||||
let args = serde_json::json!({ "cmd": command });
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_function_call(
|
||||
call_id,
|
||||
"exec_command",
|
||||
&serde_json::to_string(&args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "exec command blocked"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
if let Err(error) =
|
||||
write_pre_tool_use_hook(home, Some("^Bash$"), "exit_2", "blocked exec command")
|
||||
{
|
||||
panic!("failed to write pre tool use hook test fixture: {error}");
|
||||
}
|
||||
})
|
||||
.with_config(|config| {
|
||||
config.use_experimental_unified_exec_tool = true;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::UnifiedExec)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
if marker.exists() {
|
||||
fs::remove_file(&marker).context("remove leftover exec marker")?;
|
||||
}
|
||||
|
||||
test.submit_turn("run the blocked exec command").await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let output_item = requests[1].function_call_output(call_id);
|
||||
let output = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("exec command output string");
|
||||
assert!(
|
||||
output.contains("Bash command blocked by hook: blocked exec command"),
|
||||
"blocked exec command output should surface the hook reason",
|
||||
);
|
||||
assert!(
|
||||
output.contains(&format!("Command: {command}")),
|
||||
"blocked exec command output should surface the blocked command",
|
||||
);
|
||||
assert!(!marker.exists(), "blocked exec command should not execute");
|
||||
|
||||
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
|
||||
assert_eq!(hook_inputs.len(), 1);
|
||||
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
||||
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
|
||||
assert!(
|
||||
hook_inputs[0]["turn_id"]
|
||||
.as_str()
|
||||
.is_some_and(|turn_id| !turn_id.is_empty())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn pre_tool_use_does_not_fire_for_non_shell_tools() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "pretooluse-update-plan";
|
||||
let args = serde_json::json!({
|
||||
"plan": [{
|
||||
"step": "watch the tide",
|
||||
"status": "pending",
|
||||
}]
|
||||
});
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_function_call(
|
||||
call_id,
|
||||
"update_plan",
|
||||
&serde_json::to_string(&args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "plan updated"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
if let Err(error) = write_pre_tool_use_hook(home, None, "json_deny", "should not fire")
|
||||
{
|
||||
panic!("failed to write pre tool use hook test fixture: {error}");
|
||||
}
|
||||
})
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.submit_turn("update the plan").await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let output_item = requests[1].function_call_output(call_id);
|
||||
let output = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("update plan output string");
|
||||
assert!(
|
||||
!output.contains("should not fire"),
|
||||
"non-shell tool output should not be blocked by PreToolUse",
|
||||
);
|
||||
|
||||
let hook_log_path = test.codex_home_path().join("pre_tool_use_hook_log.jsonl");
|
||||
assert!(
|
||||
!hook_log_path.exists(),
|
||||
"non-shell tools should not trigger pre tool use hooks",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -205,6 +206,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -522,6 +522,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result<
|
||||
final_output_json_schema: None,
|
||||
cwd: std::env::current_dir()?,
|
||||
approval_policy: codex_protocol::protocol::AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
model: session_configured.model.clone(),
|
||||
effort: None,
|
||||
@@ -598,6 +599,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: std::env::current_dir()?,
|
||||
approval_policy: codex_protocol::protocol::AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
model: session_configured.model.clone(),
|
||||
effort: None,
|
||||
@@ -706,6 +708,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done
|
||||
final_output_json_schema: None,
|
||||
cwd: std::env::current_dir()?,
|
||||
approval_policy: codex_protocol::protocol::AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
model: session_configured.model.clone(),
|
||||
effort: None,
|
||||
@@ -892,6 +895,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p
|
||||
final_output_json_schema: None,
|
||||
cwd: std::env::current_dir()?,
|
||||
approval_policy: codex_protocol::protocol::AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
model: session_configured.model.clone(),
|
||||
effort: None,
|
||||
@@ -1005,6 +1009,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: std::env::current_dir()?,
|
||||
approval_policy: codex_protocol::protocol::AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
model: session_configured.model.clone(),
|
||||
effort: None,
|
||||
|
||||
@@ -80,6 +80,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> {
|
||||
final_output_json_schema: Some(serde_json::from_str(SCHEMA)?),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
|
||||
@@ -61,6 +61,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str)
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -126,6 +126,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<(
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -162,6 +163,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<(
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: next_model.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -221,6 +223,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -257,6 +260,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: next_model.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -398,6 +402,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: image_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -418,6 +423,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: text_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -525,6 +531,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: image_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -545,6 +552,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: image_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -655,6 +663,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: image_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -675,6 +684,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: text_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -787,6 +797,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history()
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: image_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -815,6 +826,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history()
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: image_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -969,6 +981,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: large_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -1027,6 +1040,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: smaller_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
|
||||
@@ -121,6 +121,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -144,6 +145,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: preturn_context_diff_cwd,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -222,6 +224,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd_one.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -245,6 +248,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd_two,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -354,6 +358,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul
|
||||
final_output_json_schema: None,
|
||||
cwd: resume_override_cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: resumed.session_configured.model.clone(),
|
||||
effort: resumed.config.model_reasoning_effort,
|
||||
|
||||
@@ -96,6 +96,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: codex_protocol::protocol::AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
|
||||
@@ -102,6 +102,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -973,6 +973,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() {
|
||||
.features
|
||||
.disable(Feature::GhostCommit)
|
||||
.expect("test config should allow feature update");
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -989,7 +990,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
logs_assert(|lines: &[&str]| {
|
||||
let line = lines
|
||||
|
||||
@@ -103,6 +103,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -153,6 +154,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result<
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -210,6 +212,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -273,6 +276,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -324,6 +328,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -361,6 +366,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -427,6 +433,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -464,6 +471,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -543,6 +551,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -580,6 +589,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -696,6 +706,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: remote_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -814,6 +825,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: remote_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -851,6 +863,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: remote_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
|
||||
@@ -703,6 +703,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
|
||||
}],
|
||||
cwd: new_cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: new_policy.clone(),
|
||||
model: "o3".to_string(),
|
||||
effort: Some(ReasoningEffort::High),
|
||||
@@ -815,6 +816,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
|
||||
}],
|
||||
cwd: default_cwd.clone(),
|
||||
approval_policy: default_approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: default_sandbox_policy.clone(),
|
||||
model: default_model.clone(),
|
||||
effort: default_effort,
|
||||
@@ -835,6 +837,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
|
||||
}],
|
||||
cwd: default_cwd.clone(),
|
||||
approval_policy: default_approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: default_sandbox_policy.clone(),
|
||||
model: default_model.clone(),
|
||||
effort: default_effort,
|
||||
@@ -939,6 +942,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
|
||||
}],
|
||||
cwd: default_cwd.clone(),
|
||||
approval_policy: default_approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: default_sandbox_policy.clone(),
|
||||
model: default_model,
|
||||
effort: default_effort,
|
||||
@@ -959,6 +963,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
|
||||
}],
|
||||
cwd: default_cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: "o3".to_string(),
|
||||
effort: Some(ReasoningEffort::High),
|
||||
|
||||
@@ -173,6 +173,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<(
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
model: requested_model.to_string(),
|
||||
effort: None,
|
||||
@@ -231,6 +232,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning()
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
model: requested_model.to_string(),
|
||||
effort: None,
|
||||
@@ -394,6 +396,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REMOTE_MODEL_SLUG.to_string(),
|
||||
effort: None,
|
||||
@@ -612,6 +615,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: model.to_string(),
|
||||
effort: None,
|
||||
|
||||
@@ -193,6 +193,7 @@ async fn submit_turn(
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -145,6 +145,7 @@ async fn submit_turn(
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -138,6 +138,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -255,6 +256,7 @@ where
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -129,6 +129,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -298,6 +299,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -501,6 +503,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: text_only_model_slug.to_string(),
|
||||
effort: None,
|
||||
@@ -615,6 +618,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -776,6 +780,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1022,6 +1027,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -45,6 +45,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() ->
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REQUESTED_MODEL.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -143,6 +144,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REQUESTED_MODEL.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -228,6 +230,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REQUESTED_MODEL.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -277,6 +280,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REQUESTED_MODEL.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
|
||||
@@ -164,6 +164,7 @@ async fn run_snapshot_command_with_options(
|
||||
final_output_json_schema: None,
|
||||
cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -254,6 +255,7 @@ async fn run_shell_command_snapshot_with_options(
|
||||
final_output_json_schema: None,
|
||||
cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -324,6 +326,7 @@ async fn run_tool_turn_on_harness(
|
||||
final_output_json_schema: None,
|
||||
cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -557,6 +560,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
|
||||
@@ -65,6 +65,7 @@ async fn submit_turn_with_policies(
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy,
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
|
||||
@@ -74,6 +74,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
---
|
||||
source: core/tests/suite/compact_resume_fork.rs
|
||||
expression: "context_snapshot::format_labeled_requests_snapshot(\"rollback currently duplicates pre-turn override context updates on the follow-up request\",\n&[(\"rolled-back turn request\", &requests[1]),\n(\"follow-up request after rollback\", &requests[2]),],\n&ContextSnapshotOptions::default().strip_capability_instructions().render_mode(ContextSnapshotRenderMode::KindWithTextPrefix\n{ max_chars: 96 }),)"
|
||||
---
|
||||
Scenario: rollback currently duplicates pre-turn override context updates on the follow-up request
|
||||
|
||||
## rolled-back turn request
|
||||
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
02:message/user:turn 1 user
|
||||
03:message/assistant:turn 1 assistant
|
||||
04:message/developer:<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>
|
||||
05:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
|
||||
06:message/user:turn 2 user
|
||||
|
||||
## follow-up request after rollback
|
||||
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
02:message/user:turn 1 user
|
||||
03:message/assistant:turn 1 assistant
|
||||
04:message/developer:<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>
|
||||
05:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
|
||||
06:message/developer:<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>
|
||||
07:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
|
||||
08:message/user:follow-up user
|
||||
@@ -395,6 +395,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result<
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
|
||||
@@ -85,6 +85,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -155,6 +156,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -235,6 +237,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -330,6 +333,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<()
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -433,6 +437,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -42,6 +42,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -358,6 +359,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -486,6 +486,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -192,6 +192,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -325,6 +326,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -407,6 +409,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -492,6 +495,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -589,6 +593,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -668,6 +673,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -748,6 +754,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -882,6 +889,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1023,6 +1031,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<(
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1187,6 +1196,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1289,6 +1299,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1411,6 +1422,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1505,6 +1517,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1590,6 +1603,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1725,6 +1739,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1897,6 +1912,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<()
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1978,6 +1994,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -2070,6 +2087,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -2178,6 +2196,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -2317,6 +2336,7 @@ PY
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -2435,6 +2455,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -2535,6 +2556,7 @@ PY
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -2634,6 +2656,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
// Important!
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
@@ -2743,6 +2766,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -2842,6 +2866,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -2981,6 +3006,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -176,6 +176,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: fixture.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: fixture.session_configured.model.clone(),
|
||||
effort: None,
|
||||
|
||||
@@ -170,6 +170,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -278,6 +279,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -418,6 +420,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -516,6 +519,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -607,6 +611,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -706,6 +711,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -816,6 +822,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -917,6 +924,7 @@ await codex.emitImage(out);
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1036,6 +1044,7 @@ console.log(out.type);
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1128,6 +1137,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1203,6 +1213,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1283,6 +1294,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
@@ -1405,6 +1417,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: model_slug.to_string(),
|
||||
effort: None,
|
||||
@@ -1479,6 +1492,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()>
|
||||
final_output_json_schema: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
|
||||
@@ -156,6 +156,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_configured.model.clone(),
|
||||
effort: None,
|
||||
|
||||
@@ -65,7 +65,7 @@ Since only 1 `Task` can be run at a time, for parallel tasks it is recommended t
|
||||
For complete documentation of the `Op` and `EventMsg` variants, refer to [protocol.rs](../protocol/src/protocol.rs). Some example payload types:
|
||||
|
||||
- `Op`
|
||||
- `Op::UserTurn` – Any input from the user to kick off a `Turn`
|
||||
- `Op::UserTurn` – Any input from the user to kick off a `Turn`, including full per-turn context such as cwd, model, sandbox, approval policy, and optional `approvals_reviewer`
|
||||
- `Op::UserInput` – Legacy form of user input
|
||||
- `Op::Interrupt` – Interrupts a running turn
|
||||
- `Op::ExecApproval` – Approve or deny code execution
|
||||
|
||||
@@ -988,6 +988,7 @@ impl EventProcessorWithHumanOutput {
|
||||
|
||||
fn hook_event_name(event_name: HookEventName) -> &'static str {
|
||||
match event_name {
|
||||
HookEventName::PreToolUse => "PreToolUse",
|
||||
HookEventName::SessionStart => "SessionStart",
|
||||
HookEventName::UserPromptSubmit => "UserPromptSubmit",
|
||||
HookEventName::Stop => "Stop",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"NullableString": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"PreToolUseToolInput": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": "string"
|
||||
},
|
||||
"hook_event_name": {
|
||||
"const": "PreToolUse",
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"permission_mode": {
|
||||
"enum": [
|
||||
"default",
|
||||
"acceptEdits",
|
||||
"plan",
|
||||
"dontAsk",
|
||||
"bypassPermissions"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"tool_input": {
|
||||
"$ref": "#/definitions/PreToolUseToolInput"
|
||||
},
|
||||
"tool_name": {
|
||||
"const": "Bash",
|
||||
"type": "string"
|
||||
},
|
||||
"tool_use_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"transcript_path": {
|
||||
"$ref": "#/definitions/NullableString"
|
||||
},
|
||||
"turn_id": {
|
||||
"description": "Codex extension: expose the active turn id to internal turn-scoped hooks.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cwd",
|
||||
"hook_event_name",
|
||||
"model",
|
||||
"permission_mode",
|
||||
"session_id",
|
||||
"tool_input",
|
||||
"tool_name",
|
||||
"tool_use_id",
|
||||
"transcript_path",
|
||||
"turn_id"
|
||||
],
|
||||
"title": "pre-tool-use.command.input",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"HookEventNameWire": {
|
||||
"enum": [
|
||||
"PreToolUse",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"Stop"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PreToolUseDecisionWire": {
|
||||
"enum": [
|
||||
"approve",
|
||||
"block"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PreToolUseHookSpecificOutputWire": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"additionalContext": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
},
|
||||
"hookEventName": {
|
||||
"$ref": "#/definitions/HookEventNameWire"
|
||||
},
|
||||
"permissionDecision": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PreToolUsePermissionDecisionWire"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"permissionDecisionReason": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
},
|
||||
"updatedInput": {
|
||||
"default": null
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"hookEventName"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PreToolUsePermissionDecisionWire": {
|
||||
"enum": [
|
||||
"allow",
|
||||
"deny",
|
||||
"ask"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"continue": {
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"decision": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PreToolUseDecisionWire"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"hookSpecificOutput": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PreToolUseHookSpecificOutputWire"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"reason": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
},
|
||||
"stopReason": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
},
|
||||
"suppressOutput": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"systemMessage": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "pre-tool-use.command.output",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"definitions": {
|
||||
"HookEventNameWire": {
|
||||
"enum": [
|
||||
"PreToolUse",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"Stop"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"HookEventNameWire": {
|
||||
"enum": [
|
||||
"PreToolUse",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"Stop"
|
||||
|
||||
@@ -8,6 +8,8 @@ pub(crate) struct HooksFile {
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct HookEvents {
|
||||
#[serde(rename = "PreToolUse", default)]
|
||||
pub pre_tool_use: Vec<MatcherGroup>,
|
||||
#[serde(rename = "SessionStart", default)]
|
||||
pub session_start: Vec<MatcherGroup>,
|
||||
#[serde(rename = "UserPromptSubmit", default)]
|
||||
|
||||
@@ -3,11 +3,12 @@ use std::path::Path;
|
||||
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use regex::Regex;
|
||||
|
||||
use super::ConfiguredHandler;
|
||||
use super::config::HookHandlerConfig;
|
||||
use super::config::HooksFile;
|
||||
use crate::events::common::matcher_pattern_for_event;
|
||||
use crate::events::common::validate_matcher_pattern;
|
||||
|
||||
pub(crate) struct DiscoveryResult {
|
||||
pub handlers: Vec<ConfiguredHandler>,
|
||||
@@ -69,6 +70,21 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
||||
}
|
||||
};
|
||||
|
||||
for group in parsed.hooks.pre_tool_use {
|
||||
append_group_handlers(
|
||||
&mut handlers,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
source_path.as_path(),
|
||||
codex_protocol::protocol::HookEventName::PreToolUse,
|
||||
matcher_pattern_for_event(
|
||||
codex_protocol::protocol::HookEventName::PreToolUse,
|
||||
group.matcher.as_deref(),
|
||||
),
|
||||
group.hooks,
|
||||
);
|
||||
}
|
||||
|
||||
for group in parsed.hooks.session_start {
|
||||
append_group_handlers(
|
||||
&mut handlers,
|
||||
@@ -76,7 +92,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
||||
&mut display_order,
|
||||
source_path.as_path(),
|
||||
codex_protocol::protocol::HookEventName::SessionStart,
|
||||
effective_matcher(
|
||||
matcher_pattern_for_event(
|
||||
codex_protocol::protocol::HookEventName::SessionStart,
|
||||
group.matcher.as_deref(),
|
||||
),
|
||||
@@ -91,7 +107,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
||||
&mut display_order,
|
||||
source_path.as_path(),
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit,
|
||||
effective_matcher(
|
||||
matcher_pattern_for_event(
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit,
|
||||
group.matcher.as_deref(),
|
||||
),
|
||||
@@ -106,7 +122,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
||||
&mut display_order,
|
||||
source_path.as_path(),
|
||||
codex_protocol::protocol::HookEventName::Stop,
|
||||
effective_matcher(
|
||||
matcher_pattern_for_event(
|
||||
codex_protocol::protocol::HookEventName::Stop,
|
||||
group.matcher.as_deref(),
|
||||
),
|
||||
@@ -118,17 +134,6 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
||||
DiscoveryResult { handlers, warnings }
|
||||
}
|
||||
|
||||
fn effective_matcher(
|
||||
event_name: codex_protocol::protocol::HookEventName,
|
||||
matcher: Option<&str>,
|
||||
) -> Option<&str> {
|
||||
match event_name {
|
||||
codex_protocol::protocol::HookEventName::SessionStart => matcher,
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit
|
||||
| codex_protocol::protocol::HookEventName::Stop => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn append_group_handlers(
|
||||
handlers: &mut Vec<ConfiguredHandler>,
|
||||
warnings: &mut Vec<String>,
|
||||
@@ -139,7 +144,7 @@ fn append_group_handlers(
|
||||
group_handlers: Vec<HookHandlerConfig>,
|
||||
) {
|
||||
if let Some(matcher) = matcher
|
||||
&& let Err(err) = Regex::new(matcher)
|
||||
&& let Err(err) = validate_matcher_pattern(matcher)
|
||||
{
|
||||
warnings.push(format!(
|
||||
"invalid matcher {matcher:?} in {}: {err}",
|
||||
@@ -205,7 +210,7 @@ mod tests {
|
||||
use super::ConfiguredHandler;
|
||||
use super::HookHandlerConfig;
|
||||
use super::append_group_handlers;
|
||||
use super::effective_matcher;
|
||||
use crate::events::common::matcher_pattern_for_event;
|
||||
|
||||
#[test]
|
||||
fn user_prompt_submit_ignores_invalid_matcher_during_discovery() {
|
||||
@@ -219,7 +224,7 @@ mod tests {
|
||||
&mut display_order,
|
||||
Path::new("/tmp/hooks.json"),
|
||||
HookEventName::UserPromptSubmit,
|
||||
effective_matcher(HookEventName::UserPromptSubmit, Some("[")),
|
||||
matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("[")),
|
||||
vec![HookHandlerConfig::Command {
|
||||
command: "echo hello".to_string(),
|
||||
timeout_sec: None,
|
||||
@@ -242,4 +247,66 @@ mod tests {
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_keeps_valid_matcher_during_discovery() {
|
||||
let mut handlers = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0;
|
||||
|
||||
append_group_handlers(
|
||||
&mut handlers,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
Path::new("/tmp/hooks.json"),
|
||||
HookEventName::PreToolUse,
|
||||
matcher_pattern_for_event(HookEventName::PreToolUse, Some("^Bash$")),
|
||||
vec![HookHandlerConfig::Command {
|
||||
command: "echo hello".to_string(),
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
}],
|
||||
);
|
||||
|
||||
assert_eq!(warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
handlers,
|
||||
vec![ConfiguredHandler {
|
||||
event_name: HookEventName::PreToolUse,
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
command: "echo hello".to_string(),
|
||||
timeout_sec: 600,
|
||||
status_message: None,
|
||||
source_path: PathBuf::from("/tmp/hooks.json"),
|
||||
display_order: 0,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_treats_star_matcher_as_match_all() {
|
||||
let mut handlers = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0;
|
||||
|
||||
append_group_handlers(
|
||||
&mut handlers,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
Path::new("/tmp/hooks.json"),
|
||||
HookEventName::PreToolUse,
|
||||
matcher_pattern_for_event(HookEventName::PreToolUse, Some("*")),
|
||||
vec![HookHandlerConfig::Command {
|
||||
command: "echo hello".to_string(),
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
}],
|
||||
);
|
||||
|
||||
assert_eq!(warnings, Vec::<String>::new());
|
||||
assert_eq!(handlers.len(), 1);
|
||||
assert_eq!(handlers[0].matcher.as_deref(), Some("*"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use super::CommandShell;
|
||||
use super::ConfiguredHandler;
|
||||
use super::command_runner::CommandRunResult;
|
||||
use super::command_runner::run_command;
|
||||
use crate::events::common::matches_matcher;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ParsedHandler<T> {
|
||||
@@ -30,13 +31,9 @@ pub(crate) fn select_handlers(
|
||||
.iter()
|
||||
.filter(|handler| handler.event_name == event_name)
|
||||
.filter(|handler| match event_name {
|
||||
HookEventName::SessionStart => match (&handler.matcher, matcher_input) {
|
||||
(Some(matcher), Some(input)) => regex::Regex::new(matcher)
|
||||
.map(|regex| regex.is_match(input))
|
||||
.unwrap_or(false),
|
||||
(None, _) => true,
|
||||
_ => false,
|
||||
},
|
||||
HookEventName::PreToolUse | HookEventName::SessionStart => {
|
||||
matches_matcher(handler.matcher.as_deref(), matcher_input)
|
||||
}
|
||||
HookEventName::UserPromptSubmit | HookEventName::Stop => true,
|
||||
})
|
||||
.cloned()
|
||||
@@ -109,7 +106,9 @@ pub(crate) fn completed_summary(
|
||||
fn scope_for_event(event_name: HookEventName) -> HookScope {
|
||||
match event_name {
|
||||
HookEventName::SessionStart => HookScope::Thread,
|
||||
HookEventName::UserPromptSubmit | HookEventName::Stop => HookScope::Turn,
|
||||
HookEventName::PreToolUse | HookEventName::UserPromptSubmit | HookEventName::Stop => {
|
||||
HookScope::Turn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +171,50 @@ mod tests {
|
||||
assert_eq!(selected[1].display_order, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_matches_tool_name() {
|
||||
let handlers = vec![
|
||||
make_handler(HookEventName::PreToolUse, Some("^Bash$"), "echo same", 0),
|
||||
make_handler(HookEventName::PreToolUse, Some("^Edit$"), "echo same", 1),
|
||||
];
|
||||
|
||||
let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash"));
|
||||
|
||||
assert_eq!(selected.len(), 1);
|
||||
assert_eq!(selected[0].display_order, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_star_matcher_matches_all_tools() {
|
||||
let handlers = vec![
|
||||
make_handler(HookEventName::PreToolUse, Some("*"), "echo same", 0),
|
||||
make_handler(HookEventName::PreToolUse, Some("^Edit$"), "echo same", 1),
|
||||
];
|
||||
|
||||
let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash"));
|
||||
|
||||
assert_eq!(selected.len(), 1);
|
||||
assert_eq!(selected[0].display_order, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_regex_alternation_matches_each_tool_name() {
|
||||
let handlers = vec![make_handler(
|
||||
HookEventName::PreToolUse,
|
||||
Some("Edit|Write"),
|
||||
"echo same",
|
||||
0,
|
||||
)];
|
||||
|
||||
let selected_edit = select_handlers(&handlers, HookEventName::PreToolUse, Some("Edit"));
|
||||
let selected_write = select_handlers(&handlers, HookEventName::PreToolUse, Some("Write"));
|
||||
let selected_bash = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash"));
|
||||
|
||||
assert_eq!(selected_edit.len(), 1);
|
||||
assert_eq!(selected_write.len(), 1);
|
||||
assert_eq!(selected_bash.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_prompt_submit_ignores_matcher() {
|
||||
let handlers = vec![
|
||||
|
||||
@@ -10,6 +10,8 @@ use std::path::PathBuf;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
|
||||
use crate::events::pre_tool_use::PreToolUseOutcome;
|
||||
use crate::events::pre_tool_use::PreToolUseRequest;
|
||||
use crate::events::session_start::SessionStartOutcome;
|
||||
use crate::events::session_start::SessionStartRequest;
|
||||
use crate::events::stop::StopOutcome;
|
||||
@@ -46,6 +48,7 @@ impl ConfiguredHandler {
|
||||
|
||||
fn event_name_label(&self) -> &'static str {
|
||||
match self.event_name {
|
||||
codex_protocol::protocol::HookEventName::PreToolUse => "pre-tool-use",
|
||||
codex_protocol::protocol::HookEventName::SessionStart => "session-start",
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit",
|
||||
codex_protocol::protocol::HookEventName::Stop => "stop",
|
||||
@@ -105,6 +108,10 @@ impl ClaudeHooksEngine {
|
||||
crate::events::session_start::preview(&self.handlers, request)
|
||||
}
|
||||
|
||||
pub(crate) fn preview_pre_tool_use(&self, request: &PreToolUseRequest) -> Vec<HookRunSummary> {
|
||||
crate::events::pre_tool_use::preview(&self.handlers, request)
|
||||
}
|
||||
|
||||
pub(crate) async fn run_session_start(
|
||||
&self,
|
||||
request: SessionStartRequest,
|
||||
@@ -113,6 +120,10 @@ impl ClaudeHooksEngine {
|
||||
crate::events::session_start::run(&self.handlers, &self.shell, request, turn_id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome {
|
||||
crate::events::pre_tool_use::run(&self.handlers, &self.shell, request).await
|
||||
}
|
||||
|
||||
pub(crate) fn preview_user_prompt_submit(
|
||||
&self,
|
||||
request: &UserPromptSubmitRequest,
|
||||
|
||||
@@ -12,6 +12,13 @@ pub(crate) struct SessionStartOutput {
|
||||
pub additional_context: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PreToolUseOutput {
|
||||
pub universal: UniversalOutput,
|
||||
pub block_reason: Option<String>,
|
||||
pub invalid_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct UserPromptSubmitOutput {
|
||||
pub universal: UniversalOutput,
|
||||
@@ -31,6 +38,9 @@ pub(crate) struct StopOutput {
|
||||
|
||||
use crate::schema::BlockDecisionWire;
|
||||
use crate::schema::HookUniversalOutputWire;
|
||||
use crate::schema::PreToolUseCommandOutputWire;
|
||||
use crate::schema::PreToolUseDecisionWire;
|
||||
use crate::schema::PreToolUsePermissionDecisionWire;
|
||||
use crate::schema::SessionStartCommandOutputWire;
|
||||
use crate::schema::StopCommandOutputWire;
|
||||
use crate::schema::UserPromptSubmitCommandOutputWire;
|
||||
@@ -46,6 +56,54 @@ pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option<PreToolUseOutput> {
|
||||
let PreToolUseCommandOutputWire {
|
||||
universal: universal_wire,
|
||||
decision,
|
||||
reason,
|
||||
hook_specific_output,
|
||||
} = parse_json(stdout)?;
|
||||
let universal = UniversalOutput::from(universal_wire);
|
||||
let hook_specific_output = hook_specific_output.as_ref();
|
||||
let use_hook_specific_decision = hook_specific_output.is_some_and(|output| {
|
||||
output.permission_decision.is_some()
|
||||
|| output.permission_decision_reason.is_some()
|
||||
|| output.updated_input.is_some()
|
||||
|| output.additional_context.is_some()
|
||||
});
|
||||
let invalid_reason = unsupported_pre_tool_use_universal(&universal).or_else(|| {
|
||||
if use_hook_specific_decision {
|
||||
hook_specific_output.and_then(unsupported_pre_tool_use_hook_specific_output)
|
||||
} else {
|
||||
unsupported_pre_tool_use_legacy_decision(decision.as_ref(), reason.as_deref())
|
||||
}
|
||||
});
|
||||
let block_reason = if invalid_reason.is_none() {
|
||||
if use_hook_specific_decision {
|
||||
hook_specific_output.and_then(|output| match output.permission_decision {
|
||||
Some(PreToolUsePermissionDecisionWire::Deny) => output
|
||||
.permission_decision_reason
|
||||
.as_deref()
|
||||
.and_then(trimmed_reason),
|
||||
_ => None,
|
||||
})
|
||||
} else {
|
||||
match decision.as_ref() {
|
||||
Some(PreToolUseDecisionWire::Block) => reason.as_deref().and_then(trimmed_reason),
|
||||
Some(PreToolUseDecisionWire::Approve) | None => None,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(PreToolUseOutput {
|
||||
universal,
|
||||
block_reason,
|
||||
invalid_reason,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_user_prompt_submit(stdout: &str) -> Option<UserPromptSubmitOutput> {
|
||||
let wire: UserPromptSubmitCommandOutputWire = parse_json(stdout)?;
|
||||
let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block));
|
||||
@@ -119,3 +177,97 @@ where
|
||||
fn invalid_block_message(event_name: &str) -> String {
|
||||
format!("{event_name} hook returned decision:block without a non-empty reason")
|
||||
}
|
||||
|
||||
fn unsupported_pre_tool_use_universal(universal: &UniversalOutput) -> Option<String> {
|
||||
if !universal.continue_processing {
|
||||
Some("PreToolUse hook returned unsupported continue:false".to_string())
|
||||
} else if universal.stop_reason.is_some() {
|
||||
Some("PreToolUse hook returned unsupported stopReason".to_string())
|
||||
} else if universal.suppress_output {
|
||||
Some("PreToolUse hook returned unsupported suppressOutput".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_pre_tool_use_hook_specific_output(
|
||||
output: &crate::schema::PreToolUseHookSpecificOutputWire,
|
||||
) -> Option<String> {
|
||||
if output.updated_input.is_some() {
|
||||
Some("PreToolUse hook returned unsupported updatedInput".to_string())
|
||||
} else if output
|
||||
.additional_context
|
||||
.as_deref()
|
||||
.and_then(trimmed_reason)
|
||||
.is_some()
|
||||
{
|
||||
Some("PreToolUse hook returned unsupported additionalContext".to_string())
|
||||
} else {
|
||||
match output.permission_decision {
|
||||
Some(PreToolUsePermissionDecisionWire::Allow) => {
|
||||
Some("PreToolUse hook returned unsupported permissionDecision:allow".to_string())
|
||||
}
|
||||
Some(PreToolUsePermissionDecisionWire::Ask) => {
|
||||
Some("PreToolUse hook returned unsupported permissionDecision:ask".to_string())
|
||||
}
|
||||
Some(PreToolUsePermissionDecisionWire::Deny) => {
|
||||
if output
|
||||
.permission_decision_reason
|
||||
.as_deref()
|
||||
.and_then(trimmed_reason)
|
||||
.is_none()
|
||||
{
|
||||
Some(invalid_pre_tool_use_reason_message())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if output.permission_decision_reason.is_some() {
|
||||
Some("PreToolUse hook returned permissionDecisionReason without permissionDecision".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_pre_tool_use_legacy_decision(
|
||||
decision: Option<&PreToolUseDecisionWire>,
|
||||
reason: Option<&str>,
|
||||
) -> Option<String> {
|
||||
match decision {
|
||||
Some(PreToolUseDecisionWire::Approve) => {
|
||||
Some("PreToolUse hook returned unsupported decision:approve".to_string())
|
||||
}
|
||||
Some(PreToolUseDecisionWire::Block) => {
|
||||
if reason.and_then(trimmed_reason).is_none() {
|
||||
Some(invalid_block_message("PreToolUse"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if reason.is_some() {
|
||||
Some("PreToolUse hook returned reason without decision".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_pre_tool_use_reason_message() -> String {
|
||||
"PreToolUse hook returned permissionDecision:deny without a non-empty permissionDecisionReason"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn trimmed_reason(reason: &str) -> Option<String> {
|
||||
let trimmed = reason.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ use serde_json::Value;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct GeneratedHookSchemas {
|
||||
pub pre_tool_use_command_input: Value,
|
||||
pub pre_tool_use_command_output: Value,
|
||||
pub session_start_command_input: Value,
|
||||
pub session_start_command_output: Value,
|
||||
pub user_prompt_submit_command_input: Value,
|
||||
@@ -15,6 +17,14 @@ pub(crate) struct GeneratedHookSchemas {
|
||||
pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas {
|
||||
static SCHEMAS: OnceLock<GeneratedHookSchemas> = OnceLock::new();
|
||||
SCHEMAS.get_or_init(|| GeneratedHookSchemas {
|
||||
pre_tool_use_command_input: parse_json_schema(
|
||||
"pre-tool-use.command.input",
|
||||
include_str!("../../schema/generated/pre-tool-use.command.input.schema.json"),
|
||||
),
|
||||
pre_tool_use_command_output: parse_json_schema(
|
||||
"pre-tool-use.command.output",
|
||||
include_str!("../../schema/generated/pre-tool-use.command.output.schema.json"),
|
||||
),
|
||||
session_start_command_input: parse_json_schema(
|
||||
"session-start.command.input",
|
||||
include_str!("../../schema/generated/session-start.command.input.schema.json"),
|
||||
@@ -56,6 +66,8 @@ mod tests {
|
||||
fn loads_generated_hook_schemas() {
|
||||
let schemas = generated_hook_schemas();
|
||||
|
||||
assert_eq!(schemas.pre_tool_use_command_input["type"], "object");
|
||||
assert_eq!(schemas.pre_tool_use_command_output["type"], "object");
|
||||
assert_eq!(schemas.session_start_command_input["type"], "object");
|
||||
assert_eq!(schemas.session_start_command_output["type"], "object");
|
||||
assert_eq!(schemas.user_prompt_submit_command_input["type"], "object");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use codex_protocol::protocol::HookCompletedEvent;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookOutputEntry;
|
||||
use codex_protocol::protocol::HookOutputEntryKind;
|
||||
use codex_protocol::protocol::HookRunStatus;
|
||||
@@ -67,3 +68,113 @@ pub(crate) fn serialization_failure_hook_events(
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn matcher_pattern_for_event(
|
||||
event_name: HookEventName,
|
||||
matcher: Option<&str>,
|
||||
) -> Option<&str> {
|
||||
match event_name {
|
||||
HookEventName::PreToolUse | HookEventName::SessionStart => matcher,
|
||||
HookEventName::UserPromptSubmit | HookEventName::Stop => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_matcher_pattern(matcher: &str) -> Result<(), regex::Error> {
|
||||
if is_match_all_matcher(matcher) {
|
||||
return Ok(());
|
||||
}
|
||||
regex::Regex::new(matcher).map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn matches_matcher(matcher: Option<&str>, input: Option<&str>) -> bool {
|
||||
match matcher {
|
||||
None => true,
|
||||
Some(matcher) if is_match_all_matcher(matcher) => true,
|
||||
Some(matcher) => input
|
||||
.and_then(|input| {
|
||||
regex::Regex::new(matcher)
|
||||
.ok()
|
||||
.map(|regex| regex.is_match(input))
|
||||
})
|
||||
.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_match_all_matcher(matcher: &str) -> bool {
|
||||
matcher.is_empty() || matcher == "*"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::matcher_pattern_for_event;
|
||||
use super::matches_matcher;
|
||||
use super::validate_matcher_pattern;
|
||||
|
||||
#[test]
|
||||
fn matcher_omitted_matches_all_occurrences() {
|
||||
assert!(matches_matcher(None, Some("Bash")));
|
||||
assert!(matches_matcher(None, Some("Write")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matcher_star_matches_all_occurrences() {
|
||||
assert!(matches_matcher(Some("*"), Some("Bash")));
|
||||
assert!(matches_matcher(Some("*"), Some("Edit")));
|
||||
assert_eq!(validate_matcher_pattern("*"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matcher_empty_string_matches_all_occurrences() {
|
||||
assert!(matches_matcher(Some(""), Some("Bash")));
|
||||
assert!(matches_matcher(Some(""), Some("SessionStart")));
|
||||
assert_eq!(validate_matcher_pattern(""), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matcher_uses_regex_matching() {
|
||||
assert!(matches_matcher(Some("Edit|Write"), Some("Edit")));
|
||||
assert!(matches_matcher(Some("Edit|Write"), Some("Write")));
|
||||
assert!(!matches_matcher(Some("Edit|Write"), Some("Bash")));
|
||||
assert_eq!(validate_matcher_pattern("Edit|Write"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matcher_supports_anchored_regexes() {
|
||||
assert!(matches_matcher(Some("^Bash$"), Some("Bash")));
|
||||
assert!(!matches_matcher(Some("^Bash$"), Some("BashOutput")));
|
||||
assert_eq!(validate_matcher_pattern("^Bash$"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_regex_is_rejected() {
|
||||
assert!(validate_matcher_pattern("[").is_err());
|
||||
assert!(!matches_matcher(Some("["), Some("Bash")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_events_ignore_matchers() {
|
||||
assert_eq!(
|
||||
matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("^hello")),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
matcher_pattern_for_event(HookEventName::Stop, Some("^done$")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_events_keep_matchers() {
|
||||
assert_eq!(
|
||||
matcher_pattern_for_event(HookEventName::PreToolUse, Some("Bash")),
|
||||
Some("Bash")
|
||||
);
|
||||
assert_eq!(
|
||||
matcher_pattern_for_event(HookEventName::SessionStart, Some("startup|resume")),
|
||||
Some("startup|resume")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod common;
|
||||
pub(crate) mod common;
|
||||
pub mod pre_tool_use;
|
||||
pub mod session_start;
|
||||
pub mod stop;
|
||||
pub mod user_prompt_submit;
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::HookCompletedEvent;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookOutputEntry;
|
||||
use codex_protocol::protocol::HookOutputEntryKind;
|
||||
use codex_protocol::protocol::HookRunStatus;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
|
||||
use super::common;
|
||||
use crate::engine::CommandShell;
|
||||
use crate::engine::ConfiguredHandler;
|
||||
use crate::engine::command_runner::CommandRunResult;
|
||||
use crate::engine::dispatcher;
|
||||
use crate::engine::output_parser;
|
||||
use crate::schema::PreToolUseCommandInput;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreToolUseRequest {
|
||||
pub session_id: ThreadId,
|
||||
pub turn_id: String,
|
||||
pub cwd: PathBuf,
|
||||
pub transcript_path: Option<PathBuf>,
|
||||
pub model: String,
|
||||
pub permission_mode: String,
|
||||
pub tool_name: String,
|
||||
pub tool_use_id: String,
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PreToolUseOutcome {
|
||||
pub hook_events: Vec<HookCompletedEvent>,
|
||||
pub should_block: bool,
|
||||
pub block_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct PreToolUseHandlerData {
|
||||
should_block: bool,
|
||||
block_reason: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn preview(
|
||||
handlers: &[ConfiguredHandler],
|
||||
request: &PreToolUseRequest,
|
||||
) -> Vec<HookRunSummary> {
|
||||
dispatcher::select_handlers(
|
||||
handlers,
|
||||
HookEventName::PreToolUse,
|
||||
Some(&request.tool_name),
|
||||
)
|
||||
.into_iter()
|
||||
.map(|handler| dispatcher::running_summary(&handler))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn run(
|
||||
handlers: &[ConfiguredHandler],
|
||||
shell: &CommandShell,
|
||||
request: PreToolUseRequest,
|
||||
) -> PreToolUseOutcome {
|
||||
let matched = dispatcher::select_handlers(
|
||||
handlers,
|
||||
HookEventName::PreToolUse,
|
||||
Some(&request.tool_name),
|
||||
);
|
||||
if matched.is_empty() {
|
||||
return PreToolUseOutcome {
|
||||
hook_events: Vec::new(),
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
};
|
||||
}
|
||||
|
||||
let input_json = match serde_json::to_string(&PreToolUseCommandInput {
|
||||
session_id: request.session_id.to_string(),
|
||||
turn_id: request.turn_id.clone(),
|
||||
transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),
|
||||
cwd: request.cwd.display().to_string(),
|
||||
hook_event_name: "PreToolUse".to_string(),
|
||||
model: request.model.clone(),
|
||||
permission_mode: request.permission_mode.clone(),
|
||||
tool_name: "Bash".to_string(),
|
||||
tool_input: crate::schema::PreToolUseToolInput {
|
||||
command: request.command.clone(),
|
||||
},
|
||||
tool_use_id: request.tool_use_id.clone(),
|
||||
}) {
|
||||
Ok(input_json) => input_json,
|
||||
Err(error) => {
|
||||
return serialization_failure_outcome(common::serialization_failure_hook_events(
|
||||
matched,
|
||||
Some(request.turn_id),
|
||||
format!("failed to serialize pre tool use hook input: {error}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
matched,
|
||||
input_json,
|
||||
request.cwd.as_path(),
|
||||
Some(request.turn_id),
|
||||
parse_completed,
|
||||
)
|
||||
.await;
|
||||
|
||||
let should_block = results.iter().any(|result| result.data.should_block);
|
||||
let block_reason = results
|
||||
.iter()
|
||||
.find_map(|result| result.data.block_reason.clone());
|
||||
|
||||
PreToolUseOutcome {
|
||||
hook_events: results.into_iter().map(|result| result.completed).collect(),
|
||||
should_block,
|
||||
block_reason,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_completed(
|
||||
handler: &ConfiguredHandler,
|
||||
run_result: CommandRunResult,
|
||||
turn_id: Option<String>,
|
||||
) -> dispatcher::ParsedHandler<PreToolUseHandlerData> {
|
||||
let mut entries = Vec::new();
|
||||
let mut status = HookRunStatus::Completed;
|
||||
let mut should_block = false;
|
||||
let mut block_reason = None;
|
||||
|
||||
match run_result.error.as_deref() {
|
||||
Some(error) => {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: error.to_string(),
|
||||
});
|
||||
}
|
||||
None => match run_result.exit_code {
|
||||
Some(0) => {
|
||||
let trimmed_stdout = run_result.stdout.trim();
|
||||
if trimmed_stdout.is_empty() {
|
||||
} else if let Some(parsed) = output_parser::parse_pre_tool_use(&run_result.stdout) {
|
||||
if let Some(system_message) = parsed.universal.system_message {
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Warning,
|
||||
text: system_message,
|
||||
});
|
||||
}
|
||||
if let Some(invalid_reason) = parsed.invalid_reason {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: invalid_reason,
|
||||
});
|
||||
} else if let Some(reason) = parsed.block_reason {
|
||||
status = HookRunStatus::Blocked;
|
||||
should_block = true;
|
||||
block_reason = Some(reason.clone());
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Feedback,
|
||||
text: reason,
|
||||
});
|
||||
}
|
||||
} else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "hook returned invalid pre-tool-use JSON output".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(2) => {
|
||||
if let Some(reason) = common::trimmed_non_empty(&run_result.stderr) {
|
||||
status = HookRunStatus::Blocked;
|
||||
should_block = true;
|
||||
block_reason = Some(reason.clone());
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Feedback,
|
||||
text: reason,
|
||||
});
|
||||
} else {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "PreToolUse hook exited with code 2 but did not write a blocking reason to stderr".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(exit_code) => {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: format!("hook exited with code {exit_code}"),
|
||||
});
|
||||
}
|
||||
None => {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "hook exited without a status code".to_string(),
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
let completed = HookCompletedEvent {
|
||||
turn_id,
|
||||
run: dispatcher::completed_summary(handler, &run_result, status, entries),
|
||||
};
|
||||
|
||||
dispatcher::ParsedHandler {
|
||||
completed,
|
||||
data: PreToolUseHandlerData {
|
||||
should_block,
|
||||
block_reason,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> PreToolUseOutcome {
|
||||
PreToolUseOutcome {
|
||||
hook_events,
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookOutputEntry;
|
||||
use codex_protocol::protocol::HookOutputEntryKind;
|
||||
use codex_protocol::protocol::HookRunStatus;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::PreToolUseHandlerData;
|
||||
use super::parse_completed;
|
||||
use crate::engine::ConfiguredHandler;
|
||||
use crate::engine::command_runner::CommandRunResult;
|
||||
|
||||
#[test]
|
||||
fn permission_decision_deny_blocks_processing() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(
|
||||
Some(0),
|
||||
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"do not run that"}}"#,
|
||||
"",
|
||||
),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
PreToolUseHandlerData {
|
||||
should_block: true,
|
||||
block_reason: Some("do not run that".to_string()),
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Feedback,
|
||||
text: "do not run that".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deprecated_block_decision_blocks_processing() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(
|
||||
Some(0),
|
||||
r#"{"decision":"block","reason":"do not run that"}"#,
|
||||
"",
|
||||
),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
PreToolUseHandlerData {
|
||||
should_block: true,
|
||||
block_reason: Some("do not run that".to_string()),
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Feedback,
|
||||
text: "do not run that".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_permission_decision_fails_open() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(
|
||||
Some(0),
|
||||
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"please confirm"}}"#,
|
||||
"",
|
||||
),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
PreToolUseHandlerData {
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "PreToolUse hook returned unsupported permissionDecision:ask".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deprecated_approve_decision_fails_open() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(Some(0), r#"{"decision":"approve"}"#, ""),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
PreToolUseHandlerData {
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "PreToolUse hook returned unsupported decision:approve".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_additional_context_fails_open() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(
|
||||
Some(0),
|
||||
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"do not run that","additionalContext":"nope"}}"#,
|
||||
"",
|
||||
),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
PreToolUseHandlerData {
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "PreToolUse hook returned unsupported additionalContext".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_stdout_is_ignored() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(Some(0), "hook ran successfully\n", ""),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
PreToolUseHandlerData {
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
|
||||
assert_eq!(parsed.completed.run.entries, vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_like_stdout_fails_instead_of_becoming_noop() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(Some(0), "{\"decision\":\n", ""),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
PreToolUseHandlerData {
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "hook returned invalid pre-tool-use JSON output".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_code_two_blocks_processing() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(Some(2), "", "blocked by policy\n"),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
PreToolUseHandlerData {
|
||||
should_block: true,
|
||||
block_reason: Some("blocked by policy".to_string()),
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Feedback,
|
||||
text: "blocked by policy".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
fn handler() -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::PreToolUse,
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
command: "echo hook".to_string(),
|
||||
timeout_sec: 5,
|
||||
status_message: None,
|
||||
source_path: PathBuf::from("/tmp/hooks.json"),
|
||||
display_order: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_result(exit_code: Option<i32>, stdout: &str, stderr: &str) -> CommandRunResult {
|
||||
CommandRunResult {
|
||||
started_at: 1,
|
||||
completed_at: 2,
|
||||
duration_ms: 1,
|
||||
exit_code,
|
||||
stdout: stdout.to_string(),
|
||||
stderr: stderr.to_string(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ mod registry;
|
||||
mod schema;
|
||||
mod types;
|
||||
|
||||
pub use events::pre_tool_use::PreToolUseOutcome;
|
||||
pub use events::pre_tool_use::PreToolUseRequest;
|
||||
pub use events::session_start::SessionStartOutcome;
|
||||
pub use events::session_start::SessionStartRequest;
|
||||
pub use events::session_start::SessionStartSource;
|
||||
|
||||
@@ -3,6 +3,8 @@ use tokio::process::Command;
|
||||
|
||||
use crate::engine::ClaudeHooksEngine;
|
||||
use crate::engine::CommandShell;
|
||||
use crate::events::pre_tool_use::PreToolUseOutcome;
|
||||
use crate::events::pre_tool_use::PreToolUseRequest;
|
||||
use crate::events::session_start::SessionStartOutcome;
|
||||
use crate::events::session_start::SessionStartRequest;
|
||||
use crate::events::stop::StopOutcome;
|
||||
@@ -92,6 +94,13 @@ impl Hooks {
|
||||
self.engine.preview_session_start(request)
|
||||
}
|
||||
|
||||
pub fn preview_pre_tool_use(
|
||||
&self,
|
||||
request: &PreToolUseRequest,
|
||||
) -> Vec<codex_protocol::protocol::HookRunSummary> {
|
||||
self.engine.preview_pre_tool_use(request)
|
||||
}
|
||||
|
||||
pub async fn run_session_start(
|
||||
&self,
|
||||
request: SessionStartRequest,
|
||||
@@ -100,6 +109,10 @@ impl Hooks {
|
||||
self.engine.run_session_start(request, turn_id).await
|
||||
}
|
||||
|
||||
pub async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome {
|
||||
self.engine.run_pre_tool_use(request).await
|
||||
}
|
||||
|
||||
pub fn preview_user_prompt_submit(
|
||||
&self,
|
||||
request: &UserPromptSubmitRequest,
|
||||
|
||||
@@ -13,6 +13,8 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const GENERATED_DIR: &str = "generated";
|
||||
const PRE_TOOL_USE_INPUT_FIXTURE: &str = "pre-tool-use.command.input.schema.json";
|
||||
const PRE_TOOL_USE_OUTPUT_FIXTURE: &str = "pre-tool-use.command.output.schema.json";
|
||||
const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.json";
|
||||
const SESSION_START_OUTPUT_FIXTURE: &str = "session-start.command.output.schema.json";
|
||||
const USER_PROMPT_SUBMIT_INPUT_FIXTURE: &str = "user-prompt-submit.command.input.schema.json";
|
||||
@@ -63,6 +65,8 @@ pub(crate) struct HookUniversalOutputWire {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub(crate) enum HookEventNameWire {
|
||||
#[serde(rename = "PreToolUse")]
|
||||
PreToolUse,
|
||||
#[serde(rename = "SessionStart")]
|
||||
SessionStart,
|
||||
#[serde(rename = "UserPromptSubmit")]
|
||||
@@ -71,6 +75,81 @@ pub(crate) enum HookEventNameWire {
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(rename = "pre-tool-use.command.output")]
|
||||
pub(crate) struct PreToolUseCommandOutputWire {
|
||||
#[serde(flatten)]
|
||||
pub universal: HookUniversalOutputWire,
|
||||
#[serde(default)]
|
||||
pub decision: Option<PreToolUseDecisionWire>,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hook_specific_output: Option<PreToolUseHookSpecificOutputWire>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct PreToolUseHookSpecificOutputWire {
|
||||
pub hook_event_name: HookEventNameWire,
|
||||
#[serde(default)]
|
||||
pub permission_decision: Option<PreToolUsePermissionDecisionWire>,
|
||||
#[serde(default)]
|
||||
pub permission_decision_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_input: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub additional_context: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub(crate) enum PreToolUsePermissionDecisionWire {
|
||||
#[serde(rename = "allow")]
|
||||
Allow,
|
||||
#[serde(rename = "deny")]
|
||||
Deny,
|
||||
#[serde(rename = "ask")]
|
||||
Ask,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub(crate) enum PreToolUseDecisionWire {
|
||||
#[serde(rename = "approve")]
|
||||
Approve,
|
||||
#[serde(rename = "block")]
|
||||
Block,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct PreToolUseToolInput {
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(rename = "pre-tool-use.command.input")]
|
||||
pub(crate) struct PreToolUseCommandInput {
|
||||
pub session_id: String,
|
||||
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
|
||||
pub turn_id: String,
|
||||
pub transcript_path: NullableString,
|
||||
pub cwd: String,
|
||||
#[schemars(schema_with = "pre_tool_use_hook_event_name_schema")]
|
||||
pub hook_event_name: String,
|
||||
pub model: String,
|
||||
#[schemars(schema_with = "permission_mode_schema")]
|
||||
pub permission_mode: String,
|
||||
#[schemars(schema_with = "pre_tool_use_tool_name_schema")]
|
||||
pub tool_name: String,
|
||||
pub tool_input: PreToolUseToolInput,
|
||||
pub tool_use_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -212,6 +291,14 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
|
||||
let generated_dir = schema_root.join(GENERATED_DIR);
|
||||
ensure_empty_dir(&generated_dir)?;
|
||||
|
||||
write_schema(
|
||||
&generated_dir.join(PRE_TOOL_USE_INPUT_FIXTURE),
|
||||
schema_json::<PreToolUseCommandInput>()?,
|
||||
)?;
|
||||
write_schema(
|
||||
&generated_dir.join(PRE_TOOL_USE_OUTPUT_FIXTURE),
|
||||
schema_json::<PreToolUseCommandOutputWire>()?,
|
||||
)?;
|
||||
write_schema(
|
||||
&generated_dir.join(SESSION_START_INPUT_FIXTURE),
|
||||
schema_json::<SessionStartCommandInput>()?,
|
||||
@@ -295,6 +382,14 @@ fn session_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("SessionStart")
|
||||
}
|
||||
|
||||
fn pre_tool_use_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("PreToolUse")
|
||||
}
|
||||
|
||||
fn pre_tool_use_tool_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("Bash")
|
||||
}
|
||||
|
||||
fn user_prompt_submit_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("UserPromptSubmit")
|
||||
}
|
||||
@@ -346,6 +441,9 @@ fn default_continue() -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PRE_TOOL_USE_INPUT_FIXTURE;
|
||||
use super::PRE_TOOL_USE_OUTPUT_FIXTURE;
|
||||
use super::PreToolUseCommandInput;
|
||||
use super::SESSION_START_INPUT_FIXTURE;
|
||||
use super::SESSION_START_OUTPUT_FIXTURE;
|
||||
use super::STOP_INPUT_FIXTURE;
|
||||
@@ -362,6 +460,12 @@ mod tests {
|
||||
|
||||
fn expected_fixture(name: &str) -> &'static str {
|
||||
match name {
|
||||
PRE_TOOL_USE_INPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/pre-tool-use.command.input.schema.json")
|
||||
}
|
||||
PRE_TOOL_USE_OUTPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/pre-tool-use.command.output.schema.json")
|
||||
}
|
||||
SESSION_START_INPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/session-start.command.input.schema.json")
|
||||
}
|
||||
@@ -395,6 +499,8 @@ mod tests {
|
||||
write_schema_fixtures(&schema_root).expect("write generated hook schemas");
|
||||
|
||||
for fixture in [
|
||||
PRE_TOOL_USE_INPUT_FIXTURE,
|
||||
PRE_TOOL_USE_OUTPUT_FIXTURE,
|
||||
SESSION_START_INPUT_FIXTURE,
|
||||
SESSION_START_OUTPUT_FIXTURE,
|
||||
USER_PROMPT_SUBMIT_INPUT_FIXTURE,
|
||||
@@ -414,6 +520,10 @@ mod tests {
|
||||
fn turn_scoped_hook_inputs_include_codex_turn_id_extension() {
|
||||
// Codex intentionally diverges from Claude's public hook docs here so
|
||||
// internal hook consumers can key off the active turn.
|
||||
let pre_tool_use: Value = serde_json::from_slice(
|
||||
&schema_json::<PreToolUseCommandInput>().expect("serialize pre tool use input schema"),
|
||||
)
|
||||
.expect("parse pre tool use input schema");
|
||||
let user_prompt_submit: Value = serde_json::from_slice(
|
||||
&schema_json::<UserPromptSubmitCommandInput>()
|
||||
.expect("serialize user prompt submit input schema"),
|
||||
@@ -424,7 +534,7 @@ mod tests {
|
||||
)
|
||||
.expect("parse stop input schema");
|
||||
|
||||
for schema in [&user_prompt_submit, &stop] {
|
||||
for schema in [&pre_tool_use, &user_prompt_submit, &stop] {
|
||||
assert_eq!(schema["properties"]["turn_id"]["type"], "string");
|
||||
assert!(
|
||||
schema["required"]
|
||||
|
||||
@@ -38,6 +38,7 @@ use crate::message_history::HistoryEntry;
|
||||
use crate::models::BaseInstructions;
|
||||
use crate::models::ContentItem;
|
||||
use crate::models::MessagePhase;
|
||||
use crate::models::ResponseInputItem;
|
||||
use crate::models::ResponseItem;
|
||||
use crate::models::WebSearchAction;
|
||||
use crate::num_format::format_with_separators;
|
||||
@@ -254,6 +255,11 @@ pub enum Op {
|
||||
/// Policy to use for command approval.
|
||||
approval_policy: AskForApproval,
|
||||
|
||||
/// Reviewer to use for approval requests raised during this turn.
|
||||
///
|
||||
/// When omitted, the session keeps the current setting
|
||||
approvals_reviewer: Option<ApprovalsReviewer>,
|
||||
|
||||
/// Policy to use for tool calls such as `local_shell`.
|
||||
sandbox_policy: SandboxPolicy,
|
||||
|
||||
@@ -293,6 +299,12 @@ pub enum Op {
|
||||
personality: Option<Personality>,
|
||||
},
|
||||
|
||||
/// Inter-agent communication that should be recorded as assistant history
|
||||
/// while still using the normal thread submission lifecycle.
|
||||
InterAgentCommunication {
|
||||
communication: InterAgentCommunication,
|
||||
},
|
||||
|
||||
/// Override parts of the persistent turn context for subsequent turns.
|
||||
///
|
||||
/// All fields are optional; when omitted, the existing value is preserved.
|
||||
@@ -499,6 +511,53 @@ pub enum Op {
|
||||
ListModels,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
pub struct InterAgentCommunication {
|
||||
pub author: AgentPath,
|
||||
pub recipient: AgentPath,
|
||||
#[serde(default)]
|
||||
pub other_recipients: Vec<AgentPath>,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl InterAgentCommunication {
|
||||
pub fn new(
|
||||
author: AgentPath,
|
||||
recipient: AgentPath,
|
||||
other_recipients: Vec<AgentPath>,
|
||||
content: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
author,
|
||||
recipient,
|
||||
other_recipients,
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_response_input_item(&self) -> ResponseInputItem {
|
||||
ResponseInputItem::Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: serde_json::to_string(self).unwrap_or_default(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_message_content(content: &[ContentItem]) -> bool {
|
||||
Self::from_message_content(content).is_some()
|
||||
}
|
||||
|
||||
fn from_message_content(content: &[ContentItem]) -> Option<Self> {
|
||||
match content {
|
||||
[ContentItem::InputText { text }] | [ContentItem::OutputText { text }] => {
|
||||
serde_json::from_str(text).ok()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Op {
|
||||
pub fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -510,6 +569,7 @@ impl Op {
|
||||
Self::RealtimeConversationClose => "realtime_conversation_close",
|
||||
Self::UserInput { .. } => "user_input",
|
||||
Self::UserTurn { .. } => "user_turn",
|
||||
Self::InterAgentCommunication { .. } => "inter_agent_communication",
|
||||
Self::OverrideTurnContext { .. } => "override_turn_context",
|
||||
Self::ExecApproval { .. } => "exec_approval",
|
||||
Self::PatchApproval { .. } => "patch_approval",
|
||||
@@ -1342,6 +1402,7 @@ pub enum EventMsg {
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HookEventName {
|
||||
PreToolUse,
|
||||
SessionStart,
|
||||
UserPromptSubmit,
|
||||
Stop,
|
||||
|
||||
@@ -5267,6 +5267,7 @@ impl ChatWidget {
|
||||
items,
|
||||
cwd: self.config.cwd.clone(),
|
||||
approval_policy: self.config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: self.config.permissions.sandbox_policy.get().clone(),
|
||||
model: effective_mode.model().to_string(),
|
||||
effort: effective_mode.reasoning_effort(),
|
||||
@@ -9610,6 +9611,7 @@ fn extract_first_bold(s: &str) -> Option<String> {
|
||||
|
||||
fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str {
|
||||
match event_name {
|
||||
codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse",
|
||||
codex_protocol::protocol::HookEventName::SessionStart => "SessionStart",
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit",
|
||||
codex_protocol::protocol::HookEventName::Stop => "Stop",
|
||||
|
||||
+2
-3
@@ -1,10 +1,9 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 8586
|
||||
expression: combined
|
||||
---
|
||||
• Running SessionStart hook: warming the shell
|
||||
• Running PreToolUse hook: warming the shell
|
||||
|
||||
SessionStart hook (completed)
|
||||
PreToolUse hook (completed)
|
||||
warning: Heads up from the hook
|
||||
hook context: Remember the startup checklist.
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 8586
|
||||
expression: combined
|
||||
---
|
||||
• Running SessionStart hook: warming the shell
|
||||
@@ -12052,7 +12052,33 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_events_render_snapshot() {
|
||||
async fn pre_tool_use_hook_events_render_snapshot() {
|
||||
assert_hook_events_snapshot(
|
||||
codex_protocol::protocol::HookEventName::PreToolUse,
|
||||
"pre-tool-use:0:/tmp/hooks.json",
|
||||
"warming the shell",
|
||||
"pre_tool_use_hook_events_render_snapshot",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_start_hook_events_render_snapshot() {
|
||||
assert_hook_events_snapshot(
|
||||
codex_protocol::protocol::HookEventName::SessionStart,
|
||||
"session-start:0:/tmp/hooks.json",
|
||||
"warming the shell",
|
||||
"session_start_hook_events_render_snapshot",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn assert_hook_events_snapshot(
|
||||
event_name: codex_protocol::protocol::HookEventName,
|
||||
run_id: &str,
|
||||
status_message: &str,
|
||||
snapshot_name: &str,
|
||||
) {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -12060,15 +12086,15 @@ async fn hook_events_render_snapshot() {
|
||||
msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent {
|
||||
turn_id: None,
|
||||
run: codex_protocol::protocol::HookRunSummary {
|
||||
id: "session-start:0:/tmp/hooks.json".to_string(),
|
||||
event_name: codex_protocol::protocol::HookEventName::SessionStart,
|
||||
id: run_id.to_string(),
|
||||
event_name,
|
||||
handler_type: codex_protocol::protocol::HookHandlerType::Command,
|
||||
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
|
||||
scope: codex_protocol::protocol::HookScope::Thread,
|
||||
scope: codex_protocol::protocol::HookScope::Turn,
|
||||
source_path: PathBuf::from("/tmp/hooks.json"),
|
||||
display_order: 0,
|
||||
status: codex_protocol::protocol::HookRunStatus::Running,
|
||||
status_message: Some("warming the shell".to_string()),
|
||||
status_message: Some(status_message.to_string()),
|
||||
started_at: 1,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
@@ -12082,15 +12108,15 @@ async fn hook_events_render_snapshot() {
|
||||
msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent {
|
||||
turn_id: None,
|
||||
run: codex_protocol::protocol::HookRunSummary {
|
||||
id: "session-start:0:/tmp/hooks.json".to_string(),
|
||||
event_name: codex_protocol::protocol::HookEventName::SessionStart,
|
||||
id: run_id.to_string(),
|
||||
event_name,
|
||||
handler_type: codex_protocol::protocol::HookHandlerType::Command,
|
||||
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
|
||||
scope: codex_protocol::protocol::HookScope::Thread,
|
||||
scope: codex_protocol::protocol::HookScope::Turn,
|
||||
source_path: PathBuf::from("/tmp/hooks.json"),
|
||||
display_order: 0,
|
||||
status: codex_protocol::protocol::HookRunStatus::Completed,
|
||||
status_message: Some("warming the shell".to_string()),
|
||||
status_message: Some(status_message.to_string()),
|
||||
started_at: 1,
|
||||
completed_at: Some(11),
|
||||
duration_ms: Some(10),
|
||||
@@ -12113,7 +12139,7 @@ async fn hook_events_render_snapshot() {
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<String>();
|
||||
assert_snapshot!("hook_events_render_snapshot", combined);
|
||||
assert_snapshot!(snapshot_name, combined);
|
||||
}
|
||||
|
||||
// Combined visual snapshot using vt100 for history + direct buffer overlay for UI.
|
||||
|
||||
@@ -2010,6 +2010,7 @@ impl App {
|
||||
items,
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
model,
|
||||
effort,
|
||||
@@ -2042,7 +2043,8 @@ impl App {
|
||||
items.to_vec(),
|
||||
cwd.clone(),
|
||||
approval_policy,
|
||||
self.chat_widget.config_ref().approvals_reviewer,
|
||||
approvals_reviewer
|
||||
.unwrap_or(self.chat_widget.config_ref().approvals_reviewer),
|
||||
sandbox_policy.clone(),
|
||||
model.to_string(),
|
||||
effort,
|
||||
|
||||
@@ -42,6 +42,7 @@ pub(crate) enum AppCommandView<'a> {
|
||||
items: &'a [UserInput],
|
||||
cwd: &'a PathBuf,
|
||||
approval_policy: AskForApproval,
|
||||
approvals_reviewer: &'a Option<ApprovalsReviewer>,
|
||||
sandbox_policy: &'a SandboxPolicy,
|
||||
model: &'a str,
|
||||
effort: Option<ReasoningEffortConfig>,
|
||||
@@ -159,6 +160,7 @@ impl AppCommand {
|
||||
items,
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy,
|
||||
model,
|
||||
effort,
|
||||
@@ -303,6 +305,7 @@ impl AppCommand {
|
||||
items,
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
model,
|
||||
effort,
|
||||
@@ -315,6 +318,7 @@ impl AppCommand {
|
||||
items,
|
||||
cwd,
|
||||
approval_policy: *approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
model,
|
||||
effort: *effort,
|
||||
|
||||
@@ -10781,6 +10781,7 @@ fn extract_first_bold(s: &str) -> Option<String> {
|
||||
|
||||
fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str {
|
||||
match event_name {
|
||||
codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse",
|
||||
codex_protocol::protocol::HookEventName::SessionStart => "SessionStart",
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit",
|
||||
codex_protocol::protocol::HookEventName::Stop => "Stop",
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui_app_server/src/chatwidget/tests.rs
|
||||
expression: combined
|
||||
---
|
||||
• Running PreToolUse hook: warming the shell
|
||||
|
||||
PreToolUse hook (completed)
|
||||
warning: Heads up from the hook
|
||||
hook context: Remember the startup checklist.
|
||||
@@ -12465,7 +12465,33 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_events_render_snapshot() {
|
||||
async fn pre_tool_use_hook_events_render_snapshot() {
|
||||
assert_hook_events_snapshot(
|
||||
codex_protocol::protocol::HookEventName::PreToolUse,
|
||||
"pre-tool-use:0:/tmp/hooks.json",
|
||||
"warming the shell",
|
||||
"pre_tool_use_hook_events_render_snapshot",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_start_hook_events_render_snapshot() {
|
||||
assert_hook_events_snapshot(
|
||||
codex_protocol::protocol::HookEventName::SessionStart,
|
||||
"session-start:0:/tmp/hooks.json",
|
||||
"warming the shell",
|
||||
"session_start_hook_events_render_snapshot",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn assert_hook_events_snapshot(
|
||||
event_name: codex_protocol::protocol::HookEventName,
|
||||
run_id: &str,
|
||||
status_message: &str,
|
||||
snapshot_name: &str,
|
||||
) {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -12473,15 +12499,15 @@ async fn hook_events_render_snapshot() {
|
||||
msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent {
|
||||
turn_id: None,
|
||||
run: codex_protocol::protocol::HookRunSummary {
|
||||
id: "session-start:0:/tmp/hooks.json".to_string(),
|
||||
event_name: codex_protocol::protocol::HookEventName::SessionStart,
|
||||
id: run_id.to_string(),
|
||||
event_name,
|
||||
handler_type: codex_protocol::protocol::HookHandlerType::Command,
|
||||
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
|
||||
scope: codex_protocol::protocol::HookScope::Thread,
|
||||
scope: codex_protocol::protocol::HookScope::Turn,
|
||||
source_path: PathBuf::from("/tmp/hooks.json"),
|
||||
display_order: 0,
|
||||
status: codex_protocol::protocol::HookRunStatus::Running,
|
||||
status_message: Some("warming the shell".to_string()),
|
||||
status_message: Some(status_message.to_string()),
|
||||
started_at: 1,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
@@ -12495,15 +12521,15 @@ async fn hook_events_render_snapshot() {
|
||||
msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent {
|
||||
turn_id: None,
|
||||
run: codex_protocol::protocol::HookRunSummary {
|
||||
id: "session-start:0:/tmp/hooks.json".to_string(),
|
||||
event_name: codex_protocol::protocol::HookEventName::SessionStart,
|
||||
id: run_id.to_string(),
|
||||
event_name,
|
||||
handler_type: codex_protocol::protocol::HookHandlerType::Command,
|
||||
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
|
||||
scope: codex_protocol::protocol::HookScope::Thread,
|
||||
scope: codex_protocol::protocol::HookScope::Turn,
|
||||
source_path: PathBuf::from("/tmp/hooks.json"),
|
||||
display_order: 0,
|
||||
status: codex_protocol::protocol::HookRunStatus::Completed,
|
||||
status_message: Some("warming the shell".to_string()),
|
||||
status_message: Some(status_message.to_string()),
|
||||
started_at: 1,
|
||||
completed_at: Some(11),
|
||||
duration_ms: Some(10),
|
||||
@@ -12526,7 +12552,7 @@ async fn hook_events_render_snapshot() {
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<String>();
|
||||
assert_snapshot!("hook_events_render_snapshot", combined);
|
||||
assert_snapshot!(snapshot_name, combined);
|
||||
}
|
||||
|
||||
// Combined visual snapshot using vt100 for history + direct buffer overlay for UI.
|
||||
|
||||
Reference in New Issue
Block a user