Compare commits

...

6 Commits

Author SHA1 Message Date
Roy Han
cf119ea2a0 wiring in approvals 2026-03-05 14:26:48 -08:00
Roy Han
1ed80dbd5a fix merge conflict errors 2026-03-05 12:00:30 -08:00
rhan-oai
4a715ef4cb Merge branch 'main' into rhan/item-metadata-v1 2026-03-05 11:45:30 -08:00
Roy Han
9d9e8d8a52 schema update 2026-03-05 11:43:50 -08:00
Roy Han
ef74096ed1 ide context heuristic clarification 2026-03-05 11:39:59 -08:00
Roy Han
2330d8b4dd step 1 2026-03-05 11:23:09 -08:00
19 changed files with 1072 additions and 42 deletions

View File

@@ -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": [
{

View File

@@ -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": {

View File

@@ -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": [

View File

@@ -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";

View File

@@ -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, };

View File

@@ -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, };

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -1265,6 +1265,7 @@ mod tests {
item: CoreTurnItem::UserMessage(CoreUserMessageItem {
id: "user-item-id".to_string(),
content: Vec::new(),
metadata: None,
}),
}),
EventMsg::TurnComplete(TurnCompleteEvent {

View File

@@ -4703,6 +4703,7 @@ mod tests {
path: "app://demo-app".to_string(),
},
],
metadata: None,
});
assert_eq!(

View File

@@ -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"
}
})
);
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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,
}
}

View File

@@ -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,
}),
}),
});