diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 6d6d44656054..bc9618c454ac 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -171,9 +171,10 @@ impl AnalyticsEventsClient { &self, tracking: &GuardianReviewTrackContext, result: GuardianReviewAnalyticsResult, + completed_at_ms: u64, ) { self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::GuardianReview( - Box::new(tracking.event_params(result)), + Box::new(tracking.event_params(result, completed_at_ms)), ))); } @@ -340,8 +341,9 @@ impl AnalyticsEventsClient { }); } - pub fn track_server_response(&self, response: ServerResponse) { + pub fn track_server_response(&self, completed_at_ms: u64, response: ServerResponse) { self.record_fact(AnalyticsFact::ServerResponse { + completed_at_ms, response: Box::new(response), }); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 2bcd43f3808e..2c13d3f94d2d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -18,7 +18,6 @@ use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; -use crate::now_unix_seconds; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CommandExecutionSource; use codex_login::default_client::originator; @@ -70,6 +69,8 @@ pub(crate) enum TrackEventRequest { CollabAgentToolCall(CodexCollabAgentToolCallEventRequest), WebSearch(CodexWebSearchEventRequest), ImageGeneration(CodexImageGenerationEventRequest), + #[allow(dead_code)] + ReviewEvent(CodexReviewEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -259,36 +260,41 @@ pub struct GuardianReviewTrackContext { approval_request_source: GuardianApprovalRequestSource, reviewed_action: GuardianReviewedAction, review_timeout_ms: u64, - started_at: u64, + started_at_ms: u64, started_instant: Instant, } +pub struct GuardianReviewTrackContextInit { + pub thread_id: String, + pub turn_id: String, + pub review_id: String, + pub target_item_id: Option, + pub approval_request_source: GuardianApprovalRequestSource, + pub reviewed_action: GuardianReviewedAction, + pub review_timeout_ms: u64, + pub started_at_ms: u64, + pub started_instant: Instant, +} + impl GuardianReviewTrackContext { - pub fn new( - thread_id: String, - turn_id: String, - review_id: String, - target_item_id: Option, - approval_request_source: GuardianApprovalRequestSource, - reviewed_action: GuardianReviewedAction, - review_timeout_ms: u64, - ) -> Self { + pub fn new(init: GuardianReviewTrackContextInit) -> Self { Self { - thread_id, - turn_id, - review_id, - target_item_id, - approval_request_source, - reviewed_action, - review_timeout_ms, - started_at: now_unix_seconds(), - started_instant: Instant::now(), + thread_id: init.thread_id, + turn_id: init.turn_id, + review_id: init.review_id, + target_item_id: init.target_item_id, + approval_request_source: init.approval_request_source, + reviewed_action: init.reviewed_action, + review_timeout_ms: init.review_timeout_ms, + started_at_ms: init.started_at_ms, + started_instant: init.started_instant, } } pub(crate) fn event_params( &self, result: GuardianReviewAnalyticsResult, + completed_at_ms: u64, ) -> GuardianReviewEventParams { GuardianReviewEventParams { thread_id: self.thread_id.clone(), @@ -314,8 +320,8 @@ impl GuardianReviewTrackContext { tool_call_count: None, time_to_first_token_ms: result.time_to_first_token_ms, completion_latency_ms: Some(self.started_instant.elapsed().as_millis() as u64), - started_at: self.started_at, - completed_at: Some(now_unix_seconds()), + started_at: self.started_at_ms / 1_000, + completed_at: Some(completed_at_ms / 1_000), input_tokens: result.token_usage.as_ref().map(|usage| usage.input_tokens), cached_input_tokens: result .token_usage @@ -462,6 +468,81 @@ pub(crate) struct CodexToolItemEventBase { pub(crate) requested_network_access: bool, } +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ReviewSubjectKind { + CommandExecution, + FileChange, + McpToolCall, + Permissions, + NetworkAccess, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Reviewer { + Guardian, + User, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ReviewTrigger { + Initial, + SandboxDenial, + NetworkPolicyDenial, + ExecveIntercept, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ReviewStatus { + Approved, + Denied, + Aborted, + TimedOut, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ReviewResolution { + None, + SessionApproval, + ExecPolicyAmendment, + NetworkPolicyAmendment, +} + +#[derive(Serialize)] +pub(crate) struct CodexReviewEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) item_id: Option, + pub(crate) review_id: String, + pub(crate) thread_source: Option<&'static str>, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) tool_kind: ReviewSubjectKind, + pub(crate) tool_name: String, + pub(crate) reviewer: Reviewer, + pub(crate) trigger: ReviewTrigger, + pub(crate) status: ReviewStatus, + pub(crate) resolution: ReviewResolution, + pub(crate) started_at_ms: Option, + pub(crate) completed_at_ms: u64, + pub(crate) duration_ms: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexReviewEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexReviewEventParams, +} +#[allow(dead_code)] #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub(crate) enum WebSearchActionKind { diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 861e6534a2aa..d0446e8c0ca2 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -296,6 +296,7 @@ pub(crate) enum AnalyticsFact { request: Box, }, ServerResponse { + completed_at_ms: u64, response: Box, }, Notification(Box), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 2fb23199cb64..b3f7f3976027 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -16,6 +16,7 @@ pub use events::GuardianReviewFailureReason; pub use events::GuardianReviewSessionKind; pub use events::GuardianReviewTerminalStatus; pub use events::GuardianReviewTrackContext; +pub use events::GuardianReviewTrackContextInit; pub use events::GuardianReviewedAction; pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 81530444de48..4aa0edceefdf 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -325,6 +325,7 @@ impl AnalyticsReducer { } => {} AnalyticsFact::ServerResponse { response: _response, + .. } => {} AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index ce587a7f106b..5b6c4cd18534 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -593,6 +593,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -602,6 +607,7 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], diff --git a/codex-rs/app-server-protocol/schema/json/FileChangeRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/FileChangeRequestApprovalParams.json index f52e98cd0da5..f17388aa53a4 100644 --- a/codex-rs/app-server-protocol/schema/json/FileChangeRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/FileChangeRequestApprovalParams.json @@ -18,6 +18,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -27,6 +32,7 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json index adb50dee4351..1383da6124e3 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -297,6 +297,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -308,6 +313,7 @@ "cwd", "itemId", "permissions", + "startedAtMs", "threadId", "turnId" ], diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 5dc3c09a44d9..4e9e63d30273 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1963,6 +1963,11 @@ "action": { "$ref": "#/definitions/GuardianApprovalReviewAction" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review completed.", + "format": "int64", + "type": "integer" + }, "decisionSource": { "$ref": "#/definitions/AutoReviewDecisionSource" }, @@ -1973,6 +1978,11 @@ "description": "Stable identifier for this review.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, "targetItemId": { "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", "type": [ @@ -1989,9 +1999,11 @@ }, "required": [ "action", + "completedAtMs", "decisionSource", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], @@ -2010,6 +2022,11 @@ "description": "Stable identifier for this review.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, "targetItemId": { "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", "type": [ @@ -2028,6 +2045,7 @@ "action", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 51cab50810fd..9844eac0b835 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -417,6 +417,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -426,6 +431,7 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], @@ -598,6 +604,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -607,6 +618,7 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], @@ -1587,6 +1599,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -1598,6 +1615,7 @@ "cwd", "itemId", "permissions", + "startedAtMs", "threadId", "turnId" ], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 06ccdb48b263..46ede3ac0941 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -2218,6 +2218,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -2227,6 +2232,7 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], @@ -2483,6 +2489,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -2492,6 +2503,7 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], @@ -3663,6 +3675,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -3674,6 +3691,7 @@ "cwd", "itemId", "permissions", + "startedAtMs", "threadId", "turnId" ], @@ -10244,6 +10262,11 @@ "action": { "$ref": "#/definitions/v2/GuardianApprovalReviewAction" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review completed.", + "format": "int64", + "type": "integer" + }, "decisionSource": { "$ref": "#/definitions/v2/AutoReviewDecisionSource" }, @@ -10254,6 +10277,11 @@ "description": "Stable identifier for this review.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, "targetItemId": { "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", "type": [ @@ -10270,9 +10298,11 @@ }, "required": [ "action", + "completedAtMs", "decisionSource", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], @@ -10293,6 +10323,11 @@ "description": "Stable identifier for this review.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, "targetItemId": { "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", "type": [ @@ -10311,6 +10346,7 @@ "action", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 852cc2489d08..ac5828d85f54 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6855,6 +6855,11 @@ "action": { "$ref": "#/definitions/GuardianApprovalReviewAction" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review completed.", + "format": "int64", + "type": "integer" + }, "decisionSource": { "$ref": "#/definitions/AutoReviewDecisionSource" }, @@ -6865,6 +6870,11 @@ "description": "Stable identifier for this review.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, "targetItemId": { "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", "type": [ @@ -6881,9 +6891,11 @@ }, "required": [ "action", + "completedAtMs", "decisionSource", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], @@ -6904,6 +6916,11 @@ "description": "Stable identifier for this review.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, "targetItemId": { "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", "type": [ @@ -6922,6 +6939,7 @@ "action", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json index 98f44e50a2cf..991d4de0504a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json @@ -574,6 +574,11 @@ "action": { "$ref": "#/definitions/GuardianApprovalReviewAction" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review completed.", + "format": "int64", + "type": "integer" + }, "decisionSource": { "$ref": "#/definitions/AutoReviewDecisionSource" }, @@ -584,6 +589,11 @@ "description": "Stable identifier for this review.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, "targetItemId": { "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", "type": [ @@ -600,9 +610,11 @@ }, "required": [ "action", + "completedAtMs", "decisionSource", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json index 16e47c2d726d..75ffeb753af0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json @@ -574,6 +574,11 @@ "description": "Stable identifier for this review.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, "targetItemId": { "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", "type": [ @@ -592,6 +597,7 @@ "action", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts index ca2d0b0aa0de..0e9100836a61 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts @@ -8,6 +8,9 @@ import type { NetworkApprovalContext } from "./NetworkApprovalContext"; import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; export type CommandExecutionRequestApprovalParams = {threadId: string, turnId: string, itemId: string, /** + * Unix timestamp (in milliseconds) when this approval request started. + */ +startedAtMs: number, /** * Unique identifier for this specific approval callback. * * For regular shell/unified_exec approvals, this is null. diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FileChangeRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FileChangeRequestApprovalParams.ts index c514ed621955..2db7be9ec494 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FileChangeRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FileChangeRequestApprovalParams.ts @@ -3,6 +3,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type FileChangeRequestApprovalParams = { threadId: string, turnId: string, itemId: string, +/** + * Unix timestamp (in milliseconds) when this approval request started. + */ +startedAtMs: number, /** * Optional explanatory reason (e.g. request for extra write access). */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewCompletedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewCompletedNotification.ts index 5b162cf4b97c..32d12be60843 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewCompletedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewCompletedNotification.ts @@ -10,6 +10,14 @@ import type { GuardianApprovalReviewAction } from "./GuardianApprovalReviewActio * shape is expected to change soon. */ export type ItemGuardianApprovalReviewCompletedNotification = { threadId: string, turnId: string, +/** + * Unix timestamp (in milliseconds) when this review started. + */ +startedAtMs: number, +/** + * Unix timestamp (in milliseconds) when this review completed. + */ +completedAtMs: number, /** * Stable identifier for this review. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewStartedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewStartedNotification.ts index 81ba2cdebf10..92d34fdebc1a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewStartedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewStartedNotification.ts @@ -9,6 +9,10 @@ import type { GuardianApprovalReviewAction } from "./GuardianApprovalReviewActio * shape is expected to change soon. */ export type ItemGuardianApprovalReviewStartedNotification = { threadId: string, turnId: string, +/** + * Unix timestamp (in milliseconds) when this review started. + */ +startedAtMs: number, /** * Stable identifier for this review. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts index 308670a8098f..509f60923bab 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts @@ -4,4 +4,8 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { RequestPermissionProfile } from "./RequestPermissionProfile"; -export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, cwd: AbsolutePathBuf, reason: string | null, permissions: RequestPermissionProfile, }; +export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, +/** + * Unix timestamp (in milliseconds) when this approval request started. + */ +startedAtMs: number, cwd: AbsolutePathBuf, reason: string | null, permissions: RequestPermissionProfile, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index d687a79ec945..1c9870684eb2 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2975,6 +2975,7 @@ mod tests { thread_id: "thr_123".to_string(), turn_id: "turn_123".to_string(), item_id: "call_123".to_string(), + started_at_ms: 0, approval_id: None, reason: None, network_approval_context: None, diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders.rs b/codex-rs/app-server-protocol/src/protocol/item_builders.rs index 69ba331ce6b5..17e0f9aef48a 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -243,6 +243,7 @@ pub fn guardian_auto_approval_review_notification( thread_id: conversation_id.to_string(), turn_id, review_id: assessment.id.clone(), + started_at_ms: assessment.started_at_ms, target_item_id: assessment.target_item_id.clone(), review, action, @@ -258,6 +259,10 @@ pub fn guardian_auto_approval_review_notification( thread_id: conversation_id.to_string(), turn_id, review_id: assessment.id.clone(), + started_at_ms: assessment.started_at_ms, + completed_at_ms: assessment + .completed_at_ms + .unwrap_or(assessment.started_at_ms), target_item_id: assessment.target_item_id.clone(), decision_source: assessment .decision_source diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 1f45180c9efc..1121d3a35b6c 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -2143,6 +2143,8 @@ mod tests { 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: None, status: GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, @@ -2160,6 +2162,8 @@ mod tests { 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::Denied, risk_level: Some(codex_protocol::protocol::GuardianRiskLevel::High), user_authorization: Some(codex_protocol::protocol::GuardianUserAuthorization::Low), @@ -2222,6 +2226,8 @@ mod tests { id: "review-guardian-execve".into(), target_item_id: Some("guardian-execve".into()), turn_id: "turn-1".into(), + started_at_ms: 2_000, + completed_at_ms: None, status: GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, @@ -2525,6 +2531,7 @@ mod tests { EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { call_id: "patch-call".into(), turn_id: turn_id.to_string(), + started_at_ms: 0, changes: [( PathBuf::from("README.md"), codex_protocol::protocol::FileChange::Add { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index 2c3a926c913c..0e22c485900e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -1073,6 +1073,9 @@ pub struct ItemStartedNotification { pub struct ItemGuardianApprovalReviewStartedNotification { pub thread_id: String, pub turn_id: String, + /// Unix timestamp (in milliseconds) when this review started. + #[ts(type = "number")] + pub started_at_ms: i64, /// Stable identifier for this review. pub review_id: String, /// Identifier for the reviewed item or tool call when one exists. @@ -1099,6 +1102,12 @@ pub struct ItemGuardianApprovalReviewStartedNotification { pub struct ItemGuardianApprovalReviewCompletedNotification { pub thread_id: String, pub turn_id: String, + /// Unix timestamp (in milliseconds) when this review started. + #[ts(type = "number")] + pub started_at_ms: i64, + /// Unix timestamp (in milliseconds) when this review completed. + #[ts(type = "number")] + pub completed_at_ms: i64, /// Stable identifier for this review. pub review_id: String, /// Identifier for the reviewed item or tool call when one exists. @@ -1248,6 +1257,9 @@ pub struct CommandExecutionRequestApprovalParams { pub thread_id: String, pub turn_id: String, pub item_id: String, + /// Unix timestamp (in milliseconds) when this approval request started. + #[ts(type = "number")] + pub started_at_ms: i64, /// Unique identifier for this specific approval callback. /// /// For regular shell/unified_exec approvals, this is null. @@ -1321,6 +1333,9 @@ pub struct FileChangeRequestApprovalParams { pub thread_id: String, pub turn_id: String, pub item_id: String, + /// Unix timestamp (in milliseconds) when this approval request started. + #[ts(type = "number")] + pub started_at_ms: i64, /// Optional explanatory reason (e.g. request for extra write access). #[ts(optional = nullable)] pub reason: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 8ce47e58cb72..86614a6aeb21 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -826,6 +826,9 @@ pub struct PermissionsRequestApprovalParams { pub thread_id: String, pub turn_id: String, pub item_id: String, + /// Unix timestamp (in milliseconds) when this approval request started. + #[ts(type = "number")] + pub started_at_ms: i64, pub cwd: AbsolutePathBuf, pub reason: Option, pub permissions: RequestPermissionProfile, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index ba6f4e0eebcc..e3d352365769 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -224,6 +224,7 @@ fn command_execution_request_approval_rejects_relative_additional_permission_pat "threadId": "thr_123", "turnId": "turn_123", "itemId": "call_123", + "startedAtMs": 1, "command": "cat file", "cwd": absolute_path_string("tmp"), "commandActions": null, @@ -264,6 +265,7 @@ fn permissions_request_approval_uses_request_permission_profile() { "threadId": "thr_123", "turnId": "turn_123", "itemId": "call_123", + "startedAtMs": 1, "cwd": absolute_path_string("repo"), "reason": "Select a workspace root", "permissions": { @@ -326,6 +328,7 @@ fn permissions_request_approval_rejects_macos_permissions() { "threadId": "thr_123", "turnId": "turn_123", "itemId": "call_123", + "startedAtMs": 1, "cwd": absolute_path_string("repo"), "reason": "Select a workspace root", "permissions": { diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index edea431c61f8..e67f6e02f3bf 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1945,6 +1945,7 @@ impl CodexClient { thread_id, turn_id, item_id, + started_at_ms: _, approval_id, reason, network_approval_context, @@ -2020,6 +2021,7 @@ impl CodexClient { thread_id, turn_id, item_id, + started_at_ms: _, reason, grant_root, } = params; diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 64215566ee33..f50c1d8e588d 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -511,6 +511,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn_id: event.turn_id.clone(), item_id: item_id.clone(), + started_at_ms: event.started_at_ms, reason: event.reason.clone(), grant_root: event.grant_root.clone(), }; @@ -542,6 +543,7 @@ pub(crate) async fn apply_bespoke_event_handling( call_id, approval_id, turn_id, + started_at_ms, command, cwd, reason, @@ -615,6 +617,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn_id: turn_id.clone(), item_id: call_id.clone(), + started_at_ms, approval_id: approval_id.clone(), reason, network_approval_context, @@ -764,6 +767,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn_id: request.turn_id.clone(), item_id: request.call_id.clone(), + started_at_ms: request.started_at_ms, cwd: request_cwd.clone(), reason: request.reason, permissions: request.permissions.into(), @@ -2249,6 +2253,9 @@ mod tests { id: format!("review-{id}"), target_item_id: Some(id.to_string()), turn_id: turn_id.to_string(), + started_at_ms: 1_000, + completed_at_ms: (!matches!(status, GuardianAssessmentStatus::InProgress)) + .then_some(1_042), status, risk_level, user_authorization, @@ -2313,6 +2320,8 @@ mod tests { id: "review-1".to_string(), target_item_id: Some("item-1".to_string()), turn_id: String::new(), + started_at_ms: 1_000, + completed_at_ms: None, status: codex_protocol::protocol::GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, @@ -2327,6 +2336,7 @@ mod tests { assert_eq!(payload.thread_id, conversation_id.to_string()); assert_eq!(payload.turn_id, "turn-from-event"); assert_eq!(payload.review_id, "review-1"); + assert_eq!(payload.started_at_ms, 1_000); assert_eq!(payload.target_item_id.as_deref(), Some("item-1")); assert_eq!( payload.review.status, @@ -2356,6 +2366,8 @@ mod tests { id: "review-2".to_string(), target_item_id: Some("item-2".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::Denied, risk_level: Some(codex_protocol::protocol::GuardianRiskLevel::High), user_authorization: Some(codex_protocol::protocol::GuardianUserAuthorization::Low), @@ -2372,6 +2384,8 @@ mod tests { assert_eq!(payload.thread_id, conversation_id.to_string()); assert_eq!(payload.turn_id, "turn-from-assessment"); assert_eq!(payload.review_id, "review-2"); + assert_eq!(payload.started_at_ms, 1_000); + assert_eq!(payload.completed_at_ms, 1_042); assert_eq!(payload.target_item_id.as_deref(), Some("item-2")); assert_eq!(payload.decision_source, AutoReviewDecisionSource::Agent); assert_eq!(payload.review.status, GuardianApprovalReviewStatus::Denied); @@ -2406,6 +2420,8 @@ mod tests { id: "review-3".to_string(), target_item_id: None, turn_id: "turn-from-assessment".to_string(), + started_at_ms: 1_000, + completed_at_ms: Some(1_042), status: codex_protocol::protocol::GuardianAssessmentStatus::Aborted, risk_level: None, user_authorization: None, diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index a7420f8c7814..cbe196cd9869 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ClientResponsePayload; @@ -357,8 +359,10 @@ impl OutgoingMessageSender { match entry { Some((id, entry)) => { + let completed_at_ms = now_unix_timestamp_ms(); if let Ok(response) = entry.request.response_from_result(result.clone()) { - self.analytics_events_client.track_server_response(response); + self.analytics_events_client + .track_server_response(completed_at_ms, response); } if let Err(err) = entry.callback.send(Ok(result)) { warn!("could not notify callback for {id:?} due to: {err:?}"); @@ -648,6 +652,15 @@ impl OutgoingMessageSender { } } +fn now_unix_timestamp_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or_default() +} + #[cfg(test)] mod tests { use std::time::Duration; @@ -903,6 +916,7 @@ mod tests { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "item-1".to_string(), + started_at_ms: 0, approval_id: None, reason: None, network_approval_context: None, @@ -1195,6 +1209,7 @@ mod tests { thread_id: thread_id.to_string(), turn_id: "turn-1".to_string(), item_id: "call-2".to_string(), + started_at_ms: 0, reason: None, grant_root: None, }, diff --git a/codex-rs/app-server/src/transport_tests.rs b/codex-rs/app-server/src/transport_tests.rs index 1600b8be87fa..5790e46a1746 100644 --- a/codex-rs/app-server/src/transport_tests.rs +++ b/codex-rs/app-server/src/transport_tests.rs @@ -258,6 +258,7 @@ async fn command_execution_request_approval_strips_additional_permissions_withou thread_id: "thr_123".to_string(), turn_id: "turn_123".to_string(), item_id: "call_123".to_string(), + started_at_ms: 0, approval_id: None, reason: Some("Need extra read access".to_string()), network_approval_context: None, @@ -322,6 +323,7 @@ async fn command_execution_request_approval_keeps_additional_permissions_with_ca thread_id: "thr_123".to_string(), turn_id: "turn_123".to_string(), item_id: "call_123".to_string(), + started_at_ms: 0, approval_id: None, reason: Some("Need extra read access".to_string()), network_approval_context: None, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 84224ea2d528..ecd392e3e76e 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -225,6 +225,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { RequestPermissionsEvent { call_id: request_call_id, turn_id: "child-turn-1".to_string(), + started_at_ms: 0, reason: Some("need access".to_string()), permissions: RequestPermissionProfile { network: Some(NetworkPermissions { @@ -313,6 +314,7 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f call_id: "command-item-1".to_string(), approval_id: Some("callback-approval-1".to_string()), turn_id: "child-turn-1".to_string(), + started_at_ms: 0, command: vec!["rm".to_string(), "-rf".to_string(), "tmp".to_string()], cwd: test_path_buf("/tmp").abs(), reason: Some("unsafe subcommand".to_string()), diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 850d84dd2aae..8609dd8d8c32 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::time::Instant; use codex_analytics::GuardianApprovalRequestSource; use codex_analytics::GuardianReviewAnalyticsResult; @@ -6,6 +7,7 @@ use codex_analytics::GuardianReviewDecision; use codex_analytics::GuardianReviewFailureReason; use codex_analytics::GuardianReviewTerminalStatus; use codex_analytics::GuardianReviewTrackContext; +use codex_analytics::GuardianReviewTrackContextInit; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -23,6 +25,7 @@ use tokio_util::sync::CancellationToken; use crate::session::session::Session; use crate::session::turn_context::TurnContext; +use crate::turn_timing::now_unix_timestamp_ms; use super::GUARDIAN_REVIEW_TIMEOUT; use super::GUARDIAN_REVIEWER_NAME; @@ -162,11 +165,12 @@ fn track_guardian_review( session: &Session, tracking: &GuardianReviewTrackContext, result: GuardianReviewAnalyticsResult, + completed_at_ms: u64, ) { session .services .analytics_events_client - .track_guardian_review(tracking, result); + .track_guardian_review(tracking, result, completed_at_ms); } async fn record_guardian_non_denial(session: &Arc, turn_id: &str) { @@ -242,15 +246,19 @@ async fn run_guardian_review( let target_item_id = guardian_request_target_item_id(&request).map(str::to_string); let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string(); let action_summary = guardian_assessment_action(&request); - let review_tracking = GuardianReviewTrackContext::new( - session.conversation_id.to_string(), - assessment_turn_id.clone(), - review_id.clone(), - target_item_id.clone(), + let started_at_ms = now_unix_timestamp_ms(); + let started_instant = Instant::now(); + let review_tracking = GuardianReviewTrackContext::new(GuardianReviewTrackContextInit { + thread_id: session.conversation_id.to_string(), + turn_id: assessment_turn_id.clone(), + review_id: review_id.clone(), + target_item_id: target_item_id.clone(), approval_request_source, - guardian_reviewed_action(&request), - GUARDIAN_REVIEW_TIMEOUT.as_millis() as u64, - ); + reviewed_action: guardian_reviewed_action(&request), + review_timeout_ms: GUARDIAN_REVIEW_TIMEOUT.as_millis() as u64, + started_at_ms: started_at_ms.try_into().unwrap_or_default(), + started_instant, + }); session .send_event( turn.as_ref(), @@ -258,6 +266,8 @@ async fn run_guardian_review( id: review_id.clone(), target_item_id: target_item_id.clone(), turn_id: assessment_turn_id.clone(), + started_at_ms, + completed_at_ms: None, status: GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, @@ -272,6 +282,7 @@ async fn run_guardian_review( .as_ref() .is_some_and(CancellationToken::is_cancelled) { + let completed_at_ms = now_unix_timestamp_ms(); track_guardian_review( session.as_ref(), &review_tracking, @@ -281,6 +292,7 @@ async fn run_guardian_review( failure_reason: Some(GuardianReviewFailureReason::Cancelled), ..GuardianReviewAnalyticsResult::without_session() }, + completed_at_ms.try_into().unwrap_or_default(), ); session .send_event( @@ -289,6 +301,8 @@ async fn run_guardian_review( id: review_id, target_item_id, turn_id: assessment_turn_id.clone(), + started_at_ms, + completed_at_ms: Some(completed_at_ms), status: GuardianAssessmentStatus::Aborted, risk_level: None, user_authorization: None, @@ -314,9 +328,10 @@ async fn run_guardian_review( )) .await; - let (assessment, count_denial_for_circuit_breaker) = match outcome { + let (assessment, count_denial_for_circuit_breaker, completed_at_ms) = match outcome { GuardianReviewOutcome::Completed(assessment) => { let approved = matches!(assessment.outcome, GuardianAssessmentOutcome::Allow); + let completed_at_ms = now_unix_timestamp_ms(); track_guardian_review( session.as_ref(), &review_tracking, @@ -337,16 +352,22 @@ async fn run_guardian_review( outcome: Some(assessment.outcome), ..analytics_result }, + completed_at_ms.try_into().unwrap_or_default(), ); let count_denial_for_circuit_breaker = matches!(assessment.outcome, GuardianAssessmentOutcome::Deny); - (assessment, count_denial_for_circuit_breaker) + ( + assessment, + count_denial_for_circuit_breaker, + completed_at_ms, + ) } GuardianReviewOutcome::Error(error) => match error { GuardianReviewError::Timeout => { let rationale = "Automatic approval review timed out while evaluating the requested approval." .to_string(); + let completed_at_ms = now_unix_timestamp_ms(); track_guardian_review( session.as_ref(), &review_tracking, @@ -356,6 +377,7 @@ async fn run_guardian_review( failure_reason: Some(error.failure_reason()), ..analytics_result }, + completed_at_ms.try_into().unwrap_or_default(), ); session .send_event( @@ -372,6 +394,8 @@ async fn run_guardian_review( id: review_id, target_item_id, turn_id: assessment_turn_id.clone(), + started_at_ms, + completed_at_ms: Some(completed_at_ms), status: GuardianAssessmentStatus::TimedOut, risk_level: None, user_authorization: None, @@ -385,6 +409,7 @@ async fn run_guardian_review( return ReviewDecision::TimedOut; } GuardianReviewError::Cancelled => { + let completed_at_ms = now_unix_timestamp_ms(); track_guardian_review( session.as_ref(), &review_tracking, @@ -394,6 +419,7 @@ async fn run_guardian_review( failure_reason: Some(error.failure_reason()), ..analytics_result }, + completed_at_ms.try_into().unwrap_or_default(), ); session .send_event( @@ -402,6 +428,8 @@ async fn run_guardian_review( id: review_id, target_item_id, turn_id: assessment_turn_id.clone(), + started_at_ms, + completed_at_ms: Some(completed_at_ms), status: GuardianAssessmentStatus::Aborted, risk_level: None, user_authorization: None, @@ -426,6 +454,7 @@ async fn run_guardian_review( } }; let rationale = format!("Automatic approval review failed: {message}"); + let completed_at_ms = now_unix_timestamp_ms(); track_guardian_review( session.as_ref(), &review_tracking, @@ -435,6 +464,7 @@ async fn run_guardian_review( failure_reason: Some(error.failure_reason()), ..analytics_result }, + completed_at_ms.try_into().unwrap_or_default(), ); ( GuardianAssessment { @@ -444,6 +474,7 @@ async fn run_guardian_review( rationale, }, false, + completed_at_ms, ) } }, @@ -495,6 +526,8 @@ async fn run_guardian_review( id: review_id, target_item_id, turn_id: assessment_turn_id.clone(), + started_at_ms, + completed_at_ms: Some(completed_at_ms), status, risk_level: Some(assessment.risk_level), user_authorization: Some(assessment.user_authorization), diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 6d03dfa8137d..3334e19b1253 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -971,23 +971,36 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() { assert_eq!(decision, ReviewDecision::Abort); - let mut guardian_statuses = Vec::new(); + let mut guardian_events = Vec::new(); let mut warnings = Vec::new(); while let Ok(event) = rx.try_recv() { match event.msg { - EventMsg::GuardianAssessment(event) => guardian_statuses.push(event.status), + EventMsg::GuardianAssessment(event) => guardian_events.push(event), EventMsg::GuardianWarning(event) => warnings.push(event.message), _ => {} } } assert_eq!( - guardian_statuses, + guardian_events + .iter() + .map(|event| event.status) + .collect::>(), vec![ GuardianAssessmentStatus::InProgress, GuardianAssessmentStatus::Aborted, ] ); + let [started, completed] = guardian_events.as_slice() else { + panic!("expected started and completed guardian review events"); + }; + assert_eq!(started.completed_at_ms, None); + assert_eq!(completed.started_at_ms, started.started_at_ms); + assert!( + completed + .completed_at_ms + .is_some_and(|completed_at_ms| completed_at_ms >= started.started_at_ms) + ); assert!(warnings.is_empty()); } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 0910a3f8790a..a0c1c40002c5 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1927,6 +1927,7 @@ impl Session { call_id, approval_id, turn_id: turn_context.sub_id.clone(), + started_at_ms: now_unix_timestamp_ms(), command, cwd, reason, @@ -1973,6 +1974,7 @@ impl Session { let event = EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { call_id, turn_id: turn_context.sub_id.clone(), + started_at_ms: now_unix_timestamp_ms(), changes, reason, grant_root, @@ -2136,6 +2138,7 @@ impl Session { let event = EventMsg::RequestPermissions(RequestPermissionsEvent { call_id: call_id.clone(), turn_id: turn_context.sub_id.clone(), + started_at_ms: now_unix_timestamp_ms(), reason: args.reason, permissions: requested_permissions, cwd: Some(cwd), diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index b462022dbcb5..4e2d5c08e5c6 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -222,6 +222,7 @@ async fn run_codex_tool_session_inner( let approval_id = ev.effective_approval_id(); let ExecApprovalRequestEvent { turn_id: _, + started_at_ms: _, command, cwd, call_id, @@ -278,6 +279,7 @@ async fn run_codex_tool_session_inner( EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { call_id, turn_id: _, + started_at_ms: _, reason, grant_root, changes, diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 73283e3eb6d7..12323ccaa83f 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -187,6 +187,12 @@ pub struct GuardianAssessmentEvent { /// Uses `#[serde(default)]` for backwards compatibility. #[serde(default)] pub turn_id: String, + #[serde(default)] + #[ts(type = "number")] + pub started_at_ms: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub completed_at_ms: Option, pub status: GuardianAssessmentStatus, /// Coarse risk label. Omitted while the assessment is in progress. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -223,6 +229,8 @@ pub struct ExecApprovalRequestEvent { /// Uses `#[serde(default)]` for backwards compatibility. #[serde(default)] pub turn_id: String, + #[ts(type = "number")] + pub started_at_ms: i64, /// The command to be executed. pub command: Vec, /// The command's working directory. @@ -370,6 +378,8 @@ pub struct ApplyPatchApprovalRequestEvent { /// Uses `#[serde(default)]` for backwards compatibility with older senders. #[serde(default)] pub turn_id: String, + #[ts(type = "number")] + pub started_at_ms: i64, pub changes: HashMap, /// Optional explanatory reason (e.g. request for extra write access). #[serde(skip_serializing_if = "Option::is_none")] @@ -406,6 +416,22 @@ mod tests { ); } + #[test] + fn guardian_assessment_defaults_missing_started_at_ms() { + let event: GuardianAssessmentEvent = serde_json::from_value(serde_json::json!({ + "id": "review-1", + "status": "in_progress", + "action": { + "type": "apply_patch", + "cwd": test_path_buf("/tmp"), + "files": [], + } + })) + .expect("guardian assessment"); + + assert_eq!(event.started_at_ms, 0); + } + #[cfg(unix)] #[test] fn guardian_assessment_action_round_trips_execve_shape() { diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index 6c7b699daf84..be6b88ef521d 100644 --- a/codex-rs/protocol/src/request_permissions.rs +++ b/codex-rs/protocol/src/request_permissions.rs @@ -71,6 +71,8 @@ pub struct RequestPermissionsEvent { /// Uses `#[serde(default)]` for backwards compatibility. #[serde(default)] pub turn_id: String, + #[ts(type = "number")] + pub started_at_ms: i64, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, pub permissions: RequestPermissionProfile, diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index 4b587b0fc894..dce87f367ede 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -429,6 +429,7 @@ mod tests { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "call-1".to_string(), + started_at_ms: 0, approval_id: Some("approval-1".to_string()), reason: None, network_approval_context: None, @@ -481,6 +482,7 @@ mod tests { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "perm-1".to_string(), + started_at_ms: 0, cwd: absolute_path(if cfg!(windows) { r"C:\tmp" } else { "/tmp" }), reason: None, permissions: serde_json::from_value(json!({ @@ -686,6 +688,7 @@ mod tests { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "patch-1".to_string(), + started_at_ms: 0, reason: None, grant_root: None, }, @@ -715,6 +718,7 @@ mod tests { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "call-1".to_string(), + started_at_ms: 0, approval_id: Some("approval-1".to_string()), reason: None, network_approval_context: None, diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 671e41461c5e..1a21d4df50e3 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -612,6 +612,7 @@ mod tests { thread_id: "thread-1".to_string(), turn_id: turn_id.to_string(), item_id: call_id.to_string(), + started_at_ms: 0, approval_id: approval_id.map(str::to_string), reason: None, network_approval_context: None, @@ -633,6 +634,7 @@ mod tests { thread_id: "thread-1".to_string(), turn_id: turn_id.to_string(), item_id: call_id.to_string(), + started_at_ms: 0, reason: None, grant_root: None, }, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 5ab85887a797..eacb6d505379 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2659,6 +2659,7 @@ async fn inactive_thread_file_change_approval_recovers_buffered_changes() { thread_id: thread_id.to_string(), turn_id: "turn-approval".to_string(), item_id: "patch-approval".to_string(), + started_at_ms: 0, reason: Some("command failed; retry without sandbox?".to_string()), grant_root: None, }, @@ -2709,6 +2710,7 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions( thread_id: thread_id.to_string(), turn_id: "turn-approval".to_string(), item_id: "call-approval".to_string(), + started_at_ms: 0, cwd: test_absolute_path("/tmp"), reason: Some("Need access to .git".to_string()), permissions: codex_app_server_protocol::RequestPermissionProfile { @@ -4259,6 +4261,7 @@ fn exec_approval_request( thread_id: thread_id.to_string(), turn_id: turn_id.to_string(), item_id: item_id.to_string(), + started_at_ms: 0, approval_id: approval_id.map(str::to_string), reason: Some("needs approval".to_string()), network_approval_context: None, diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 56477c18cfc6..431bf5f804cb 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -465,6 +465,7 @@ mod tests { thread_id: thread_id.to_string(), turn_id: turn_id.to_string(), item_id: item_id.to_string(), + started_at_ms: 0, approval_id: approval_id.map(str::to_string), reason: Some("needs approval".to_string()), network_approval_context: None, diff --git a/codex-rs/tui/src/auto_review_denials.rs b/codex-rs/tui/src/auto_review_denials.rs index e51e071e2101..149a60f04939 100644 --- a/codex-rs/tui/src/auto_review_denials.rs +++ b/codex-rs/tui/src/auto_review_denials.rs @@ -88,6 +88,8 @@ mod tests { id: format!("review-{id}"), target_item_id: None, turn_id: "turn-1".to_string(), + started_at_ms: 0, + completed_at_ms: Some(1), status: GuardianAssessmentStatus::Denied, risk_level: None, user_authorization: None, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index adaba76b24fa..207ebffa73e7 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1037,6 +1037,16 @@ pub(crate) struct ChatWidget { last_non_retry_error: Option<(String, String)>, } +struct GuardianReviewNotificationInput { + id: String, + turn_id: String, + started_at_ms: i64, + completed_at_ms: Option, + review: codex_app_server_protocol::GuardianApprovalReview, + decision_source: Option, + action: GuardianApprovalReviewAction, +} + #[cfg_attr(not(test), allow(dead_code))] enum CodexOpTarget { Direct(UnboundedSender), @@ -1637,6 +1647,7 @@ fn request_permissions_from_params( RequestPermissionsEvent { turn_id: params.turn_id, call_id: params.item_id, + started_at_ms: params.started_at_ms, reason: params.reason, permissions: params.permissions.into(), cwd: Some(params.cwd), @@ -6371,22 +6382,26 @@ impl ChatWidget { self.on_mcp_server_status_updated(notification) } ServerNotification::ItemGuardianApprovalReviewStarted(notification) => { - self.on_guardian_review_notification( - notification.review_id, - notification.turn_id, - notification.review, - /*decision_source*/ None, - notification.action, - ); + self.on_guardian_review_notification(GuardianReviewNotificationInput { + id: notification.review_id, + turn_id: notification.turn_id, + started_at_ms: notification.started_at_ms, + completed_at_ms: None, + review: notification.review, + decision_source: None, + action: notification.action, + }); } ServerNotification::ItemGuardianApprovalReviewCompleted(notification) => { - self.on_guardian_review_notification( - notification.review_id, - notification.turn_id, - notification.review, - Some(notification.decision_source), - notification.action, - ); + self.on_guardian_review_notification(GuardianReviewNotificationInput { + id: notification.review_id, + turn_id: notification.turn_id, + started_at_ms: notification.started_at_ms, + completed_at_ms: Some(notification.completed_at_ms), + review: notification.review, + decision_source: Some(notification.decision_source), + action: notification.action, + }); } ServerNotification::ThreadClosed(_) => { if !from_replay { @@ -6563,19 +6578,14 @@ impl ChatWidget { fn on_patch_apply_output_delta(&mut self, _item_id: String, _delta: String) {} - fn on_guardian_review_notification( - &mut self, - id: String, - turn_id: String, - review: codex_app_server_protocol::GuardianApprovalReview, - decision_source: Option, - action: GuardianApprovalReviewAction, - ) { + fn on_guardian_review_notification(&mut self, input: GuardianReviewNotificationInput) { self.on_guardian_assessment(GuardianAssessmentEvent { - id, + id: input.id, target_item_id: None, - turn_id, - status: match review.status { + turn_id: input.turn_id, + started_at_ms: input.started_at_ms, + completed_at_ms: input.completed_at_ms, + status: match input.review.status { codex_app_server_protocol::GuardianApprovalReviewStatus::InProgress => { GuardianAssessmentStatus::InProgress } @@ -6592,7 +6602,7 @@ impl ChatWidget { GuardianAssessmentStatus::Aborted } }, - risk_level: review.risk_level.map(|risk_level| match risk_level { + risk_level: input.review.risk_level.map(|risk_level| match risk_level { codex_app_server_protocol::GuardianRiskLevel::Low => { codex_protocol::approvals::GuardianRiskLevel::Low } @@ -6606,7 +6616,7 @@ impl ChatWidget { codex_protocol::approvals::GuardianRiskLevel::Critical } }), - user_authorization: review.user_authorization.map(|user_authorization| { + user_authorization: input.review.user_authorization.map(|user_authorization| { match user_authorization { codex_app_server_protocol::GuardianUserAuthorization::Unknown => { codex_protocol::approvals::GuardianUserAuthorization::Unknown @@ -6622,13 +6632,13 @@ impl ChatWidget { } } }), - rationale: review.rationale, - decision_source: decision_source.map(|source| match source { + rationale: input.review.rationale, + decision_source: input.decision_source.map(|source| match source { codex_app_server_protocol::AutoReviewDecisionSource::Agent => { GuardianAssessmentDecisionSource::Agent } }), - action: action.into(), + action: input.action.into(), }); } diff --git a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs index 93e2a38c13f4..85c8fc6c0320 100644 --- a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs +++ b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs @@ -55,6 +55,7 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "item-1".to_string(), + started_at_ms: 0, approval_id: Some("approval-1".to_string()), reason: None, network_approval_context: None, @@ -93,6 +94,7 @@ fn app_server_exec_approval_request_preserves_permissions_context() { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "item-1".to_string(), + started_at_ms: 0, approval_id: Some("approval-1".to_string()), reason: None, network_approval_context: Some(codex_app_server_protocol::NetworkApprovalContext { @@ -156,6 +158,7 @@ fn app_server_request_permissions_preserves_file_system_permissions() { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "item-1".to_string(), + started_at_ms: 0, cwd: cwd.clone(), reason: Some("Select a workspace root".to_string()), permissions: codex_app_server_protocol::RequestPermissionProfile { diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index b1949d7809c6..0045e7d1261e 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -54,6 +54,7 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "item-1".to_string(), + started_at_ms: 0, approval_id: Some("approval-1".to_string()), reason: None, network_approval_context: None, diff --git a/codex-rs/tui/src/chatwidget/tests/guardian.rs b/codex-rs/tui/src/chatwidget/tests/guardian.rs index c51b3f66876a..7cdc9f760be4 100644 --- a/codex-rs/tui/src/chatwidget/tests/guardian.rs +++ b/codex-rs/tui/src/chatwidget/tests/guardian.rs @@ -6,6 +6,8 @@ fn auto_review_denial_event() -> GuardianAssessmentEvent { id: "auto-review-recent-1".into(), target_item_id: Some("target-auto-review-recent-1".into()), turn_id: "turn-recent-1".into(), + started_at_ms: 0, + completed_at_ms: Some(1), status: GuardianAssessmentStatus::Denied, risk_level: Some(GuardianRiskLevel::High), user_authorization: Some(GuardianUserAuthorization::Low), @@ -73,6 +75,8 @@ async fn guardian_denied_exec_renders_warning_and_denied_request() { 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, @@ -85,6 +89,8 @@ async fn guardian_denied_exec_renders_warning_and_denied_request() { 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::Denied, risk_level: Some(GuardianRiskLevel::High), user_authorization: Some(GuardianUserAuthorization::Low), @@ -127,6 +133,8 @@ async fn guardian_approved_exec_renders_approved_request() { id: "thread:child-thread:guardian-1".into(), target_item_id: Some("guardian-approved-target".into()), turn_id: "turn-1".into(), + started_at_ms: 0, + completed_at_ms: Some(1), status: GuardianAssessmentStatus::Approved, risk_level: Some(GuardianRiskLevel::Low), user_authorization: Some(GuardianUserAuthorization::High), @@ -183,6 +191,8 @@ async fn guardian_approved_request_permissions_renders_request_summary() { id: "guardian-request-permissions".into(), target_item_id: None, turn_id: "turn-1".into(), + started_at_ms: 0, + completed_at_ms: None, status: GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, @@ -205,6 +215,8 @@ async fn guardian_approved_request_permissions_renders_request_summary() { id: "guardian-request-permissions".into(), target_item_id: None, turn_id: "turn-1".into(), + started_at_ms: 0, + completed_at_ms: Some(1), status: GuardianAssessmentStatus::Approved, risk_level: Some(GuardianRiskLevel::Low), user_authorization: Some(GuardianUserAuthorization::High), @@ -253,6 +265,8 @@ async fn guardian_timed_out_exec_renders_warning_and_timed_out_request() { 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, @@ -265,6 +279,8 @@ async fn guardian_timed_out_exec_renders_warning_and_timed_out_request() { 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::TimedOut, risk_level: None, user_authorization: None, @@ -315,6 +331,7 @@ async fn app_server_guardian_review_started_sets_review_status() { ItemGuardianApprovalReviewStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, review_id: "guardian-1".to_string(), target_item_id: Some("guardian-target-1".to_string()), review: GuardianApprovalReview { @@ -356,6 +373,7 @@ async fn app_server_guardian_review_denied_renders_denied_request_snapshot() { ItemGuardianApprovalReviewStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, review_id: "guardian-1".to_string(), target_item_id: Some("guardian-target-1".to_string()), review: GuardianApprovalReview { @@ -375,6 +393,8 @@ async fn app_server_guardian_review_denied_renders_denied_request_snapshot() { ItemGuardianApprovalReviewCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, + completed_at_ms: 1, review_id: "guardian-1".to_string(), target_item_id: Some("guardian-target-1".to_string()), decision_source: AppServerGuardianApprovalReviewDecisionSource::Agent, @@ -431,6 +451,7 @@ async fn app_server_guardian_review_timed_out_renders_timed_out_request_snapshot ItemGuardianApprovalReviewStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, review_id: "guardian-1".to_string(), target_item_id: Some("guardian-target-1".to_string()), review: GuardianApprovalReview { @@ -450,6 +471,8 @@ async fn app_server_guardian_review_timed_out_renders_timed_out_request_snapshot ItemGuardianApprovalReviewCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, + completed_at_ms: 1, review_id: "guardian-1".to_string(), target_item_id: Some("guardian-target-1".to_string()), decision_source: AppServerGuardianApprovalReviewDecisionSource::Agent, @@ -506,6 +529,8 @@ async fn guardian_parallel_reviews_render_aggregate_status_snapshot() { id: id.to_string(), target_item_id: Some(format!("{id}-target")), turn_id: "turn-1".to_string(), + started_at_ms: 0, + completed_at_ms: None, status: GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, @@ -535,6 +560,8 @@ async fn guardian_parallel_reviews_keep_remaining_review_visible_after_denial() id: "guardian-1".to_string(), target_item_id: Some("guardian-1-target".to_string()), turn_id: "turn-1".to_string(), + started_at_ms: 0, + completed_at_ms: None, status: GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, @@ -550,6 +577,8 @@ async fn guardian_parallel_reviews_keep_remaining_review_visible_after_denial() id: "guardian-2".to_string(), target_item_id: Some("guardian-2-target".to_string()), turn_id: "turn-1".to_string(), + started_at_ms: 0, + completed_at_ms: None, status: GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, @@ -565,6 +594,8 @@ async fn guardian_parallel_reviews_keep_remaining_review_visible_after_denial() id: "guardian-1".to_string(), target_item_id: Some("guardian-1-target".to_string()), turn_id: "turn-1".to_string(), + started_at_ms: 0, + completed_at_ms: Some(1), status: GuardianAssessmentStatus::Denied, risk_level: Some(GuardianRiskLevel::High), user_authorization: Some(GuardianUserAuthorization::Low),