//! Defines the protocol for a Codex session between a client and an agent. //! //! Uses a SQ (Submission Queue) / EQ (Event Queue) pattern to asynchronously communicate //! between user and agent. use std::collections::HashMap; use std::fmt; use std::path::Path; use std::path::PathBuf; use std::str::FromStr; use std::time::Duration; use crate::config_types::ReasoningEffort as ReasoningEffortConfig; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::custom_prompts::CustomPrompt; use crate::mcp_protocol::ConversationId; use crate::message_history::HistoryEntry; use crate::models::ContentItem; use crate::models::ResponseItem; use crate::num_format::format_with_separators; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; use mcp_types::CallToolResult; use mcp_types::Tool as McpTool; use serde::Deserialize; use serde::Serialize; use serde_with::serde_as; use strum_macros::Display; use ts_rs::TS; /// Open/close tags for special user-input blocks. Used across crates to avoid /// duplicated hardcoded strings. pub const USER_INSTRUCTIONS_OPEN_TAG: &str = ""; pub const USER_INSTRUCTIONS_CLOSE_TAG: &str = ""; pub const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = ""; pub const ENVIRONMENT_CONTEXT_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Submission { /// Unique id for this Submission to correlate with Events pub id: String, /// Payload pub op: Op, } /// Submission operation #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] #[allow(clippy::large_enum_variant)] #[non_exhaustive] pub enum Op { /// Abort current task. /// This server sends [`EventMsg::TurnAborted`] in response. Interrupt, /// Input from the user UserInput { /// User input items, see `InputItem` items: Vec, }, /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_conversation::CodexConversation`]. UserTurn { /// User input items, see `InputItem` items: Vec, /// `cwd` to use with the [`SandboxPolicy`] and potentially tool calls /// such as `local_shell`. cwd: PathBuf, /// Policy to use for command approval. approval_policy: AskForApproval, /// Policy to use for tool calls such as `local_shell`. sandbox_policy: SandboxPolicy, /// Must be a valid model slug for the [`crate::client::ModelClient`] /// associated with this conversation. model: String, /// Will only be honored if the model is configured to use reasoning. #[serde(skip_serializing_if = "Option::is_none")] effort: Option, /// Will only be honored if the model is configured to use reasoning. summary: ReasoningSummaryConfig, }, /// Override parts of the persistent turn context for subsequent turns. /// /// All fields are optional; when omitted, the existing value is preserved. /// This does not enqueue any input – it only updates defaults used for /// future `UserInput` turns. OverrideTurnContext { /// Updated `cwd` for sandbox/tool calls. #[serde(skip_serializing_if = "Option::is_none")] cwd: Option, /// Updated command approval policy. #[serde(skip_serializing_if = "Option::is_none")] approval_policy: Option, /// Updated sandbox policy for tool calls. #[serde(skip_serializing_if = "Option::is_none")] sandbox_policy: Option, /// Updated model slug. When set, the model family is derived /// automatically. #[serde(skip_serializing_if = "Option::is_none")] model: Option, /// Updated reasoning effort (honored only for reasoning-capable models). /// /// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear /// the effort, or `None` to leave the existing value unchanged. #[serde(skip_serializing_if = "Option::is_none")] effort: Option>, /// Updated reasoning summary preference (honored only for reasoning-capable models). #[serde(skip_serializing_if = "Option::is_none")] summary: Option, }, /// Approve a command execution ExecApproval { /// The id of the submission we are approving id: String, /// The user's decision in response to the request. decision: ReviewDecision, }, /// Approve a code patch PatchApproval { /// The id of the submission we are approving id: String, /// The user's decision in response to the request. decision: ReviewDecision, }, /// Append an entry to the persistent cross-session message history. /// /// Note the entry is not guaranteed to be logged if the user has /// history disabled, it matches the list of "sensitive" patterns, etc. AddToHistory { /// The message text to be stored. text: String, }, /// Request a single history entry identified by `log_id` + `offset`. GetHistoryEntryRequest { offset: usize, log_id: u64 }, /// Request the full in-memory conversation transcript for the current session. /// Reply is delivered via `EventMsg::ConversationHistory`. GetPath, /// Request the list of MCP tools available across all configured servers. /// Reply is delivered via `EventMsg::McpListToolsResponse`. ListMcpTools, /// Request the list of available custom prompts. ListCustomPrompts, /// Request the agent to summarize the current conversation context. /// The agent will use its existing context (either conversation history or previous response id) /// to generate a summary which will be returned as an AgentMessage event. Compact, /// Request a code review from the agent. Review { review_request: ReviewRequest }, /// Request to shut down codex instance. Shutdown, } /// Determines the conditions under which the user is consulted to approve /// running the command proposed by Codex. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, Display, TS)] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum AskForApproval { /// Under this policy, only "known safe" commands—as determined by /// `is_safe_command()`—that **only read files** are auto‑approved. /// Everything else will ask the user to approve. #[serde(rename = "untrusted")] #[strum(serialize = "untrusted")] UnlessTrusted, /// *All* commands are auto‑approved, but they are expected to run inside a /// sandbox where network access is disabled and writes are confined to a /// specific set of paths. If the command fails, it will be escalated to /// the user to approve execution without a sandbox. OnFailure, /// The model decides when to ask the user for approval. #[default] OnRequest, /// Never ask the user to approve commands. Failures are immediately returned /// to the model, and never escalated to the user for approval. Never, } /// Determines execution restrictions for model shell commands. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, TS)] #[strum(serialize_all = "kebab-case")] #[serde(tag = "mode", rename_all = "kebab-case")] pub enum SandboxPolicy { /// No restrictions whatsoever. Use with caution. #[serde(rename = "danger-full-access")] DangerFullAccess, /// Read-only access to the entire file-system. #[serde(rename = "read-only")] ReadOnly, /// Same as `ReadOnly` but additionally grants write access to the current /// working directory ("workspace"). #[serde(rename = "workspace-write")] WorkspaceWrite { /// Additional folders (beyond cwd and possibly TMPDIR) that should be /// writable from within the sandbox. #[serde(default, skip_serializing_if = "Vec::is_empty")] writable_roots: Vec, /// When set to `true`, outbound network access is allowed. `false` by /// default. #[serde(default)] network_access: bool, /// When set to `true`, will NOT include the per-user `TMPDIR` /// environment variable among the default writable roots. Defaults to /// `false`. #[serde(default)] exclude_tmpdir_env_var: bool, /// When set to `true`, will NOT include the `/tmp` among the default /// writable roots on UNIX. Defaults to `false`. #[serde(default)] exclude_slash_tmp: bool, }, } /// A writable root path accompanied by a list of subpaths that should remain /// read‑only even when the root is writable. This is primarily used to ensure /// top‑level VCS metadata directories (e.g. `.git`) under a writable root are /// not modified by the agent. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WritableRoot { /// Absolute path, by construction. pub root: PathBuf, /// Also absolute paths, by construction. pub read_only_subpaths: Vec, } impl WritableRoot { pub fn is_path_writable(&self, path: &Path) -> bool { // Check if the path is under the root. if !path.starts_with(&self.root) { return false; } // Check if the path is under any of the read-only subpaths. for subpath in &self.read_only_subpaths { if path.starts_with(subpath) { return false; } } true } } impl FromStr for SandboxPolicy { type Err = serde_json::Error; fn from_str(s: &str) -> Result { serde_json::from_str(s) } } impl SandboxPolicy { /// Returns a policy with read-only disk access and no network. pub fn new_read_only_policy() -> Self { SandboxPolicy::ReadOnly } /// Returns a policy that can read the entire disk, but can only write to /// the current working directory and the per-user tmp dir on macOS. It does /// not allow network access. pub fn new_workspace_write_policy() -> Self { SandboxPolicy::WorkspaceWrite { writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, } } /// Always returns `true`; restricting read access is not supported. pub fn has_full_disk_read_access(&self) -> bool { true } pub fn has_full_disk_write_access(&self) -> bool { match self { SandboxPolicy::DangerFullAccess => true, SandboxPolicy::ReadOnly => false, SandboxPolicy::WorkspaceWrite { .. } => false, } } pub fn has_full_network_access(&self) -> bool { match self { SandboxPolicy::DangerFullAccess => true, SandboxPolicy::ReadOnly => false, SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access, } } /// Returns the list of writable roots (tailored to the current working /// directory) together with subpaths that should remain read‑only under /// each writable root. pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec { match self { SandboxPolicy::DangerFullAccess => Vec::new(), SandboxPolicy::ReadOnly => Vec::new(), SandboxPolicy::WorkspaceWrite { writable_roots, exclude_tmpdir_env_var, exclude_slash_tmp, network_access: _, } => { // Start from explicitly configured writable roots. let mut roots: Vec = writable_roots.clone(); // Always include defaults: cwd, /tmp (if present on Unix), and // on macOS, the per-user TMPDIR unless explicitly excluded. roots.push(cwd.to_path_buf()); // Include /tmp on Unix unless explicitly excluded. if cfg!(unix) && !exclude_slash_tmp { let slash_tmp = PathBuf::from("/tmp"); if slash_tmp.is_dir() { roots.push(slash_tmp); } } // Include $TMPDIR unless explicitly excluded. On macOS, TMPDIR // is per-user, so writes to TMPDIR should not be readable by // other users on the system. // // By comparison, TMPDIR is not guaranteed to be defined on // Linux or Windows, but supporting it here gives users a way to // provide the model with their own temporary directory without // having to hardcode it in the config. if !exclude_tmpdir_env_var && let Some(tmpdir) = std::env::var_os("TMPDIR") && !tmpdir.is_empty() { roots.push(PathBuf::from(tmpdir)); } // For each root, compute subpaths that should remain read-only. roots .into_iter() .map(|writable_root| { let mut subpaths = Vec::new(); let top_level_git = writable_root.join(".git"); if top_level_git.is_dir() { subpaths.push(top_level_git); } WritableRoot { root: writable_root, read_only_subpaths: subpaths, } }) .collect() } } } } /// User input #[non_exhaustive] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum InputItem { Text { text: String, }, /// Pre‑encoded data: URI image. Image { image_url: String, }, /// Local image path provided by the user. This will be converted to an /// `Image` variant (base64 data URL) during request serialization. LocalImage { path: std::path::PathBuf, }, } /// Event Queue Entry - events from agent #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Event { /// Submission `id` that this event is correlated with. pub id: String, /// Payload pub msg: EventMsg, } /// Response event from the agent /// NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen. #[derive(Debug, Clone, Deserialize, Serialize, Display, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum EventMsg { /// Error while executing a submission Error(ErrorEvent), /// Agent has started a task TaskStarted(TaskStartedEvent), /// Agent has completed all actions TaskComplete(TaskCompleteEvent), /// Usage update for the current session, including totals and last turn. /// Optional means unknown — UIs should not display when `None`. TokenCount(TokenCountEvent), /// Agent text output message AgentMessage(AgentMessageEvent), /// User/system input message (what was sent to the model) UserMessage(UserMessageEvent), /// Agent text output delta message AgentMessageDelta(AgentMessageDeltaEvent), /// Reasoning event from agent. AgentReasoning(AgentReasoningEvent), /// Agent reasoning delta event from agent. AgentReasoningDelta(AgentReasoningDeltaEvent), /// Raw chain-of-thought from agent. AgentReasoningRawContent(AgentReasoningRawContentEvent), /// Agent reasoning content delta event from agent. AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent), /// Signaled when the model begins a new reasoning summary section (e.g., a new titled block). AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent), /// Ack the client's configure message. SessionConfigured(SessionConfiguredEvent), McpToolCallBegin(McpToolCallBeginEvent), McpToolCallEnd(McpToolCallEndEvent), WebSearchBegin(WebSearchBeginEvent), WebSearchEnd(WebSearchEndEvent), /// Notification that the server is about to execute a command. ExecCommandBegin(ExecCommandBeginEvent), /// Incremental chunk of output from a running command. ExecCommandOutputDelta(ExecCommandOutputDeltaEvent), ExecCommandEnd(ExecCommandEndEvent), ExecApprovalRequest(ExecApprovalRequestEvent), ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent), BackgroundEvent(BackgroundEventEvent), /// Notification that a model stream experienced an error or disconnect /// and the system is handling it (e.g., retrying with backoff). StreamError(StreamErrorEvent), /// Notification that the agent is about to apply a code patch. Mirrors /// `ExecCommandBegin` so front‑ends can show progress indicators. PatchApplyBegin(PatchApplyBeginEvent), /// Notification that a patch application has finished. PatchApplyEnd(PatchApplyEndEvent), TurnDiff(TurnDiffEvent), /// Response to GetHistoryEntryRequest. GetHistoryEntryResponse(GetHistoryEntryResponseEvent), /// List of MCP tools available to the agent. McpListToolsResponse(McpListToolsResponseEvent), /// List of custom prompts available to the agent. ListCustomPromptsResponse(ListCustomPromptsResponseEvent), PlanUpdate(UpdatePlanArgs), TurnAborted(TurnAbortedEvent), /// Notification that the agent is shutting down. ShutdownComplete, ConversationPath(ConversationPathResponseEvent), /// Entered review mode. EnteredReviewMode(ReviewRequest), /// Exited review mode with an optional final result to apply. ExitedReviewMode(ExitedReviewModeEvent), } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct ExitedReviewModeEvent { pub review_output: Option, } // Individual event payload types matching each `EventMsg` variant. #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct ErrorEvent { pub message: String, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct TaskCompleteEvent { pub last_agent_message: Option, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct TaskStartedEvent { pub model_context_window: Option, } #[derive(Debug, Clone, Deserialize, Serialize, Default, TS)] pub struct TokenUsage { pub input_tokens: u64, pub cached_input_tokens: u64, pub output_tokens: u64, pub reasoning_output_tokens: u64, pub total_tokens: u64, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct TokenUsageInfo { pub total_token_usage: TokenUsage, pub last_token_usage: TokenUsage, pub model_context_window: Option, } impl TokenUsageInfo { pub fn new_or_append( info: &Option, last: &Option, model_context_window: Option, ) -> Option { if info.is_none() && last.is_none() { return None; } let mut info = match info { Some(info) => info.clone(), None => Self { total_token_usage: TokenUsage::default(), last_token_usage: TokenUsage::default(), model_context_window, }, }; if let Some(last) = last { info.append_last_usage(last); } Some(info) } pub fn append_last_usage(&mut self, last: &TokenUsage) { self.total_token_usage.add_assign(last); self.last_token_usage = last.clone(); } } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct TokenCountEvent { pub info: Option, } // Includes prompts, tools and space to call compact. const BASELINE_TOKENS: u64 = 12000; impl TokenUsage { pub fn is_zero(&self) -> bool { self.total_tokens == 0 } pub fn cached_input(&self) -> u64 { self.cached_input_tokens } pub fn non_cached_input(&self) -> u64 { self.input_tokens.saturating_sub(self.cached_input()) } /// Primary count for display as a single absolute value: non-cached input + output. pub fn blended_total(&self) -> u64 { self.non_cached_input() + self.output_tokens } /// For estimating what % of the model's context window is used, we need to account /// for reasoning output tokens from prior turns being dropped from the context window. /// We approximate this here by subtracting reasoning output tokens from the total. /// This will be off for the current turn and pending function calls. pub fn tokens_in_context_window(&self) -> u64 { self.total_tokens .saturating_sub(self.reasoning_output_tokens) } /// Estimate the remaining user-controllable percentage of the model's context window. /// /// `context_window` is the total size of the model's context window. /// `BASELINE_TOKENS` should capture tokens that are always present in /// the context (e.g., system prompt and fixed tool instructions) so that /// the percentage reflects the portion the user can influence. /// /// This normalizes both the numerator and denominator by subtracting the /// baseline, so immediately after the first prompt the UI shows 100% left /// and trends toward 0% as the user fills the effective window. pub fn percent_of_context_window_remaining(&self, context_window: u64) -> u8 { if context_window <= BASELINE_TOKENS { return 0; } let effective_window = context_window - BASELINE_TOKENS; let used = self .tokens_in_context_window() .saturating_sub(BASELINE_TOKENS); let remaining = effective_window.saturating_sub(used); ((remaining as f32 / effective_window as f32) * 100.0).clamp(0.0, 100.0) as u8 } /// In-place element-wise sum of token counts. pub fn add_assign(&mut self, other: &TokenUsage) { self.input_tokens += other.input_tokens; self.cached_input_tokens += other.cached_input_tokens; self.output_tokens += other.output_tokens; self.reasoning_output_tokens += other.reasoning_output_tokens; self.total_tokens += other.total_tokens; } } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct FinalOutput { pub token_usage: TokenUsage, } impl From for FinalOutput { fn from(token_usage: TokenUsage) -> Self { Self { token_usage } } } impl fmt::Display for FinalOutput { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let token_usage = &self.token_usage; write!( f, "Token usage: total={} input={}{} output={}{}", format_with_separators(token_usage.blended_total()), format_with_separators(token_usage.non_cached_input()), if token_usage.cached_input() > 0 { format!( " (+ {} cached)", format_with_separators(token_usage.cached_input()) ) } else { String::new() }, format_with_separators(token_usage.output_tokens), if token_usage.reasoning_output_tokens > 0 { format!( " (reasoning {})", format_with_separators(token_usage.reasoning_output_tokens) ) } else { String::new() } ) } } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct AgentMessageEvent { pub message: String, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] #[serde(rename_all = "snake_case")] pub enum InputMessageKind { /// Plain user text (default) Plain, /// XML-wrapped user instructions (...) UserInstructions, /// XML-wrapped environment context (...) EnvironmentContext, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct UserMessageEvent { pub message: String, #[serde(skip_serializing_if = "Option::is_none")] pub kind: Option, #[serde(skip_serializing_if = "Option::is_none")] pub images: Option>, } impl From<(T, U)> for InputMessageKind where T: AsRef, U: AsRef, { fn from(value: (T, U)) -> Self { let (_role, message) = value; let message = message.as_ref(); let trimmed = message.trim(); if starts_with_ignore_ascii_case(trimmed, ENVIRONMENT_CONTEXT_OPEN_TAG) && ends_with_ignore_ascii_case(trimmed, ENVIRONMENT_CONTEXT_CLOSE_TAG) { InputMessageKind::EnvironmentContext } else if starts_with_ignore_ascii_case(trimmed, USER_INSTRUCTIONS_OPEN_TAG) && ends_with_ignore_ascii_case(trimmed, USER_INSTRUCTIONS_CLOSE_TAG) { InputMessageKind::UserInstructions } else { InputMessageKind::Plain } } } fn starts_with_ignore_ascii_case(text: &str, prefix: &str) -> bool { let text_bytes = text.as_bytes(); let prefix_bytes = prefix.as_bytes(); text_bytes.len() >= prefix_bytes.len() && text_bytes .iter() .zip(prefix_bytes.iter()) .all(|(a, b)| a.eq_ignore_ascii_case(b)) } fn ends_with_ignore_ascii_case(text: &str, suffix: &str) -> bool { let text_bytes = text.as_bytes(); let suffix_bytes = suffix.as_bytes(); text_bytes.len() >= suffix_bytes.len() && text_bytes[text_bytes.len() - suffix_bytes.len()..] .iter() .zip(suffix_bytes.iter()) .all(|(a, b)| a.eq_ignore_ascii_case(b)) } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct AgentMessageDeltaEvent { pub delta: String, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct AgentReasoningEvent { pub text: String, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct AgentReasoningRawContentEvent { pub text: String, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct AgentReasoningRawContentDeltaEvent { pub delta: String, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct AgentReasoningSectionBreakEvent {} #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct AgentReasoningDeltaEvent { pub delta: String, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct McpInvocation { /// Name of the MCP server as defined in the config. pub server: String, /// Name of the tool as given by the MCP server. pub tool: String, /// Arguments to the tool call. pub arguments: Option, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct McpToolCallBeginEvent { /// Identifier so this can be paired with the McpToolCallEnd event. pub call_id: String, pub invocation: McpInvocation, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct McpToolCallEndEvent { /// Identifier for the corresponding McpToolCallBegin that finished. pub call_id: String, pub invocation: McpInvocation, #[ts(type = "string")] pub duration: Duration, /// Result of the tool call. Note this could be an error. pub result: Result, } impl McpToolCallEndEvent { pub fn is_success(&self) -> bool { match &self.result { Ok(result) => !result.is_error.unwrap_or(false), Err(_) => false, } } } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct WebSearchBeginEvent { pub call_id: String, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct WebSearchEndEvent { pub call_id: String, pub query: String, } /// Response payload for `Op::GetHistory` containing the current session's /// in-memory transcript. #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct ConversationPathResponseEvent { pub conversation_id: ConversationId, pub path: PathBuf, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct ResumedHistory { pub conversation_id: ConversationId, pub history: Vec, pub rollout_path: PathBuf, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub enum InitialHistory { New, Resumed(ResumedHistory), Forked(Vec), } impl InitialHistory { pub fn get_rollout_items(&self) -> Vec { match self { InitialHistory::New => Vec::new(), InitialHistory::Resumed(resumed) => resumed.history.clone(), InitialHistory::Forked(items) => items.clone(), } } pub fn get_event_msgs(&self) -> Option> { match self { InitialHistory::New => None, InitialHistory::Resumed(resumed) => Some( resumed .history .iter() .filter_map(|ri| match ri { RolloutItem::EventMsg(ev) => Some(ev.clone()), _ => None, }) .collect(), ), InitialHistory::Forked(items) => Some( items .iter() .filter_map(|ri| match ri { RolloutItem::EventMsg(ev) => Some(ev.clone()), _ => None, }) .collect(), ), } } } #[derive(Serialize, Deserialize, Clone, Default, Debug, TS)] pub struct SessionMeta { pub id: ConversationId, pub timestamp: String, pub cwd: PathBuf, pub originator: String, pub cli_version: String, pub instructions: Option, } #[derive(Serialize, Deserialize, Debug, Clone, TS)] pub struct SessionMetaLine { #[serde(flatten)] pub meta: SessionMeta, #[serde(skip_serializing_if = "Option::is_none")] pub git: Option, } #[derive(Serialize, Deserialize, Debug, Clone, TS)] #[serde(tag = "type", content = "payload", rename_all = "snake_case")] pub enum RolloutItem { SessionMeta(SessionMetaLine), ResponseItem(ResponseItem), Compacted(CompactedItem), TurnContext(TurnContextItem), EventMsg(EventMsg), } #[derive(Serialize, Deserialize, Clone, Debug, TS)] pub struct CompactedItem { pub message: String, } impl From for ResponseItem { fn from(value: CompactedItem) -> Self { ResponseItem::Message { id: None, role: "assistant".to_string(), content: vec![ContentItem::OutputText { text: value.message, }], } } } #[derive(Serialize, Deserialize, Clone, Debug, TS)] pub struct TurnContextItem { pub cwd: PathBuf, pub approval_policy: AskForApproval, pub sandbox_policy: SandboxPolicy, pub model: String, #[serde(skip_serializing_if = "Option::is_none")] pub effort: Option, pub summary: ReasoningSummaryConfig, } #[derive(Serialize, Deserialize, Clone)] pub struct RolloutLine { pub timestamp: String, #[serde(flatten)] pub item: RolloutItem, } #[derive(Serialize, Deserialize, Clone, Debug, TS)] pub struct GitInfo { /// Current commit hash (SHA) #[serde(skip_serializing_if = "Option::is_none")] pub commit_hash: Option, /// Current branch name #[serde(skip_serializing_if = "Option::is_none")] pub branch: Option, /// Repository URL (if available from remote) #[serde(skip_serializing_if = "Option::is_none")] pub repository_url: Option, } /// Review request sent to the review session. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] pub struct ReviewRequest { pub prompt: String, pub user_facing_hint: String, } /// Structured review result produced by a child review session. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] pub struct ReviewOutputEvent { pub findings: Vec, pub overall_correctness: String, pub overall_explanation: String, pub overall_confidence_score: f32, } impl Default for ReviewOutputEvent { fn default() -> Self { Self { findings: Vec::new(), overall_correctness: String::default(), overall_explanation: String::default(), overall_confidence_score: 0.0, } } } /// A single review finding describing an observed issue or recommendation. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] pub struct ReviewFinding { pub title: String, pub body: String, pub confidence_score: f32, pub priority: i32, pub code_location: ReviewCodeLocation, } /// Location of the code related to a review finding. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] pub struct ReviewCodeLocation { pub absolute_file_path: PathBuf, pub line_range: ReviewLineRange, } /// Inclusive line range in a file associated with the finding. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] pub struct ReviewLineRange { pub start: u32, pub end: u32, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct ExecCommandBeginEvent { /// Identifier so this can be paired with the ExecCommandEnd event. pub call_id: String, /// The command to be executed. pub command: Vec, /// The command's working directory if not the default cwd for the agent. pub cwd: PathBuf, pub parsed_cmd: Vec, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct ExecCommandEndEvent { /// Identifier for the ExecCommandBegin that finished. pub call_id: String, /// Captured stdout pub stdout: String, /// Captured stderr pub stderr: String, /// Captured aggregated output #[serde(default)] pub aggregated_output: String, /// The command's exit code. pub exit_code: i32, /// The duration of the command execution. #[ts(type = "string")] pub duration: Duration, /// Formatted output from the command, as seen by the model. pub formatted_output: String, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] #[serde(rename_all = "snake_case")] pub enum ExecOutputStream { Stdout, Stderr, } #[serde_as] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] pub struct ExecCommandOutputDeltaEvent { /// Identifier for the ExecCommandBegin that produced this chunk. pub call_id: String, /// Which stream produced this chunk. pub stream: ExecOutputStream, /// Raw bytes from the stream (may not be valid UTF-8). #[serde_as(as = "serde_with::base64::Base64")] #[ts(type = "string")] pub chunk: Vec, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct ExecApprovalRequestEvent { /// Identifier for the associated exec call, if available. pub call_id: String, /// The command to be executed. pub command: Vec, /// The command's working directory. pub cwd: PathBuf, /// Optional human-readable reason for the approval (e.g. retry without sandbox). #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct ApplyPatchApprovalRequestEvent { /// Responses API call id for the associated patch apply call, if available. pub call_id: String, pub changes: HashMap, /// Optional explanatory reason (e.g. request for extra write access). #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, /// When set, the agent is asking the user to allow writes under this root for the remainder of the session. #[serde(skip_serializing_if = "Option::is_none")] pub grant_root: Option, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct BackgroundEventEvent { pub message: String, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct StreamErrorEvent { pub message: String, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct PatchApplyBeginEvent { /// Identifier so this can be paired with the PatchApplyEnd event. pub call_id: String, /// If true, there was no ApplyPatchApprovalRequest for this patch. pub auto_approved: bool, /// The changes to be applied. pub changes: HashMap, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct PatchApplyEndEvent { /// Identifier for the PatchApplyBegin that finished. pub call_id: String, /// Captured stdout (summary printed by apply_patch). pub stdout: String, /// Captured stderr (parser errors, IO failures, etc.). pub stderr: String, /// Whether the patch was applied successfully. pub success: bool, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct TurnDiffEvent { pub unified_diff: String, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct GetHistoryEntryResponseEvent { pub offset: usize, pub log_id: u64, /// The entry at the requested offset, if available and parseable. #[serde(skip_serializing_if = "Option::is_none")] pub entry: Option, } /// Response payload for `Op::ListMcpTools`. #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct McpListToolsResponseEvent { /// Fully qualified tool name -> tool definition. pub tools: std::collections::HashMap, } /// Response payload for `Op::ListCustomPrompts`. #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct ListCustomPromptsResponseEvent { pub custom_prompts: Vec, } #[derive(Debug, Default, Clone, Deserialize, Serialize, TS)] pub struct SessionConfiguredEvent { /// Name left as session_id instead of conversation_id for backwards compatibility. pub session_id: ConversationId, /// Tell the client what model is being queried. pub model: String, /// The effort the model is putting into reasoning about the user's request. #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, /// Identifier of the history log file (inode on Unix, 0 otherwise). pub history_log_id: u64, /// Current number of entries in the history log. pub history_entry_count: usize, /// Optional initial messages (as events) for resumed sessions. /// When present, UIs can use these to seed the history. #[serde(skip_serializing_if = "Option::is_none")] pub initial_messages: Option>, pub rollout_path: PathBuf, } /// User's decision in response to an ExecApprovalRequest. #[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, TS)] #[serde(rename_all = "snake_case")] pub enum ReviewDecision { /// User has approved this command and the agent should execute it. Approved, /// User has approved this command and wants to automatically approve any /// future identical instances (`command` and `cwd` match exactly) for the /// remainder of the session. ApprovedForSession, /// User has denied this command and the agent should not execute it, but /// it should continue the session and try something else. #[default] Denied, /// User has denied this command and the agent should not do anything until /// the user's next command. Abort, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] #[serde(rename_all = "snake_case")] pub enum FileChange { Add { content: String, }, Delete { content: String, }, Update { unified_diff: String, move_path: Option, }, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct Chunk { /// 1-based line index of the first line in the original file pub orig_index: u32, pub deleted_lines: Vec, pub inserted_lines: Vec, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct TurnAbortedEvent { pub reason: TurnAbortReason, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] #[serde(rename_all = "snake_case")] pub enum TurnAbortReason { Interrupted, Replaced, } #[cfg(test)] mod tests { use super::*; use crate::models::LocalShellAction; use crate::models::LocalShellStatus; use crate::models::ReasoningItemContent; use crate::models::ReasoningItemReasoningSummary; use crate::models::WebSearchAction; use serde_json::Value; use serde_json::json; use std::path::PathBuf; use tempfile::NamedTempFile; /// Serialize Event to verify that its JSON representation has the expected /// amount of nesting. #[test] fn serialize_event() { let conversation_id = ConversationId(uuid::uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8")); let rollout_file = NamedTempFile::new().unwrap(); let event = Event { id: "1234".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, model: "codex-mini-latest".to_string(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, initial_messages: None, rollout_path: rollout_file.path().to_path_buf(), }), }; let expected = json!({ "id": "1234", "msg": { "type": "session_configured", "session_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", "model": "codex-mini-latest", "reasoning_effort": "medium", "history_log_id": 0, "history_entry_count": 0, "rollout_path": format!("{}", rollout_file.path().display()), } }); assert_eq!(expected, serde_json::to_value(&event).unwrap()); } #[test] fn vec_u8_as_base64_serialization_and_deserialization() { let event = ExecCommandOutputDeltaEvent { call_id: "call21".to_string(), stream: ExecOutputStream::Stdout, chunk: vec![1, 2, 3, 4, 5], }; let serialized = serde_json::to_string(&event).unwrap(); assert_eq!( r#"{"call_id":"call21","stream":"stdout","chunk":"AQIDBAU="}"#, serialized, ); let deserialized: ExecCommandOutputDeltaEvent = serde_json::from_str(&serialized).unwrap(); assert_eq!(deserialized, event); } fn parse_rollout_line(value: Value, case: &str) -> RolloutLine { let serialized = serde_json::to_string(&value) .unwrap_or_else(|err| panic!("failed to serialize {case}: {err}")); serde_json::from_str(&serialized) .unwrap_or_else(|err| panic!("failed to parse {case}: {err}")) } #[test] fn deserialize_rollout_session_meta_lines() { let timestamp = "2025-01-02T03:04:05.678Z"; let conversation_id = uuid::uuid!("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); let cases: Vec<(&str, Value)> = vec![ ( "with_git", json!({ "timestamp": timestamp, "type": "session_meta", "payload": { "id": conversation_id, "timestamp": timestamp, "cwd": "/workspace", "originator": "codex-cli", "cli_version": "1.0.0", "instructions": "Remember the tests", "git": { "commit_hash": "abc123", "branch": "main", "repository_url": "https://example.com/repo.git" } } }), ), ( "without_git", json!({ "timestamp": timestamp, "type": "session_meta", "payload": { "id": conversation_id, "timestamp": timestamp, "cwd": "/workspace", "originator": "codex-cli", "cli_version": "1.0.0", "instructions": null } }), ), ]; for (case, value) in cases { let parsed = parse_rollout_line(value, case); assert_eq!(parsed.timestamp, timestamp); match parsed.item { RolloutItem::SessionMeta(session_meta) => { assert_eq!(session_meta.meta.id, ConversationId(conversation_id)); assert_eq!(session_meta.meta.cli_version, "1.0.0"); assert_eq!(session_meta.meta.originator, "codex-cli"); assert_eq!(session_meta.meta.cwd, PathBuf::from("/workspace")); assert_eq!(session_meta.meta.timestamp, timestamp); match case { "with_git" => { assert_eq!( session_meta.meta.instructions.as_deref(), Some("Remember the tests") ); let git = session_meta.git.expect("expected git info"); assert_eq!(git.commit_hash.as_deref(), Some("abc123")); assert_eq!(git.branch.as_deref(), Some("main")); assert_eq!( git.repository_url.as_deref(), Some("https://example.com/repo.git") ); } "without_git" => { assert!(session_meta.meta.instructions.is_none()); assert!(session_meta.git.is_none()); } _ => unreachable!(), } } other => panic!("case {case} parsed as unexpected item {other:?}"), } } } #[test] #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] fn deserialize_rollout_response_item_lines() { let timestamp = "2025-01-02T03:04:05.678Z"; let cases: Vec<(&str, Value)> = vec![ ( "message", json!({ "timestamp": timestamp, "type": "response_item", "payload": { "type": "message", "id": "legacy-message", "role": "assistant", "content": [ { "type": "output_text", "text": "Hello from assistant" } ] } }), ), ( "reasoning", json!({ "timestamp": timestamp, "type": "response_item", "payload": { "type": "reasoning", "id": "reasoning-1", "summary": [ { "type": "summary_text", "text": "Summarized thoughts" } ], "content": [ { "type": "reasoning_text", "text": "Detailed reasoning" } ], "encrypted_content": "encrypted" } }), ), ( "local_shell_call", json!({ "timestamp": timestamp, "type": "response_item", "payload": { "type": "local_shell_call", "id": "legacy-shell-call", "call_id": "shell-call-1", "status": "completed", "action": { "type": "exec", "command": ["ls", "-la"], "timeout_ms": 1200, "working_directory": "/workspace", "env": { "PATH": "/usr/bin" }, "user": "codex" } } }), ), ( "function_call", json!({ "timestamp": timestamp, "type": "response_item", "payload": { "type": "function_call", "id": "legacy-function", "name": "shell", "arguments": "{\"command\":[\"echo\",\"hi\"]}", "call_id": "call-123" } }), ), ( "function_call_output", json!({ "timestamp": timestamp, "type": "response_item", "payload": { "type": "function_call_output", "call_id": "call-123", "output": "{\"stdout\":\"done\"}" } }), ), ( "custom_tool_call", json!({ "timestamp": timestamp, "type": "response_item", "payload": { "type": "custom_tool_call", "id": "legacy-tool", "status": "completed", "call_id": "tool-456", "name": "my_tool", "input": "{\"foo\":1}" } }), ), ( "custom_tool_call_output", json!({ "timestamp": timestamp, "type": "response_item", "payload": { "type": "custom_tool_call_output", "call_id": "tool-456", "output": "tool finished" } }), ), ( "web_search_call", json!({ "timestamp": timestamp, "type": "response_item", "payload": { "type": "web_search_call", "id": "legacy-search", "status": "completed", "action": { "type": "search", "query": "weather in SF" } } }), ), ( "other", json!({ "timestamp": timestamp, "type": "response_item", "payload": { "type": "new_future_item", "foo": "bar" } }), ), ]; for (case, value) in cases { let parsed = parse_rollout_line(value, case); assert_eq!(parsed.timestamp, timestamp); match (case, parsed.item) { ( "message", RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }), ) => { assert_eq!(role, "assistant"); assert_eq!(content.len(), 1); if let ContentItem::OutputText { text } = &content[0] { assert_eq!(text, "Hello from assistant"); } else { panic!("unexpected content variant in message case"); } } ( "reasoning", RolloutItem::ResponseItem(ResponseItem::Reasoning { summary, content, encrypted_content, .. }), ) => { assert_eq!(summary.len(), 1); match &summary[0] { ReasoningItemReasoningSummary::SummaryText { text } => { assert_eq!(text, "Summarized thoughts"); } other => panic!("unexpected summary variant: {other:?}"), } let reasoning_content = content.expect("expected reasoning content"); assert_eq!(reasoning_content.len(), 1); if let ReasoningItemContent::ReasoningText { text } = &reasoning_content[0] { assert_eq!(text, "Detailed reasoning"); } else { panic!("unexpected reasoning content variant"); } assert_eq!(encrypted_content.as_deref(), Some("encrypted")); } ( "local_shell_call", RolloutItem::ResponseItem(ResponseItem::LocalShellCall { call_id, status, action, .. }), ) => { assert_eq!(call_id.as_deref(), Some("shell-call-1")); assert!(matches!(status, LocalShellStatus::Completed)); match action { LocalShellAction::Exec(exec) => { assert_eq!(exec.command, vec!["ls", "-la"]); assert_eq!(exec.timeout_ms, Some(1200)); assert_eq!(exec.working_directory.as_deref(), Some("/workspace")); let env = exec.env.expect("expected env map"); assert_eq!(env.get("PATH"), Some(&"/usr/bin".to_string())); assert_eq!(exec.user.as_deref(), Some("codex")); } } } ( "function_call", RolloutItem::ResponseItem(ResponseItem::FunctionCall { name, arguments, call_id, .. }), ) => { assert_eq!(name, "shell"); assert_eq!(arguments, "{\"command\":[\"echo\",\"hi\"]}"); assert_eq!(call_id, "call-123"); } ( "function_call_output", RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput { output, .. }), ) => { assert_eq!(output.content, "{\"stdout\":\"done\"}"); assert!(output.success.is_none()); } ( "custom_tool_call", RolloutItem::ResponseItem(ResponseItem::CustomToolCall { status, call_id, name, input, .. }), ) => { assert_eq!(status.as_deref(), Some("completed")); assert_eq!(call_id, "tool-456"); assert_eq!(name, "my_tool"); assert_eq!(input, "{\"foo\":1}"); } ( "custom_tool_call_output", RolloutItem::ResponseItem(ResponseItem::CustomToolCallOutput { output, .. }), ) => { assert_eq!(output, "tool finished"); } ( "web_search_call", RolloutItem::ResponseItem(ResponseItem::WebSearchCall { status, action, .. }), ) => { assert_eq!(status.as_deref(), Some("completed")); match action { WebSearchAction::Search { query } => { assert_eq!(query, "weather in SF"); } WebSearchAction::Other => panic!("unexpected web search action variant"), } } ("other", RolloutItem::ResponseItem(ResponseItem::Other)) => {} (case, item) => panic!("case {case} returned unexpected item {item:?}"), } } } #[test] #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] fn deserialize_rollout_event_msg_lines() { let timestamp = "2025-01-02T03:04:05.678Z"; let cases: Vec<(&str, Value)> = vec![ ( "user_message", json!({ "timestamp": timestamp, "type": "event_msg", "payload": { "type": "user_message", "message": "Please help", "kind": "plain", "images": ["data:image/png;base64,AAA"] } }), ), ( "agent_message", json!({ "timestamp": timestamp, "type": "event_msg", "payload": { "type": "agent_message", "message": "Sure thing" } }), ), ( "agent_reasoning", json!({ "timestamp": timestamp, "type": "event_msg", "payload": { "type": "agent_reasoning", "text": "Thinking..." } }), ), ( "agent_reasoning_raw_content", json!({ "timestamp": timestamp, "type": "event_msg", "payload": { "type": "agent_reasoning_raw_content", "text": "raw reasoning" } }), ), ( "token_count_info", json!({ "timestamp": timestamp, "type": "event_msg", "payload": { "type": "token_count", "info": { "total_token_usage": { "input_tokens": 120, "cached_input_tokens": 10, "output_tokens": 30, "reasoning_output_tokens": 5, "total_tokens": 165 }, "last_token_usage": { "input_tokens": 20, "cached_input_tokens": 0, "output_tokens": 15, "reasoning_output_tokens": 5, "total_tokens": 40 }, "model_context_window": 16000 } } }), ), ( "token_count_none", json!({ "timestamp": timestamp, "type": "event_msg", "payload": { "type": "token_count", "info": null } }), ), ( "entered_review_mode", json!({ "timestamp": timestamp, "type": "event_msg", "payload": { "type": "entered_review_mode", "prompt": "Need review", "user_facing_hint": "double-check work" } }), ), ( "exited_review_mode", json!({ "timestamp": timestamp, "type": "event_msg", "payload": { "type": "exited_review_mode", "review_output": { "findings": [ { "title": "Bug", "body": "Found an issue", "confidence_score": 0.4, "priority": 1, "code_location": { "absolute_file_path": "/workspace/src/lib.rs", "line_range": { "start": 1, "end": 3 } } } ], "overall_correctness": "needs_changes", "overall_explanation": "Please fix", "overall_confidence_score": 0.9 } } }), ), ( "turn_aborted", json!({ "timestamp": timestamp, "type": "event_msg", "payload": { "type": "turn_aborted", "reason": "interrupted" } }), ), ]; for (case, value) in cases { let parsed = parse_rollout_line(value, case); assert_eq!(parsed.timestamp, timestamp); match (case, parsed.item) { ("user_message", RolloutItem::EventMsg(EventMsg::UserMessage(event))) => { assert_eq!(event.message, "Please help"); assert!(matches!(event.kind, Some(InputMessageKind::Plain))); let images = event.images.expect("expected images"); assert_eq!(images, vec!["data:image/png;base64,AAA".to_string()]); } ("agent_message", RolloutItem::EventMsg(EventMsg::AgentMessage(event))) => { assert_eq!(event.message, "Sure thing"); } ("agent_reasoning", RolloutItem::EventMsg(EventMsg::AgentReasoning(event))) => { assert_eq!(event.text, "Thinking..."); } ( "agent_reasoning_raw_content", RolloutItem::EventMsg(EventMsg::AgentReasoningRawContent(event)), ) => { assert_eq!(event.text, "raw reasoning"); } ("token_count_info", RolloutItem::EventMsg(EventMsg::TokenCount(event))) => { let info = event.info.expect("expected token info"); assert_eq!(info.total_token_usage.input_tokens, 120); assert_eq!(info.total_token_usage.cached_input_tokens, 10); assert_eq!(info.total_token_usage.output_tokens, 30); assert_eq!(info.total_token_usage.reasoning_output_tokens, 5); assert_eq!(info.total_token_usage.total_tokens, 165); assert_eq!(info.last_token_usage.output_tokens, 15); assert_eq!(info.model_context_window, Some(16000)); } ("token_count_none", RolloutItem::EventMsg(EventMsg::TokenCount(event))) => { assert!(event.info.is_none()); } ( "entered_review_mode", RolloutItem::EventMsg(EventMsg::EnteredReviewMode(request)), ) => { assert_eq!(request.prompt, "Need review"); assert_eq!(request.user_facing_hint, "double-check work"); } ( "exited_review_mode", RolloutItem::EventMsg(EventMsg::ExitedReviewMode(event)), ) => { let output = event.review_output.expect("expected review output"); assert_eq!(output.findings.len(), 1); let finding = &output.findings[0]; assert_eq!(finding.title, "Bug"); assert_eq!(finding.body, "Found an issue"); assert_eq!(finding.confidence_score, 0.4); assert_eq!(finding.priority, 1); assert_eq!( finding.code_location.absolute_file_path, PathBuf::from("/workspace/src/lib.rs") ); assert_eq!(finding.code_location.line_range.start, 1); assert_eq!(finding.code_location.line_range.end, 3); assert_eq!(output.overall_correctness, "needs_changes"); assert_eq!(output.overall_explanation, "Please fix"); assert_eq!(output.overall_confidence_score, 0.9); } ("turn_aborted", RolloutItem::EventMsg(EventMsg::TurnAborted(event))) => { assert_eq!(event.reason, TurnAbortReason::Interrupted); } (case, item) => panic!("case {case} returned unexpected item {item:?}"), } } } #[test] #[allow(clippy::too_many_lines)] fn deserialize_rollout_misc_lines() { let timestamp = "2025-01-02T03:04:05.678Z"; let cases: Vec<(&str, Value)> = vec![ ( "compacted", json!({ "timestamp": timestamp, "type": "compacted", "payload": { "message": "Turn summary" } }), ), ( "turn_context_workspace", json!({ "timestamp": timestamp, "type": "turn_context", "payload": { "cwd": "/workspace", "approval_policy": "on-request", "sandbox_policy": { "mode": "workspace-write", "writable_roots": ["/workspace/tmp"], "network_access": true, "exclude_tmpdir_env_var": false, "exclude_slash_tmp": true }, "model": "gpt-5", "effort": "high", "summary": "detailed" } }), ), ( "turn_context_read_only", json!({ "timestamp": timestamp, "type": "turn_context", "payload": { "cwd": "/workspace", "approval_policy": "never", "sandbox_policy": { "mode": "read-only" }, "model": "gpt-5", "summary": "auto" } }), ), ]; for (case, value) in cases { let parsed = parse_rollout_line(value, case); assert_eq!(parsed.timestamp, timestamp); match (case, parsed.item) { ("compacted", RolloutItem::Compacted(CompactedItem { message })) => { assert_eq!(message, "Turn summary"); } ("turn_context_workspace", RolloutItem::TurnContext(turn_context)) => { assert_eq!(turn_context.cwd, PathBuf::from("/workspace")); assert_eq!(turn_context.approval_policy, AskForApproval::OnRequest); match turn_context.sandbox_policy { SandboxPolicy::WorkspaceWrite { writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, } => { assert_eq!(writable_roots, vec![PathBuf::from("/workspace/tmp")]); assert!(network_access); assert!(!exclude_tmpdir_env_var); assert!(exclude_slash_tmp); } other => panic!("expected workspace-write sandbox policy, got {other:?}"), } assert_eq!(turn_context.model, "gpt-5"); assert_eq!(turn_context.effort, Some(ReasoningEffortConfig::High)); assert_eq!(turn_context.summary, ReasoningSummaryConfig::Detailed); } ("turn_context_read_only", RolloutItem::TurnContext(turn_context)) => { assert_eq!(turn_context.cwd, PathBuf::from("/workspace")); assert_eq!(turn_context.approval_policy, AskForApproval::Never); assert!(turn_context.effort.is_none()); assert_eq!(turn_context.summary, ReasoningSummaryConfig::Auto); match turn_context.sandbox_policy { SandboxPolicy::ReadOnly => {} other => panic!("expected read-only sandbox policy, got {other:?}"), } } (case, item) => panic!("case {case} returned unexpected item {item:?}"), } } } }