Attribute command and plugin analytics to the invoking model (#45445)

## Why

Command execution and plugin measurement events lack model and reasoning-effort labels. Attribution needs to reflect the step that invoked the command, even when model settings change before a background process finishes.

## What changed

- Add `model_slug` and `reasoning_effort` to command execution and plugin measurement analytics.
- Capture model context from resolved step settings and carry it through execution, approval, Guardian review, and plugin metrics collection.
- Preserve the first command-start model context when subsequent start notifications arrive.
- Keep the carried context out of serialized protocol items and generated schemas.

## Testing

Extend analytics tests to cover model switches before invocation and during background execution, default reasoning effort, Guardian-denied commands, and repeated start notifications retaining the original model context.

GitOrigin-RevId: af90e1c0d39bab625f2e89786085b61a9b96c0ce
This commit is contained in:
jwang-openai
2026-09-14 15:30:22 +00:00
committed by copyberry
parent b6a5d5bb14
commit 4d8eca1ff3
40 changed files with 355 additions and 43 deletions
@@ -869,6 +869,8 @@ fn plugin_measurements(rows: Vec<PluginMeasurementRow>) -> PluginMeasurementsInp
turn_id: "turn-1".to_string(),
item_id: "item-1".to_string(),
originator: "codex_cli_rs".to_string(),
model_slug: None,
reasoning_effort: None,
plugin_id: "sample@openai-curated".to_string(),
execution_id: "execution-1".to_string(),
operation: "security_scan".to_string(),
@@ -946,6 +948,7 @@ fn sample_command_execution_item_with_id(
duration_ms: Option<i64>,
) -> ThreadItem {
ThreadItem::CommandExecution {
model_context: None,
id: id.to_string(),
plugin_id: None,
script_path: None,
@@ -1790,6 +1793,8 @@ fn command_execution_event_serializes_expected_shape() {
let event = TrackEventRequest::CommandExecution(CodexCommandExecutionEventRequest {
event_type: "codex_command_execution_event",
event_params: CodexCommandExecutionEventParams {
model_slug: None,
reasoning_effort: None,
base: CodexToolItemEventBase {
thread_id: "thread-1".to_string(),
session_id: "session-thread-1".to_string(),
@@ -1848,6 +1853,8 @@ fn command_execution_event_serializes_expected_shape() {
json!({
"event_type": "codex_command_execution_event",
"event_params": {
"model_slug": null,
"reasoning_effort": null,
"thread_id": "thread-1",
"session_id": "session-thread-1",
"turn_id": "turn-1",
@@ -2781,23 +2788,35 @@ async fn item_lifecycle_notifications_publish_command_execution_event() {
&mut events,
)
.await;
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted(
ItemStartedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 1_000,
item: sample_command_execution_item(
CommandExecutionStatus::InProgress,
/*exit_code*/ None,
/*duration_ms*/ None,
),
},
))),
&mut events,
)
.await;
for model in ["invoking-model", "later-model"] {
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted(
ItemStartedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 1_000,
item: {
let mut item = sample_command_execution_item(
CommandExecutionStatus::InProgress,
/*exit_code*/ None,
/*duration_ms*/ None,
);
if let ThreadItem::CommandExecution { model_context, .. } = &mut item {
*model_context =
Some(codex_protocol::items::ModelInvocationContext {
model_slug: model.to_string(),
reasoning_effort: Some("max".to_string()),
});
}
item
},
},
))),
&mut events,
)
.await;
}
assert!(
events.is_empty(),
"tool item event should emit on completion"
@@ -2844,6 +2863,8 @@ async fn item_lifecycle_notifications_publish_command_execution_event() {
let payload = serde_json::to_value(&events).expect("serialize events");
assert_eq!(payload.as_array().expect("events array").len(), 1);
assert_eq!(payload[0]["event_params"]["model_slug"], "invoking-model");
assert_eq!(payload[0]["event_params"]["reasoning_effort"], "max");
assert_eq!(payload[0]["event_type"], "codex_command_execution_event");
assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1");
assert_eq!(payload[0]["event_params"]["session_id"], "session-thread-1");
@@ -2899,7 +2920,7 @@ async fn plugin_measurement_batch_emits_directly_and_filters_invalid_rows() {
for index in 0..9 {
too_many_dimensions.insert(format!("dimension_{index}"), "allowed".to_string());
}
let measurements = plugin_measurements(vec![
let mut measurements = plugin_measurements(vec![
PluginMeasurementRow {
measurement_name: "finding_count".to_string(),
number_value: 3.0,
@@ -2921,6 +2942,8 @@ async fn plugin_measurement_batch_emits_directly_and_filters_invalid_rows() {
dimensions: BTreeMap::new(),
},
]);
measurements.model_slug = Some("invoking-model".to_string());
measurements.reasoning_effort = Some("max".to_string());
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::PluginMeasurements(measurements)),
@@ -2942,6 +2965,8 @@ async fn plugin_measurement_batch_emits_directly_and_filters_invalid_rows() {
"operation": "security_scan",
"measurement_name": "finding_count",
"originator": "codex_cli_rs",
"model_slug": "invoking-model",
"reasoning_effort": "max",
"number_value": 3.0,
"dimensions": {"severity": "high"},
},
@@ -2957,6 +2982,8 @@ async fn plugin_measurement_batch_emits_directly_and_filters_invalid_rows() {
"operation": "security_scan",
"measurement_name": "files_scanned",
"originator": "codex_cli_rs",
"model_slug": "invoking-model",
"reasoning_effort": "max",
"number_value": 17.0,
"dimensions": null,
},
+4
View File
@@ -420,6 +420,8 @@ async fn api_key_auth_sends_only_plugin_events_to_codex_backend() {
TrackEventRequest::PluginMeasurement(CodexPluginMeasurementEventRequest {
event_type: "codex_plugin_measurement_event",
event_params: CodexPluginMeasurementEventParams {
model_slug: None,
reasoning_effort: None,
thread_id: thread_id.to_string(),
turn_id: "turn-1".to_string(),
item_id: "item-1".to_string(),
@@ -712,6 +714,8 @@ fn track_plugin_measurements_rejects_unbounded_inputs_before_queueing() {
turn_id: "turn-1".to_string(),
item_id: "item-1".to_string(),
originator: "codex_cli_rs".to_string(),
model_slug: None,
reasoning_effort: None,
plugin_id: "sample@openai-curated".to_string(),
execution_id: "execution-1".to_string(),
operation: "security_scan".to_string(),
+4
View File
@@ -778,6 +778,8 @@ pub(crate) enum WebSearchActionKind {
#[derive(Serialize)]
pub(crate) struct CodexCommandExecutionEventParams {
pub(crate) model_slug: Option<String>,
pub(crate) reasoning_effort: Option<String>,
#[serde(flatten)]
pub(crate) base: CodexToolItemEventBase,
pub(crate) plugin_id: Option<String>,
@@ -803,6 +805,8 @@ pub(crate) struct CodexPluginMeasurementEventParams {
pub(crate) turn_id: String,
pub(crate) item_id: String,
pub(crate) originator: String,
pub(crate) model_slug: Option<String>,
pub(crate) reasoning_effort: Option<String>,
pub(crate) plugin_id: String,
pub(crate) execution_id: String,
pub(crate) operation: String,
+2
View File
@@ -604,6 +604,8 @@ pub struct PluginMeasurementsInput {
pub turn_id: String,
pub item_id: String,
pub originator: String,
pub model_slug: Option<String>,
pub reasoning_effort: Option<String>,
pub plugin_id: String,
pub execution_id: String,
pub operation: String,
+24 -7
View File
@@ -159,6 +159,7 @@ use codex_login::default_client::originator;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::items::ModelInvocationContext;
use codex_protocol::items::is_safe_plugin_relative_path;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::SessionSource;
@@ -203,7 +204,7 @@ pub(crate) struct AnalyticsReducer {
turns: HashMap<String, TurnState>,
connections: HashMap<u64, ConnectionState>,
threads: HashMap<String, ThreadAnalyticsState>,
tool_items_started_at_ms: HashMap<ToolItemKey, u64>,
tool_items_started_at_ms: HashMap<ToolItemKey, (u64, Option<ModelInvocationContext>)>,
tool_response_states: HashMap<(String, String), ToolResponseState>,
code_mode_cells: HashMap<String, HashMap<String, CodeModeCellState>>,
pending_reviews: HashMap<RequestId, PendingReviewState>,
@@ -1891,14 +1892,18 @@ impl AnalyticsReducer {
else {
return;
};
self.tool_items_started_at_ms.insert(
ToolItemKey {
let model_context = match &notification.item {
ThreadItem::CommandExecution { model_context, .. } => model_context.clone(),
_ => None,
};
self.tool_items_started_at_ms
.entry(ToolItemKey {
thread_id: notification.thread_id,
turn_id: notification.turn_id,
item_id: item_id.to_string(),
},
started_at_ms,
);
})
.and_modify(|(timestamp, _)| *timestamp = started_at_ms)
.or_insert((started_at_ms, model_context));
}
ServerNotification::ItemCompleted(notification) => {
if matches!(notification.item, ThreadItem::SubAgentActivity { .. }) {
@@ -1931,7 +1936,9 @@ impl AnalyticsReducer {
turn_id: notification.turn_id.clone(),
item_id: item_id.to_string(),
};
let Some(started_at_ms) = self.tool_items_started_at_ms.remove(&key) else {
let Some((started_at_ms, model_context)) =
self.tool_items_started_at_ms.remove(&key)
else {
tracing::warn!(
thread_id = %notification.thread_id,
turn_id = %notification.turn_id,
@@ -1953,6 +1960,7 @@ impl AnalyticsReducer {
thread_id: &notification.thread_id,
turn_id: &notification.turn_id,
item: &notification.item,
model_context: model_context.as_ref(),
started_at_ms,
completed_at_ms,
connection_state,
@@ -2063,6 +2071,8 @@ impl AnalyticsReducer {
turn_id,
item_id,
originator,
model_slug,
reasoning_effort,
plugin_id,
execution_id,
operation,
@@ -2079,6 +2089,8 @@ impl AnalyticsReducer {
turn_id: turn_id.clone(),
item_id: item_id.clone(),
originator: originator.clone(),
model_slug: model_slug.clone(),
reasoning_effort: reasoning_effort.clone(),
plugin_id: plugin_id.clone(),
execution_id: execution_id.clone(),
operation: operation.clone(),
@@ -2598,6 +2610,7 @@ struct ToolItemEventInput<'a> {
thread_id: &'a str,
turn_id: &'a str,
item: &'a ThreadItem,
model_context: Option<&'a ModelInvocationContext>,
started_at_ms: u64,
completed_at_ms: u64,
connection_state: &'a ConnectionState,
@@ -2611,6 +2624,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
thread_id,
turn_id,
item,
model_context,
started_at_ms,
completed_at_ms,
connection_state,
@@ -2655,6 +2669,9 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
CodexCommandExecutionEventRequest {
event_type: "codex_command_execution_event",
event_params: CodexCommandExecutionEventParams {
model_slug: model_context.map(|context| context.model_slug.clone()),
reasoning_effort: model_context
.and_then(|context| context.reasoning_effort.clone()),
base,
plugin_id: plugin_id.clone(),
script_path: safe_plugin_relative_script_path(
@@ -97,6 +97,7 @@ pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> Th
let presentation =
CommandExecutionPresentation::from_raw(&payload.command, &payload.parsed_cmd, &payload.cwd);
ThreadItem::CommandExecution {
model_context: None,
id: payload.call_id.clone(),
plugin_id: payload.plugin_id.clone(),
script_path: payload.script_path.clone(),
@@ -123,6 +124,7 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread
CommandExecutionPresentation::from_raw(&payload.command, &payload.parsed_cmd, &payload.cwd);
ThreadItem::CommandExecution {
model_context: None,
id: payload.call_id.clone(),
plugin_id: payload.plugin_id.clone(),
script_path: payload.script_path.clone(),
@@ -199,6 +201,7 @@ pub fn build_item_from_guardian_event(
}];
Some(ThreadItem::CommandExecution {
id: id.clone(),
model_context: assessment.model_context.clone(),
plugin_id: assessment.plugin_id.clone(),
script_path: assessment.script_path.clone(),
command,
@@ -237,6 +240,7 @@ pub fn build_item_from_guardian_event(
};
Some(ThreadItem::CommandExecution {
id: id.clone(),
model_context: assessment.model_context.clone(),
plugin_id: assessment.plugin_id.clone(),
script_path: assessment.script_path.clone(),
command,
@@ -131,6 +131,7 @@ fn guardian_stdin_reviews_preserve_parent_command_history() {
let assessment = GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: "review-stdin".into(),
target_item_id: Some("terminal-command".into()),
plugin_id: None,
@@ -2255,6 +2255,7 @@ mod tests {
.to_string(),
}];
let command_item = CoreTurnItem::CommandExecution(CoreCommandExecutionItem {
model_context: None,
id: "exec-1".to_string(),
plugin_id: Some("sample@openai-curated".to_string()),
script_path: Some("scripts/run.py".to_string()),
@@ -2340,6 +2341,7 @@ mod tests {
assert_eq!(
build_turns_from_rollout_items(&items[..2])[0].items,
vec![ThreadItem::CommandExecution {
model_context: None,
id: "exec-1".to_string(),
plugin_id: Some("sample@openai-curated".to_string()),
script_path: Some("scripts/run.py".to_string()),
@@ -2365,6 +2367,7 @@ mod tests {
assert_eq!(
turns[0].items,
vec![ThreadItem::CommandExecution {
model_context: None,
id: "exec-1".to_string(),
plugin_id: Some("sample@openai-curated".to_string()),
script_path: Some("scripts/run.py".to_string()),
@@ -3051,6 +3054,7 @@ mod tests {
assert_eq!(
turns[0].items[2],
ThreadItem::CommandExecution {
model_context: None,
id: "exec-1".into(),
plugin_id: None,
script_path: None,
@@ -3321,6 +3325,7 @@ mod tests {
assert_eq!(
turns[0].items[1],
ThreadItem::CommandExecution {
model_context: None,
id: "exec-declined".into(),
plugin_id: None,
script_path: None,
@@ -3372,6 +3377,7 @@ mod tests {
}),
EventMsg::GuardianAssessment(GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: "review-guardian-exec".into(),
target_item_id: Some("guardian-exec".into()),
plugin_id: Some("sample@openai-curated".into()),
@@ -3394,6 +3400,7 @@ mod tests {
}),
EventMsg::GuardianAssessment(GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: "review-guardian-exec".into(),
target_item_id: Some("guardian-exec".into()),
plugin_id: Some("sample@openai-curated".into()),
@@ -3428,6 +3435,7 @@ mod tests {
assert_eq!(
turns[0].items[1],
ThreadItem::CommandExecution {
model_context: None,
id: "guardian-exec".into(),
plugin_id: Some("sample@openai-curated".into()),
script_path: Some("scripts/run.py".into()),
@@ -3467,6 +3475,7 @@ mod tests {
}),
EventMsg::GuardianAssessment(GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: "review-guardian-execve".into(),
target_item_id: Some("guardian-execve".into()),
plugin_id: Some("sample@openai-curated".into()),
@@ -3500,6 +3509,7 @@ mod tests {
assert_eq!(
turns[0].items[1],
ThreadItem::CommandExecution {
model_context: None,
id: "guardian-execve".into(),
plugin_id: Some("sample@openai-curated".into()),
script_path: Some("scripts/run.py".into()),
@@ -3701,6 +3711,7 @@ mod tests {
assert_eq!(
turns[0].items[1],
ThreadItem::CommandExecution {
model_context: None,
id: "exec-late".into(),
plugin_id: None,
script_path: None,
@@ -287,6 +287,10 @@ pub enum ThreadItem {
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
CommandExecution {
#[serde(skip)]
#[schemars(skip)]
#[ts(skip)]
model_context: Option<codex_protocol::items::ModelInvocationContext>,
id: String,
/// Trusted first-party plugin id when this command resolves to one plugin script.
#[serde(default)]
@@ -910,6 +914,7 @@ impl From<CoreTurnItem> for ThreadItem {
);
ThreadItem::CommandExecution {
id: command.id,
model_context: command.model_context,
plugin_id: command.plugin_id,
script_path: command.script_path,
command: presentation.command,
@@ -3210,6 +3210,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
);
let command_item = TurnItem::CommandExecution(CommandExecutionItem {
model_context: None,
id: "exec-1".to_string(),
plugin_id: Some("sample@openai-curated".to_string()),
script_path: Some("scripts/run.py".to_string()),
@@ -3242,6 +3243,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
assert_eq!(
ThreadItem::from(command_item),
ThreadItem::CommandExecution {
model_context: None,
id: "exec-1".to_string(),
plugin_id: Some("sample@openai-curated".to_string()),
script_path: Some("scripts/run.py".to_string()),
@@ -92,6 +92,7 @@ use codex_core::ThreadManager;
use codex_features::Feature;
use codex_protocol::ThreadId;
use codex_protocol::items::CollabAgentTool as CoreCollabAgentTool;
use codex_protocol::items::ModelInvocationContext;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile;
use codex_protocol::plan_tool::UpdatePlanArgs;
@@ -129,6 +130,7 @@ enum CommandExecutionApprovalPresentation {
#[derive(Debug, PartialEq)]
struct CommandExecutionCompletionItem {
model_context: Option<ModelInvocationContext>,
plugin_id: Option<String>,
script_path: Option<String>,
command: String,
@@ -294,6 +296,7 @@ pub(crate) async fn apply_bespoke_event_handling(
) {
Some(ThreadItem::CommandExecution {
id,
model_context,
plugin_id,
script_path,
command,
@@ -303,6 +306,7 @@ pub(crate) async fn apply_bespoke_event_handling(
}) => Some((
id,
CommandExecutionCompletionItem {
model_context,
plugin_id,
script_path,
command,
@@ -324,6 +328,7 @@ pub(crate) async fn apply_bespoke_event_handling(
&conversation_id,
assessment_turn_id.clone(),
target_item_id.clone(),
completion_item.model_context.clone(),
completion_item.plugin_id.clone(),
completion_item.script_path.clone(),
completion_item.command.clone(),
@@ -669,6 +674,7 @@ pub(crate) async fn apply_bespoke_event_handling(
.map(CommandExecutionApprovalDecision::from)
.collect::<Vec<_>>();
let ExecApprovalRequestEvent {
model_context,
kind,
call_id,
plugin_id,
@@ -723,6 +729,7 @@ pub(crate) async fn apply_bespoke_event_handling(
CommandExecutionApprovalPresentation::Network(network_approval_context)
} else {
let completion_item = CommandExecutionCompletionItem {
model_context,
plugin_id,
script_path,
command: command_presentation.command,
@@ -751,6 +758,7 @@ pub(crate) async fn apply_bespoke_event_handling(
&conversation_id,
event_turn_id.clone(),
call_id.clone(),
completion_item.model_context.clone(),
completion_item.plugin_id.clone(),
completion_item.script_path.clone(),
completion_item.command.clone(),
@@ -1389,6 +1397,7 @@ async fn start_command_execution_item(
conversation_id: &ThreadId,
turn_id: String,
item_id: String,
model_context: Option<ModelInvocationContext>,
plugin_id: Option<String>,
script_path: Option<String>,
command: String,
@@ -1412,6 +1421,7 @@ async fn start_command_execution_item(
started_at_ms: now_unix_timestamp_ms(),
item: ThreadItem::CommandExecution {
id: item_id,
model_context,
plugin_id,
script_path,
command,
@@ -1456,6 +1466,7 @@ async fn complete_command_execution_item(
let item = ThreadItem::CommandExecution {
id: item_id,
model_context: completion_item.model_context,
plugin_id: completion_item.plugin_id,
script_path: completion_item.script_path,
command: completion_item.command,
@@ -2183,6 +2194,7 @@ mod tests {
fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem {
CommandExecutionCompletionItem {
model_context: None,
plugin_id: Some("sample@openai-curated".to_string()),
script_path: Some("scripts/run.py".to_string()),
command: command.to_string(),
@@ -2217,6 +2229,7 @@ mod tests {
};
GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: format!("review-{id}"),
target_item_id: Some(id.to_string()),
plugin_id: Some("sample@openai-curated".to_string()),
@@ -2285,6 +2298,7 @@ mod tests {
"turn-from-event",
&GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: "review-1".to_string(),
target_item_id: Some("item-1".to_string()),
plugin_id: None,
@@ -2334,6 +2348,7 @@ mod tests {
"turn-from-event",
&GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: "review-2".to_string(),
target_item_id: Some("item-2".to_string()),
plugin_id: None,
@@ -2391,6 +2406,7 @@ mod tests {
"turn-from-event",
&GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: "review-3".to_string(),
target_item_id: None,
plugin_id: None,
@@ -2446,6 +2462,7 @@ mod tests {
&conversation_id,
"turn-1".to_string(),
"cmd-1".to_string(),
completion_item.model_context.clone(),
completion_item.plugin_id.clone(),
completion_item.script_path.clone(),
completion_item.command.clone(),
@@ -2466,6 +2483,7 @@ mod tests {
assert_eq!(
payload.item,
ThreadItem::CommandExecution {
model_context: None,
id: "cmd-1".to_string(),
plugin_id: completion_item.plugin_id.clone(),
script_path: completion_item.script_path.clone(),
@@ -2488,6 +2506,7 @@ mod tests {
&conversation_id,
"turn-1".to_string(),
"cmd-1".to_string(),
completion_item.model_context.clone(),
completion_item.plugin_id.clone(),
completion_item.script_path.clone(),
completion_item.command.clone(),
@@ -2524,6 +2543,7 @@ mod tests {
&conversation_id,
"turn-1".to_string(),
"cmd-1".to_string(),
completion_item.model_context.clone(),
completion_item.plugin_id.clone(),
completion_item.script_path.clone(),
completion_item.command.clone(),
+123 -16
View File
@@ -14,11 +14,15 @@ use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxPolicy;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnSettingsUpdateParams;
use codex_app_server_protocol::TurnSettingsUpdateResponse;
use codex_app_server_protocol::TurnSettingsUpdateStatus;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput;
@@ -29,7 +33,10 @@ use codex_core::config::ConfigBuilder;
use codex_core_plugins::loader::curated_plugin_cache_version;
use codex_core_plugins::store::PluginStore;
use codex_features::Feature;
use codex_models_manager::bundled_models_response;
use codex_plugin::PluginId;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_remote;
@@ -135,6 +142,7 @@ async fn guardian_review_turns_and_tools_reach_analytics() -> Result<()> {
text: "Review three commands".to_string(),
text_elements: Vec::new(),
}],
effort: Some(ReasoningEffort::High),
..Default::default()
})
.await?;
@@ -166,6 +174,19 @@ async fn guardian_review_turns_and_tools_reach_analytics() -> Result<()> {
timeout(READ_TIMEOUT, app_server.shutdown_gracefully()).await??;
let events = captured_analytics_events(&server).await;
assert!(!serde_json::to_string(&events)?.contains(PRIVATE));
let parent_command = events
.iter()
.find(|event| {
event["event_type"] == "codex_command_execution_event"
&& event["event_params"]["thread_id"] == thread.id
&& event["event_params"]["item_id"] == "parent-first"
})
.expect("denied parent command analytics");
let params = &parent_command["event_params"];
assert_eq!(
json!([params["model_slug"], params["reasoning_effort"]]),
json!(["mock-model", "high"])
);
let children = events
.iter()
.filter(|event| event["event_params"]["subagent_source"] == "guardian")
@@ -692,6 +713,7 @@ operations:
&script_path,
r#"test -n "$CODEX_PLUGIN_METRICS_OUTPUT"
sleep "${1:-0.3}"
while [ -n "${2:-}" ] && [ ! -f "$2" ]; do sleep 0.01; done
printf '%s' '{"version":1,"measurements":[{"name":"findings","value":3,"dimensions":{"severity":"high"}},{"name":"files_scanned","value":17}]}' > "$CODEX_PLUGIN_METRICS_OUTPUT"
"#,
)?;
@@ -724,12 +746,14 @@ async fn assert_plugin_measurement_analytics(remote: bool, background: bool) ->
let codex_home = TempDir::new()?;
let script_path = write_curated_metrics_plugin(codex_home.path())?.canonicalize()?;
let release_path = codex_home.path().join("release-command");
let mut command = vec![
"/bin/sh".to_string(),
script_path.to_string_lossy().into_owned(),
];
if background {
command.push("1.0".to_string());
command.push(release_path.to_string_lossy().into_owned());
}
let call_id = "curated-plugin-metrics";
let arguments = serde_json::to_string(&json!({
@@ -742,8 +766,32 @@ async fn assert_plugin_measurement_analytics(remote: bool, background: bool) ->
responses::ev_completed("resp-1"),
]);
let final_response = create_final_assistant_message_sse_response("done")?;
let server =
create_mock_responses_server_sequence(vec![command_response, final_response]).await;
let pause_response = |id: &str| {
responses::sse(vec![
responses::ev_response_created(id),
responses::ev_function_call(
id,
"request_user_input",
&json!({
"questions": [{
"id": "continue", "header": "Continue", "question": "Continue?",
"options": [
{"label": "Yes", "description": "Continue the turn."},
{"label": "No", "description": "Stop the turn."}
]
}]
})
.to_string(),
),
responses::ev_completed(id),
])
};
let mut response_sequence = vec![pause_response("before-command"), command_response];
if background {
response_sequence.push(pause_response("after-launch"));
}
response_sequence.push(final_response);
let server = create_mock_responses_server_sequence(response_sequence).await;
let analytics_server = responses::start_mock_server().await;
write_mock_responses_config_toml_with_chatgpt_base_url(
@@ -753,11 +801,40 @@ async fn assert_plugin_measurement_analytics(remote: bool, background: bool) ->
)?;
let config_path = codex_home.path().join("config.toml");
let config = std::fs::read_to_string(&config_path)?;
let model = bundled_models_response()?
.models
.into_iter()
.find(|model| model.slug == "gpt-5.4")
.expect("bundled gpt-5.4 model");
let models = [
("initial-model", ReasoningEffort::Low),
("invoking-model", ReasoningEffort::High),
]
.into_iter()
.map(|(slug, effort)| {
let mut model = model.clone();
model.slug = slug.to_string();
model.default_reasoning_level = Some(effort);
model
})
.collect();
let catalog_path = codex_home.path().join("measurement-models.json");
std::fs::write(
&catalog_path,
serde_json::to_vec(&ModelsResponse { models })?,
)?;
let catalog_config = format!(
"model_catalog_json = {}",
serde_json::to_string(&catalog_path)?
);
std::fs::write(
config_path,
format!(
r#"{config}
r#"{catalog_config}
{config}
[features]
step_model_switching = true
default_mode_request_user_input = true
plugins = true
remote_plugin = false
unified_exec = true
@@ -787,7 +864,7 @@ enabled = true
timeout(Duration::from_secs(10), mcp.initialize()).await??;
let thread_request = mcp
.send_thread_start_request_with_auto_env(ThreadStartParams {
model: Some("mock-model".to_string()),
model: Some("initial-model".to_string()),
service_name: Some("codex_work_desktop".to_string()),
..Default::default()
})
@@ -813,11 +890,43 @@ enabled = true
..Default::default()
})
.await?;
timeout(
let turn_response = timeout(
Duration::from_secs(10),
mcp.read_stream_until_response_message(RequestId::Integer(turn_request)),
)
.await??;
let turn_response: TurnStartResponse = to_response(turn_response)?;
for model in if background {
vec!["invoking-model", "initial-model"]
} else {
vec!["invoking-model"]
} {
let request = timeout(
Duration::from_secs(10),
mcp.read_stream_until_request_message(),
)
.await??;
let ServerRequest::ToolRequestUserInput { request_id, .. } = request else {
anyhow::bail!("expected request_user_input, received {request:?}");
};
let response: TurnSettingsUpdateResponse = mcp
.request(|request_id| ClientRequest::TurnSettingsUpdate {
request_id,
params: TurnSettingsUpdateParams {
thread_id: thread_id.clone(),
turn_id: turn_response.turn.id.clone(),
model: Some(model.to_string()),
..Default::default()
},
})
.await?;
assert_eq!(response.status, TurnSettingsUpdateStatus::Applied);
mcp.send_response(
request_id,
json!({"answers": {"continue": {"answers": ["Yes"]}}}),
)
.await?;
}
let completed_turn = timeout(
Duration::from_secs(10),
mcp.read_stream_until_notification_message("turn/completed"),
@@ -830,17 +939,7 @@ enabled = true
.expect("completed turn id");
if background {
let model_request_bodies = server
.received_requests()
.await
.unwrap_or_default()
.into_iter()
.filter(|request| request.url.path().ends_with("/responses"))
.map(|request| serde_json::from_slice::<Value>(&request.body))
.collect::<Result<Vec<_>, _>>()?;
let request_text = serde_json::to_string(&model_request_bodies)?;
assert!(request_text.contains("Process running with session ID "));
assert!(!request_text.contains("Process exited with code 0"));
std::fs::write(release_path, "release")?;
}
for measurement_name in ["findings", "files_scanned"] {
@@ -859,12 +958,16 @@ enabled = true
.await?;
assert_eq!(
json!({
"model_slug": command_event["event_params"]["model_slug"],
"reasoning_effort": command_event["event_params"]["reasoning_effort"],
"plugin_id": command_event["event_params"]["plugin_id"],
"script_path": command_event["event_params"]["script_path"],
"item_id": command_event["event_params"]["item_id"],
"exit_code": command_event["event_params"]["exit_code"],
}),
json!({
"model_slug": "invoking-model",
"reasoning_effort": "high",
"plugin_id": METRICS_PLUGIN_ID,
"script_path": "scripts/run.sh",
"item_id": call_id,
@@ -952,10 +1055,12 @@ enabled = true
"execution_id",
"item_id",
"measurement_name",
"model_slug",
"number_value",
"operation",
"originator",
"plugin_id",
"reasoning_effort",
"thread_id",
"turn_id",
]
@@ -963,6 +1068,8 @@ enabled = true
assert_eq!(event_params["thread_id"], thread_id);
assert_eq!(event_params["turn_id"], turn_id);
assert_eq!(event_params["originator"], "codex_work_desktop");
assert_eq!(event_params["model_slug"], "invoking-model");
assert_eq!(event_params["reasoning_effort"], "high");
}
Ok(())
+13
View File
@@ -20,6 +20,7 @@ mod review_session;
mod reviewer_config;
mod runtime;
use codex_protocol::items::ModelInvocationContext;
use std::sync::Arc;
use codex_protocol::config_types::ApprovalsReviewer;
@@ -82,6 +83,7 @@ pub(crate) struct GuardianReviewContext {
pub(crate) parent_response_id: Option<String>,
turn: Arc<TurnContext>,
environments: TurnEnvironmentSnapshot,
// Model and reasoning inputs are carried for the follow-up Guardian and V2 migrations.
pub(crate) model_info: Arc<ModelInfo>,
pub(crate) reasoning_effort: Option<ReasoningEffort>,
pub(crate) reasoning_summary: ReasoningSummary,
@@ -91,6 +93,17 @@ pub(crate) struct GuardianReviewContext {
}
impl GuardianReviewContext {
pub(crate) fn model_context(&self) -> ModelInvocationContext {
ModelInvocationContext {
model_slug: self.model_info.slug.clone(),
reasoning_effort: self
.reasoning_effort
.as_ref()
.or(self.model_info.default_reasoning_level.as_ref())
.map(ToString::to_string),
}
}
pub(crate) fn from_resolved_settings(
turn: Arc<TurnContext>,
settings: &ResolvedStepSettings,
@@ -44,6 +44,7 @@ impl ReviewHost for super::super::runtime::ReviewRuntime {
Ok(request) => request.clone(),
Err(decision) => return Err(decision),
};
let model_context = context.model_context();
let turn = Arc::clone(context.turn());
let GuardianReviewOptions {
plugin_attribution_override,
@@ -101,6 +102,7 @@ impl ReviewHost for super::super::runtime::ReviewRuntime {
reviewed_action: guardian_reviewed_action(&request),
action: guardian_assessment_action(&request),
review_reason,
model_context,
});
session
.send_event(
+4
View File
@@ -4,6 +4,7 @@ use crate::tools::sandboxing::ToolCtx;
use codex_analytics::PluginMeasurementsInput;
use codex_core_plugins::PluginMetricsSidecar;
use codex_exec_server::Environment;
use codex_protocol::items::ModelInvocationContext;
use codex_utils_path_uri::PathUri;
/// Creates a metrics sidecar for one plugin command.
@@ -34,6 +35,7 @@ pub(crate) async fn finish_and_track_measurements(
exit_code: i32,
session: &Session,
turn: &TurnContext,
model_context: &ModelInvocationContext,
item_id: &str,
) {
let Some(metrics_sidecar) = metrics_sidecar else {
@@ -50,6 +52,8 @@ pub(crate) async fn finish_and_track_measurements(
turn_id: turn.sub_id.clone(),
item_id: item_id.to_string(),
originator: turn.originator.clone(),
model_slug: Some(model_context.model_slug.clone()),
reasoning_effort: model_context.reasoning_effort.clone(),
plugin_id: batch.plugin_id,
execution_id: batch.execution_id,
operation: batch.operation,
@@ -52,6 +52,8 @@ async fn command_approval_holds_an_elicitation_until_response() {
.request_command_approval(
turn_context.as_ref(),
ExecApprovalKind::Command,
crate::guardian::GuardianReviewContext::from(turn_context.clone())
.model_context(),
"call-1".to_string(),
/*approval_id*/ None,
/*environment_id*/ None,
+4 -1
View File
@@ -1,4 +1,3 @@
use crate::context::GuardianContextMode;
use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::HashSet;
@@ -23,6 +22,7 @@ use crate::config::ManagedFeatures;
use crate::config::resolve_tool_suggest_config_from_layer_stack;
use crate::context::ContextualUserFragment;
use crate::context::DeveloperInstructions;
use crate::context::GuardianContextMode;
use crate::context::GuardianPolicy;
use crate::context::ManagedDeveloperInstructions;
use crate::context::ModelSwitchInstructions;
@@ -107,6 +107,7 @@ use codex_protocol::config_types::WebSearchMode;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::items::EnteredReviewModeItem;
use codex_protocol::items::ModelInvocationContext;
use codex_protocol::items::SubAgentActivityItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
@@ -2790,6 +2791,7 @@ impl Session {
&self,
turn_context: &TurnContext,
kind: ExecApprovalKind,
model_context: ModelInvocationContext,
call_id: String,
approval_id: Option<String>,
environment_id: Option<String>,
@@ -2853,6 +2855,7 @@ impl Session {
.map(PluginCommandAttribution::serialized_fields)
.unzip();
let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
model_context: Some(model_context),
kind,
call_id,
plugin_id,
+11
View File
@@ -12,6 +12,7 @@ use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
use codex_exec_server::ResolvedSelectedCapabilityRoot;
use codex_mcp::McpBinding;
use codex_otel::SessionTelemetry;
use codex_protocol::items::ModelInvocationContext;
use codex_protocol::protocol::TurnContextItem;
/// Request-scoped state that may change between model sampling requests.
@@ -43,4 +44,14 @@ impl StepContext {
item.summary = self.settings.reasoning_summary;
item
}
pub(crate) fn model_context(&self) -> ModelInvocationContext {
ModelInvocationContext {
model_slug: self.settings.model_info.slug.clone(),
reasoning_effort: self
.settings
.effective_reasoning_effort()
.map(|effort| effort.to_string()),
}
}
}
+4
View File
@@ -188,6 +188,7 @@ pub(crate) async fn execute_user_shell_command(
.emit_turn_item_started(
turn_context.as_ref(),
&TurnItem::CommandExecution(CommandExecutionItem {
model_context: None,
id: call_id.clone(),
plugin_id: None,
script_path: None,
@@ -271,6 +272,7 @@ pub(crate) async fn execute_user_shell_command(
.emit_turn_item_completed(
turn_context.as_ref(),
TurnItem::CommandExecution(CommandExecutionItem {
model_context: None,
id: call_id,
plugin_id: None,
script_path: None,
@@ -296,6 +298,7 @@ pub(crate) async fn execute_user_shell_command(
.emit_turn_item_completed(
turn_context.as_ref(),
TurnItem::CommandExecution(CommandExecutionItem {
model_context: None,
id: call_id.clone(),
plugin_id: None,
script_path: None,
@@ -341,6 +344,7 @@ pub(crate) async fn execute_user_shell_command(
.emit_turn_item_completed(
turn_context.as_ref(),
TurnItem::CommandExecution(CommandExecutionItem {
model_context: None,
id: call_id,
plugin_id: None,
script_path: None,
+4
View File
@@ -701,6 +701,7 @@ impl Session {
self.request_command_approval(
ctx.review_context.turn(),
ExecApprovalKind::Command,
ctx.review_context.model_context(),
ctx.call_id.clone(),
/*approval_id*/ None,
Some(environment_id.clone()),
@@ -730,6 +731,7 @@ impl Session {
self.request_command_approval(
ctx.review_context.turn(),
ExecApprovalKind::WriteStdin,
ctx.review_context.model_context(),
id.clone(),
Some(approval_id.clone()),
Some(environment_id.clone()),
@@ -761,6 +763,7 @@ impl Session {
self.request_command_approval(
ctx.review_context.turn(),
ExecApprovalKind::Command,
ctx.review_context.model_context(),
ctx.call_id.clone(),
Some(approval_id.clone()),
Some(environment_id.clone()),
@@ -833,6 +836,7 @@ impl Session {
self.request_command_approval(
ctx.review_context.turn(),
ExecApprovalKind::Command,
ctx.review_context.model_context(),
ctx.call_id.clone(),
/*approval_id*/ None,
Some(environment_id.clone()),
+5
View File
@@ -17,6 +17,7 @@ use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::items::CommandExecutionItem;
use codex_protocol::items::CommandExecutionStatus;
use codex_protocol::items::FileChangeItem;
use codex_protocol::items::ModelInvocationContext;
use codex_protocol::items::TurnItem;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::parse_command::ParsedCommand;
@@ -43,6 +44,7 @@ pub(super) fn truncate_rejection_message(message: &str) -> String {
#[derive(Clone, Copy)]
pub(crate) struct ToolEventCtx<'a> {
pub model_context: Option<&'a ModelInvocationContext>,
pub session: &'a Session,
pub turn: &'a TurnContext,
/// Model captured by the step that issued this call, including delayed completion events.
@@ -60,6 +62,7 @@ impl<'a> ToolEventCtx<'a> {
turn_diff_tracker: Option<&'a SharedTurnDiffTracker>,
) -> Self {
Self {
model_context: None,
session,
turn,
model_info,
@@ -168,6 +171,7 @@ async fn emit_exec_command_begin(ctx: ToolEventCtx<'_>, exec_input: &ExecCommand
ctx.turn,
&TurnItem::CommandExecution(CommandExecutionItem {
id: ctx.call_id.to_string(),
model_context: ctx.model_context.cloned(),
plugin_id,
script_path,
process_id: exec_input.process_id.map(str::to_owned),
@@ -581,6 +585,7 @@ async fn emit_exec_end(
ctx.turn,
TurnItem::CommandExecution(CommandExecutionItem {
id: ctx.call_id.to_string(),
model_context: ctx.model_context.cloned(),
plugin_id,
script_path,
process_id: exec_input.process_id.map(str::to_owned),
@@ -178,6 +178,7 @@ pub(crate) fn spawn_exit_watcher(
let session_ref = Arc::clone(&context.session);
let turn_ref = Arc::clone(&context.step_context.turn);
let model_info = Arc::clone(&context.step_context.settings.model_info);
let model_context = context.step_context.model_context();
let call_id = context.call_id.clone();
let exit_token = process.cancellation_token();
let output_drained = process.output_drained_notify();
@@ -223,6 +224,7 @@ pub(crate) fn spawn_exit_watcher(
exit_code,
&session_ref,
&turn_ref,
&model_context,
&call_id,
)
.await;
@@ -276,11 +276,13 @@ struct InitialExecCommandGuard {
impl InitialExecCommandGuard {
async fn finish_plugin_metrics(&mut self, context: &UnifiedExecContext, exit_code: i32) {
let model_context = context.step_context.model_context();
finish_and_track_measurements(
self.metrics_sidecar.take(),
exit_code,
&context.session,
&context.step_context.turn,
&model_context,
&context.call_id,
)
.await;
@@ -530,13 +532,15 @@ impl UnifiedExecProcessManager {
});
let transcript = Arc::new(tokio::sync::Mutex::new(HeadTailBuffer::default()));
let event_ctx = ToolEventCtx::new(
let model_context = context.step_context.model_context();
let mut event_ctx = ToolEventCtx::new(
context.session.as_ref(),
context.step_context.turn.as_ref(),
&context.step_context.settings.model_info,
&context.call_id,
/*turn_diff_tracker*/ None,
);
event_ctx.model_context = Some(&model_context);
let plugin_attribution = if request.turn_environment.environment.is_remote() {
let file_system = request.turn_environment.environment.get_filesystem();
context
@@ -727,6 +731,7 @@ impl UnifiedExecProcessManager {
exit_code.unwrap_or(-1),
&context.session,
&context.step_context.turn,
&model_context,
&context.call_id,
)
.await;
@@ -76,6 +76,7 @@ async fn first_request_item_types_roles_and_content_annotations() -> Result<()>
.submit(Op::ApproveGuardianDeniedAction {
event: GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: "guardian-review".to_string(),
target_item_id: None,
plugin_id: None,
@@ -169,6 +169,7 @@ fn turn_started_emits_turn_started_event() {
fn command_execution_started_and_completed_translate_to_thread_events() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let command_item = ThreadItem::CommandExecution {
model_context: None,
id: "cmd-1".to_string(),
command: "ls".to_string(),
cwd: test_path_buf("/tmp/project").abs().into(),
@@ -211,6 +212,7 @@ fn command_execution_started_and_completed_translate_to_thread_events() {
let completed = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::CommandExecution {
model_context: None,
id: "cmd-1".to_string(),
command: "ls".to_string(),
cwd: test_path_buf("/tmp/project").abs().into(),
@@ -1348,6 +1350,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() {
let started =
processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification {
item: ThreadItem::CommandExecution {
model_context: None,
id: "cmd-1".to_string(),
command: "ls".to_string(),
cwd: test_path_buf("/tmp/project").abs().into(),
@@ -1390,6 +1393,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() {
id: "turn-1".to_string(),
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![ThreadItem::CommandExecution {
model_context: None,
id: "cmd-1".to_string(),
command: "ls".to_string(),
cwd: test_path_buf("/tmp/project").abs().into(),
@@ -12,6 +12,7 @@ use codex_extension_api::ExtensionData;
use codex_otel::SessionTelemetry;
use codex_protocol::approvals::GuardianAssessmentAction;
use codex_protocol::approvals::GuardianReviewReason;
use codex_protocol::items::ModelInvocationContext;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::GuardianAssessmentDecisionSource;
use codex_protocol::protocol::GuardianAssessmentEvent;
@@ -36,6 +37,7 @@ pub struct ReviewMetadata {
pub reviewed_action: GuardianReviewedAction,
pub action: GuardianAssessmentAction,
pub review_reason: GuardianReviewReason,
pub model_context: ModelInvocationContext,
}
pub struct ReviewReport {
@@ -58,6 +60,7 @@ impl ReviewReport {
);
let started = GuardianAssessmentEvent {
review_reason: Some(metadata.review_reason),
model_context: Some(metadata.model_context),
id: metadata.review_id,
target_item_id: metadata.target_item_id,
plugin_id: metadata.plugin_id,
+8
View File
@@ -208,6 +208,10 @@ pub struct GuardianAssessmentEvent {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub review_reason: Option<GuardianReviewReason>,
#[serde(skip)]
#[schemars(skip)]
#[ts(skip)]
pub model_context: Option<crate::items::ModelInvocationContext>,
/// Stable identifier for this guardian review lifecycle.
pub id: String,
/// Thread item being reviewed, when the review maps to a concrete item.
@@ -264,6 +268,10 @@ pub enum ExecApprovalKind {
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ExecApprovalRequestEvent {
#[serde(skip)]
#[schemars(skip)]
#[ts(skip)]
pub model_context: Option<crate::items::ModelInvocationContext>,
/// Missing on older events, which retain command approval semantics.
#[serde(default)]
pub kind: ExecApprovalKind,
+11
View File
@@ -232,8 +232,19 @@ pub fn is_safe_plugin_relative_path(path: &str) -> bool {
})
}
/// Immutable model labels carried within command lifecycle events for analytics.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelInvocationContext {
pub model_slug: String,
pub reasoning_effort: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
pub struct CommandExecutionItem {
#[serde(skip)]
#[schemars(skip)]
#[ts(skip)]
pub model_context: Option<ModelInvocationContext>,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
+2
View File
@@ -5516,6 +5516,7 @@ mod tests {
turn_id: "turn-1".into(),
started_at_ms: 10,
item: TurnItem::CommandExecution(CommandExecutionItem {
model_context: None,
id: "exec-1".into(),
plugin_id: Some("sample@openai-curated".into()),
script_path: Some("scripts/run.py".into()),
@@ -5542,6 +5543,7 @@ mod tests {
started_at_ms: Some(10),
completed_at_ms: 20,
item: TurnItem::CommandExecution(CommandExecutionItem {
model_context: None,
id: "exec-1".into(),
plugin_id: Some("sample@openai-curated".into()),
script_path: Some("scripts/run.py".into()),
@@ -213,6 +213,7 @@ pub(super) fn completed_item(
)),
EventMsg::ExecCommandEnd(event) => Some((
TurnItem::CommandExecution(CommandExecutionItem {
model_context: None,
id: event.call_id.clone(),
plugin_id: event.plugin_id.clone(),
script_path: event.script_path.clone(),
@@ -10,6 +10,7 @@ fn agent_status_uses_bounded_buffered_activity() {
store.push_notification(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::CommandExecution {
model_context: None,
id: "command-1".to_string(),
command: "cargo test -p codex-tui".to_string(),
cwd: AbsolutePathBuf::try_from("/workspace")
+1
View File
@@ -95,6 +95,7 @@ mod tests {
fn denied_event(id: usize) -> GuardianAssessmentEvent {
GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: format!("review-{id}"),
target_item_id: None,
plugin_id: None,
@@ -194,6 +194,7 @@ mod tests {
fn command_execution(call_id: &str) -> ThreadItem {
ThreadItem::CommandExecution {
model_context: None,
id: call_id.to_string(),
command: "true".to_string(),
cwd: AbsolutePathBuf::current_dir().expect("current dir").into(),
@@ -96,6 +96,7 @@ impl ChatWidget {
self.on_guardian_assessment(GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id,
target_item_id: None,
plugin_id: None,
@@ -1080,6 +1080,7 @@ async fn live_app_server_command_execution_strips_shell_wrapper() {
turn_id: "turn-1".to_string(),
started_at_ms: 0,
item: AppServerThreadItem::CommandExecution {
model_context: None,
id: "cmd-1".to_string(),
command: command.clone(),
cwd: test_path_buf("/tmp").abs().into(),
@@ -1104,6 +1105,7 @@ async fn live_app_server_command_execution_strips_shell_wrapper() {
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
item: AppServerThreadItem::CommandExecution {
model_context: None,
id: "cmd-1".to_string(),
command,
cwd: test_path_buf("/tmp").abs().into(),
@@ -164,6 +164,7 @@ async fn replayed_commands_preserve_individual_output_and_failure_status() {
let cwd = chat.config.cwd.clone();
let replayed_command =
|id: &str, output: &str, source: ExecCommandSource| AppServerThreadItem::CommandExecution {
model_context: None,
id: id.to_string(),
command: format!("printf {output}"),
cwd: cwd.clone().into(),
@@ -612,6 +613,7 @@ async fn exec_end_without_begin_uses_event_command() {
handle_exec_end(
&mut chat,
AppServerThreadItem::CommandExecution {
model_context: None,
id: "call-orphan".to_string(),
command: codex_shell_command::parse_command::shlex_join(&command),
cwd: cwd.into(),
@@ -4,6 +4,7 @@ use pretty_assertions::assert_eq;
fn auto_review_denial_event() -> GuardianAssessmentEvent {
GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: "auto-review-recent-1".into(),
target_item_id: Some("target-auto-review-recent-1".into()),
plugin_id: None,
@@ -33,6 +34,7 @@ fn guardian_command_event(
let terminal = status != GuardianAssessmentStatus::InProgress;
GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: id.to_string(),
target_item_id: Some(format!("{id}-target")),
plugin_id: None,
@@ -247,6 +249,7 @@ async fn guardian_denied_exec_renders_warning_and_denied_request() {
chat.on_guardian_assessment(GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: "guardian-1".into(),
target_item_id: Some("guardian-target-1".into()),
plugin_id: None,
@@ -264,6 +267,7 @@ async fn guardian_denied_exec_renders_warning_and_denied_request() {
chat.on_warning("Automatic approval review denied (risk: high): The planned action would transmit the full contents of a workspace source file (`core/src/codex.rs`) to `https://example.com`, which is an external and untrusted endpoint.");
chat.on_guardian_assessment(GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: "guardian-1".into(),
target_item_id: Some("guardian-target-1".into()),
plugin_id: None,
@@ -318,6 +322,7 @@ async fn guardian_approved_exec_is_hidden_from_history() {
);
chat.on_guardian_assessment(GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: "thread:child-thread:guardian-1".into(),
target_item_id: Some("guardian-approved-target".into()),
plugin_id: None,
@@ -376,6 +381,7 @@ async fn guardian_approved_request_permissions_clears_status_without_history() {
chat.on_guardian_assessment(GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: "guardian-request-permissions".into(),
target_item_id: None,
plugin_id: None,
@@ -403,6 +409,7 @@ async fn guardian_approved_request_permissions_clears_status_without_history() {
chat.on_guardian_assessment(GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: "guardian-request-permissions".into(),
target_item_id: None,
plugin_id: None,
@@ -456,6 +463,7 @@ async fn guardian_timed_out_exec_renders_warning_and_timed_out_request() {
chat.on_guardian_assessment(GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: "guardian-1".into(),
target_item_id: Some("guardian-target-1".into()),
plugin_id: None,
@@ -473,6 +481,7 @@ async fn guardian_timed_out_exec_renders_warning_and_timed_out_request() {
chat.on_warning("Automatic approval review timed out while evaluating the requested approval.");
chat.on_guardian_assessment(GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: "guardian-1".into(),
target_item_id: Some("guardian-target-1".into()),
plugin_id: None,
@@ -883,6 +883,7 @@ pub(super) fn begin_exec_with_source(
.map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd))
.collect();
let item = AppServerThreadItem::CommandExecution {
model_context: None,
id: call_id.to_string(),
command: codex_shell_command::parse_command::shlex_join(&command),
cwd: chat.config.cwd.clone().into(),
@@ -908,6 +909,7 @@ pub(super) fn begin_unified_exec_startup(
) -> AppServerThreadItem {
let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()];
let item = AppServerThreadItem::CommandExecution {
model_context: None,
id: call_id.to_string(),
command: codex_shell_command::parse_command::shlex_join(&command),
cwd: chat.config.cwd.clone().into(),
@@ -1142,6 +1144,7 @@ pub(super) fn end_exec(
handle_exec_end(
chat,
AppServerThreadItem::CommandExecution {
model_context: None,
id,
command,
cwd,
@@ -535,6 +535,7 @@ async fn mcp_startup_complete_preserves_review_status() {
chat.on_guardian_assessment(GuardianAssessmentEvent {
review_reason: None,
model_context: None,
id: "guardian-1".to_string(),
target_item_id: Some("guardian-target-1".to_string()),
plugin_id: None,
@@ -5668,6 +5668,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() {
handle_exec_begin(
&mut chat,
AppServerThreadItem::CommandExecution {
model_context: None,
id: "c1".into(),
command: codex_shell_command::parse_command::shlex_join(&command),
cwd: cwd.clone().into(),
@@ -5685,6 +5686,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() {
handle_exec_end(
&mut chat,
AppServerThreadItem::CommandExecution {
model_context: None,
id: "c1".into(),
command: codex_shell_command::parse_command::shlex_join(&command),
cwd: cwd.into(),