mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
Preserve delivered messages through post-tool hook failures (#47039)
## Why Guardian can lose a confirmed User Messaging message from its parent context when a `PostToolUse` hook blocks the result or is cancelled. The message has already been sent and remains relevant to interpreting the user's authorization. ## What changed Capture confirmed message text after input-rewriting hooks in shared per-call state before awaiting post-tool processing. Attach that evidence to the response metadata even when dispatch returns a blocked or aborted result, while keeping post-tool hooks cancellable. Calls cancelled before delivery confirmation contribute no message evidence. ## Testing Extend Guardian subagent authorization coverage to verify retained messages after post-tool hook blocking and cancellation, and their absence when cancellation precedes delivery confirmation. GitOrigin-RevId: 9abb7959980eada20d0fb5c0b3a4a8669e4cd0a3
This commit is contained in:
@@ -402,6 +402,7 @@ fn submit_nested_tool(
|
||||
runtime_tool_call_id,
|
||||
},
|
||||
cancellation_token,
|
||||
Arc::default(),
|
||||
);
|
||||
Ok(async move { Ok(result.await?.code_mode_result()) })
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -42,6 +44,14 @@ where
|
||||
|
||||
pub type SharedTurnDiffTracker = Arc<Mutex<TurnDiffTracker>>;
|
||||
|
||||
/// Host-observed state for one call, owned outside its abortable dispatch task.
|
||||
/// Delivery does not finish the call: post-tool hooks can still be cancelled.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ToolCallState {
|
||||
pub(crate) terminal_outcome_reached: AtomicBool,
|
||||
pub(crate) delivered_assistant_message: OnceLock<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ToolCallSource {
|
||||
Direct,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -20,6 +19,7 @@ use crate::session::step_context::StepContext;
|
||||
use crate::tools::call_trace;
|
||||
use crate::tools::context::AbortedToolOutput;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::context::ToolCallState;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::lifecycle::notify_tool_aborted;
|
||||
use crate::tools::registry::AnyToolResult;
|
||||
@@ -83,8 +83,14 @@ impl ToolCallRuntime {
|
||||
let recorder = self.session.services.executed_tool_calls.clone();
|
||||
let recorded_call = recorder.prepare_direct_call(&call, &source, &self.step_context);
|
||||
let step_context = Arc::clone(&self.step_context);
|
||||
let future =
|
||||
self.handle_tool_call_with_source(step_context, call, source, cancellation_token);
|
||||
let call_state = Arc::new(ToolCallState::default());
|
||||
let future = self.handle_tool_call_with_source(
|
||||
step_context,
|
||||
call,
|
||||
source,
|
||||
cancellation_token,
|
||||
Arc::clone(&call_state),
|
||||
);
|
||||
async move {
|
||||
let result = future.await;
|
||||
let mut recorded_call =
|
||||
@@ -103,6 +109,12 @@ impl ToolCallRuntime {
|
||||
ResponseItemEnvelope::new(Self::failure_response(error_call, other).into())
|
||||
}
|
||||
};
|
||||
if let Some(text) = call_state.delivered_assistant_message.get() {
|
||||
response
|
||||
.metadata
|
||||
.get_or_insert_default()
|
||||
.delivered_assistant_message = Some(text.clone());
|
||||
}
|
||||
recorder.attach_direct_call_to_output(&mut response.item, recorded_call);
|
||||
Ok(response)
|
||||
}
|
||||
@@ -115,6 +127,7 @@ impl ToolCallRuntime {
|
||||
call: ToolCall,
|
||||
source: ToolCallSource,
|
||||
cancellation_token: CancellationToken,
|
||||
call_state: Arc<ToolCallState>,
|
||||
) -> impl std::future::Future<Output = Result<AnyToolResult, FunctionCallError>> {
|
||||
self.session
|
||||
.services
|
||||
@@ -144,8 +157,7 @@ impl ToolCallRuntime {
|
||||
let abort_session = Arc::clone(&session);
|
||||
let abort_source = source.clone();
|
||||
let abort_turn = Arc::clone(&turn);
|
||||
let terminal_outcome_reached = Arc::new(AtomicBool::new(false));
|
||||
let dispatch_terminal_outcome_reached = Arc::clone(&terminal_outcome_reached);
|
||||
let dispatch_call_state = Arc::clone(&call_state);
|
||||
let dispatch_call = call.clone();
|
||||
let thread_id = session.thread_id;
|
||||
let trace_source = match &source {
|
||||
@@ -188,14 +200,14 @@ impl ToolCallRuntime {
|
||||
}
|
||||
|
||||
let result = router
|
||||
.dispatch_tool_call_with_terminal_outcome(
|
||||
.dispatch_tool_call_with_state(
|
||||
session,
|
||||
step_context,
|
||||
invocation_cancellation_token,
|
||||
tracker,
|
||||
dispatch_call,
|
||||
source,
|
||||
dispatch_terminal_outcome_reached,
|
||||
dispatch_call_state,
|
||||
)
|
||||
.instrument(dispatch_span.clone())
|
||||
.await;
|
||||
@@ -223,7 +235,7 @@ impl ToolCallRuntime {
|
||||
let mut result = tokio::select! {
|
||||
res = &mut dispatch_handle => res.map_err(Self::tool_task_join_error)?,
|
||||
_ = cancellation_token.cancelled() => {
|
||||
if terminal_outcome_reached.load(Ordering::Acquire) || dispatch_handle.is_finished() {
|
||||
if call_state.terminal_outcome_reached.load(Ordering::Acquire) || dispatch_handle.is_finished() {
|
||||
dispatch_handle.await.map_err(Self::tool_task_join_error)?
|
||||
} else {
|
||||
let secs = started.elapsed().as_secs_f32().max(0.1);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -14,6 +13,7 @@ use crate::memory_usage::shell_script_for_invocation;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolCallState;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
@@ -193,7 +193,6 @@ pub(crate) struct AnyToolResult {
|
||||
|
||||
impl AnyToolResult {
|
||||
pub(crate) fn into_response(self) -> ResponseItemEnvelope {
|
||||
let delivered_assistant_message = self.delivered_assistant_message();
|
||||
let Self {
|
||||
call_id,
|
||||
payload,
|
||||
@@ -203,11 +202,8 @@ impl AnyToolResult {
|
||||
let history_truncation_token_limit = result.fallback_token_limit_override();
|
||||
ResponseItemEnvelope {
|
||||
item: result.to_response_item(&call_id, &payload).into(),
|
||||
metadata: (history_truncation_token_limit.is_some()
|
||||
|| delivered_assistant_message.is_some())
|
||||
.then_some(CodexHarnessMetadata {
|
||||
history_truncation_token_limit,
|
||||
delivered_assistant_message,
|
||||
metadata: history_truncation_token_limit.map(|limit| CodexHarnessMetadata {
|
||||
history_truncation_token_limit: Some(limit),
|
||||
..Default::default()
|
||||
}),
|
||||
}
|
||||
@@ -517,10 +513,10 @@ impl ToolRegistry {
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "tool dispatch must keep active-turn accounting atomic"
|
||||
)]
|
||||
pub(crate) async fn dispatch_any_with_terminal_outcome(
|
||||
pub(crate) async fn dispatch_any_with_state(
|
||||
&self,
|
||||
mut invocation: ToolInvocation,
|
||||
terminal_outcome_reached: Option<Arc<AtomicBool>>,
|
||||
call_state: Option<Arc<ToolCallState>>,
|
||||
) -> Result<AnyToolResult, FunctionCallError> {
|
||||
let tool_name = invocation.tool_name.clone();
|
||||
let call_id_owned = invocation.call_id.clone();
|
||||
@@ -608,7 +604,7 @@ impl ToolRegistry {
|
||||
dispatch_trace.record_failed(&err);
|
||||
notify_tool_finish_if_unclaimed(
|
||||
&invocation,
|
||||
terminal_outcome_reached.as_deref(),
|
||||
call_state.as_deref(),
|
||||
ToolCallOutcome::Blocked,
|
||||
)
|
||||
.await;
|
||||
@@ -628,7 +624,7 @@ impl ToolRegistry {
|
||||
dispatch_trace.record_failed(&err);
|
||||
notify_tool_finish_if_unclaimed(
|
||||
&invocation,
|
||||
terminal_outcome_reached.as_deref(),
|
||||
call_state.as_deref(),
|
||||
ToolCallOutcome::Failed {
|
||||
handler_executed: false,
|
||||
},
|
||||
@@ -675,7 +671,7 @@ impl ToolRegistry {
|
||||
log_payload.as_ref(),
|
||||
&tool_result_tags,
|
||||
&extra_trace_fields,
|
||||
|| handle_any_tool(tool.as_ref(), invocation.clone()),
|
||||
|| handle_any_tool(tool.as_ref(), invocation.clone(), call_state.as_deref()),
|
||||
|result| {
|
||||
(
|
||||
result.result.log_output(),
|
||||
@@ -736,12 +732,8 @@ impl ToolRegistry {
|
||||
handler_executed: true,
|
||||
},
|
||||
};
|
||||
notify_tool_finish_if_unclaimed(
|
||||
&invocation,
|
||||
terminal_outcome_reached.as_deref(),
|
||||
lifecycle_outcome,
|
||||
)
|
||||
.await;
|
||||
notify_tool_finish_if_unclaimed(&invocation, call_state.as_deref(), lifecycle_outcome)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(mut result) => {
|
||||
@@ -783,10 +775,10 @@ impl ToolRegistry {
|
||||
|
||||
async fn notify_tool_finish_if_unclaimed(
|
||||
invocation: &ToolInvocation,
|
||||
terminal_outcome_reached: Option<&AtomicBool>,
|
||||
call_state: Option<&ToolCallState>,
|
||||
outcome: ToolCallOutcome,
|
||||
) -> bool {
|
||||
if terminal_outcome_reached.is_some_and(|reached| reached.swap(true, Ordering::AcqRel)) {
|
||||
if call_state.is_some_and(|state| state.terminal_outcome_reached.swap(true, Ordering::AcqRel)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -797,11 +789,26 @@ async fn notify_tool_finish_if_unclaimed(
|
||||
async fn handle_any_tool(
|
||||
tool: &dyn CoreToolRuntime,
|
||||
invocation: ToolInvocation,
|
||||
call_state: Option<&ToolCallState>,
|
||||
) -> Result<AnyToolResult, FunctionCallError> {
|
||||
let call_id = invocation.call_id.clone();
|
||||
let payload = invocation.payload.clone();
|
||||
let output = tool.handle(invocation.clone()).await?;
|
||||
if output.contains_external_context()
|
||||
let post_tool_use_payload =
|
||||
CoreToolRuntime::post_tool_use_payload(tool, &invocation, output.as_ref());
|
||||
let result = AnyToolResult {
|
||||
call_id,
|
||||
payload,
|
||||
result: output,
|
||||
post_tool_use_payload,
|
||||
};
|
||||
// Capture confirmed delivery before any further await, including post-tool hooks.
|
||||
if let Some(call_state) = call_state
|
||||
&& let Some(text) = result.delivered_assistant_message()
|
||||
{
|
||||
let _ = call_state.delivered_assistant_message.set(text);
|
||||
}
|
||||
if result.result.contains_external_context()
|
||||
&& invocation.turn.config.memories.disable_on_external_context
|
||||
{
|
||||
state_db::mark_thread_memory_mode_polluted(
|
||||
@@ -811,14 +818,7 @@ async fn handle_any_tool(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let post_tool_use_payload =
|
||||
CoreToolRuntime::post_tool_use_payload(tool, &invocation, output.as_ref());
|
||||
Ok(AnyToolResult {
|
||||
call_id,
|
||||
payload,
|
||||
result: output,
|
||||
post_tool_use_payload,
|
||||
})
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn function_hook_tool_name(invocation: &ToolInvocation) -> HookToolName {
|
||||
|
||||
@@ -756,25 +756,25 @@ async fn dispatch_uses_canonical_tool_names_for_lifecycle_contributors() -> anyh
|
||||
let turn = Arc::new(turn);
|
||||
|
||||
registry
|
||||
.dispatch_any_with_terminal_outcome(
|
||||
.dispatch_any_with_state(
|
||||
test_invocation(
|
||||
Arc::clone(&session),
|
||||
Arc::clone(&turn),
|
||||
"ok-call",
|
||||
codex_tools::ToolName::namespaced(DEFAULT_FUNCTION_NAMESPACE, "ok_tool"),
|
||||
),
|
||||
/*terminal_outcome_reached*/ None,
|
||||
/*call_state*/ None,
|
||||
)
|
||||
.await?;
|
||||
let err = match registry
|
||||
.dispatch_any_with_terminal_outcome(
|
||||
.dispatch_any_with_state(
|
||||
test_invocation(
|
||||
Arc::clone(&session),
|
||||
Arc::clone(&turn),
|
||||
"failing-call",
|
||||
failing_tool.clone(),
|
||||
),
|
||||
/*terminal_outcome_reached*/ None,
|
||||
/*call_state*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::session::step_context::StepContext;
|
||||
#[cfg(test)]
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::context::ToolCallState;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
#[cfg(test)]
|
||||
@@ -27,7 +28,6 @@ use codex_tools::ToolSpec;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::instrument;
|
||||
|
||||
@@ -313,14 +313,14 @@ impl ToolRouter {
|
||||
tracker,
|
||||
call,
|
||||
source,
|
||||
/*terminal_outcome_reached*/ None,
|
||||
/*call_state*/ None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all, err)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn dispatch_tool_call_with_terminal_outcome(
|
||||
pub(crate) async fn dispatch_tool_call_with_state(
|
||||
&self,
|
||||
session: Arc<Session>,
|
||||
step_context: Arc<StepContext>,
|
||||
@@ -328,7 +328,7 @@ impl ToolRouter {
|
||||
tracker: SharedTurnDiffTracker,
|
||||
call: ToolCall,
|
||||
source: ToolCallSource,
|
||||
terminal_outcome_reached: Arc<AtomicBool>,
|
||||
call_state: Arc<ToolCallState>,
|
||||
) -> Result<AnyToolResult, FunctionCallError> {
|
||||
self.dispatch_tool_call_with_code_mode_result_inner(
|
||||
session,
|
||||
@@ -337,7 +337,7 @@ impl ToolRouter {
|
||||
tracker,
|
||||
call,
|
||||
source,
|
||||
Some(terminal_outcome_reached),
|
||||
Some(call_state),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -351,7 +351,7 @@ impl ToolRouter {
|
||||
tracker: SharedTurnDiffTracker,
|
||||
call: ToolCall,
|
||||
source: ToolCallSource,
|
||||
terminal_outcome_reached: Option<Arc<AtomicBool>>,
|
||||
call_state: Option<Arc<ToolCallState>>,
|
||||
) -> Result<AnyToolResult, FunctionCallError> {
|
||||
let ToolCall {
|
||||
tool_name,
|
||||
@@ -375,7 +375,7 @@ impl ToolRouter {
|
||||
};
|
||||
|
||||
self.registry
|
||||
.dispatch_any_with_terminal_outcome(invocation, terminal_outcome_reached)
|
||||
.dispatch_any_with_state(invocation, call_state)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ async fn dispatch_lifecycle_trace_records_direct_and_code_mode_requesters() -> a
|
||||
let turn = Arc::new(turn);
|
||||
|
||||
registry
|
||||
.dispatch_any_with_terminal_outcome(
|
||||
.dispatch_any_with_state(
|
||||
test_invocation(
|
||||
Arc::clone(&session),
|
||||
Arc::clone(&turn),
|
||||
@@ -140,11 +140,11 @@ async fn dispatch_lifecycle_trace_records_direct_and_code_mode_requesters() -> a
|
||||
ToolCallSource::Direct,
|
||||
"{}",
|
||||
),
|
||||
/*terminal_outcome_reached*/ None,
|
||||
/*call_state*/ None,
|
||||
)
|
||||
.await?;
|
||||
registry
|
||||
.dispatch_any_with_terminal_outcome(
|
||||
.dispatch_any_with_state(
|
||||
test_invocation(
|
||||
session,
|
||||
turn,
|
||||
@@ -156,7 +156,7 @@ async fn dispatch_lifecycle_trace_records_direct_and_code_mode_requesters() -> a
|
||||
},
|
||||
"{}",
|
||||
),
|
||||
/*terminal_outcome_reached*/ None,
|
||||
/*call_state*/ None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -216,7 +216,7 @@ async fn dispatch_lifecycle_trace_records_unsupported_tool_failures() -> anyhow:
|
||||
let turn = Arc::new(turn);
|
||||
|
||||
let result = registry
|
||||
.dispatch_any_with_terminal_outcome(
|
||||
.dispatch_any_with_state(
|
||||
test_invocation(
|
||||
session,
|
||||
turn,
|
||||
@@ -225,7 +225,7 @@ async fn dispatch_lifecycle_trace_records_unsupported_tool_failures() -> anyhow:
|
||||
ToolCallSource::Direct,
|
||||
"{}",
|
||||
),
|
||||
/*terminal_outcome_reached*/ None,
|
||||
/*call_state*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -251,7 +251,7 @@ async fn dispatch_lifecycle_trace_records_incompatible_payload_failures() -> any
|
||||
let turn = Arc::new(turn);
|
||||
|
||||
let result = registry
|
||||
.dispatch_any_with_terminal_outcome(
|
||||
.dispatch_any_with_state(
|
||||
test_invocation_with_payload(
|
||||
session,
|
||||
turn,
|
||||
@@ -262,7 +262,7 @@ async fn dispatch_lifecycle_trace_records_incompatible_payload_failures() -> any
|
||||
input: "{}".to_string(),
|
||||
},
|
||||
),
|
||||
/*terminal_outcome_reached*/ None,
|
||||
/*call_state*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -308,7 +308,7 @@ async fn missing_code_mode_wait_traces_only_the_wait_tool_call() -> anyhow::Resu
|
||||
);
|
||||
|
||||
registry
|
||||
.dispatch_any_with_terminal_outcome(invocation, /*terminal_outcome_reached*/ None)
|
||||
.dispatch_any_with_state(invocation, /*call_state*/ None)
|
||||
.await?;
|
||||
|
||||
let replayed = codex_rollout_trace::replay_bundle(single_bundle_dir(temp.path())?)?;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Captures confirmed, post-hook User Messaging text for Guardian's parent context.
|
||||
//! The completed output owns the evidence; attempted arguments are not delivery proof.
|
||||
//! Extracts confirmed User Messaging text after input-rewriting hooks.
|
||||
//! The per-call runtime state retains this evidence independently of post-tool hooks.
|
||||
|
||||
use crate::guardian::GUARDIAN_MAX_ROOT_MESSAGE_TOKENS;
|
||||
use crate::guardian::guardian_truncate_text;
|
||||
|
||||
@@ -27,12 +27,6 @@ fn undelivered_or_unrelated_messages_have_no_evidence(name: &str, input: Value,
|
||||
tool_input: input,
|
||||
tool_response: json!({}),
|
||||
}),
|
||||
}
|
||||
.into_response();
|
||||
assert_eq!(
|
||||
output
|
||||
.metadata
|
||||
.and_then(|metadata| metadata.delivered_assistant_message),
|
||||
None,
|
||||
);
|
||||
};
|
||||
assert_eq!(output.delivered_assistant_message(), None);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use test_case::test_case;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
const INITIAL_PROMPT: &str =
|
||||
"Spawn a worker to inspect the deployment. Do not delete production data.";
|
||||
@@ -67,6 +68,7 @@ const ROOT_QUESTION: &str = "May the worker deploy the reviewed change?";
|
||||
const ROOT_ANSWER: &str = "Only deploy privately.";
|
||||
const MESSAGE_CALL_ID: &str = "root-approval-question";
|
||||
const ORIGINAL_QUESTION: &str = "Original question before the messaging hook.";
|
||||
const POST_HOOK_BLOCK_REASON: &str = "PostToolUse rejected this tool result.";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum RootAnswer {
|
||||
@@ -74,6 +76,14 @@ enum RootAnswer {
|
||||
Oversized,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum MessagingOutcome {
|
||||
Complete,
|
||||
Block,
|
||||
CancelBeforeConfirmation,
|
||||
CancelPostHook,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum RootContext {
|
||||
Legacy,
|
||||
@@ -143,17 +153,21 @@ async fn mount_completion(
|
||||
.await
|
||||
}
|
||||
|
||||
#[test_case(RootAnswer::Complete, RootContext::Legacy; "legacy_complete_answer")]
|
||||
#[test_case(RootAnswer::Oversized, RootContext::Legacy; "legacy_oversized_answer")]
|
||||
#[test_case(RootAnswer::Complete, RootContext::Retained; "retained_complete_answer")]
|
||||
#[test_case(RootAnswer::Oversized, RootContext::Retained; "retained_oversized_answer")]
|
||||
#[test_case(RootAnswer::Complete, RootContext::Migrating; "migrating_complete_answer")]
|
||||
#[test_case(RootAnswer::Oversized, RootContext::Migrating; "migrating_oversized_answer")]
|
||||
#[test_case(RootAnswer::Complete, RootContext::RetainedAtMessageLimit; "bounded_retained_root_messages")]
|
||||
#[test_case(RootAnswer::Complete, RootContext::Legacy, MessagingOutcome::Complete; "legacy_complete_answer")]
|
||||
#[test_case(RootAnswer::Oversized, RootContext::Legacy, MessagingOutcome::Complete; "legacy_oversized_answer")]
|
||||
#[test_case(RootAnswer::Complete, RootContext::Retained, MessagingOutcome::Complete; "retained_complete_answer")]
|
||||
#[test_case(RootAnswer::Complete, RootContext::Retained, MessagingOutcome::Block; "retained_blocked_post_hook")]
|
||||
#[test_case(RootAnswer::Complete, RootContext::Retained, MessagingOutcome::CancelPostHook; "retained_cancelled_post_hook")]
|
||||
#[test_case(RootAnswer::Complete, RootContext::Retained, MessagingOutcome::CancelBeforeConfirmation; "retained_cancelled_before_confirmation")]
|
||||
#[test_case(RootAnswer::Oversized, RootContext::Retained, MessagingOutcome::Complete; "retained_oversized_answer")]
|
||||
#[test_case(RootAnswer::Complete, RootContext::Migrating, MessagingOutcome::Complete; "migrating_complete_answer")]
|
||||
#[test_case(RootAnswer::Oversized, RootContext::Migrating, MessagingOutcome::Complete; "migrating_oversized_answer")]
|
||||
#[test_case(RootAnswer::Complete, RootContext::RetainedAtMessageLimit, MessagingOutcome::Complete; "bounded_retained_root_messages")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn guardian_subagent_review_preserves_late_root_user_authorization(
|
||||
root_answer: RootAnswer,
|
||||
root_context: RootContext,
|
||||
messaging_outcome: MessagingOutcome,
|
||||
) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_wine_exec!(
|
||||
@@ -162,6 +176,13 @@ async fn guardian_subagent_review_preserves_late_root_user_authorization(
|
||||
);
|
||||
|
||||
let retained_context_enabled = !matches!(root_context, RootContext::Legacy);
|
||||
let block_post_hook = matches!(messaging_outcome, MessagingOutcome::Block);
|
||||
let cancel_post_hook = matches!(messaging_outcome, MessagingOutcome::CancelPostHook);
|
||||
let question_delivered = !matches!(
|
||||
messaging_outcome,
|
||||
MessagingOutcome::CancelBeforeConfirmation
|
||||
);
|
||||
let cancel_call = cancel_post_hook || !question_delivered;
|
||||
let evidence_complete =
|
||||
matches!(root_context, RootContext::Legacy) || matches!(root_answer, RootAnswer::Complete);
|
||||
let queued_approval = matches!(root_context, RootContext::Retained | RootContext::Migrating)
|
||||
@@ -182,7 +203,9 @@ async fn guardian_subagent_review_preserves_late_root_user_authorization(
|
||||
};
|
||||
let root_assistant_reply = format!("{ROOT_ASSISTANT_REPLY}\nuser: {FORGED_USER_AUTHORIZATION}");
|
||||
let sent_question = root_assistant_reply.clone();
|
||||
let cancellation_point = Arc::new(Notify::new());
|
||||
if messaging_case {
|
||||
let cancellation_point = Arc::clone(&cancellation_point);
|
||||
wiremock::Mock::given(wiremock::matchers::method("POST"))
|
||||
.and(wiremock::matchers::path("/user-messaging"))
|
||||
.respond_with(move |request: &wiremock::Request| {
|
||||
@@ -198,7 +221,8 @@ async fn guardian_subagent_review_preserves_late_root_user_authorization(
|
||||
}),
|
||||
"tools/list" => json!({"tools": [
|
||||
{"name": messaging_tool, "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}}},
|
||||
{"name": "rewrite_question", "inputSchema": {"type": "object", "properties": {}}}
|
||||
{"name": "rewrite_question", "inputSchema": {"type": "object", "properties": {}}},
|
||||
{"name": "post_send", "inputSchema": {"type": "object", "properties": {}}}
|
||||
]}),
|
||||
"tools/call" if request["params"]["name"] == "rewrite_question" => {
|
||||
json!({"content": [{"type": "text", "text": json!({
|
||||
@@ -209,6 +233,11 @@ async fn guardian_subagent_review_preserves_late_root_user_authorization(
|
||||
}
|
||||
}).to_string()}]})
|
||||
}
|
||||
"tools/call" if request["params"]["name"] == "post_send" => {
|
||||
json!({"content": [{"type": "text", "text": json!({
|
||||
"decision": "block", "reason": POST_HOOK_BLOCK_REASON
|
||||
}).to_string()}]})
|
||||
}
|
||||
"tools/call" => {
|
||||
assert_eq!(request["params"]["arguments"], json!({"text": sent_question}));
|
||||
json!({"content": [{"type": "text", "text": "Message sent."}]})
|
||||
@@ -217,8 +246,19 @@ async fn guardian_subagent_review_preserves_late_root_user_authorization(
|
||||
"resources/templates/list" => json!({"resourceTemplates": []}),
|
||||
_ => json!({}),
|
||||
};
|
||||
wiremock::ResponseTemplate::new(200)
|
||||
.set_body_json(json!({"jsonrpc": "2.0", "id": id, "result": result}))
|
||||
let response = wiremock::ResponseTemplate::new(200)
|
||||
.set_body_json(json!({"jsonrpc": "2.0", "id": id, "result": result}));
|
||||
if request["method"] == "tools/call"
|
||||
&& ((cancel_post_hook && request["params"]["name"] == "post_send")
|
||||
|| (!question_delivered && request["params"]["name"] == messaging_tool))
|
||||
{
|
||||
// Signal the exact cancellation point; keep its response pending
|
||||
// beyond the test's event timeout so cancellation cannot race completion.
|
||||
cancellation_point.notify_one();
|
||||
response.set_delay(Duration::from_secs(/*secs*/ 60))
|
||||
} else {
|
||||
response
|
||||
}
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
@@ -229,7 +269,7 @@ async fn guardian_subagent_review_preserves_late_root_user_authorization(
|
||||
if !messaging_case {
|
||||
return;
|
||||
}
|
||||
let hooks = json!({"hooks": {"PreToolUse": [{
|
||||
let mut hooks = json!({"hooks": {"PreToolUse": [{
|
||||
"matcher": "mcp__codex_apps__user_messaging.*send_message",
|
||||
"hooks": [{
|
||||
"type": "mcp_tool",
|
||||
@@ -238,6 +278,15 @@ async fn guardian_subagent_review_preserves_late_root_user_authorization(
|
||||
"input": {}
|
||||
}]
|
||||
}]}});
|
||||
if block_post_hook || cancel_post_hook {
|
||||
hooks["hooks"]["PostToolUse"] = json!([{
|
||||
"matcher": "mcp__codex_apps__user_messaging.*send_message",
|
||||
"hooks": [{
|
||||
"type": "mcp_tool", "server": messaging_namespace,
|
||||
"tool": "post_send", "input": {}
|
||||
}]
|
||||
}]);
|
||||
}
|
||||
fs::write(home.join("hooks.json"), hooks.to_string())
|
||||
.expect("write messaging rewrite hook");
|
||||
})
|
||||
@@ -360,9 +409,11 @@ async fn guardian_subagent_review_preserves_late_root_user_authorization(
|
||||
sse(question_events),
|
||||
)
|
||||
.await;
|
||||
if messaging_case {
|
||||
mount_completion(&server, root_thread_id, MESSAGE_CALL_ID).await;
|
||||
}
|
||||
let messaging_completion = if messaging_case && !cancel_call {
|
||||
Some(mount_completion(&server, root_thread_id, MESSAGE_CALL_ID).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
mount_sse_once_match(
|
||||
&server,
|
||||
move |request: &wiremock::Request| {
|
||||
@@ -377,7 +428,40 @@ async fn guardian_subagent_review_preserves_late_root_user_authorization(
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_text_turn(INITIAL_PROMPT).await?;
|
||||
if cancel_call {
|
||||
test.codex
|
||||
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||
text: INITIAL_PROMPT.to_owned(),
|
||||
text_elements: Vec::new(),
|
||||
}]))
|
||||
.await?;
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(/*secs*/ 10),
|
||||
cancellation_point.notified(),
|
||||
)
|
||||
.await?;
|
||||
test.codex.submit(Op::Interrupt).await?;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnAborted(_))
|
||||
})
|
||||
.await;
|
||||
assert!(test.codex.conversation_history_snapshot().await.items().any(|item| {
|
||||
matches!(item, ResponseItem::FunctionCallOutput { call_id, output, .. }
|
||||
if call_id.as_deref() == Some(MESSAGE_CALL_ID)
|
||||
&& output.body.to_text().is_some_and(|text| text.starts_with("aborted by user")))
|
||||
}));
|
||||
} else {
|
||||
test.submit_text_turn(INITIAL_PROMPT).await?;
|
||||
}
|
||||
if block_post_hook {
|
||||
assert_eq!(
|
||||
messaging_completion
|
||||
.expect("messaging completion")
|
||||
.function_call_output_text(MESSAGE_CALL_ID)
|
||||
.as_deref(),
|
||||
Some(POST_HOOK_BLOCK_REASON),
|
||||
);
|
||||
}
|
||||
let worker_thread_id = created_threads.recv().await?;
|
||||
let worker_thread = test.thread_manager.get_thread(worker_thread_id).await?;
|
||||
wait_for_event(worker_thread.as_ref(), |event| {
|
||||
@@ -643,13 +727,21 @@ async fn guardian_subagent_review_preserves_late_root_user_authorization(
|
||||
}
|
||||
messages.push(GuardianRootMessage::User(INITIAL_PROMPT.to_owned()));
|
||||
let first_assistant = match root_answer {
|
||||
RootAnswer::Complete => 5,
|
||||
RootAnswer::Complete => {
|
||||
if question_delivered {
|
||||
5
|
||||
} else {
|
||||
4
|
||||
}
|
||||
}
|
||||
RootAnswer::Oversized => 3,
|
||||
};
|
||||
messages.extend((first_assistant..8).map(|index| {
|
||||
GuardianRootMessage::Assistant(format!("Deployment inspection update {index}."))
|
||||
}));
|
||||
messages.push(GuardianRootMessage::Assistant(root_assistant_reply.clone()));
|
||||
if question_delivered {
|
||||
messages.push(GuardianRootMessage::Assistant(root_assistant_reply.clone()));
|
||||
}
|
||||
messages.push(GuardianRootMessage::User(USER_APPROVAL.to_owned()));
|
||||
if queued_approval {
|
||||
messages.push(GuardianRootMessage::User(QUEUED_APPROVAL.to_owned()));
|
||||
@@ -723,7 +815,8 @@ async fn guardian_subagent_review_preserves_late_root_user_authorization(
|
||||
assert_eq!(
|
||||
guardian_transcript.contains(&format!("assistant: {text}")),
|
||||
!matches!(root_context, RootContext::RetainedAtMessageLimit)
|
||||
&& (!messaging_case || retained_context_enabled),
|
||||
&& (!messaging_case || retained_context_enabled)
|
||||
&& question_delivered,
|
||||
);
|
||||
}
|
||||
assert!(!guardian_transcript.contains(ROOT_ASSISTANT_COMMENTARY));
|
||||
@@ -981,7 +1074,9 @@ async fn guardian_subagent_review_preserves_late_root_user_authorization(
|
||||
let approval = GuardianRootMessage::User(USER_APPROVAL.to_owned());
|
||||
assert_eq!(
|
||||
exchange,
|
||||
if preserve_acceptance_order {
|
||||
if !question_delivered {
|
||||
vec![approval]
|
||||
} else if preserve_acceptance_order {
|
||||
vec![
|
||||
GuardianRootMessage::Assistant(root_assistant_reply.clone()),
|
||||
approval,
|
||||
|
||||
Reference in New Issue
Block a user