mirror of
https://github.com/openai/codex.git
synced 2026-04-21 05:04:49 +00:00
Compare commits
6 Commits
codex-debu
...
rhan/escal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf119ea2a0 | ||
|
|
1ed80dbd5a | ||
|
|
4a715ef4cb | ||
|
|
9d9e8d8a52 | ||
|
|
ef74096ed1 | ||
|
|
2330d8b4dd |
@@ -594,6 +594,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"EscalationStatus": {
|
||||
"enum": [
|
||||
"approved",
|
||||
"rejected"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"EventMsg": {
|
||||
"description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.",
|
||||
"oneOf": [
|
||||
@@ -5964,6 +5971,16 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/UserMessageMetadata"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"UserMessage"
|
||||
@@ -6286,6 +6303,90 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"UserMessageMetadata": {
|
||||
"properties": {
|
||||
"escalation_status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/EscalationStatus"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"is_plan_mode": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"is_tool_call_escalated": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"personality": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/UserMessagePersonality"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sandbox_policy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/UserMessageSandboxPolicy"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"user_message_type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/UserMessageType"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"UserMessagePersonality": {
|
||||
"enum": [
|
||||
"friendly",
|
||||
"pragmatic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"UserMessageSandboxPolicy": {
|
||||
"enum": [
|
||||
"read_only",
|
||||
"sandbox",
|
||||
"full_access"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"UserMessageType": {
|
||||
"enum": [
|
||||
"prompt",
|
||||
"prompt_steering",
|
||||
"prompt_queued",
|
||||
"prompt_with_ide_context",
|
||||
"agents_md_default",
|
||||
"agents_md_custom",
|
||||
"environment_context"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"WebSearchAction": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -1824,6 +1824,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"EscalationStatus": {
|
||||
"enum": [
|
||||
"approved",
|
||||
"rejected"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"EventMsg": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.",
|
||||
@@ -7461,6 +7468,16 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/UserMessageMetadata"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"UserMessage"
|
||||
@@ -7658,6 +7675,90 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"UserMessageMetadata": {
|
||||
"properties": {
|
||||
"escalation_status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/EscalationStatus"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"is_plan_mode": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"is_tool_call_escalated": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"personality": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/UserMessagePersonality"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sandbox_policy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/UserMessageSandboxPolicy"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"user_message_type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/UserMessageType"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"UserMessagePersonality": {
|
||||
"enum": [
|
||||
"friendly",
|
||||
"pragmatic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"UserMessageSandboxPolicy": {
|
||||
"enum": [
|
||||
"read_only",
|
||||
"sandbox",
|
||||
"full_access"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"UserMessageType": {
|
||||
"enum": [
|
||||
"prompt",
|
||||
"prompt_steering",
|
||||
"prompt_queued",
|
||||
"prompt_with_ide_context",
|
||||
"agents_md_default",
|
||||
"agents_md_custom",
|
||||
"environment_context"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"W3cTraceContext": {
|
||||
"properties": {
|
||||
"traceparent": {
|
||||
|
||||
@@ -3361,6 +3361,13 @@
|
||||
"title": "ErrorNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"EscalationStatus": {
|
||||
"enum": [
|
||||
"approved",
|
||||
"rejected"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"EventMsg": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.",
|
||||
@@ -13742,6 +13749,16 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/UserMessageMetadata"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"UserMessage"
|
||||
@@ -14299,6 +14316,90 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"UserMessageMetadata": {
|
||||
"properties": {
|
||||
"escalation_status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/EscalationStatus"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"is_plan_mode": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"is_tool_call_escalated": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"personality": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/UserMessagePersonality"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sandbox_policy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/UserMessageSandboxPolicy"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"user_message_type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/UserMessageType"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"UserMessagePersonality": {
|
||||
"enum": [
|
||||
"friendly",
|
||||
"pragmatic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"UserMessageSandboxPolicy": {
|
||||
"enum": [
|
||||
"read_only",
|
||||
"sandbox",
|
||||
"full_access"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"UserMessageType": {
|
||||
"enum": [
|
||||
"prompt",
|
||||
"prompt_steering",
|
||||
"prompt_queued",
|
||||
"prompt_with_ide_context",
|
||||
"agents_md_default",
|
||||
"agents_md_custom",
|
||||
"environment_context"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"Verbosity": {
|
||||
"description": "Controls output length/detail on GPT-5 models via the Responses API. Serialized with lowercase values to match the OpenAI API.",
|
||||
"enum": [
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type EscalationStatus = "approved" | "rejected";
|
||||
@@ -2,5 +2,6 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { UserInput } from "./UserInput";
|
||||
import type { UserMessageMetadata } from "./UserMessageMetadata";
|
||||
|
||||
export type UserMessageItem = { id: string, content: Array<UserInput>, };
|
||||
export type UserMessageItem = { id: string, content: Array<UserInput>, metadata?: UserMessageMetadata, };
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { EscalationStatus } from "./EscalationStatus";
|
||||
import type { UserMessagePersonality } from "./UserMessagePersonality";
|
||||
import type { UserMessageSandboxPolicy } from "./UserMessageSandboxPolicy";
|
||||
import type { UserMessageType } from "./UserMessageType";
|
||||
|
||||
export type UserMessageMetadata = { is_plan_mode?: boolean, is_tool_call_escalated?: boolean, escalation_status?: EscalationStatus, sandbox_policy?: UserMessageSandboxPolicy, user_message_type?: UserMessageType, personality?: UserMessagePersonality, };
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type UserMessagePersonality = "friendly" | "pragmatic";
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type UserMessageSandboxPolicy = "read_only" | "sandbox" | "full_access";
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type UserMessageType = "prompt" | "prompt_steering" | "prompt_queued" | "prompt_with_ide_context" | "agents_md_default" | "agents_md_custom" | "environment_context";
|
||||
@@ -51,6 +51,7 @@ export type { DynamicToolCallResponseEvent } from "./DynamicToolCallResponseEven
|
||||
export type { ElicitationRequest } from "./ElicitationRequest";
|
||||
export type { ElicitationRequestEvent } from "./ElicitationRequestEvent";
|
||||
export type { ErrorEvent } from "./ErrorEvent";
|
||||
export type { EscalationStatus } from "./EscalationStatus";
|
||||
export type { EventMsg } from "./EventMsg";
|
||||
export type { ExecApprovalRequestEvent } from "./ExecApprovalRequestEvent";
|
||||
export type { ExecCommandApprovalParams } from "./ExecCommandApprovalParams";
|
||||
@@ -206,6 +207,10 @@ export type { UpdatePlanArgs } from "./UpdatePlanArgs";
|
||||
export type { UserInput } from "./UserInput";
|
||||
export type { UserMessageEvent } from "./UserMessageEvent";
|
||||
export type { UserMessageItem } from "./UserMessageItem";
|
||||
export type { UserMessageMetadata } from "./UserMessageMetadata";
|
||||
export type { UserMessagePersonality } from "./UserMessagePersonality";
|
||||
export type { UserMessageSandboxPolicy } from "./UserMessageSandboxPolicy";
|
||||
export type { UserMessageType } from "./UserMessageType";
|
||||
export type { Verbosity } from "./Verbosity";
|
||||
export type { ViewImageToolCallEvent } from "./ViewImageToolCallEvent";
|
||||
export type { WarningEvent } from "./WarningEvent";
|
||||
|
||||
@@ -1265,6 +1265,7 @@ mod tests {
|
||||
item: CoreTurnItem::UserMessage(CoreUserMessageItem {
|
||||
id: "user-item-id".to_string(),
|
||||
content: Vec::new(),
|
||||
metadata: None,
|
||||
}),
|
||||
}),
|
||||
EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
|
||||
@@ -4703,6 +4703,7 @@ mod tests {
|
||||
path: "app://demo-app".to_string(),
|
||||
},
|
||||
],
|
||||
metadata: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -42,6 +42,36 @@ pub(crate) struct SkillInvocation {
|
||||
pub(crate) invocation_type: InvocationType,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum ApprovalKind {
|
||||
ExecCommand,
|
||||
ApplyPatch,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum ApprovalLifecycleStage {
|
||||
Requested,
|
||||
Resolved,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum ApprovalEscalationStatus {
|
||||
Approved,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ApprovalItemInvocation {
|
||||
pub(crate) call_id: String,
|
||||
pub(crate) approval_id: String,
|
||||
pub(crate) approval_kind: ApprovalKind,
|
||||
pub(crate) lifecycle_stage: ApprovalLifecycleStage,
|
||||
pub(crate) escalation_status: Option<ApprovalEscalationStatus>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum InvocationType {
|
||||
@@ -81,6 +111,9 @@ impl AnalyticsEventsQueue {
|
||||
TrackEventsJob::AppUsed(job) => {
|
||||
send_track_app_used(&auth_manager, job).await;
|
||||
}
|
||||
TrackEventsJob::ApprovalItem(job) => {
|
||||
send_track_approval_item(&auth_manager, job).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -149,12 +182,26 @@ impl AnalyticsEventsClient {
|
||||
pub(crate) fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) {
|
||||
track_app_used(&self.queue, Arc::clone(&self.config), Some(tracking), app);
|
||||
}
|
||||
|
||||
pub(crate) fn track_approval_item(
|
||||
&self,
|
||||
tracking: TrackEventsContext,
|
||||
approval: ApprovalItemInvocation,
|
||||
) {
|
||||
track_approval_item(
|
||||
&self.queue,
|
||||
Arc::clone(&self.config),
|
||||
Some(tracking),
|
||||
approval,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum TrackEventsJob {
|
||||
SkillInvocations(TrackSkillInvocationsJob),
|
||||
AppMentioned(TrackAppMentionedJob),
|
||||
AppUsed(TrackAppUsedJob),
|
||||
ApprovalItem(TrackApprovalItemJob),
|
||||
}
|
||||
|
||||
struct TrackSkillInvocationsJob {
|
||||
@@ -175,6 +222,12 @@ struct TrackAppUsedJob {
|
||||
app: AppInvocation,
|
||||
}
|
||||
|
||||
struct TrackApprovalItemJob {
|
||||
config: Arc<Config>,
|
||||
tracking: TrackEventsContext,
|
||||
approval: ApprovalItemInvocation,
|
||||
}
|
||||
|
||||
const ANALYTICS_EVENTS_QUEUE_SIZE: usize = 256;
|
||||
const ANALYTICS_EVENTS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const ANALYTICS_APP_USED_DEDUPE_MAX_KEYS: usize = 4096;
|
||||
@@ -190,6 +243,7 @@ enum TrackEventRequest {
|
||||
SkillInvocation(SkillInvocationEventRequest),
|
||||
AppMentioned(CodexAppMentionedEventRequest),
|
||||
AppUsed(CodexAppUsedEventRequest),
|
||||
ApprovalItem(CodexApprovalItemEventRequest),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -233,6 +287,27 @@ struct CodexAppUsedEventRequest {
|
||||
event_params: CodexAppMetadata,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CodexApprovalItemMetadata {
|
||||
thread_id: Option<String>,
|
||||
turn_id: Option<String>,
|
||||
approval_id: String,
|
||||
call_id: String,
|
||||
approval_kind: ApprovalKind,
|
||||
lifecycle_stage: ApprovalLifecycleStage,
|
||||
is_tool_call_escalated: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
escalation_status: Option<ApprovalEscalationStatus>,
|
||||
product_client_id: Option<String>,
|
||||
model_slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CodexApprovalItemEventRequest {
|
||||
event_type: &'static str,
|
||||
event_params: CodexApprovalItemMetadata,
|
||||
}
|
||||
|
||||
pub(crate) fn track_skill_invocations(
|
||||
queue: &AnalyticsEventsQueue,
|
||||
config: Arc<Config>,
|
||||
@@ -302,6 +377,26 @@ pub(crate) fn track_app_used(
|
||||
queue.try_send(job);
|
||||
}
|
||||
|
||||
pub(crate) fn track_approval_item(
|
||||
queue: &AnalyticsEventsQueue,
|
||||
config: Arc<Config>,
|
||||
tracking: Option<TrackEventsContext>,
|
||||
approval: ApprovalItemInvocation,
|
||||
) {
|
||||
if config.analytics_enabled == Some(false) {
|
||||
return;
|
||||
}
|
||||
let Some(tracking) = tracking else {
|
||||
return;
|
||||
};
|
||||
let job = TrackEventsJob::ApprovalItem(TrackApprovalItemJob {
|
||||
config,
|
||||
tracking,
|
||||
approval,
|
||||
});
|
||||
queue.try_send(job);
|
||||
}
|
||||
|
||||
async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackSkillInvocationsJob) {
|
||||
let TrackSkillInvocationsJob {
|
||||
config,
|
||||
@@ -385,6 +480,22 @@ async fn send_track_app_used(auth_manager: &AuthManager, job: TrackAppUsedJob) {
|
||||
send_track_events(auth_manager, config, events).await;
|
||||
}
|
||||
|
||||
async fn send_track_approval_item(auth_manager: &AuthManager, job: TrackApprovalItemJob) {
|
||||
let TrackApprovalItemJob {
|
||||
config,
|
||||
tracking,
|
||||
approval,
|
||||
} = job;
|
||||
let event_params = codex_approval_item_metadata(&tracking, approval);
|
||||
let events = vec![TrackEventRequest::ApprovalItem(
|
||||
CodexApprovalItemEventRequest {
|
||||
event_type: "codex_approval_item",
|
||||
event_params,
|
||||
},
|
||||
)];
|
||||
send_track_events(auth_manager, config, events).await;
|
||||
}
|
||||
|
||||
fn codex_app_metadata(tracking: &TrackEventsContext, app: AppInvocation) -> CodexAppMetadata {
|
||||
CodexAppMetadata {
|
||||
connector_id: app.connector_id,
|
||||
@@ -397,6 +508,24 @@ fn codex_app_metadata(tracking: &TrackEventsContext, app: AppInvocation) -> Code
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_approval_item_metadata(
|
||||
tracking: &TrackEventsContext,
|
||||
approval: ApprovalItemInvocation,
|
||||
) -> CodexApprovalItemMetadata {
|
||||
CodexApprovalItemMetadata {
|
||||
thread_id: Some(tracking.thread_id.clone()),
|
||||
turn_id: Some(tracking.turn_id.clone()),
|
||||
approval_id: approval.approval_id,
|
||||
call_id: approval.call_id,
|
||||
approval_kind: approval.approval_kind,
|
||||
lifecycle_stage: approval.lifecycle_stage,
|
||||
is_tool_call_escalated: true,
|
||||
escalation_status: approval.escalation_status,
|
||||
product_client_id: Some(crate::default_client::originator().value),
|
||||
model_slug: Some(tracking.model_slug.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_track_events(
|
||||
auth_manager: &AuthManager,
|
||||
config: Arc<Config>,
|
||||
@@ -492,12 +621,18 @@ fn normalize_path_for_skill_id(
|
||||
mod tests {
|
||||
use super::AnalyticsEventsQueue;
|
||||
use super::AppInvocation;
|
||||
use super::ApprovalEscalationStatus;
|
||||
use super::ApprovalItemInvocation;
|
||||
use super::ApprovalKind;
|
||||
use super::ApprovalLifecycleStage;
|
||||
use super::CodexAppMentionedEventRequest;
|
||||
use super::CodexAppUsedEventRequest;
|
||||
use super::CodexApprovalItemEventRequest;
|
||||
use super::InvocationType;
|
||||
use super::TrackEventRequest;
|
||||
use super::TrackEventsContext;
|
||||
use super::codex_app_metadata;
|
||||
use super::codex_approval_item_metadata;
|
||||
use super::normalize_path_for_skill_id;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
@@ -667,4 +802,90 @@ mod tests {
|
||||
assert_eq!(queue.should_enqueue_app_used(&turn_1, &app), false);
|
||||
assert_eq!(queue.should_enqueue_app_used(&turn_2, &app), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_item_requested_event_serializes_expected_shape() {
|
||||
let tracking = TrackEventsContext {
|
||||
model_slug: "gpt-5".to_string(),
|
||||
thread_id: "thread-9".to_string(),
|
||||
turn_id: "turn-9".to_string(),
|
||||
};
|
||||
let event = TrackEventRequest::ApprovalItem(CodexApprovalItemEventRequest {
|
||||
event_type: "codex_approval_item",
|
||||
event_params: codex_approval_item_metadata(
|
||||
&tracking,
|
||||
ApprovalItemInvocation {
|
||||
call_id: "call-1".to_string(),
|
||||
approval_id: "approval-1".to_string(),
|
||||
approval_kind: ApprovalKind::ExecCommand,
|
||||
lifecycle_stage: ApprovalLifecycleStage::Requested,
|
||||
escalation_status: None,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
let payload =
|
||||
serde_json::to_value(&event).expect("serialize approval item requested event");
|
||||
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"event_type": "codex_approval_item",
|
||||
"event_params": {
|
||||
"thread_id": "thread-9",
|
||||
"turn_id": "turn-9",
|
||||
"approval_id": "approval-1",
|
||||
"call_id": "call-1",
|
||||
"approval_kind": "exec_command",
|
||||
"lifecycle_stage": "requested",
|
||||
"is_tool_call_escalated": true,
|
||||
"product_client_id": crate::default_client::originator().value,
|
||||
"model_slug": "gpt-5"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_item_resolved_event_serializes_expected_shape() {
|
||||
let tracking = TrackEventsContext {
|
||||
model_slug: "gpt-5".to_string(),
|
||||
thread_id: "thread-10".to_string(),
|
||||
turn_id: "turn-10".to_string(),
|
||||
};
|
||||
let event = TrackEventRequest::ApprovalItem(CodexApprovalItemEventRequest {
|
||||
event_type: "codex_approval_item",
|
||||
event_params: codex_approval_item_metadata(
|
||||
&tracking,
|
||||
ApprovalItemInvocation {
|
||||
call_id: "call-2".to_string(),
|
||||
approval_id: "approval-2".to_string(),
|
||||
approval_kind: ApprovalKind::ApplyPatch,
|
||||
lifecycle_stage: ApprovalLifecycleStage::Resolved,
|
||||
escalation_status: Some(ApprovalEscalationStatus::Rejected),
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
let payload = serde_json::to_value(&event).expect("serialize approval item resolved event");
|
||||
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"event_type": "codex_approval_item",
|
||||
"event_params": {
|
||||
"thread_id": "thread-10",
|
||||
"turn_id": "turn-10",
|
||||
"approval_id": "approval-2",
|
||||
"call_id": "call-2",
|
||||
"approval_kind": "apply_patch",
|
||||
"lifecycle_stage": "resolved",
|
||||
"is_tool_call_escalated": true,
|
||||
"escalation_status": "rejected",
|
||||
"product_client_id": crate::default_client::originator().value,
|
||||
"model_slug": "gpt-5"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ use crate::agent::AgentStatus;
|
||||
use crate::agent::agent_status_from_event;
|
||||
use crate::analytics_client::AnalyticsEventsClient;
|
||||
use crate::analytics_client::AppInvocation;
|
||||
use crate::analytics_client::ApprovalEscalationStatus;
|
||||
use crate::analytics_client::ApprovalItemInvocation;
|
||||
use crate::analytics_client::ApprovalKind;
|
||||
use crate::analytics_client::ApprovalLifecycleStage;
|
||||
use crate::analytics_client::InvocationType;
|
||||
use crate::analytics_client::build_track_events_context;
|
||||
use crate::apps::render_apps_section;
|
||||
@@ -78,6 +82,10 @@ use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::items::PlanItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::items::UserMessageItem;
|
||||
use codex_protocol::items::UserMessageMetadata;
|
||||
use codex_protocol::items::UserMessagePersonality;
|
||||
use codex_protocol::items::UserMessageSandboxPolicy;
|
||||
use codex_protocol::items::UserMessageType;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
@@ -152,6 +160,8 @@ use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::context_manager::ContextManager;
|
||||
use crate::context_manager::TotalTokenUsageBreakdown;
|
||||
use crate::contextual_user_message::AGENTS_MD_FRAGMENT;
|
||||
use crate::contextual_user_message::ENVIRONMENT_CONTEXT_FRAGMENT;
|
||||
use crate::environment_context::EnvironmentContext;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CodexResult;
|
||||
@@ -170,6 +180,136 @@ pub enum SteerInputError {
|
||||
EmptyInput,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum UserMessageItemSource {
|
||||
Prompt,
|
||||
PromptQueued,
|
||||
PromptSteering,
|
||||
}
|
||||
|
||||
impl From<PendingInputSource> for UserMessageItemSource {
|
||||
fn from(value: PendingInputSource) -> Self {
|
||||
match value {
|
||||
PendingInputSource::Queued => Self::PromptQueued,
|
||||
PendingInputSource::Steering => Self::PromptSteering,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sandbox_policy_item_metadata(policy: &SandboxPolicy) -> UserMessageSandboxPolicy {
|
||||
match policy {
|
||||
SandboxPolicy::ReadOnly { .. } => UserMessageSandboxPolicy::ReadOnly,
|
||||
SandboxPolicy::WorkspaceWrite { .. } => UserMessageSandboxPolicy::Sandbox,
|
||||
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
|
||||
UserMessageSandboxPolicy::FullAccess
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn personality_item_metadata(personality: Option<Personality>) -> Option<UserMessagePersonality> {
|
||||
match personality {
|
||||
Some(Personality::Friendly) => Some(UserMessagePersonality::Friendly),
|
||||
Some(Personality::Pragmatic) => Some(UserMessagePersonality::Pragmatic),
|
||||
Some(Personality::None) | None => None,
|
||||
}
|
||||
}
|
||||
|
||||
// POC limitation: `prompt_with_ide_context` is heuristic. We only detect it when
|
||||
// IDE context survives into `UserInput` markers (text elements, skills, mentions).
|
||||
// If upstream clients flatten IDE context into plain text, this will be classified as
|
||||
// a regular prompt until richer source tagging is wired through.
|
||||
fn is_ide_context_user_input(input: &[UserInput]) -> bool {
|
||||
input.iter().any(|entry| match entry {
|
||||
UserInput::Text { text_elements, .. } => !text_elements.is_empty(),
|
||||
UserInput::Skill { .. } | UserInput::Mention { .. } => true,
|
||||
UserInput::Image { .. } | UserInput::LocalImage { .. } => false,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_agents_md_message(input: &[UserInput]) -> bool {
|
||||
input.iter().any(|entry| match entry {
|
||||
UserInput::Text { text, .. } => AGENTS_MD_FRAGMENT.matches_text(text),
|
||||
UserInput::Image { .. }
|
||||
| UserInput::LocalImage { .. }
|
||||
| UserInput::Skill { .. }
|
||||
| UserInput::Mention { .. } => false,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_environment_context_message(input: &[UserInput]) -> bool {
|
||||
input.iter().any(|entry| match entry {
|
||||
UserInput::Text { text, .. } => ENVIRONMENT_CONTEXT_FRAGMENT.matches_text(text),
|
||||
UserInput::Image { .. }
|
||||
| UserInput::LocalImage { .. }
|
||||
| UserInput::Skill { .. }
|
||||
| UserInput::Mention { .. } => false,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn user_message_type_metadata(
|
||||
input: &[UserInput],
|
||||
source: UserMessageItemSource,
|
||||
) -> UserMessageType {
|
||||
if is_environment_context_message(input) {
|
||||
return UserMessageType::EnvironmentContext;
|
||||
}
|
||||
if is_agents_md_message(input) {
|
||||
// POC note: we can reliably detect AGENTS.md payloads, but not yet distinguish
|
||||
// between default and custom sources in all call paths.
|
||||
return UserMessageType::AgentsMdCustom;
|
||||
}
|
||||
if is_ide_context_user_input(input) {
|
||||
return UserMessageType::PromptWithIdeContext;
|
||||
}
|
||||
match source {
|
||||
UserMessageItemSource::Prompt => UserMessageType::Prompt,
|
||||
UserMessageItemSource::PromptQueued => UserMessageType::PromptQueued,
|
||||
UserMessageItemSource::PromptSteering => UserMessageType::PromptSteering,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_user_message_metadata(
|
||||
turn_context: &TurnContext,
|
||||
input: &[UserInput],
|
||||
source: UserMessageItemSource,
|
||||
) -> UserMessageMetadata {
|
||||
UserMessageMetadata {
|
||||
is_plan_mode: Some(turn_context.collaboration_mode.mode == ModeKind::Plan),
|
||||
is_tool_call_escalated: None,
|
||||
escalation_status: None,
|
||||
sandbox_policy: Some(sandbox_policy_item_metadata(
|
||||
turn_context.sandbox_policy.get(),
|
||||
)),
|
||||
user_message_type: Some(user_message_type_metadata(input, source)),
|
||||
personality: personality_item_metadata(turn_context.personality),
|
||||
}
|
||||
}
|
||||
|
||||
fn approval_kind_for_telemetry(kind: PendingApprovalKind) -> ApprovalKind {
|
||||
match kind {
|
||||
PendingApprovalKind::ExecCommand => ApprovalKind::ExecCommand,
|
||||
PendingApprovalKind::ApplyPatch => ApprovalKind::ApplyPatch,
|
||||
}
|
||||
}
|
||||
|
||||
fn escalation_status_for_review_decision(decision: &ReviewDecision) -> ApprovalEscalationStatus {
|
||||
match decision {
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
| ReviewDecision::ApprovedForSession => ApprovalEscalationStatus::Approved,
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
} => match network_policy_amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => ApprovalEscalationStatus::Approved,
|
||||
NetworkPolicyRuleAction::Deny => ApprovalEscalationStatus::Rejected,
|
||||
},
|
||||
ReviewDecision::Denied | ReviewDecision::Abort => ApprovalEscalationStatus::Rejected,
|
||||
}
|
||||
}
|
||||
|
||||
/// Notes from the previous real user turn.
|
||||
///
|
||||
/// Conceptually this is the same role that `previous_model` used to fill, but
|
||||
@@ -263,6 +403,11 @@ use crate::skills::injection::app_id_from_path;
|
||||
use crate::skills::injection::tool_kind_for_path;
|
||||
use crate::skills::resolve_skill_dependencies_for_turn;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::state::PendingApproval;
|
||||
use crate::state::PendingApprovalKind;
|
||||
use crate::state::PendingApprovalTelemetry;
|
||||
use crate::state::PendingInputItem;
|
||||
use crate::state::PendingInputSource;
|
||||
use crate::state::SessionServices;
|
||||
use crate::state::SessionState;
|
||||
use crate::state_db;
|
||||
@@ -2658,6 +2803,29 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
fn track_pending_approval_item(
|
||||
&self,
|
||||
telemetry: &PendingApprovalTelemetry,
|
||||
lifecycle_stage: ApprovalLifecycleStage,
|
||||
escalation_status: Option<ApprovalEscalationStatus>,
|
||||
) {
|
||||
let tracking = build_track_events_context(
|
||||
telemetry.model_slug.clone(),
|
||||
self.conversation_id.to_string(),
|
||||
telemetry.turn_id.clone(),
|
||||
);
|
||||
self.services.analytics_events_client.track_approval_item(
|
||||
tracking,
|
||||
ApprovalItemInvocation {
|
||||
call_id: telemetry.call_id.clone(),
|
||||
approval_id: telemetry.approval_id.clone(),
|
||||
approval_kind: approval_kind_for_telemetry(telemetry.kind),
|
||||
lifecycle_stage,
|
||||
escalation_status,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Emit an exec approval request event and await the user's decision.
|
||||
///
|
||||
/// The request is keyed by `call_id` + `approval_id` so matching responses
|
||||
@@ -2684,6 +2852,13 @@ impl Session {
|
||||
// command-level approvals use `call_id`.
|
||||
// `approval_id` is only present for subcommand callbacks (execve intercept)
|
||||
let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone());
|
||||
let pending_telemetry = PendingApprovalTelemetry {
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
call_id: call_id.clone(),
|
||||
approval_id: effective_approval_id.clone(),
|
||||
model_slug: turn_context.model_info.slug.clone(),
|
||||
kind: PendingApprovalKind::ExecCommand,
|
||||
};
|
||||
// Add the tx_approve callback to the map before sending the request.
|
||||
let (tx_approve, rx_approve) = oneshot::channel();
|
||||
let prev_entry = {
|
||||
@@ -2691,7 +2866,13 @@ impl Session {
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
ts.insert_pending_approval(effective_approval_id.clone(), tx_approve)
|
||||
ts.insert_pending_approval(
|
||||
effective_approval_id.clone(),
|
||||
PendingApproval {
|
||||
tx: tx_approve,
|
||||
telemetry: pending_telemetry.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
@@ -2699,6 +2880,11 @@ impl Session {
|
||||
if prev_entry.is_some() {
|
||||
warn!("Overwriting existing pending approval for call_id: {effective_approval_id}");
|
||||
}
|
||||
self.track_pending_approval_item(
|
||||
&pending_telemetry,
|
||||
ApprovalLifecycleStage::Requested,
|
||||
None,
|
||||
);
|
||||
|
||||
let parsed_cmd = parse_command(&command);
|
||||
let proposed_network_policy_amendments = network_approval_context.as_ref().map(|context| {
|
||||
@@ -2750,12 +2936,25 @@ impl Session {
|
||||
// Add the tx_approve callback to the map before sending the request.
|
||||
let (tx_approve, rx_approve) = oneshot::channel();
|
||||
let approval_id = call_id.clone();
|
||||
let pending_telemetry = PendingApprovalTelemetry {
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
call_id: call_id.clone(),
|
||||
approval_id: approval_id.clone(),
|
||||
model_slug: turn_context.model_info.slug.clone(),
|
||||
kind: PendingApprovalKind::ApplyPatch,
|
||||
};
|
||||
let prev_entry = {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
ts.insert_pending_approval(approval_id.clone(), tx_approve)
|
||||
ts.insert_pending_approval(
|
||||
approval_id.clone(),
|
||||
PendingApproval {
|
||||
tx: tx_approve,
|
||||
telemetry: pending_telemetry.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
@@ -2763,6 +2962,11 @@ impl Session {
|
||||
if prev_entry.is_some() {
|
||||
warn!("Overwriting existing pending approval for call_id: {approval_id}");
|
||||
}
|
||||
self.track_pending_approval_item(
|
||||
&pending_telemetry,
|
||||
ApprovalLifecycleStage::Requested,
|
||||
None,
|
||||
);
|
||||
|
||||
let event = EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
|
||||
call_id,
|
||||
@@ -2865,8 +3069,40 @@ impl Session {
|
||||
}
|
||||
};
|
||||
match entry {
|
||||
Some(tx_approve) => {
|
||||
tx_approve.send(decision).ok();
|
||||
Some(pending_approval) => {
|
||||
let escalation_status = escalation_status_for_review_decision(&decision);
|
||||
self.track_pending_approval_item(
|
||||
&pending_approval.telemetry,
|
||||
ApprovalLifecycleStage::Resolved,
|
||||
Some(escalation_status),
|
||||
);
|
||||
pending_approval.tx.send(decision).ok();
|
||||
}
|
||||
None => {
|
||||
warn!("No pending approval found for call_id: {approval_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn discard_pending_approval(&self, approval_id: &str, decision: ReviewDecision) {
|
||||
let entry = {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
ts.remove_pending_approval(approval_id)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
match entry {
|
||||
Some(pending_approval) => {
|
||||
let escalation_status = escalation_status_for_review_decision(&decision);
|
||||
self.track_pending_approval_item(
|
||||
&pending_approval.telemetry,
|
||||
ApprovalLifecycleStage::Resolved,
|
||||
Some(escalation_status),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
warn!("No pending approval found for call_id: {approval_id}");
|
||||
@@ -3337,13 +3573,16 @@ impl Session {
|
||||
turn_context: &TurnContext,
|
||||
input: &[UserInput],
|
||||
response_item: ResponseItem,
|
||||
source: UserMessageItemSource,
|
||||
) {
|
||||
// Persist the user message to history, but emit the turn item from `UserInput` so
|
||||
// UI-only `text_elements` are preserved. `ResponseItem::Message` does not carry
|
||||
// those spans, and `record_response_item_and_emit_turn_item` would drop them.
|
||||
self.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
|
||||
.await;
|
||||
let turn_item = TurnItem::UserMessage(UserMessageItem::new(input));
|
||||
let metadata = build_user_message_metadata(turn_context, input, source);
|
||||
let turn_item =
|
||||
TurnItem::UserMessage(UserMessageItem::new_with_metadata(input, Some(metadata)));
|
||||
self.emit_turn_item_started(turn_context, &turn_item).await;
|
||||
self.emit_turn_item_completed(turn_context, turn_item).await;
|
||||
self.ensure_rollout_materialized().await;
|
||||
@@ -3437,7 +3676,7 @@ impl Session {
|
||||
}
|
||||
|
||||
let mut turn_state = active_turn.turn_state.lock().await;
|
||||
turn_state.push_pending_input(input.into());
|
||||
turn_state.push_pending_input(input.into(), PendingInputSource::Steering);
|
||||
Ok(active_turn_id.clone())
|
||||
}
|
||||
|
||||
@@ -3451,7 +3690,7 @@ impl Session {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
for item in input {
|
||||
ts.push_pending_input(item);
|
||||
ts.push_pending_input(item, PendingInputSource::Queued);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -3459,7 +3698,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_pending_input(&self) -> Vec<ResponseInputItem> {
|
||||
pub async fn get_pending_input(&self) -> Vec<PendingInputItem> {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
@@ -4169,6 +4408,8 @@ mod handlers {
|
||||
}
|
||||
match decision {
|
||||
ReviewDecision::Abort => {
|
||||
sess.discard_pending_approval(&approval_id, ReviewDecision::Abort)
|
||||
.await;
|
||||
sess.interrupt_task().await;
|
||||
}
|
||||
other => sess.notify_approval(&approval_id, other).await,
|
||||
@@ -4178,6 +4419,8 @@ mod handlers {
|
||||
pub async fn patch_approval(sess: &Arc<Session>, id: String, decision: ReviewDecision) {
|
||||
match decision {
|
||||
ReviewDecision::Abort => {
|
||||
sess.discard_pending_approval(&id, ReviewDecision::Abort)
|
||||
.await;
|
||||
sess.interrupt_task().await;
|
||||
}
|
||||
other => sess.notify_approval(&id, other).await,
|
||||
@@ -5043,8 +5286,13 @@ pub(crate) async fn run_turn(
|
||||
|
||||
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone());
|
||||
let response_item: ResponseItem = initial_input_for_turn.clone().into();
|
||||
sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item)
|
||||
.await;
|
||||
sess.record_user_prompt_and_emit_turn_item(
|
||||
turn_context.as_ref(),
|
||||
&input,
|
||||
response_item,
|
||||
UserMessageItemSource::Prompt,
|
||||
)
|
||||
.await;
|
||||
// Track the previous-turn baseline from the regular user-turn path only so
|
||||
// standalone tasks (compact/shell/review/undo) cannot suppress future
|
||||
// model/realtime injections.
|
||||
@@ -5080,17 +5328,23 @@ pub(crate) async fn run_turn(
|
||||
.get_pending_input()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(ResponseItem::from)
|
||||
.collect::<Vec<ResponseItem>>();
|
||||
.map(|pending| {
|
||||
(
|
||||
UserMessageItemSource::from(pending.source),
|
||||
ResponseItem::from(pending.input),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<(UserMessageItemSource, ResponseItem)>>();
|
||||
|
||||
if !pending_response_items.is_empty() {
|
||||
for response_item in pending_response_items {
|
||||
for (source, response_item) in pending_response_items {
|
||||
if let Some(TurnItem::UserMessage(user_message)) = parse_turn_item(&response_item) {
|
||||
// todo(aibrahim): move pending input to be UserInput only to keep TextElements. context: https://github.com/openai/codex/pull/10656#discussion_r2765522480
|
||||
sess.record_user_prompt_and_emit_turn_item(
|
||||
turn_context.as_ref(),
|
||||
&user_message.content,
|
||||
response_item,
|
||||
source,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
@@ -9668,30 +9922,70 @@ mod tests {
|
||||
.await
|
||||
.expect("expected item started event")
|
||||
.expect("channel open");
|
||||
assert!(matches!(
|
||||
second.msg,
|
||||
EventMsg::ItemStarted(ItemStartedEvent {
|
||||
item: TurnItem::UserMessage(UserMessageItem { content, .. }),
|
||||
..
|
||||
}) if content == vec![UserInput::Text {
|
||||
let EventMsg::ItemStarted(ItemStartedEvent {
|
||||
item:
|
||||
TurnItem::UserMessage(UserMessageItem {
|
||||
content,
|
||||
metadata: Some(metadata),
|
||||
..
|
||||
}),
|
||||
..
|
||||
}) = second.msg
|
||||
else {
|
||||
panic!("expected started user message item");
|
||||
};
|
||||
assert_eq!(
|
||||
content,
|
||||
vec![UserInput::Text {
|
||||
text: "late pending input".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.user_message_type,
|
||||
Some(UserMessageType::PromptQueued)
|
||||
);
|
||||
assert_eq!(metadata.is_plan_mode, Some(false));
|
||||
assert!(matches!(
|
||||
metadata.sandbox_policy,
|
||||
Some(UserMessageSandboxPolicy::ReadOnly)
|
||||
| Some(UserMessageSandboxPolicy::Sandbox)
|
||||
| Some(UserMessageSandboxPolicy::FullAccess)
|
||||
));
|
||||
|
||||
let third = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
|
||||
.await
|
||||
.expect("expected item completed event")
|
||||
.expect("channel open");
|
||||
assert!(matches!(
|
||||
third.msg,
|
||||
EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||
item: TurnItem::UserMessage(UserMessageItem { content, .. }),
|
||||
..
|
||||
}) if content == vec![UserInput::Text {
|
||||
let EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||
item:
|
||||
TurnItem::UserMessage(UserMessageItem {
|
||||
content,
|
||||
metadata: Some(metadata),
|
||||
..
|
||||
}),
|
||||
..
|
||||
}) = third.msg
|
||||
else {
|
||||
panic!("expected completed user message item");
|
||||
};
|
||||
assert_eq!(
|
||||
content,
|
||||
vec![UserInput::Text {
|
||||
text: "late pending input".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.user_message_type,
|
||||
Some(UserMessageType::PromptQueued)
|
||||
);
|
||||
assert_eq!(metadata.is_plan_mode, Some(false));
|
||||
assert!(matches!(
|
||||
metadata.sandbox_policy,
|
||||
Some(UserMessageSandboxPolicy::ReadOnly)
|
||||
| Some(UserMessageSandboxPolicy::Sandbox)
|
||||
| Some(UserMessageSandboxPolicy::FullAccess)
|
||||
));
|
||||
|
||||
let fourth = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
|
||||
@@ -9724,6 +10018,67 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn task_finish_marks_steer_input_as_prompt_steering_metadata() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
let input = vec![UserInput::Text {
|
||||
text: "hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
sess.spawn_task(
|
||||
Arc::clone(&tc),
|
||||
input,
|
||||
NeverEndingTask {
|
||||
kind: TaskKind::Regular,
|
||||
listen_to_cancellation_token: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
|
||||
let steer_turn_id = sess
|
||||
.steer_input(
|
||||
vec![UserInput::Text {
|
||||
text: "steered input".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
Some(tc.sub_id.as_str()),
|
||||
)
|
||||
.await
|
||||
.expect("steer input should enqueue while task is active");
|
||||
assert_eq!(steer_turn_id, tc.sub_id);
|
||||
|
||||
sess.on_task_finished(Arc::clone(&tc), None).await;
|
||||
|
||||
let first = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
|
||||
.await
|
||||
.expect("expected raw response item event")
|
||||
.expect("channel open");
|
||||
assert!(matches!(first.msg, EventMsg::RawResponseItem(_)));
|
||||
|
||||
let second = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
|
||||
.await
|
||||
.expect("expected item started event")
|
||||
.expect("channel open");
|
||||
let EventMsg::ItemStarted(ItemStartedEvent {
|
||||
item:
|
||||
TurnItem::UserMessage(UserMessageItem {
|
||||
metadata: Some(metadata),
|
||||
..
|
||||
}),
|
||||
..
|
||||
}) = second.msg
|
||||
else {
|
||||
panic!("expected started user message item");
|
||||
};
|
||||
assert_eq!(
|
||||
metadata.user_message_type,
|
||||
Some(UserMessageType::PromptSteering)
|
||||
);
|
||||
assert_eq!(metadata.is_plan_mode, Some(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steer_input_requires_active_turn() {
|
||||
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
|
||||
|
||||
@@ -5,5 +5,10 @@ mod turn;
|
||||
pub(crate) use service::SessionServices;
|
||||
pub(crate) use session::SessionState;
|
||||
pub(crate) use turn::ActiveTurn;
|
||||
pub(crate) use turn::PendingApproval;
|
||||
pub(crate) use turn::PendingApprovalKind;
|
||||
pub(crate) use turn::PendingApprovalTelemetry;
|
||||
pub(crate) use turn::PendingInputItem;
|
||||
pub(crate) use turn::PendingInputSource;
|
||||
pub(crate) use turn::RunningTask;
|
||||
pub(crate) use turn::TaskKind;
|
||||
|
||||
@@ -18,6 +18,26 @@ use crate::protocol::ReviewDecision;
|
||||
use crate::protocol::TokenUsage;
|
||||
use crate::tasks::SessionTask;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum PendingApprovalKind {
|
||||
ExecCommand,
|
||||
ApplyPatch,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct PendingApprovalTelemetry {
|
||||
pub(crate) turn_id: String,
|
||||
pub(crate) call_id: String,
|
||||
pub(crate) approval_id: String,
|
||||
pub(crate) model_slug: String,
|
||||
pub(crate) kind: PendingApprovalKind,
|
||||
}
|
||||
|
||||
pub(crate) struct PendingApproval {
|
||||
pub(crate) tx: oneshot::Sender<ReviewDecision>,
|
||||
pub(crate) telemetry: PendingApprovalTelemetry,
|
||||
}
|
||||
|
||||
/// Metadata about the currently running turn.
|
||||
pub(crate) struct ActiveTurn {
|
||||
pub(crate) tasks: IndexMap<String, RunningTask>,
|
||||
@@ -40,6 +60,18 @@ pub(crate) enum TaskKind {
|
||||
Compact,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum PendingInputSource {
|
||||
Queued,
|
||||
Steering,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct PendingInputItem {
|
||||
pub(crate) input: ResponseInputItem,
|
||||
pub(crate) source: PendingInputSource,
|
||||
}
|
||||
|
||||
pub(crate) struct RunningTask {
|
||||
pub(crate) done: Arc<Notify>,
|
||||
pub(crate) kind: TaskKind,
|
||||
@@ -70,10 +102,10 @@ impl ActiveTurn {
|
||||
/// Mutable state for a single turn.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TurnState {
|
||||
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
|
||||
pending_approvals: HashMap<String, PendingApproval>,
|
||||
pending_user_input: HashMap<String, oneshot::Sender<RequestUserInputResponse>>,
|
||||
pending_dynamic_tools: HashMap<String, oneshot::Sender<DynamicToolResponse>>,
|
||||
pending_input: Vec<ResponseInputItem>,
|
||||
pending_input: Vec<PendingInputItem>,
|
||||
pub(crate) tool_calls: u64,
|
||||
pub(crate) token_usage_at_turn_start: TokenUsage,
|
||||
}
|
||||
@@ -82,15 +114,12 @@ impl TurnState {
|
||||
pub(crate) fn insert_pending_approval(
|
||||
&mut self,
|
||||
key: String,
|
||||
tx: oneshot::Sender<ReviewDecision>,
|
||||
) -> Option<oneshot::Sender<ReviewDecision>> {
|
||||
self.pending_approvals.insert(key, tx)
|
||||
pending: PendingApproval,
|
||||
) -> Option<PendingApproval> {
|
||||
self.pending_approvals.insert(key, pending)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_pending_approval(
|
||||
&mut self,
|
||||
key: &str,
|
||||
) -> Option<oneshot::Sender<ReviewDecision>> {
|
||||
pub(crate) fn remove_pending_approval(&mut self, key: &str) -> Option<PendingApproval> {
|
||||
self.pending_approvals.remove(key)
|
||||
}
|
||||
|
||||
@@ -131,11 +160,15 @@ impl TurnState {
|
||||
self.pending_dynamic_tools.remove(key)
|
||||
}
|
||||
|
||||
pub(crate) fn push_pending_input(&mut self, input: ResponseInputItem) {
|
||||
self.pending_input.push(input);
|
||||
pub(crate) fn push_pending_input(
|
||||
&mut self,
|
||||
input: ResponseInputItem,
|
||||
source: PendingInputSource,
|
||||
) {
|
||||
self.pending_input.push(PendingInputItem { input, source });
|
||||
}
|
||||
|
||||
pub(crate) fn take_pending_input(&mut self) -> Vec<ResponseInputItem> {
|
||||
pub(crate) fn take_pending_input(&mut self) -> Vec<PendingInputItem> {
|
||||
if self.pending_input.is_empty() {
|
||||
Vec::with_capacity(0)
|
||||
} else {
|
||||
|
||||
@@ -21,6 +21,7 @@ use tracing::warn;
|
||||
use crate::AuthManager;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::codex::UserMessageItemSource;
|
||||
use crate::contextual_user_message::TURN_ABORTED_OPEN_TAG;
|
||||
use crate::event_mapping::parse_turn_item;
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
@@ -29,11 +30,11 @@ use crate::protocol::TurnAbortReason;
|
||||
use crate::protocol::TurnAbortedEvent;
|
||||
use crate::protocol::TurnCompleteEvent;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::state::PendingInputItem;
|
||||
use crate::state::RunningTask;
|
||||
use crate::state::TaskKind;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
@@ -210,7 +211,7 @@ impl Session {
|
||||
.cancel_git_enrichment_task();
|
||||
|
||||
let mut active = self.active_turn.lock().await;
|
||||
let mut pending_input = Vec::<ResponseInputItem>::new();
|
||||
let mut pending_input = Vec::<PendingInputItem>::new();
|
||||
let mut should_clear_active_turn = false;
|
||||
let mut token_usage_at_turn_start = None;
|
||||
let mut turn_tool_calls = 0_u64;
|
||||
@@ -230,9 +231,14 @@ impl Session {
|
||||
if !pending_input.is_empty() {
|
||||
let pending_response_items = pending_input
|
||||
.into_iter()
|
||||
.map(ResponseItem::from)
|
||||
.map(|pending| {
|
||||
(
|
||||
UserMessageItemSource::from(pending.source),
|
||||
ResponseItem::from(pending.input),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for response_item in pending_response_items {
|
||||
for (source, response_item) in pending_response_items {
|
||||
if let Some(TurnItem::UserMessage(user_message)) = parse_turn_item(&response_item) {
|
||||
// Keep leftover user input on the same persistence + lifecycle path as the
|
||||
// normal pre-sampling drain. This helper records the response item once, then
|
||||
@@ -241,6 +247,7 @@ impl Session {
|
||||
turn_context.as_ref(),
|
||||
&user_message.content,
|
||||
response_item,
|
||||
source,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
|
||||
@@ -29,10 +29,73 @@ pub enum TurnItem {
|
||||
ContextCompaction(ContextCompactionItem),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(rename_all = "snake_case")]
|
||||
pub enum UserMessageType {
|
||||
Prompt,
|
||||
PromptSteering,
|
||||
PromptQueued,
|
||||
PromptWithIdeContext,
|
||||
AgentsMdDefault,
|
||||
AgentsMdCustom,
|
||||
EnvironmentContext,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(rename_all = "snake_case")]
|
||||
pub enum EscalationStatus {
|
||||
Approved,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(rename_all = "snake_case")]
|
||||
pub enum UserMessageSandboxPolicy {
|
||||
ReadOnly,
|
||||
Sandbox,
|
||||
FullAccess,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(rename_all = "snake_case")]
|
||||
pub enum UserMessagePersonality {
|
||||
Friendly,
|
||||
Pragmatic,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
||||
pub struct UserMessageMetadata {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub is_plan_mode: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub is_tool_call_escalated: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub escalation_status: Option<EscalationStatus>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub sandbox_policy: Option<UserMessageSandboxPolicy>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub user_message_type: Option<UserMessageType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub personality: Option<UserMessagePersonality>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
||||
pub struct UserMessageItem {
|
||||
pub id: String,
|
||||
pub content: Vec<UserInput>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub metadata: Option<UserMessageMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
||||
@@ -116,9 +179,14 @@ impl Default for ContextCompactionItem {
|
||||
|
||||
impl UserMessageItem {
|
||||
pub fn new(content: &[UserInput]) -> Self {
|
||||
Self::new_with_metadata(content, None)
|
||||
}
|
||||
|
||||
pub fn new_with_metadata(content: &[UserInput], metadata: Option<UserMessageMetadata>) -> Self {
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
content: content.to_vec(),
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3424,6 +3424,7 @@ fn complete_user_message_for_inputs(chat: &mut ChatWidget, item_id: &str, conten
|
||||
item: TurnItem::UserMessage(UserMessageItem {
|
||||
id: item_id.to_string(),
|
||||
content,
|
||||
metadata: None,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user