Replace TUI core protocol imports

This commit is contained in:
Eric Traut
2026-04-29 00:25:31 -07:00
parent 857146b328
commit 0b1fe1caff
81 changed files with 4884 additions and 5635 deletions
+24 -10
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""Verify codex-tui does not depend on or import codex-core directly."""
"""Verify codex-tui stays behind the app-server/core boundary."""
from __future__ import annotations
@@ -14,10 +14,22 @@ ROOT = Path(__file__).resolve().parents[2]
TUI_ROOT = ROOT / "codex-rs" / "tui"
TUI_MANIFEST = TUI_ROOT / "Cargo.toml"
FORBIDDEN_PACKAGE = "codex-core"
FORBIDDEN_SOURCE_PATTERNS = (
re.compile(r"\bcodex_core::"),
re.compile(r"\buse\s+codex_core\b"),
re.compile(r"\bextern\s+crate\s+codex_core\b"),
FORBIDDEN_SOURCE_RULES = (
(
"imports `codex_core`",
(
re.compile(r"\bcodex_core::"),
re.compile(r"\buse\s+codex_core\b"),
re.compile(r"\bextern\s+crate\s+codex_core\b"),
),
),
(
"references `codex_protocol::protocol`",
(
re.compile(r"\bcodex_protocol\s*::\s*protocol\b"),
re.compile(r"\bcodex_protocol\s*::\s*\{[^}\n]*\bprotocol\b"),
),
),
)
@@ -29,10 +41,11 @@ def main() -> int:
if not failures:
return 0
print("codex-tui must not depend on or import codex-core directly.")
print("codex-tui must stay behind the app-server/core boundary.")
print(
"Use the app-server protocol/client boundary instead; temporary embedded "
"startup gaps belong behind codex_app_server_client::legacy_core."
"Use app-server protocol types at the TUI boundary; temporary embedded "
"startup gaps belong behind codex_app_server_client::legacy_core, and "
"core protocol references should remain outside codex-tui."
)
print()
for failure in failures:
@@ -76,8 +89,9 @@ def source_failures() -> list[str]:
for path in sorted(TUI_ROOT.glob("**/*.rs")):
text = path.read_text()
for line_number, line in enumerate(text.splitlines(), start=1):
if any(pattern.search(line) for pattern in FORBIDDEN_SOURCE_PATTERNS):
failures.append(f"{relative_path(path)}:{line_number} imports `codex_core`")
for message, patterns in FORBIDDEN_SOURCE_RULES:
if any(pattern.search(line) for pattern in patterns):
failures.append(f"{relative_path(path)}:{line_number} {message}")
return failures
+3 -5
View File
@@ -31,6 +31,7 @@ use codex_state::state_db_path;
use codex_tui::AppExitInfo;
use codex_tui::Cli as TuiCli;
use codex_tui::ExitReason;
use codex_tui::FinalOutput;
use codex_tui::UpdateAction;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::CliConfigOverrides;
@@ -534,10 +535,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
let mut lines = Vec::new();
if !token_usage.is_zero() {
lines.push(format!(
"{}",
codex_protocol::protocol::FinalOutput::from(token_usage)
));
lines.push(FinalOutput::from(token_usage).to_string());
}
if let Some(resume_cmd) =
@@ -1690,7 +1688,7 @@ mod tests {
use super::*;
use assert_matches::assert_matches;
use codex_protocol::ThreadId;
use codex_protocol::protocol::TokenUsage;
use codex_tui::TokenUsage;
use pretty_assertions::assert_eq;
fn finalize_resume_from_args(args: &[&str]) -> TuiCli {
+16 -13
View File
@@ -46,13 +46,14 @@ fn format_warning(additional_dirs: &[PathBuf]) -> String {
#[cfg(test)]
mod tests {
use super::add_dir_warning_message;
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_app_server_protocol::FileSystemAccessMode;
use codex_app_server_protocol::FileSystemPath;
use codex_app_server_protocol::FileSystemSandboxEntry;
use codex_app_server_protocol::FileSystemSpecialPath;
use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile;
use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::NetworkSandboxPolicy;
use pretty_assertions::assert_eq;
use std::path::Path;
use std::path::PathBuf;
@@ -79,9 +80,10 @@ mod tests {
#[test]
fn returns_none_for_external_sandbox() {
let profile = PermissionProfile::External {
network: NetworkSandboxPolicy::Enabled,
};
let profile: PermissionProfile = AppServerPermissionProfile::External {
network: PermissionProfileNetworkPermissions { enabled: true },
}
.into();
let dirs = vec![PathBuf::from("/tmp/example")];
assert_eq!(
add_dir_warning_message(&dirs, &profile, Path::new("/tmp/project")),
@@ -103,8 +105,9 @@ mod tests {
#[test]
fn warns_when_profile_can_write_elsewhere_but_not_cwd() {
let profile = PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Restricted {
let profile: PermissionProfile = AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
file_system: PermissionProfileFileSystemPermissions::Restricted {
entries: vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
@@ -121,8 +124,8 @@ mod tests {
],
glob_scan_max_depth: None,
},
network: NetworkSandboxPolicy::Restricted,
};
}
.into();
let dirs = vec![PathBuf::from("/tmp/extra")];
assert_eq!(
+61 -134
View File
@@ -5,20 +5,18 @@
use crate::app_backtrack::BacktrackState;
use crate::app_command::AppCommand;
use crate::app_command::AppCommandView;
use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
use crate::app_event::FeedbackCategory;
use crate::app_event::HistoryLookupResponse;
use crate::app_event::RateLimitRefreshOrigin;
use crate::app_event::RealtimeAudioDeviceKind;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event_sender::AppEventSender;
use crate::app_server_approval_conversions::network_approval_context_to_core;
use crate::app_server_session::AppServerSession;
use crate::app_server_session::AppServerStartedThread;
use crate::app_server_session::ThreadSessionState;
use crate::app_server_session::app_server_rate_limit_snapshots_to_core;
use crate::app_server_session::app_server_rate_limit_snapshots;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::FeedbackAudience;
use crate::bottom_pane::McpServerElicitationFormRequest;
@@ -62,17 +60,19 @@ use crate::multi_agents::format_agent_picker_item_name;
use crate::multi_agents::next_agent_shortcut_matches;
use crate::multi_agents::previous_agent_shortcut_matches;
use crate::pager_overlay::Overlay;
use crate::read_session_model;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::Renderable;
use crate::resume_picker::SessionSelection;
use crate::resume_picker::SessionTarget;
use crate::session_state::ThreadSessionState;
#[cfg(test)]
use crate::test_support::PathBufExt;
#[cfg(test)]
use crate::test_support::test_path_buf;
#[cfg(test)]
use crate::test_support::test_path_display;
use crate::token_usage::FinalOutput;
use crate::token_usage::TokenUsage;
use crate::transcript_reflow::TranscriptReflowState;
use crate::tui;
use crate::tui::TuiEvent;
@@ -82,6 +82,7 @@ use codex_ansi_escape::ansi_escape_line;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use codex_app_server_protocol::ConfigLayerSource;
@@ -92,6 +93,8 @@ use codex_app_server_protocol::FeedbackUploadResponse;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
#[cfg(test)]
use codex_app_server_protocol::McpAuthStatus;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::McpServerStatusDetail;
use codex_app_server_protocol::MergeStrategy;
@@ -103,10 +106,12 @@ use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SendAddCreditsNudgeEmailParams;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SkillErrorInfo;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::ThreadItem;
@@ -127,7 +132,6 @@ use codex_models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT
use codex_models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::approvals::ExecApprovalRequestEvent;
use codex_protocol::config_types::Personality;
#[cfg(target_os = "windows")]
use codex_protocol::config_types::WindowsSandboxLevel;
@@ -136,19 +140,6 @@ use codex_protocol::openai_models::ModelAvailabilityNux;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::AskForApproval;
#[cfg(target_os = "windows")]
use codex_protocol::protocol::FileSystemSandboxKind;
use codex_protocol::protocol::FinalOutput;
use codex_protocol::protocol::GetHistoryEntryResponseEvent;
use codex_protocol::protocol::ListSkillsResponseEvent;
#[cfg(test)]
use codex_protocol::protocol::McpAuthStatus;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SkillErrorInfo;
use codex_protocol::protocol::TokenUsage;
use codex_terminal_detection::user_agent;
use codex_utils_absolute_path::AbsolutePathBuf;
use color_eyre::eyre::Result;
@@ -184,7 +175,8 @@ use tokio::task::JoinHandle;
use toml::Value as TomlValue;
use uuid::Uuid;
mod agent_navigation;
mod app_server_adapter;
mod app_server_event_targets;
mod app_server_events;
pub(crate) mod app_server_requests;
mod background_requests;
mod config_persistence;
@@ -237,35 +229,6 @@ fn app_server_request_id_to_mcp_request_id(
}
}
fn command_execution_decision_to_review_decision(
decision: codex_app_server_protocol::CommandExecutionApprovalDecision,
) -> codex_protocol::protocol::ReviewDecision {
match decision {
codex_app_server_protocol::CommandExecutionApprovalDecision::Accept => {
codex_protocol::protocol::ReviewDecision::Approved
}
codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptForSession => {
codex_protocol::protocol::ReviewDecision::ApprovedForSession
}
codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
execpolicy_amendment,
} => codex_protocol::protocol::ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: execpolicy_amendment.into_core(),
},
codex_app_server_protocol::CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment,
} => codex_protocol::protocol::ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment: network_policy_amendment.into_core(),
},
codex_app_server_protocol::CommandExecutionApprovalDecision::Decline => {
codex_protocol::protocol::ReviewDecision::Denied
}
codex_app_server_protocol::CommandExecutionApprovalDecision::Cancel => {
codex_protocol::protocol::ReviewDecision::Abort
}
}
}
/// Extracts `receiver_thread_ids` from collab agent tool-call notifications.
///
/// Only `ItemStarted` and `ItemCompleted` notifications with a `CollabAgentToolCall` item carry
@@ -291,19 +254,53 @@ fn collab_receiver_thread_ids(notification: &ServerNotification) -> Option<&[Str
}
fn default_exec_approval_decisions(
network_approval_context: Option<&codex_protocol::protocol::NetworkApprovalContext>,
proposed_execpolicy_amendment: Option<&codex_protocol::approvals::ExecPolicyAmendment>,
network_approval_context: Option<&codex_app_server_protocol::NetworkApprovalContext>,
proposed_execpolicy_amendment: Option<&codex_app_server_protocol::ExecPolicyAmendment>,
proposed_network_policy_amendments: Option<
&[codex_protocol::approvals::NetworkPolicyAmendment],
&[codex_app_server_protocol::NetworkPolicyAmendment],
>,
additional_permissions: Option<&codex_protocol::models::AdditionalPermissionProfile>,
) -> Vec<codex_protocol::protocol::ReviewDecision> {
ExecApprovalRequestEvent::default_available_decisions(
network_approval_context,
proposed_execpolicy_amendment,
proposed_network_policy_amendments,
additional_permissions,
)
additional_permissions: Option<&codex_app_server_protocol::AdditionalPermissionProfile>,
) -> Vec<codex_app_server_protocol::CommandExecutionApprovalDecision> {
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::NetworkPolicyRuleAction;
if network_approval_context.is_some() {
let mut decisions = vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::AcceptForSession,
];
if let Some(amendment) = proposed_network_policy_amendments.and_then(|amendments| {
amendments
.iter()
.find(|amendment| amendment.action == NetworkPolicyRuleAction::Allow)
}) {
decisions.push(
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment: amendment.clone(),
},
);
}
decisions.push(CommandExecutionApprovalDecision::Cancel);
return decisions;
}
if additional_permissions.is_some() {
return vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
];
}
let mut decisions = vec![CommandExecutionApprovalDecision::Accept];
if let Some(execpolicy_amendment) = proposed_execpolicy_amendment {
decisions.push(
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
execpolicy_amendment: execpolicy_amendment.clone(),
},
);
}
decisions.push(CommandExecutionApprovalDecision::Cancel);
decisions
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -417,86 +414,15 @@ fn rollout_path_is_resumable(rollout_path: &Path) -> bool {
std::fs::metadata(rollout_path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
}
fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec<SkillErrorInfo> {
fn errors_for_cwd(cwd: &Path, response: &SkillsListResponse) -> Vec<SkillErrorInfo> {
response
.skills
.data
.iter()
.find(|entry| entry.cwd.as_path() == cwd)
.map(|entry| entry.errors.clone())
.unwrap_or_default()
}
fn list_skills_response_to_core(response: SkillsListResponse) -> ListSkillsResponseEvent {
ListSkillsResponseEvent {
skills: response
.data
.into_iter()
.map(|entry| codex_protocol::protocol::SkillsListEntry {
cwd: entry.cwd,
skills: entry
.skills
.into_iter()
.map(|skill| codex_protocol::protocol::SkillMetadata {
name: skill.name,
description: skill.description,
short_description: skill.short_description,
interface: skill.interface.map(|interface| {
codex_protocol::protocol::SkillInterface {
display_name: interface.display_name,
short_description: interface.short_description,
icon_small: interface.icon_small,
icon_large: interface.icon_large,
brand_color: interface.brand_color,
default_prompt: interface.default_prompt,
}
}),
dependencies: skill.dependencies.map(|dependencies| {
codex_protocol::protocol::SkillDependencies {
tools: dependencies
.tools
.into_iter()
.map(|tool| codex_protocol::protocol::SkillToolDependency {
r#type: tool.r#type,
value: tool.value,
description: tool.description,
transport: tool.transport,
command: tool.command,
url: tool.url,
})
.collect(),
}
}),
path: skill.path,
scope: match skill.scope {
codex_app_server_protocol::SkillScope::User => {
codex_protocol::protocol::SkillScope::User
}
codex_app_server_protocol::SkillScope::Repo => {
codex_protocol::protocol::SkillScope::Repo
}
codex_app_server_protocol::SkillScope::System => {
codex_protocol::protocol::SkillScope::System
}
codex_app_server_protocol::SkillScope::Admin => {
codex_protocol::protocol::SkillScope::Admin
}
},
enabled: skill.enabled,
})
.collect(),
errors: entry
.errors
.into_iter()
.map(|error| codex_protocol::protocol::SkillErrorInfo {
path: error.path,
message: error.message,
})
.collect(),
})
.collect(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SessionSummary {
usage_line: Option<String>,
@@ -767,7 +693,8 @@ impl App {
codex_login::default_client::originator().value,
config.otel.log_user_prompt,
user_agent(),
SessionSource::Cli,
serde_json::from_value(serde_json::json!("cli"))
.unwrap_or_else(|err| panic!("cli session source should deserialize: {err}")),
);
if config
.tui_status_line
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,218 @@
//! Thread targeting helpers for app-server requests and notifications.
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_protocol::ThreadId;
pub(super) fn server_request_thread_id(request: &ServerRequest) -> Option<ThreadId> {
match request {
ServerRequest::CommandExecutionRequestApproval { params, .. } => {
ThreadId::from_string(&params.thread_id).ok()
}
ServerRequest::FileChangeRequestApproval { params, .. } => {
ThreadId::from_string(&params.thread_id).ok()
}
ServerRequest::ToolRequestUserInput { params, .. } => {
ThreadId::from_string(&params.thread_id).ok()
}
ServerRequest::McpServerElicitationRequest { params, .. } => {
ThreadId::from_string(&params.thread_id).ok()
}
ServerRequest::PermissionsRequestApproval { params, .. } => {
ThreadId::from_string(&params.thread_id).ok()
}
ServerRequest::DynamicToolCall { params, .. } => {
ThreadId::from_string(&params.thread_id).ok()
}
ServerRequest::ChatgptAuthTokensRefresh { .. }
| ServerRequest::ApplyPatchApproval { .. }
| ServerRequest::ExecCommandApproval { .. } => None,
}
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum ServerNotificationThreadTarget {
Thread(ThreadId),
InvalidThreadId(String),
Global,
}
pub(super) fn server_notification_thread_target(
notification: &ServerNotification,
) -> ServerNotificationThreadTarget {
let thread_id = match notification {
ServerNotification::Error(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ThreadStarted(notification) => Some(notification.thread.id.as_str()),
ServerNotification::ThreadStatusChanged(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadArchived(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ThreadUnarchived(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ThreadClosed(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ThreadNameUpdated(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadTokenUsageUpdated(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadGoalUpdated(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadGoalCleared(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::TurnStarted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::HookStarted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::TurnCompleted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::HookCompleted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::TurnDiffUpdated(notification) => Some(notification.thread_id.as_str()),
ServerNotification::TurnPlanUpdated(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ItemStarted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ItemGuardianApprovalReviewStarted(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ItemGuardianApprovalReviewCompleted(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ItemCompleted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::RawResponseItemCompleted(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::AgentMessageDelta(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::PlanDelta(notification) => Some(notification.thread_id.as_str()),
ServerNotification::CommandExecutionOutputDelta(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::TerminalInteraction(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::FileChangeOutputDelta(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::FileChangePatchUpdated(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ServerRequestResolved(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::McpToolCallProgress(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ReasoningSummaryTextDelta(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ReasoningSummaryPartAdded(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ReasoningTextDelta(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ContextCompacted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ModelRerouted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ModelVerification(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadRealtimeStarted(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadRealtimeItemAdded(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadRealtimeTranscriptDelta(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadRealtimeTranscriptDone(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadRealtimeSdp(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadRealtimeError(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadRealtimeClosed(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::Warning(notification) => notification.thread_id.as_deref(),
ServerNotification::GuardianWarning(notification) => Some(notification.thread_id.as_str()),
ServerNotification::SkillsChanged(_)
| ServerNotification::McpServerStatusUpdated(_)
| ServerNotification::McpServerOauthLoginCompleted(_)
| ServerNotification::AccountUpdated(_)
| ServerNotification::AccountRateLimitsUpdated(_)
| ServerNotification::AppListUpdated(_)
| ServerNotification::RemoteControlStatusChanged(_)
| ServerNotification::ExternalAgentConfigImportCompleted(_)
| ServerNotification::DeprecationNotice(_)
| ServerNotification::ConfigWarning(_)
| ServerNotification::FuzzyFileSearchSessionUpdated(_)
| ServerNotification::FuzzyFileSearchSessionCompleted(_)
| ServerNotification::CommandExecOutputDelta(_)
| ServerNotification::FsChanged(_)
| ServerNotification::WindowsWorldWritableWarning(_)
| ServerNotification::WindowsSandboxSetupCompleted(_)
| ServerNotification::AccountLoginCompleted(_) => None,
};
match thread_id {
Some(thread_id) => match ThreadId::from_string(thread_id) {
Ok(thread_id) => ServerNotificationThreadTarget::Thread(thread_id),
Err(_) => ServerNotificationThreadTarget::InvalidThreadId(thread_id.to_string()),
},
None => ServerNotificationThreadTarget::Global,
}
}
#[cfg(test)]
mod tests {
use super::ServerNotificationThreadTarget;
use super::server_notification_thread_target;
use codex_app_server_protocol::GuardianWarningNotification;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::WarningNotification;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
#[test]
fn warning_notifications_without_threads_are_global() {
let notification = ServerNotification::Warning(WarningNotification {
thread_id: None,
message: "warning".to_string(),
});
let target = server_notification_thread_target(&notification);
assert_eq!(target, ServerNotificationThreadTarget::Global);
}
#[test]
fn warning_notifications_route_to_threads_when_thread_id_is_present() {
let thread_id = ThreadId::new();
let notification = ServerNotification::Warning(WarningNotification {
thread_id: Some(thread_id.to_string()),
message: "warning".to_string(),
});
let target = server_notification_thread_target(&notification);
assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id));
}
#[test]
fn guardian_warning_notifications_route_to_threads() {
let thread_id = ThreadId::new();
let notification = ServerNotification::GuardianWarning(GuardianWarningNotification {
thread_id: thread_id.to_string(),
message: "warning".to_string(),
});
let target = server_notification_thread_target(&notification);
assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id));
}
}
+186
View File
@@ -0,0 +1,186 @@
//! App-server event stream handling for the TUI app.
use super::App;
use super::app_server_event_targets::ServerNotificationThreadTarget;
use super::app_server_event_targets::server_notification_thread_target;
use super::app_server_event_targets::server_request_thread_id;
use crate::app_command::AppCommand;
use crate::app_event::AppEvent;
use crate::app_server_session::AppServerSession;
use crate::app_server_session::status_account_display_from_auth_mode;
use codex_app_server_client::AppServerEvent;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
impl App {
fn refresh_mcp_startup_expected_servers_from_config(&mut self) {
let enabled_config_mcp_servers: Vec<String> = self
.chat_widget
.config_ref()
.mcp_servers
.get()
.iter()
.filter_map(|(name, server)| server.enabled.then_some(name.clone()))
.collect();
self.chat_widget
.set_mcp_startup_expected_servers(enabled_config_mcp_servers);
}
pub(super) async fn handle_app_server_event(
&mut self,
app_server_client: &AppServerSession,
event: AppServerEvent,
) {
match event {
AppServerEvent::Lagged { skipped } => {
tracing::warn!(
skipped,
"app-server event consumer lagged; dropping ignored events"
);
self.refresh_mcp_startup_expected_servers_from_config();
self.chat_widget.finish_mcp_startup_after_lag();
}
AppServerEvent::ServerNotification(notification) => {
self.handle_server_notification_event(app_server_client, notification)
.await;
}
AppServerEvent::ServerRequest(request) => {
self.handle_server_request_event(app_server_client, request)
.await;
}
AppServerEvent::Disconnected { message } => {
tracing::warn!("app-server event stream disconnected: {message}");
self.chat_widget.add_error_message(message.clone());
self.app_event_tx.send(AppEvent::FatalExitRequest(message));
}
}
}
async fn handle_server_notification_event(
&mut self,
app_server_client: &AppServerSession,
notification: ServerNotification,
) {
match &notification {
ServerNotification::ServerRequestResolved(notification) => {
if let Some(request) = self
.pending_app_server_requests
.resolve_notification(&notification.request_id)
{
self.chat_widget.dismiss_app_server_request(&request);
}
}
ServerNotification::McpServerStatusUpdated(_) => {
self.refresh_mcp_startup_expected_servers_from_config();
}
ServerNotification::AccountRateLimitsUpdated(notification) => {
self.chat_widget
.on_rate_limit_snapshot(Some(notification.rate_limits.clone()));
return;
}
ServerNotification::AccountUpdated(notification) => {
self.chat_widget.update_account_state(
status_account_display_from_auth_mode(
notification.auth_mode,
notification.plan_type,
),
notification.plan_type,
matches!(
notification.auth_mode,
Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens)
),
);
return;
}
ServerNotification::ExternalAgentConfigImportCompleted(_) => {
let cwd = self.chat_widget.config_ref().cwd.to_path_buf();
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(
error = %err,
"failed to refresh config after external agent config import"
);
}
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
self.fetch_plugins_list(app_server_client, cwd);
return;
}
_ => {}
}
match server_notification_thread_target(&notification) {
ServerNotificationThreadTarget::Thread(thread_id) => {
let result = if self.primary_thread_id == Some(thread_id)
|| self.primary_thread_id.is_none()
{
self.enqueue_primary_thread_notification(notification).await
} else {
self.enqueue_thread_notification(thread_id, notification)
.await
};
if let Err(err) = result {
tracing::warn!("failed to enqueue app-server notification: {err}");
}
return;
}
ServerNotificationThreadTarget::InvalidThreadId(thread_id) => {
tracing::warn!(
thread_id,
"ignoring app-server notification with invalid thread_id"
);
return;
}
ServerNotificationThreadTarget::Global => {}
}
self.chat_widget
.handle_server_notification(notification, /*replay_kind*/ None);
}
async fn handle_server_request_event(
&mut self,
app_server_client: &AppServerSession,
request: ServerRequest,
) {
if let Some(unsupported) = self
.pending_app_server_requests
.note_server_request(&request)
{
tracing::warn!(
request_id = ?unsupported.request_id,
message = unsupported.message,
"rejecting unsupported app-server request"
);
self.chat_widget
.add_error_message(unsupported.message.clone());
if let Err(err) = self
.reject_app_server_request(
app_server_client,
unsupported.request_id,
unsupported.message,
)
.await
{
tracing::warn!("{err}");
}
return;
}
let Some(thread_id) = server_request_thread_id(&request) else {
tracing::warn!("ignoring threadless app-server request");
return;
};
let result =
if self.primary_thread_id == Some(thread_id) || self.primary_thread_id.is_none() {
self.enqueue_primary_thread_request(request).await
} else {
self.enqueue_thread_request(thread_id, request).await
};
if let Err(err) = result {
tracing::warn!("failed to enqueue app-server request: {err}");
}
}
}
+43 -44
View File
@@ -1,12 +1,13 @@
use std::collections::HashMap;
use std::collections::VecDeque;
use super::App;
use crate::app_command::AppCommand;
use crate::app_command::AppCommandView;
use crate::app_server_approval_conversions::granted_permission_profile_from_request;
use crate::app_server_session::AppServerSession;
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::McpServerElicitationAction;
use codex_app_server_protocol::McpServerElicitationRequestResponse;
use codex_app_server_protocol::PermissionsRequestApprovalResponse;
@@ -14,7 +15,27 @@ use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ToolRequestUserInputResponse;
use codex_protocol::mcp::RequestId as McpRequestId;
use codex_protocol::protocol::ReviewDecision;
impl App {
pub(super) async fn reject_app_server_request(
&self,
app_server_client: &AppServerSession,
request_id: AppServerRequestId,
reason: String,
) -> std::result::Result<(), String> {
app_server_client
.reject_server_request(
request_id,
JSONRPCErrorError {
code: -32000,
message: reason,
data: None,
},
)
.await
.map_err(|err| format!("failed to reject app-server request: {err}"))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct AppServerRequestResolution {
@@ -141,15 +162,15 @@ impl PendingAppServerRequests {
T: Into<AppCommand>,
{
let op: AppCommand = op.into();
let resolution = match op.view() {
AppCommandView::ExecApproval { id, decision, .. } => self
let resolution = match &op {
AppCommand::ExecApproval { id, decision, .. } => self
.exec_approvals
.remove(id)
.map(|request_id| {
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
request_id,
result: serde_json::to_value(CommandExecutionRequestApprovalResponse {
decision: decision.clone().into(),
decision: decision.clone(),
})
.map_err(|err| {
format!("failed to serialize command execution approval response: {err}")
@@ -157,14 +178,14 @@ impl PendingAppServerRequests {
})
})
.transpose()?,
AppCommandView::PatchApproval { id, decision } => self
AppCommand::PatchApproval { id, decision } => self
.file_change_approvals
.remove(id)
.map(|request_id| {
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
request_id,
result: serde_json::to_value(FileChangeRequestApprovalResponse {
decision: file_change_decision(decision)?,
decision: decision.clone(),
})
.map_err(|err| {
format!("failed to serialize file change approval response: {err}")
@@ -172,7 +193,7 @@ impl PendingAppServerRequests {
})
})
.transpose()?,
AppCommandView::RequestPermissionsResponse { id, response } => self
AppCommand::RequestPermissionsResponse { id, response } => self
.permissions_approvals
.remove(id)
.map(|request_id| {
@@ -191,7 +212,7 @@ impl PendingAppServerRequests {
})
})
.transpose()?,
AppCommandView::UserInputAnswer { id, response } => self
AppCommand::UserInputAnswer { id, response } => self
.pop_user_input_request_for_turn(id)
.map(|pending| {
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
@@ -214,7 +235,7 @@ impl PendingAppServerRequests {
})
})
.transpose()?,
AppCommandView::ResolveElicitation {
AppCommand::ResolveElicitation {
server_name,
request_id,
decision,
@@ -395,29 +416,16 @@ fn app_server_request_id_to_mcp_request_id(request_id: &AppServerRequestId) -> M
}
}
fn file_change_decision(decision: &ReviewDecision) -> Result<FileChangeApprovalDecision, String> {
match decision {
ReviewDecision::Approved => Ok(FileChangeApprovalDecision::Accept),
ReviewDecision::ApprovedForSession => Ok(FileChangeApprovalDecision::AcceptForSession),
ReviewDecision::Denied => Ok(FileChangeApprovalDecision::Decline),
ReviewDecision::TimedOut => Ok(FileChangeApprovalDecision::Decline),
ReviewDecision::Abort => Ok(FileChangeApprovalDecision::Cancel),
ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
Err("execpolicy amendment is not a valid file change approval decision".to_string())
}
ReviewDecision::NetworkPolicyAmendment { .. } => {
Err("network policy amendment is not a valid file change approval decision".to_string())
}
}
}
#[cfg(test)]
mod tests {
use super::PendingAppServerRequests;
use super::ResolvedAppServerRequest;
use crate::app_command::AppCommand as Op;
use codex_app_server_protocol::AdditionalFileSystemPermissions;
use codex_app_server_protocol::AdditionalNetworkPermissions;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileChangeRequestApprovalParams;
use codex_app_server_protocol::McpElicitationObjectType;
use codex_app_server_protocol::McpElicitationSchema;
@@ -432,12 +440,9 @@ mod tests {
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::ToolRequestUserInputResponse;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::mcp::RequestId as McpRequestId;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
@@ -474,7 +479,7 @@ mod tests {
.take_resolution(&Op::ExecApproval {
id: "approval-1".to_string(),
turn_id: None,
decision: ReviewDecision::Approved,
decision: CommandExecutionApprovalDecision::Accept,
})
.expect("resolution should serialize")
.expect("request should be pending");
@@ -703,7 +708,7 @@ mod tests {
}
#[test]
fn rejects_invalid_patch_decisions_for_file_change_requests() {
fn resolves_patch_approval_through_app_server_request_id() {
let mut pending = PendingAppServerRequests::default();
assert_eq!(
pending.note_server_request(&ServerRequest::FileChangeRequestApproval {
@@ -719,22 +724,16 @@ mod tests {
None
);
let error = pending
let resolution = pending
.take_resolution(&Op::PatchApproval {
id: "patch-1".to_string(),
decision: ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec![
"echo".to_string(),
"hi".to_string(),
]),
},
decision: FileChangeApprovalDecision::Cancel,
})
.expect_err("invalid patch decision should fail");
.expect("resolution should serialize")
.expect("request should be pending");
assert_eq!(
error,
"execpolicy amendment is not a valid file change approval decision"
);
assert_eq!(resolution.request_id, AppServerRequestId::Integer(13));
assert_eq!(resolution.result, json!({ "decision": "cancel" }));
}
#[test]
+1 -1
View File
@@ -457,7 +457,7 @@ pub(super) async fn fetch_account_rate_limits(
.await
.wrap_err("account/rateLimits/read failed in TUI")?;
Ok(app_server_rate_limit_snapshots_to_core(response))
Ok(app_server_rate_limit_snapshots(response))
}
pub(super) async fn send_add_credits_nudge_email(
+13 -14
View File
@@ -48,7 +48,7 @@ impl App {
match self.rebuild_config_for_cwd(resume_cwd.clone()).await {
Ok(config) => Ok(config),
Err(err) => {
if crate::cwds_differ(current_cwd, &resume_cwd) {
if crate::session_resume::cwds_differ(current_cwd, &resume_cwd) {
Err(err)
} else {
let resume_cwd_display = resume_cwd.display().to_string();
@@ -65,7 +65,7 @@ impl App {
pub(super) fn apply_runtime_policy_overrides(&mut self, config: &mut Config) {
if let Some(policy) = self.runtime_approval_policy_override.as_ref()
&& let Err(err) = config.permissions.approval_policy.set(*policy)
&& let Err(err) = config.permissions.approval_policy.set(policy.to_core())
{
tracing::warn!(%err, "failed to carry forward approval policy override");
self.chat_widget.add_error_message(format!(
@@ -94,7 +94,7 @@ impl App {
user_message_prefix: &str,
log_message: &str,
) -> bool {
if let Err(err) = config.permissions.approval_policy.set(policy) {
if let Err(err) = config.permissions.approval_policy.set(policy.to_core()) {
tracing::warn!(error = %err, "{log_message}");
self.chat_widget
.add_error_message(format!("{user_message_prefix}: {err}"));
@@ -294,8 +294,9 @@ impl App {
self.set_approvals_reviewer_in_app_and_widget(self.config.approvals_reviewer);
}
if approval_policy_override.is_some() {
self.chat_widget
.set_approval_policy(self.config.permissions.approval_policy.value());
self.chat_widget.set_approval_policy(AskForApproval::from(
self.config.permissions.approval_policy.value(),
));
}
if permission_profile_override.is_some()
&& let Err(err) = self
@@ -351,8 +352,8 @@ impl App {
#[cfg(target_os = "windows")]
{
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
self.app_event_tx.send(AppEvent::CodexOp(
AppCommand::override_turn_context(
self.app_event_tx
.send(AppEvent::CodexOp(AppCommand::override_turn_context(
/*cwd*/ None,
/*approval_policy*/ None,
/*approvals_reviewer*/ None,
@@ -365,9 +366,7 @@ impl App {
/*service_tier*/ None,
/*collaboration_mode*/ None,
/*personality*/ None,
)
.into_core(),
));
)));
}
}
@@ -495,7 +494,7 @@ impl App {
(!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort))
}
pub(crate) fn token_usage(&self) -> codex_protocol::protocol::TokenUsage {
pub(crate) fn token_usage(&self) -> crate::token_usage::TokenUsage {
self.chat_widget.token_usage()
}
@@ -548,11 +547,11 @@ mod tests {
use super::*;
use crate::app::test_support::app_enabled_in_effective_config;
use crate::app::test_support::make_test_app;
use crate::chatwidget::test_events::Event;
use crate::chatwidget::test_events::EventMsg;
use crate::chatwidget::test_events::SessionConfiguredEvent;
use crate::test_support::PathBufExt;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::SessionConfiguredEvent;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
+10 -10
View File
@@ -313,15 +313,14 @@ impl App {
return Ok(AppRunControl::Exit(ExitReason::Fatal(message)));
}
AppEvent::CodexOp(op) => {
self.submit_active_thread_op(app_server, op.into()).await?;
self.submit_active_thread_op(app_server, op).await?;
}
AppEvent::ApproveRecentAutoReviewDenial { thread_id, id } => {
self.chat_widget
.approve_recent_auto_review_denial(thread_id, id);
}
AppEvent::SubmitThreadOp { thread_id, op } => {
self.submit_thread_op(app_server, thread_id, op.into())
.await?;
self.submit_thread_op(app_server, thread_id, op).await?;
}
AppEvent::ThreadHistoryEntryResponse { thread_id, event } => {
self.enqueue_thread_history_entry_response(thread_id, event)
@@ -1004,7 +1003,7 @@ impl App {
self.app_event_tx.send(AppEvent::CodexOp(
AppCommand::override_turn_context(
/*cwd*/ None,
Some(preset.approval),
Some(AskForApproval::from(preset.approval)),
Some(self.config.approvals_reviewer),
Some(preset.permission_profile.clone()),
#[cfg(target_os = "windows")]
@@ -1018,8 +1017,9 @@ impl App {
)
.into(),
));
self.app_event_tx
.send(AppEvent::UpdateAskForApprovalPolicy(preset.approval));
self.app_event_tx.send(AppEvent::UpdateAskForApprovalPolicy(
AskForApproval::from(preset.approval),
));
self.app_event_tx.send(AppEvent::UpdatePermissionProfile(
preset.permission_profile.clone(),
));
@@ -1267,10 +1267,10 @@ impl App {
return Ok(AppRunControl::Continue);
}
self.config = config;
self.runtime_approval_policy_override =
Some(self.config.permissions.approval_policy.value());
self.chat_widget
.set_approval_policy(self.config.permissions.approval_policy.value());
let approval_policy =
AskForApproval::from(self.config.permissions.approval_policy.value());
self.runtime_approval_policy_override = Some(approval_policy);
self.chat_widget.set_approval_policy(approval_policy);
self.sync_active_thread_permission_settings_to_cached_session()
.await;
}
+36 -29
View File
@@ -14,10 +14,8 @@
//! `SessionSource::SubAgent(ThreadSpawn { parent_thread_id, .. })` edges until no new children are
//! found. The primary thread itself is never included in the output.
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::Thread;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SubAgentSource;
use std::collections::HashMap;
use std::collections::HashSet;
@@ -63,15 +61,12 @@ pub(crate) fn find_loaded_subagent_threads_for_primary(
continue;
}
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: source_parent_thread_id,
..
}) = &thread.source
let Some(source_parent_thread_id) = thread_spawn_parent_thread_id(&thread.source)
else {
continue;
};
if *source_parent_thread_id != parent_thread_id {
if source_parent_thread_id != parent_thread_id {
continue;
}
@@ -96,6 +91,18 @@ pub(crate) fn find_loaded_subagent_threads_for_primary(
loaded_threads
}
fn thread_spawn_parent_thread_id(
source: &codex_app_server_protocol::SessionSource,
) -> Option<ThreadId> {
let value = serde_json::to_value(source).ok()?;
let parent_thread_id = value
.get("subAgent")?
.get("thread_spawn")?
.get("parent_thread_id")?
.as_str()?;
ThreadId::from_string(parent_thread_id).ok()
}
#[cfg(test)]
mod tests {
use super::LoadedSubagentThread;
@@ -104,7 +111,6 @@ mod tests {
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadStatus;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SubAgentSource;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
@@ -131,6 +137,25 @@ mod tests {
}
}
fn thread_spawn_source(
parent_thread_id: ThreadId,
depth: i32,
agent_nickname: &str,
agent_role: &str,
) -> SessionSource {
serde_json::from_value(serde_json::json!({
"subAgent": {
"thread_spawn": {
"parent_thread_id": parent_thread_id.to_string(),
"depth": depth,
"agent_nickname": agent_nickname,
"agent_role": agent_role,
}
}
}))
.expect("valid subagent source")
}
#[test]
fn finds_loaded_subagent_tree_for_primary_thread() {
let primary_thread_id =
@@ -146,39 +171,21 @@ mod tests {
let mut child = test_thread(
child_thread_id,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: primary_thread_id,
depth: 1,
agent_path: None,
agent_nickname: Some("Scout".to_string()),
agent_role: Some("explorer".to_string()),
}),
thread_spawn_source(primary_thread_id, 1, "Scout", "explorer"),
);
child.agent_nickname = Some("Scout".to_string());
child.agent_role = Some("explorer".to_string());
let mut grandchild = test_thread(
grandchild_thread_id,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: child_thread_id,
depth: 2,
agent_path: None,
agent_nickname: Some("Atlas".to_string()),
agent_role: Some("worker".to_string()),
}),
thread_spawn_source(child_thread_id, 2, "Atlas", "worker"),
);
grandchild.agent_nickname = Some("Atlas".to_string());
grandchild.agent_role = Some("worker".to_string());
let unrelated_child = test_thread(
unrelated_child_id,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: unrelated_parent_id,
depth: 1,
agent_path: None,
agent_nickname: Some("Other".to_string()),
agent_role: Some("researcher".to_string()),
}),
thread_spawn_source(unrelated_parent_id, 1, "Other", "researcher"),
);
let loaded = find_loaded_subagent_threads_for_primary(
@@ -1,5 +1,4 @@
use crate::app_command::AppCommand;
use crate::app_command::AppCommandView;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
@@ -77,13 +76,13 @@ impl PendingInteractiveReplayState {
{
let op: AppCommand = op.into();
matches!(
op.view(),
AppCommandView::ExecApproval { .. }
| AppCommandView::PatchApproval { .. }
| AppCommandView::ResolveElicitation { .. }
| AppCommandView::RequestPermissionsResponse { .. }
| AppCommandView::UserInputAnswer { .. }
| AppCommandView::Shutdown
&op,
AppCommand::ExecApproval { .. }
| AppCommand::PatchApproval { .. }
| AppCommand::ResolveElicitation { .. }
| AppCommand::RequestPermissionsResponse { .. }
| AppCommand::UserInputAnswer { .. }
| AppCommand::Shutdown
)
}
@@ -92,8 +91,8 @@ impl PendingInteractiveReplayState {
T: Into<AppCommand>,
{
let op: AppCommand = op.into();
match op.view() {
AppCommandView::ExecApproval { id, turn_id, .. } => {
match &op {
AppCommand::ExecApproval { id, turn_id, .. } => {
self.exec_approval_call_ids.remove(id);
if let Some(turn_id) = turn_id {
Self::remove_call_id_from_turn_map_entry(
@@ -105,7 +104,7 @@ impl PendingInteractiveReplayState {
self.pending_requests_by_request_id
.retain(|_, pending| !matches!(pending, PendingInteractiveRequest::ExecApproval { approval_id, .. } if approval_id == id));
}
AppCommandView::PatchApproval { id, .. } => {
AppCommand::PatchApproval { id, .. } => {
self.patch_approval_call_ids.remove(id);
Self::remove_call_id_from_turn_map(
&mut self.patch_approval_call_ids_by_turn_id,
@@ -114,7 +113,7 @@ impl PendingInteractiveReplayState {
self.pending_requests_by_request_id
.retain(|_, pending| !matches!(pending, PendingInteractiveRequest::PatchApproval { item_id, .. } if item_id == id));
}
AppCommandView::ResolveElicitation {
AppCommand::ResolveElicitation {
server_name,
request_id,
..
@@ -130,7 +129,7 @@ impl PendingInteractiveReplayState {
},
);
}
AppCommandView::RequestPermissionsResponse { id, .. } => {
AppCommand::RequestPermissionsResponse { id, .. } => {
self.request_permissions_call_ids.remove(id);
Self::remove_call_id_from_turn_map(
&mut self.request_permissions_call_ids_by_turn_id,
@@ -145,7 +144,7 @@ impl PendingInteractiveReplayState {
// `Op::UserInputAnswer` identifies the turn, not the prompt call_id. The UI
// answers queued prompts for the same turn in FIFO order, so remove the oldest
// queued call_id for that turn.
AppCommandView::UserInputAnswer { id, .. } => {
AppCommand::UserInputAnswer { id, .. } => {
let mut remove_turn_entry = false;
if let Some(call_ids) = self.request_user_input_call_ids_by_turn_id.get_mut(id) {
if !call_ids.is_empty() {
@@ -165,7 +164,7 @@ impl PendingInteractiveReplayState {
self.request_user_input_call_ids_by_turn_id.remove(id);
}
}
AppCommandView::Shutdown => self.clear(),
AppCommand::Shutdown => self.clear(),
_ => {}
}
}
@@ -579,6 +578,8 @@ fn app_server_request_id_to_mcp_request_id(
mod tests {
use super::super::ThreadBufferedEvent;
use super::super::ThreadEventStore;
use crate::app_command::AppCommand as Op;
use crate::approval_display::ReviewDecision;
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::FileChangeRequestApprovalParams;
use codex_app_server_protocol::McpElicitationObjectType;
@@ -594,8 +595,6 @@ mod tests {
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
@@ -767,7 +766,7 @@ mod tests {
store.note_outbound_op(&Op::ExecApproval {
id: "approval-1".to_string(),
turn_id: Some("turn-1".to_string()),
decision: ReviewDecision::Approved,
decision: ReviewDecision::Approved.into(),
});
let snapshot = store.snapshot();
@@ -854,7 +853,7 @@ mod tests {
store.note_outbound_op(&Op::PatchApproval {
id: "call-1".to_string(),
decision: ReviewDecision::Approved,
decision: codex_app_server_protocol::FileChangeApprovalDecision::Accept,
});
let snapshot = store.snapshot();
@@ -920,7 +919,7 @@ mod tests {
store.note_outbound_op(&Op::ExecApproval {
id: "call-1".to_string(),
turn_id: Some("turn-1".to_string()),
decision: ReviewDecision::Approved,
decision: ReviewDecision::Approved.into(),
});
assert_eq!(store.has_pending_thread_approvals(), false);
+1
View File
@@ -28,6 +28,7 @@ impl App {
tokio::task::spawn_blocking(move || {
let logs_base_dir_path = logs_base_dir.as_path();
let sandbox_policy = sandbox_policy.to_core();
let result = codex_windows_sandbox::apply_world_writable_scan_and_denies(
logs_base_dir_path,
cwd.as_path(),
+4 -4
View File
@@ -636,7 +636,7 @@ impl App {
let resume_cwd = if self.remote_app_server_url.is_some() {
current_cwd.clone()
} else {
match crate::resolve_cwd_for_resume_or_fork(
match crate::session_resume::resolve_cwd_for_resume_or_fork(
tui,
&self.config,
&current_cwd,
@@ -647,9 +647,9 @@ impl App {
)
.await?
{
crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd,
crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(),
crate::ResolveCwdOutcome::Exit => {
crate::session_resume::ResolveCwdOutcome::Continue(Some(cwd)) => cwd,
crate::session_resume::ResolveCwdOutcome::Continue(None) => current_cwd.clone(),
crate::session_resume::ResolveCwdOutcome::Exit => {
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
}
}
+2 -1
View File
@@ -74,7 +74,8 @@ fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry {
"test_originator".to_string(),
/*log_user_prompts*/ false,
"test".to_string(),
SessionSource::Cli,
serde_json::from_value(serde_json::json!("cli"))
.expect("cli session source should deserialize"),
)
}
+342 -314
View File
@@ -21,6 +21,11 @@ use crate::history_cell::new_session_info;
use crate::multi_agents::AgentPickerThreadEntry;
use assert_matches::assert_matches;
use crate::app_command::AppCommand as Op;
use crate::chatwidget::test_events::Event;
use crate::chatwidget::test_events::EventMsg;
use crate::chatwidget::test_events::SessionConfiguredEvent;
use crate::diff_model::FileChange;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
use crate::legacy_core::config::TerminalResizeReflowMaxRows;
@@ -28,6 +33,7 @@ use codex_app_server_protocol::AdditionalFileSystemPermissions;
use codex_app_server_protocol::AdditionalNetworkPermissions;
use codex_app_server_protocol::AdditionalPermissionProfile;
use codex_app_server_protocol::AgentMessageDeltaNotification;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::FileChangeRequestApprovalParams;
@@ -46,6 +52,7 @@ use codex_app_server_protocol::PermissionsRequestApprovalParams;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadClosedNotification;
use codex_app_server_protocol::ThreadItem;
@@ -67,21 +74,9 @@ use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::NetworkApprovalContext;
use codex_protocol::protocol::NetworkApprovalProtocol;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TurnContextItem;
use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_protocol::user_input::TextElement;
use codex_protocol::user_input::UserInput;
@@ -104,6 +99,31 @@ macro_rules! assert_app_snapshot {
};
}
fn run_large_stack_test<T>(body: impl FnOnce() -> T + Send + 'static) -> T
where
T: Send + 'static,
{
std::thread::Builder::new()
.name("codex-tui-app-test".to_string())
.stack_size(8 * 1024 * 1024)
.spawn(body)
.expect("spawn large-stack test thread")
.join()
.expect("large-stack test thread panicked")
}
macro_rules! run_large_stack_async_test {
($body:block) => {
run_large_stack_test(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime")
.block_on(async $body)
})
};
}
fn test_absolute_path(path: &str) -> AbsolutePathBuf {
AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path")
}
@@ -513,8 +533,7 @@ async fn history_lookup_response_is_routed_to_requesting_thread() -> Result<()>
&Op::GetHistoryEntryRequest {
offset: 0,
log_id: 1,
}
.into(),
},
)
.await?;
@@ -1224,196 +1243,217 @@ async fn token_usage_update_refreshes_status_line_with_runtime_context_window()
);
}
#[tokio::test]
async fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
#[test]
fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> {
run_large_stack_async_test!({
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
app.open_agent_picker(&mut app_server).await;
app.open_agent_picker(&mut app_server).await;
assert_eq!(app.thread_event_channels.contains_key(&thread_id), true);
assert_eq!(
app.agent_navigation.get(&thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: None,
agent_role: None,
is_closed: true,
})
);
assert_eq!(app.agent_navigation.ordered_thread_ids(), vec![thread_id]);
Ok(())
assert_eq!(app.thread_event_channels.contains_key(&thread_id), true);
assert_eq!(
app.agent_navigation.get(&thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: None,
agent_role: None,
is_closed: true,
})
);
assert_eq!(app.agent_navigation.ordered_thread_ids(), vec![thread_id]);
Ok(())
})
}
#[tokio::test]
async fn open_agent_picker_preserves_cached_metadata_for_replay_threads() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
app.agent_navigation.upsert(
thread_id,
Some("Robie".to_string()),
Some("explorer".to_string()),
/*is_closed*/ true,
);
#[test]
fn open_agent_picker_preserves_cached_metadata_for_replay_threads() -> Result<()> {
run_large_stack_async_test!({
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
app.agent_navigation.upsert(
thread_id,
Some("Robie".to_string()),
Some("explorer".to_string()),
/*is_closed*/ true,
);
app.open_agent_picker(&mut app_server).await;
app.open_agent_picker(&mut app_server).await;
assert_eq!(app.thread_event_channels.contains_key(&thread_id), true);
assert_eq!(
app.agent_navigation.get(&thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
is_closed: true,
})
);
Ok(())
assert_eq!(app.thread_event_channels.contains_key(&thread_id), true);
assert_eq!(
app.agent_navigation.get(&thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
is_closed: true,
})
);
Ok(())
})
}
#[tokio::test]
async fn open_agent_picker_prunes_terminal_metadata_only_threads() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.agent_navigation.upsert(
thread_id,
Some("Ghost".to_string()),
Some("worker".to_string()),
/*is_closed*/ false,
);
#[test]
fn open_agent_picker_prunes_terminal_metadata_only_threads() -> Result<()> {
run_large_stack_async_test!({
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.agent_navigation.upsert(
thread_id,
Some("Ghost".to_string()),
Some("worker".to_string()),
/*is_closed*/ false,
);
app.open_agent_picker(&mut app_server).await;
app.open_agent_picker(&mut app_server).await;
assert_eq!(app.agent_navigation.get(&thread_id), None);
assert!(app.agent_navigation.is_empty());
Ok(())
assert_eq!(app.agent_navigation.get(&thread_id), None);
assert!(app.agent_navigation.is_empty());
Ok(())
})
}
#[tokio::test]
async fn open_agent_picker_marks_terminal_read_errors_closed() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
app.agent_navigation.upsert(
thread_id,
Some("Robie".to_string()),
Some("explorer".to_string()),
/*is_closed*/ false,
);
#[test]
fn open_agent_picker_marks_terminal_read_errors_closed() -> Result<()> {
run_large_stack_async_test!({
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
app.agent_navigation.upsert(
thread_id,
Some("Robie".to_string()),
Some("explorer".to_string()),
/*is_closed*/ false,
);
app.open_agent_picker(&mut app_server).await;
app.open_agent_picker(&mut app_server).await;
assert_eq!(
app.agent_navigation.get(&thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
is_closed: true,
})
);
Ok(())
assert_eq!(
app.agent_navigation.get(&thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
is_closed: true,
})
);
Ok(())
})
}
#[tokio::test]
async fn open_agent_picker_marks_loaded_threads_open() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let started = app_server
.start_thread(app.chat_widget.config_ref())
.await?;
let thread_id = started.session.thread_id;
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
#[test]
fn open_agent_picker_marks_loaded_threads_open() -> Result<()> {
run_large_stack_async_test!({
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let started = app_server
.start_thread(app.chat_widget.config_ref())
.await?;
let thread_id = started.session.thread_id;
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
app.open_agent_picker(&mut app_server).await;
app.open_agent_picker(&mut app_server).await;
assert_eq!(
app.agent_navigation.get(&thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: None,
agent_role: None,
is_closed: false,
})
);
Ok(())
assert_eq!(
app.agent_navigation.get(&thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: None,
agent_role: None,
is_closed: false,
})
);
Ok(())
})
}
#[tokio::test]
async fn attach_live_thread_for_selection_rejects_empty_non_ephemeral_fallback_threads()
-> Result<()> {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let started = app_server
.start_thread(app.chat_widget.config_ref())
.await?;
let thread_id = started.session.thread_id;
app.agent_navigation.upsert(
thread_id,
Some("Scout".to_string()),
Some("worker".to_string()),
/*is_closed*/ false,
);
#[test]
fn attach_live_thread_for_selection_rejects_empty_non_ephemeral_fallback_threads() -> Result<()> {
run_large_stack_async_test!({
let config = {
let app = make_test_app().await;
app.chat_widget.config_ref().clone()
};
let mut app_server = crate::start_embedded_app_server_for_picker(&config)
.await
.expect("embedded app server");
let started = app_server.start_thread(&config).await?;
let thread_id = started.session.thread_id;
let mut app = make_test_app().await;
app.agent_navigation.upsert(
thread_id,
Some("Scout".to_string()),
Some("worker".to_string()),
/*is_closed*/ false,
);
let err = app
.attach_live_thread_for_selection(&mut app_server, thread_id)
.await
.expect_err("empty fallback should not attach as a blank replay-only thread");
let err = app
.attach_live_thread_for_selection(&mut app_server, thread_id)
.await
.expect_err("empty fallback should not attach as a blank replay-only thread");
assert_eq!(
err.to_string(),
format!("Agent thread {thread_id} is not yet available for replay or live attach.")
);
assert!(!app.thread_event_channels.contains_key(&thread_id));
Ok(())
assert_eq!(
err.to_string(),
format!("Agent thread {thread_id} is not yet available for replay or live attach.")
);
assert!(!app.thread_event_channels.contains_key(&thread_id));
Ok(())
})
}
#[tokio::test]
async fn attach_live_thread_for_selection_rejects_unmaterialized_fallback_threads() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let mut ephemeral_config = app.chat_widget.config_ref().clone();
ephemeral_config.ephemeral = true;
let started = app_server.start_thread(&ephemeral_config).await?;
let thread_id = started.session.thread_id;
app.agent_navigation.upsert(
thread_id,
Some("Scout".to_string()),
Some("worker".to_string()),
/*is_closed*/ false,
);
#[test]
fn attach_live_thread_for_selection_rejects_unmaterialized_fallback_threads() -> Result<()> {
run_large_stack_async_test!({
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let mut ephemeral_config = app.chat_widget.config_ref().clone();
ephemeral_config.ephemeral = true;
let started = app_server.start_thread(&ephemeral_config).await?;
let thread_id = started.session.thread_id;
app.agent_navigation.upsert(
thread_id,
Some("Scout".to_string()),
Some("worker".to_string()),
/*is_closed*/ false,
);
let err = app
.attach_live_thread_for_selection(&mut app_server, thread_id)
.await
.expect_err("ephemeral fallback should not attach as a blank live thread");
let err = app
.attach_live_thread_for_selection(&mut app_server, thread_id)
.await
.expect_err("ephemeral fallback should not attach as a blank live thread");
assert_eq!(
err.to_string(),
format!("Agent thread {thread_id} is not yet available for replay or live attach.")
);
assert!(!app.thread_event_channels.contains_key(&thread_id));
Ok(())
assert_eq!(
err.to_string(),
format!("Agent thread {thread_id} is not yet available for replay or live attach.")
);
assert!(!app.thread_event_channels.contains_key(&thread_id));
Ok(())
})
}
#[tokio::test]
@@ -1442,58 +1482,64 @@ async fn should_attach_live_thread_for_selection_skips_closed_metadata_only_thre
assert!(!app.should_attach_live_thread_for_selection(thread_id));
}
#[tokio::test]
async fn refresh_agent_picker_thread_liveness_prunes_closed_metadata_only_threads() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.agent_navigation.upsert(
thread_id,
Some("Ghost".to_string()),
Some("worker".to_string()),
/*is_closed*/ false,
);
#[test]
fn refresh_agent_picker_thread_liveness_prunes_closed_metadata_only_threads() -> Result<()> {
run_large_stack_async_test!({
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.agent_navigation.upsert(
thread_id,
Some("Ghost".to_string()),
Some("worker".to_string()),
/*is_closed*/ false,
);
let is_available = app
.refresh_agent_picker_thread_liveness(&mut app_server, thread_id)
.await;
let is_available = app
.refresh_agent_picker_thread_liveness(&mut app_server, thread_id)
.await;
assert!(!is_available);
assert_eq!(app.agent_navigation.get(&thread_id), None);
assert!(!app.thread_event_channels.contains_key(&thread_id));
Ok(())
assert!(!is_available);
assert_eq!(app.agent_navigation.get(&thread_id), None);
assert!(!app.thread_event_channels.contains_key(&thread_id));
Ok(())
})
}
#[tokio::test]
async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let _ = app.config.features.disable(Feature::Collab);
#[test]
fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> {
run_large_stack_async_test!({
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let _ = app.config.features.disable(Feature::Collab);
app.open_agent_picker(&mut app_server).await;
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
app.open_agent_picker(&mut app_server).await;
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
app_event_rx.try_recv(),
Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)]
);
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = cell
.display_lines(/*width*/ 120)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Subagents will be enabled in the next session."));
Ok(())
assert_matches!(
app_event_rx.try_recv(),
Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)]
);
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = cell
.display_lines(/*width*/ 120)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Subagents will be enabled in the next session."));
Ok(())
})
}
#[tokio::test]
@@ -1625,15 +1671,17 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result<
auto_review.approvals_reviewer
);
assert_eq!(
app.config.permissions.approval_policy.value(),
AskForApproval::from(app.config.permissions.approval_policy.value()),
auto_review.approval_policy
);
assert_eq!(
app.chat_widget
.config_ref()
.permissions
.approval_policy
.value(),
AskForApproval::from(
app.chat_widget
.config_ref()
.permissions
.approval_policy
.value(),
),
auto_review.approval_policy
);
assert_eq!(
@@ -1658,7 +1706,6 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result<
cwd: None,
approval_policy: Some(auto_review.approval_policy),
approvals_reviewer: Some(auto_review.approvals_reviewer),
sandbox_policy: None,
permission_profile: Some(auto_review.permission_profile.clone()),
windows_sandbox_level: None,
model: None,
@@ -1714,7 +1761,7 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor
app.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)?;
.set(AskForApproval::OnRequest.to_core())?;
app.config
.permissions
.set_permission_profile(PermissionProfile::workspace_write())?;
@@ -1735,7 +1782,7 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor
);
assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::User);
assert_eq!(
app.config.permissions.approval_policy.value(),
AskForApproval::from(app.config.permissions.approval_policy.value()),
AskForApproval::OnRequest
);
assert_eq!(
@@ -1749,7 +1796,6 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor
cwd: None,
approval_policy: None,
approvals_reviewer: Some(ApprovalsReviewer::User),
sandbox_policy: None,
permission_profile: None,
windows_sandbox_level: None,
model: None,
@@ -1812,7 +1858,7 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review
auto_review.approvals_reviewer
);
assert_eq!(
app.config.permissions.approval_policy.value(),
AskForApproval::from(app.config.permissions.approval_policy.value()),
auto_review.approval_policy
);
assert_eq!(
@@ -1828,7 +1874,6 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review
cwd: None,
approval_policy: Some(auto_review.approval_policy),
approvals_reviewer: Some(auto_review.approvals_reviewer),
sandbox_policy: None,
permission_profile: Some(auto_review.permission_profile.clone()),
windows_sandbox_level: None,
model: None,
@@ -1886,7 +1931,6 @@ async fn update_feature_flags_disabling_guardian_clears_manual_review_policy_wit
cwd: None,
approval_policy: None,
approvals_reviewer: Some(ApprovalsReviewer::User),
sandbox_policy: None,
permission_profile: None,
windows_sandbox_level: None,
model: None,
@@ -1946,7 +1990,6 @@ async fn update_feature_flags_enabling_guardian_in_profile_sets_profile_auto_rev
cwd: None,
approval_policy: Some(auto_review.approval_policy),
approvals_reviewer: Some(auto_review.approvals_reviewer),
sandbox_policy: None,
permission_profile: Some(auto_review.permission_profile.clone()),
windows_sandbox_level: None,
model: None,
@@ -2034,7 +2077,6 @@ guardian_approval = true
cwd: None,
approval_policy: None,
approvals_reviewer: Some(ApprovalsReviewer::User),
sandbox_policy: None,
permission_profile: None,
windows_sandbox_level: None,
model: None,
@@ -2464,35 +2506,37 @@ async fn inactive_thread_exec_approval_preserves_context() {
assert_eq!(
network_approval_context,
Some(NetworkApprovalContext {
Some(AppServerNetworkApprovalContext {
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Socks5Tcp,
protocol: AppServerNetworkApprovalProtocol::Socks5Tcp,
})
);
assert_eq!(
additional_permissions,
Some(CoreAdditionalPermissionProfile {
network: Some(NetworkPermissions {
Some(AdditionalPermissionProfile {
network: Some(AdditionalNetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![test_absolute_path("/tmp/read-only")]),
Some(vec![test_absolute_path("/tmp/write")]),
)),
file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![test_absolute_path("/tmp/read-only")]),
write: Some(vec![test_absolute_path("/tmp/write")]),
glob_scan_max_depth: None,
entries: None,
}),
})
);
assert_eq!(
available_decisions,
vec![
codex_protocol::protocol::ReviewDecision::Approved,
codex_protocol::protocol::ReviewDecision::ApprovedForSession,
codex_protocol::protocol::ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment: codex_protocol::approvals::NetworkPolicyAmendment {
codex_app_server_protocol::CommandExecutionApprovalDecision::Accept,
codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptForSession,
codex_app_server_protocol::CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment: AppServerNetworkPolicyAmendment {
host: "example.com".to_string(),
action: codex_protocol::approvals::NetworkPolicyRuleAction::Allow,
action: AppServerNetworkPolicyRuleAction::Allow,
},
},
codex_protocol::protocol::ReviewDecision::Abort,
codex_app_server_protocol::CommandExecutionApprovalDecision::Cancel,
]
);
}
@@ -2737,35 +2781,14 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re
);
let rollout_path = temp_dir.path().join("agent-rollout.jsonl");
let permission_profile = PermissionProfile::workspace_write();
let turn_context = TurnContextItem {
turn_id: None,
trace_id: None,
cwd: test_path_buf("/tmp/agent"),
current_date: None,
timezone: None,
approval_policy: primary_session.approval_policy,
sandbox_policy: permission_profile
.to_legacy_sandbox_policy(test_path_buf("/tmp/agent").as_path())
.expect("workspace profile must be legacy-compatible"),
permission_profile: Some(permission_profile),
network: None,
file_system_sandbox_policy: None,
model: "gpt-agent".to_string(),
personality: None,
collaboration_mode: None,
realtime_active: Some(false),
effort: primary_session.reasoning_effort,
summary: app.config.model_reasoning_summary.unwrap_or_default(),
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: None,
};
let rollout = RolloutLine {
timestamp: "t0".to_string(),
item: RolloutItem::TurnContext(turn_context),
};
let rollout = serde_json::json!({
"timestamp": "t0",
"type": "turn_context",
"payload": {
"cwd": test_path_buf("/tmp/agent"),
"model": "gpt-agent",
},
});
std::fs::write(
&rollout_path,
format!("{}\n", serde_json::to_string(&rollout)?),
@@ -3352,30 +3375,33 @@ async fn side_discard_selection_keeps_current_side_thread() {
#[tokio::test]
async fn discard_side_thread_removes_agent_navigation_entry() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let mut side_config = app.chat_widget.config_ref().clone();
side_config.ephemeral = true;
let started = app_server.start_thread(&side_config).await?;
let side_thread_id = started.session.thread_id;
app.side_threads
.insert(side_thread_id, SideThreadState::new(ThreadId::new()));
app.agent_navigation.upsert(
side_thread_id,
Some("Side".to_string()),
Some("side".to_string()),
/*is_closed*/ false,
);
Box::pin(async {
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let mut side_config = app.chat_widget.config_ref().clone();
side_config.ephemeral = true;
let started = app_server.start_thread(&side_config).await?;
let side_thread_id = started.session.thread_id;
app.side_threads
.insert(side_thread_id, SideThreadState::new(ThreadId::new()));
app.agent_navigation.upsert(
side_thread_id,
Some("Side".to_string()),
Some("side".to_string()),
/*is_closed*/ false,
);
assert!(
app.discard_side_thread(&mut app_server, side_thread_id)
.await
);
assert!(
app.discard_side_thread(&mut app_server, side_thread_id)
.await
);
assert_eq!(app.agent_navigation.get(&side_thread_id), None);
assert!(!app.side_threads.contains_key(&side_thread_id));
Ok(())
assert_eq!(app.agent_navigation.get(&side_thread_id), None);
assert!(!app.side_threads.contains_key(&side_thread_id));
Ok(())
})
.await
}
#[tokio::test]
@@ -3560,9 +3586,10 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String {
)) as Arc<dyn HistoryCell>
};
let make_header = |is_first| -> Arc<dyn HistoryCell> {
let event = SessionConfiguredEvent {
session_id: ThreadId::new(),
let session = ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -3571,17 +3598,17 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
cwd: test_path_buf("/tmp/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::High),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
};
Arc::new(new_session_info(
app.chat_widget.config_ref(),
app.chat_widget.current_model(),
event,
&session,
is_first,
/*tooltip_override*/ None,
/*auth_plan*/ None,
@@ -4208,7 +4235,7 @@ fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry {
"test_originator".to_string(),
/*log_user_prompts*/ false,
"test".to_string(),
SessionSource::Cli,
crate::test_support::session_source_cli(),
)
}
@@ -4311,9 +4338,10 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
};
let make_header = |is_first| {
let event = SessionConfiguredEvent {
session_id: ThreadId::new(),
let session = ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -4322,17 +4350,17 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
};
Arc::new(new_session_info(
app.chat_widget.config_ref(),
app.chat_widget.current_model(),
event,
&session,
is_first,
/*tooltip_override*/ None,
/*auth_plan*/ None,
@@ -4830,7 +4858,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() {
path: None,
cwd: test_path_buf("/tmp/project").abs(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli.into(),
source: SessionSource::Cli,
agent_nickname: None,
agent_role: None,
git_info: None,
+2 -2
View File
@@ -19,7 +19,7 @@ pub(super) struct ThreadEventSnapshot {
pub(super) enum ThreadBufferedEvent {
Notification(ServerNotification),
Request(ServerRequest),
HistoryEntryResponse(GetHistoryEntryResponseEvent),
HistoryEntryResponse(HistoryLookupResponse),
FeedbackSubmission(FeedbackThreadEvent),
}
@@ -318,6 +318,7 @@ mod tests {
use super::*;
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::HookCompletedNotification;
use codex_app_server_protocol::HookEventName as AppServerHookEventName;
@@ -334,7 +335,6 @@ mod tests {
use codex_app_server_protocol::TurnStartedNotification;
use codex_config::types::ApprovalsReviewer;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
+48 -78
View File
@@ -5,6 +5,7 @@
//! when the visible thread changes.
use super::*;
use crate::session_resume::read_session_model;
impl App {
pub(super) async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) {
@@ -213,24 +214,11 @@ impl App {
let thread_label = Some(self.thread_label(thread_id));
match request {
ServerRequest::CommandExecutionRequestApproval { params, .. } => {
let network_approval_context = params
.network_approval_context
.clone()
.map(network_approval_context_to_core);
let additional_permissions = params.additional_permissions.clone().map(Into::into);
let proposed_execpolicy_amendment = params
.proposed_execpolicy_amendment
.clone()
.map(codex_app_server_protocol::ExecPolicyAmendment::into_core);
let proposed_network_policy_amendments = params
.proposed_network_policy_amendments
.clone()
.map(|amendments| {
amendments
.into_iter()
.map(codex_app_server_protocol::NetworkPolicyAmendment::into_core)
.collect::<Vec<_>>()
});
let network_approval_context = params.network_approval_context.clone();
let additional_permissions = params.additional_permissions.clone();
let proposed_execpolicy_amendment = params.proposed_execpolicy_amendment.clone();
let proposed_network_policy_amendments =
params.proposed_network_policy_amendments.clone();
Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec {
thread_id,
thread_label,
@@ -244,23 +232,14 @@ impl App {
.map(split_command_string)
.unwrap_or_default(),
reason: params.reason.clone(),
available_decisions: params
.available_decisions
.clone()
.map(|decisions| {
decisions
.into_iter()
.map(command_execution_decision_to_review_decision)
.collect()
})
.unwrap_or_else(|| {
default_exec_approval_decisions(
network_approval_context.as_ref(),
proposed_execpolicy_amendment.as_ref(),
proposed_network_policy_amendments.as_deref(),
additional_permissions.as_ref(),
)
}),
available_decisions: params.available_decisions.clone().unwrap_or_else(|| {
default_exec_approval_decisions(
network_approval_context.as_ref(),
proposed_execpolicy_amendment.as_ref(),
proposed_network_policy_amendments.as_deref(),
additional_permissions.as_ref(),
)
}),
network_approval_context,
additional_permissions,
}))
@@ -457,9 +436,9 @@ impl App {
thread_id: ThreadId,
op: &AppCommand,
) -> Result<bool> {
match op.view() {
AppCommandView::Other(Op::AddToHistory { text }) => {
let text = text.clone();
match op {
AppCommand::AddToHistory { text } => {
let text = text.to_string();
let config = self.chat_widget.config_ref().clone();
tokio::spawn(async move {
if let Err(err) = append_message_history_entry(&text, &thread_id, &config).await
@@ -473,11 +452,11 @@ impl App {
});
Ok(true)
}
AppCommandView::Other(Op::GetHistoryEntryRequest { offset, log_id }) => {
let offset = *offset;
let log_id = *log_id;
AppCommand::GetHistoryEntryRequest { offset, log_id } => {
let config = self.chat_widget.config_ref().clone();
let app_event_tx = self.app_event_tx.clone();
let offset = *offset;
let log_id = *log_id;
tokio::spawn(async move {
let entry_opt = tokio::task::spawn_blocking(move || {
lookup_message_history_entry(log_id, offset, &config)
@@ -490,7 +469,7 @@ impl App {
app_event_tx.send(AppEvent::ThreadHistoryEntryResponse {
thread_id,
event: GetHistoryEntryResponseEvent {
event: HistoryLookupResponse {
offset,
log_id,
entry: entry_opt.map(|entry| {
@@ -515,8 +494,8 @@ impl App {
thread_id: ThreadId,
op: &AppCommand,
) -> Result<bool> {
match op.view() {
AppCommandView::Interrupt => {
match op {
AppCommand::Interrupt => {
if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await {
app_server.turn_interrupt(thread_id, turn_id).await?;
} else {
@@ -524,7 +503,7 @@ impl App {
}
Ok(true)
}
AppCommandView::UserTurn {
AppCommand::UserTurn {
items,
cwd,
approval_policy,
@@ -609,12 +588,12 @@ impl App {
thread_id,
items.to_vec(),
cwd.clone(),
approval_policy,
approvals_reviewer
*approval_policy,
(*approvals_reviewer)
.unwrap_or(self.chat_widget.config_ref().approvals_reviewer),
permission_profile.clone(),
model.to_string(),
effort,
*effort,
*summary,
*service_tier,
collaboration_mode.clone(),
@@ -625,12 +604,12 @@ impl App {
}
Ok(true)
}
AppCommandView::ListSkills { cwds, force_reload } => {
AppCommand::ListSkills { cwds, force_reload } => {
self.handle_skills_list_result(
app_server
.skills_list(codex_app_server_protocol::SkillsListParams {
cwds: cwds.to_vec(),
force_reload,
cwds: cwds.clone(),
force_reload: *force_reload,
per_cwd_extra_user_roots: None,
})
.await,
@@ -638,75 +617,67 @@ impl App {
);
Ok(true)
}
AppCommandView::Compact => {
AppCommand::Compact => {
app_server.thread_compact_start(thread_id).await?;
Ok(true)
}
AppCommandView::SetThreadName { name } => {
AppCommand::SetThreadName { name } => {
app_server
.thread_set_name(thread_id, name.to_string())
.await?;
Ok(true)
}
AppCommandView::ThreadRollback { num_turns } => {
let response = match app_server.thread_rollback(thread_id, num_turns).await {
AppCommand::ThreadRollback { num_turns } => {
let response = match app_server.thread_rollback(thread_id, *num_turns).await {
Ok(response) => response,
Err(err) => {
self.handle_backtrack_rollback_failed();
return Err(err);
}
};
self.handle_thread_rollback_response(thread_id, num_turns, &response)
self.handle_thread_rollback_response(thread_id, *num_turns, &response)
.await;
Ok(true)
}
AppCommandView::Review { review_request } => {
app_server
.review_start(thread_id, review_request.clone())
.await?;
AppCommand::Review { target } => {
app_server.review_start(thread_id, target.clone()).await?;
Ok(true)
}
AppCommandView::CleanBackgroundTerminals => {
AppCommand::CleanBackgroundTerminals => {
app_server
.thread_background_terminals_clean(thread_id)
.await?;
Ok(true)
}
AppCommandView::RealtimeConversationStart(params) => {
AppCommand::RealtimeConversationStart { transport, voice } => {
app_server
.thread_realtime_start(thread_id, params.clone())
.thread_realtime_start(thread_id, transport.clone(), voice.clone())
.await?;
Ok(true)
}
AppCommandView::RealtimeConversationAudio(params) => {
AppCommand::RealtimeConversationAudio(frame) => {
app_server
.thread_realtime_audio(thread_id, params.clone())
.thread_realtime_audio(thread_id, frame.clone())
.await?;
Ok(true)
}
AppCommandView::RealtimeConversationText(params) => {
app_server
.thread_realtime_text(thread_id, params.clone())
.await?;
Ok(true)
}
AppCommandView::RealtimeConversationClose => {
AppCommand::RealtimeConversationClose => {
app_server.thread_realtime_stop(thread_id).await?;
Ok(true)
}
AppCommandView::RunUserShellCommand { command } => {
AppCommand::RunUserShellCommand { command } => {
app_server
.thread_shell_command(thread_id, command.to_string())
.await?;
Ok(true)
}
AppCommandView::ReloadUserConfig => {
AppCommand::ReloadUserConfig => {
app_server.reload_user_config().await?;
self.refresh_in_memory_config_from_disk().await?;
Ok(true)
}
AppCommandView::OverrideTurnContext { .. } => Ok(true),
AppCommandView::Other(Op::ApproveGuardianDeniedAction { event }) => {
AppCommand::OverrideTurnContext { .. } => Ok(true),
AppCommand::ApproveGuardianDeniedAction { event } => {
app_server
.thread_approve_guardian_denied_action(thread_id, event)
.await?;
@@ -1006,7 +977,7 @@ impl App {
pub(super) async fn enqueue_thread_history_entry_response(
&mut self,
thread_id: ThreadId,
event: GetHistoryEntryResponseEvent,
event: HistoryLookupResponse,
) -> Result<()> {
let (sender, store) = {
let channel = self.ensure_thread_channel(thread_id);
@@ -1317,7 +1288,6 @@ impl App {
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_skills_list_response(&mut self, response: SkillsListResponse) {
let response = list_skills_response_to_core(response);
let cwd = self.chat_widget.config_ref().cwd.clone();
let errors = errors_for_cwd(&cwd, &response);
emit_skill_load_warnings(&self.app_event_tx, &errors);
+36 -28
View File
@@ -1,6 +1,7 @@
use super::App;
use crate::app_server_session::ThreadSessionState;
use crate::read_session_model;
use crate::session_resume::read_session_model;
use crate::session_state::ThreadSessionState;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::Thread;
use codex_protocol::ThreadId;
use codex_protocol::models::PermissionProfile;
@@ -11,7 +12,7 @@ impl App {
return;
};
let approval_policy = self.config.permissions.approval_policy.value();
let approval_policy = AskForApproval::from(self.config.permissions.approval_policy.value());
let approvals_reviewer = self.config.approvals_reviewer;
let permission_profile = self
.chat_widget
@@ -55,7 +56,9 @@ impl App {
model: self.chat_widget.current_model().to_string(),
model_provider_id: self.config.model_provider_id.clone(),
service_tier: self.chat_widget.current_service_tier(),
approval_policy: self.config.permissions.approval_policy.value(),
approval_policy: AskForApproval::from(
self.config.permissions.approval_policy.value(),
),
approvals_reviewer: self.config.approvals_reviewer,
permission_profile: permission_profile.clone(),
cwd: thread.cwd.clone(),
@@ -101,15 +104,16 @@ mod tests {
use crate::app::thread_events::ThreadEventChannel;
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::FileSystemAccessMode;
use codex_app_server_protocol::FileSystemPath;
use codex_app_server_protocol::FileSystemSandboxEntry;
use codex_app_server_protocol::FileSystemSpecialPath;
use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile;
use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
use codex_config::types::ApprovalsReviewer;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::FileSystemAccessMode;
use codex_protocol::protocol::FileSystemPath;
use codex_protocol::protocol::FileSystemSandboxEntry;
use codex_protocol::protocol::FileSystemSandboxPolicy;
use codex_protocol::protocol::FileSystemSpecialPath;
use codex_protocol::protocol::NetworkSandboxPolicy;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
@@ -171,7 +175,7 @@ mod tests {
app.side_threads
.insert(side_thread_id, SideThreadState::new(main_thread_id));
app.config.permissions.approval_policy =
codex_config::Constrained::allow_any(AskForApproval::OnRequest);
codex_config::Constrained::allow_any(AskForApproval::OnRequest.to_core());
app.config.approvals_reviewer = ApprovalsReviewer::AutoReview;
let expected_permission_profile = PermissionProfile::workspace_write();
app.chat_widget.handle_thread_session(main_session.clone());
@@ -225,23 +229,27 @@ mod tests {
let mut app = make_test_app().await;
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000403").expect("valid thread");
let profile = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
let profile: PermissionProfile = AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
file_system: PermissionProfileFileSystemPermissions::Restricted {
entries: vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "**/.env".to_string(),
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "**/.env".to_string(),
},
access: FileSystemAccessMode::None,
},
access: FileSystemAccessMode::None,
},
]),
NetworkSandboxPolicy::Restricted,
);
],
glob_scan_max_depth: None,
},
}
.into();
let session = ThreadSessionState {
permission_profile: profile.clone(),
..test_thread_session(thread_id, test_path_buf("/tmp/main"))
@@ -256,7 +264,7 @@ mod tests {
);
app.chat_widget.handle_thread_session(session.clone());
app.config.permissions.approval_policy =
codex_config::Constrained::allow_any(AskForApproval::OnRequest);
codex_config::Constrained::allow_any(AskForApproval::OnRequest.to_core());
app.sync_active_thread_permission_settings_to_cached_session()
.await;
+1 -1
View File
@@ -13,7 +13,7 @@
//! - A subsequent `Esc` opens the transcript overlay (`Ctrl+T`) and highlights a user message when
//! there is a rewind target.
//! - `Enter` requests a rollback from core and records a `pending_rollback` guard.
//! - On `EventMsg::ThreadRolledBack`, we either finish an in-flight backtrack request or queue a
//! - On rollback completion, we either finish an in-flight backtrack request or queue a
//! rollback trim so it runs in event order with transcript inserts.
//!
//! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live
+111 -235
View File
@@ -1,7 +1,14 @@
use std::path::PathBuf;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::ReviewTarget;
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
use codex_app_server_protocol::ThreadRealtimeStartTransport;
use codex_config::types::ApprovalsReviewer;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::approvals::GuardianAssessmentEvent;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
@@ -10,130 +17,132 @@ use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::mcp::RequestId as McpRequestId;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::ConversationTextParams;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::UserInput;
use serde::Serialize;
use serde_json::Value;
use crate::permission_compat::legacy_compatible_permission_profile;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct AppCommand(Op);
#[allow(clippy::large_enum_variant)]
#[allow(dead_code)]
pub(crate) enum AppCommandView<'a> {
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) enum AppCommand {
Interrupt,
CleanBackgroundTerminals,
RealtimeConversationStart(&'a ConversationStartParams),
RealtimeConversationAudio(&'a ConversationAudioParams),
RealtimeConversationText(&'a ConversationTextParams),
RealtimeConversationStart {
transport: Option<ThreadRealtimeStartTransport>,
voice: Option<Value>,
},
RealtimeConversationAudio(ThreadRealtimeAudioChunk),
RealtimeConversationClose,
RunUserShellCommand {
command: &'a str,
command: String,
},
UserTurn {
items: &'a [UserInput],
cwd: &'a PathBuf,
items: Vec<UserInput>,
cwd: PathBuf,
approval_policy: AskForApproval,
approvals_reviewer: &'a Option<ApprovalsReviewer>,
permission_profile: &'a PermissionProfile,
model: &'a str,
approvals_reviewer: Option<ApprovalsReviewer>,
permission_profile: PermissionProfile,
model: String,
effort: Option<ReasoningEffortConfig>,
summary: &'a Option<ReasoningSummaryConfig>,
service_tier: &'a Option<Option<ServiceTier>>,
final_output_json_schema: &'a Option<Value>,
collaboration_mode: &'a Option<CollaborationMode>,
personality: &'a Option<Personality>,
summary: Option<ReasoningSummaryConfig>,
service_tier: Option<Option<ServiceTier>>,
final_output_json_schema: Option<Value>,
collaboration_mode: Option<CollaborationMode>,
personality: Option<Personality>,
},
OverrideTurnContext {
cwd: &'a Option<PathBuf>,
approval_policy: &'a Option<AskForApproval>,
approvals_reviewer: &'a Option<ApprovalsReviewer>,
permission_profile: &'a Option<PermissionProfile>,
windows_sandbox_level: &'a Option<WindowsSandboxLevel>,
model: &'a Option<String>,
effort: &'a Option<Option<ReasoningEffortConfig>>,
summary: &'a Option<ReasoningSummaryConfig>,
service_tier: &'a Option<Option<ServiceTier>>,
collaboration_mode: &'a Option<CollaborationMode>,
personality: &'a Option<Personality>,
cwd: Option<PathBuf>,
approval_policy: Option<AskForApproval>,
approvals_reviewer: Option<ApprovalsReviewer>,
permission_profile: Option<PermissionProfile>,
windows_sandbox_level: Option<WindowsSandboxLevel>,
model: Option<String>,
effort: Option<Option<ReasoningEffortConfig>>,
summary: Option<ReasoningSummaryConfig>,
service_tier: Option<Option<ServiceTier>>,
collaboration_mode: Option<CollaborationMode>,
personality: Option<Personality>,
},
ExecApproval {
id: &'a str,
turn_id: &'a Option<String>,
decision: &'a ReviewDecision,
id: String,
turn_id: Option<String>,
decision: CommandExecutionApprovalDecision,
},
PatchApproval {
id: &'a str,
decision: &'a ReviewDecision,
id: String,
decision: FileChangeApprovalDecision,
},
ResolveElicitation {
server_name: &'a str,
request_id: &'a McpRequestId,
decision: &'a ElicitationAction,
content: &'a Option<Value>,
meta: &'a Option<Value>,
server_name: String,
request_id: McpRequestId,
decision: ElicitationAction,
content: Option<Value>,
meta: Option<Value>,
},
UserInputAnswer {
id: &'a str,
response: &'a RequestUserInputResponse,
id: String,
response: RequestUserInputResponse,
},
RequestPermissionsResponse {
id: &'a str,
response: &'a RequestPermissionsResponse,
id: String,
response: RequestPermissionsResponse,
},
ReloadUserConfig,
ListSkills {
cwds: &'a [PathBuf],
cwds: Vec<PathBuf>,
force_reload: bool,
},
Compact,
SetThreadName {
name: &'a str,
name: String,
},
Shutdown,
ThreadRollback {
num_turns: u32,
},
Review {
review_request: &'a ReviewRequest,
target: ReviewTarget,
},
AddToHistory {
text: String,
},
GetHistoryEntryRequest {
offset: usize,
log_id: u64,
},
ApproveGuardianDeniedAction {
event: GuardianAssessmentEvent,
},
Other(&'a Op),
}
impl AppCommand {
pub(crate) fn interrupt() -> Self {
Self(Op::Interrupt)
Self::Interrupt
}
pub(crate) fn clean_background_terminals() -> Self {
Self(Op::CleanBackgroundTerminals)
Self::CleanBackgroundTerminals
}
pub(crate) fn realtime_conversation_start(params: ConversationStartParams) -> Self {
Self(Op::RealtimeConversationStart(params))
pub(crate) fn realtime_conversation_start(
transport: Option<ThreadRealtimeStartTransport>,
voice: Option<Value>,
) -> Self {
Self::RealtimeConversationStart { transport, voice }
}
#[cfg_attr(target_os = "linux", allow(dead_code))]
pub(crate) fn realtime_conversation_audio(params: ConversationAudioParams) -> Self {
Self(Op::RealtimeConversationAudio(params))
pub(crate) fn realtime_conversation_audio(frame: ThreadRealtimeAudioChunk) -> Self {
Self::RealtimeConversationAudio(frame)
}
pub(crate) fn realtime_conversation_close() -> Self {
Self(Op::RealtimeConversationClose)
Self::RealtimeConversationClose
}
pub(crate) fn run_user_shell_command(command: String) -> Self {
Self(Op::RunUserShellCommand { command })
Self::RunUserShellCommand { command }
}
#[allow(clippy::too_many_arguments)]
@@ -150,21 +159,12 @@ impl AppCommand {
collaboration_mode: Option<CollaborationMode>,
personality: Option<Personality>,
) -> Self {
let legacy_profile =
legacy_compatible_permission_profile(&permission_profile, cwd.as_path());
let sandbox_policy = legacy_profile
.to_legacy_sandbox_policy(cwd.as_path())
.unwrap_or_else(|err| {
unreachable!("legacy-compatible permissions must project to legacy policy: {err}")
});
Self(Op::UserTurn {
Self::UserTurn {
items,
environments: None,
cwd,
approval_policy,
approvals_reviewer: None,
sandbox_policy,
permission_profile: Some(permission_profile),
permission_profile,
model,
effort,
summary,
@@ -172,7 +172,7 @@ impl AppCommand {
final_output_json_schema,
collaboration_mode,
personality,
})
}
}
#[allow(clippy::too_many_arguments)]
@@ -189,11 +189,10 @@ impl AppCommand {
collaboration_mode: Option<CollaborationMode>,
personality: Option<Personality>,
) -> Self {
Self(Op::OverrideTurnContext {
Self::OverrideTurnContext {
cwd,
approval_policy,
approvals_reviewer,
sandbox_policy: None,
permission_profile,
windows_sandbox_level,
model,
@@ -202,23 +201,23 @@ impl AppCommand {
service_tier,
collaboration_mode,
personality,
})
}
}
pub(crate) fn exec_approval(
id: String,
turn_id: Option<String>,
decision: ReviewDecision,
decision: CommandExecutionApprovalDecision,
) -> Self {
Self(Op::ExecApproval {
Self::ExecApproval {
id,
turn_id,
decision,
})
}
}
pub(crate) fn patch_approval(id: String, decision: ReviewDecision) -> Self {
Self(Op::PatchApproval { id, decision })
pub(crate) fn patch_approval(id: String, decision: FileChangeApprovalDecision) -> Self {
Self::PatchApproval { id, decision }
}
pub(crate) fn resolve_elicitation(
@@ -228,186 +227,69 @@ impl AppCommand {
content: Option<Value>,
meta: Option<Value>,
) -> Self {
Self(Op::ResolveElicitation {
Self::ResolveElicitation {
server_name,
request_id,
decision,
content,
meta,
})
}
}
pub(crate) fn user_input_answer(id: String, response: RequestUserInputResponse) -> Self {
Self(Op::UserInputAnswer { id, response })
Self::UserInputAnswer { id, response }
}
pub(crate) fn request_permissions_response(
id: String,
response: RequestPermissionsResponse,
) -> Self {
Self(Op::RequestPermissionsResponse { id, response })
Self::RequestPermissionsResponse { id, response }
}
pub(crate) fn reload_user_config() -> Self {
Self(Op::ReloadUserConfig)
Self::ReloadUserConfig
}
pub(crate) fn list_skills(cwds: Vec<PathBuf>, force_reload: bool) -> Self {
Self(Op::ListSkills { cwds, force_reload })
Self::ListSkills { cwds, force_reload }
}
pub(crate) fn compact() -> Self {
Self(Op::Compact)
Self::Compact
}
pub(crate) fn set_thread_name(name: String) -> Self {
Self(Op::SetThreadName { name })
Self::SetThreadName { name }
}
#[allow(dead_code)]
pub(crate) fn shutdown() -> Self {
Self::Shutdown
}
pub(crate) fn thread_rollback(num_turns: u32) -> Self {
Self(Op::ThreadRollback { num_turns })
Self::ThreadRollback { num_turns }
}
pub(crate) fn review(review_request: ReviewRequest) -> Self {
Self(Op::Review { review_request })
pub(crate) fn review(target: ReviewTarget) -> Self {
Self::Review { target }
}
pub(crate) fn into_core(self) -> Op {
self.0
pub(crate) fn add_to_history(text: String) -> Self {
Self::AddToHistory { text }
}
pub(crate) fn history_lookup(offset: usize, log_id: u64) -> Self {
Self::GetHistoryEntryRequest { offset, log_id }
}
pub(crate) fn approve_guardian_denied_action(event: GuardianAssessmentEvent) -> Self {
Self::ApproveGuardianDeniedAction { event }
}
pub(crate) fn is_review(&self) -> bool {
matches!(self.view(), AppCommandView::Review { .. })
}
pub(crate) fn view(&self) -> AppCommandView<'_> {
match &self.0 {
Op::Interrupt => AppCommandView::Interrupt,
Op::CleanBackgroundTerminals => AppCommandView::CleanBackgroundTerminals,
Op::RealtimeConversationStart(params) => {
AppCommandView::RealtimeConversationStart(params)
}
Op::RealtimeConversationAudio(params) => {
AppCommandView::RealtimeConversationAudio(params)
}
Op::RealtimeConversationText(params) => {
AppCommandView::RealtimeConversationText(params)
}
Op::RealtimeConversationClose => AppCommandView::RealtimeConversationClose,
Op::RunUserShellCommand { command } => AppCommandView::RunUserShellCommand { command },
Op::UserTurn {
items,
cwd,
approval_policy,
approvals_reviewer,
sandbox_policy: _,
permission_profile,
model,
effort,
summary,
service_tier,
final_output_json_schema,
collaboration_mode,
personality,
environments: _,
} => AppCommandView::UserTurn {
items,
cwd,
approval_policy: *approval_policy,
approvals_reviewer,
permission_profile: match permission_profile.as_ref() {
Some(permission_profile) => permission_profile,
None => unreachable!("AppCommand::user_turn always sets permission_profile"),
},
model,
effort: *effort,
summary,
service_tier,
final_output_json_schema,
collaboration_mode,
personality,
},
Op::OverrideTurnContext {
cwd,
approval_policy,
approvals_reviewer,
sandbox_policy: _,
permission_profile,
windows_sandbox_level,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
} => AppCommandView::OverrideTurnContext {
cwd,
approval_policy,
approvals_reviewer,
permission_profile,
windows_sandbox_level,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
},
Op::ExecApproval {
id,
turn_id,
decision,
} => AppCommandView::ExecApproval {
id,
turn_id,
decision,
},
Op::PatchApproval { id, decision } => AppCommandView::PatchApproval { id, decision },
Op::ResolveElicitation {
server_name,
request_id,
decision,
content,
meta,
} => AppCommandView::ResolveElicitation {
server_name,
request_id,
decision,
content,
meta,
},
Op::UserInputAnswer { id, response } => {
AppCommandView::UserInputAnswer { id, response }
}
Op::RequestPermissionsResponse { id, response } => {
AppCommandView::RequestPermissionsResponse { id, response }
}
Op::ReloadUserConfig => AppCommandView::ReloadUserConfig,
Op::ListSkills { cwds, force_reload } => AppCommandView::ListSkills {
cwds,
force_reload: *force_reload,
},
Op::Compact => AppCommandView::Compact,
Op::SetThreadName { name } => AppCommandView::SetThreadName { name },
Op::Shutdown => AppCommandView::Shutdown,
Op::ThreadRollback { num_turns } => AppCommandView::ThreadRollback {
num_turns: *num_turns,
},
Op::Review { review_request } => AppCommandView::Review { review_request },
op => AppCommandView::Other(op),
}
}
}
impl From<Op> for AppCommand {
fn from(value: Op) -> Self {
Self(value)
}
}
impl From<&Op> for AppCommand {
fn from(value: &Op) -> Self {
Self(value.clone())
matches!(self, Self::Review { .. })
}
}
@@ -416,9 +298,3 @@ impl From<&AppCommand> for AppCommand {
value.clone()
}
}
impl From<AppCommand> for Op {
fn from(value: AppCommand) -> Self {
value.0
}
}
+15 -8
View File
@@ -21,21 +21,22 @@ use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::ThreadGoalStatus;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
use codex_protocol::message_history::HistoryEntry;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::protocol::GetHistoryEntryResponseEvent;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_approval_presets::ApprovalPreset;
use crate::app_command::AppCommand;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::TerminalTitleItem;
use crate::chatwidget::UserMessage;
use codex_app_server_protocol::AskForApproval;
use codex_config::types::ApprovalsReviewer;
use codex_features::Feature;
use codex_plugin::PluginCapabilitySummary;
@@ -44,7 +45,6 @@ use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_realtime_webrtc::RealtimeWebrtcEvent;
use codex_realtime_webrtc::RealtimeWebrtcSessionHandle;
@@ -62,6 +62,13 @@ pub(crate) enum ThreadGoalSetMode {
ReplaceExisting,
}
#[derive(Debug, Clone)]
pub(crate) struct HistoryLookupResponse {
pub(crate) offset: usize,
pub(crate) log_id: u64,
pub(crate) entry: Option<HistoryEntry>,
}
impl RealtimeAudioDeviceKind {
pub(crate) fn title(self) -> &'static str {
match self {
@@ -132,13 +139,13 @@ pub(crate) enum AppEvent {
/// Submit an op to the specified thread, regardless of current focus.
SubmitThreadOp {
thread_id: ThreadId,
op: Op,
op: AppCommand,
},
/// Deliver a synthetic history lookup response to a specific thread channel.
ThreadHistoryEntryResponse {
thread_id: ThreadId,
event: GetHistoryEntryResponseEvent,
event: HistoryLookupResponse,
},
/// Start a new session.
@@ -180,9 +187,9 @@ pub(crate) enum AppEvent {
#[allow(dead_code)]
FatalExitRequest(String),
/// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids
/// Forward a command to the Agent. Using an `AppEvent` for this avoids
/// bubbling channels through layers of widgets.
CodexOp(Op),
CodexOp(AppCommand),
/// Approve one retry of a recent auto-review denial selected in the TUI.
ApproveRecentAutoReviewDenial {
+36 -29
View File
@@ -1,12 +1,13 @@
use std::path::PathBuf;
use crate::app_command::AppCommand;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::ReviewTarget;
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
use codex_protocol::ThreadId;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::mcp::RequestId as McpRequestId;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::request_user_input::RequestUserInputResponse;
use tokio::sync::mpsc::UnboundedSender;
@@ -38,48 +39,50 @@ impl AppEventSender {
}
pub(crate) fn interrupt(&self) {
self.send(AppEvent::CodexOp(AppCommand::interrupt().into_core()));
self.send(AppEvent::CodexOp(AppCommand::interrupt()));
}
pub(crate) fn compact(&self) {
self.send(AppEvent::CodexOp(AppCommand::compact().into_core()));
self.send(AppEvent::CodexOp(AppCommand::compact()));
}
pub(crate) fn set_thread_name(&self, name: String) {
self.send(AppEvent::CodexOp(
AppCommand::set_thread_name(name).into_core(),
));
self.send(AppEvent::CodexOp(AppCommand::set_thread_name(name)));
}
pub(crate) fn review(&self, review_request: ReviewRequest) {
self.send(AppEvent::CodexOp(
AppCommand::review(review_request).into_core(),
));
pub(crate) fn review(&self, target: ReviewTarget) {
self.send(AppEvent::CodexOp(AppCommand::review(target)));
}
pub(crate) fn list_skills(&self, cwds: Vec<PathBuf>, force_reload: bool) {
self.send(AppEvent::CodexOp(
AppCommand::list_skills(cwds, force_reload).into_core(),
));
self.send(AppEvent::CodexOp(AppCommand::list_skills(
cwds,
force_reload,
)));
}
#[cfg_attr(target_os = "linux", allow(dead_code))]
pub(crate) fn realtime_conversation_audio(&self, params: ConversationAudioParams) {
self.send(AppEvent::CodexOp(
AppCommand::realtime_conversation_audio(params).into_core(),
));
pub(crate) fn realtime_conversation_audio(&self, frame: ThreadRealtimeAudioChunk) {
self.send(AppEvent::CodexOp(AppCommand::realtime_conversation_audio(
frame,
)));
}
pub(crate) fn user_input_answer(&self, id: String, response: RequestUserInputResponse) {
self.send(AppEvent::CodexOp(
AppCommand::user_input_answer(id, response).into_core(),
));
self.send(AppEvent::CodexOp(AppCommand::user_input_answer(
id, response,
)));
}
pub(crate) fn exec_approval(&self, thread_id: ThreadId, id: String, decision: ReviewDecision) {
pub(crate) fn exec_approval(
&self,
thread_id: ThreadId,
id: String,
decision: CommandExecutionApprovalDecision,
) {
self.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::exec_approval(id, /*turn_id*/ None, decision).into_core(),
op: AppCommand::exec_approval(id, /*turn_id*/ None, decision),
});
}
@@ -91,14 +94,19 @@ impl AppEventSender {
) {
self.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::request_permissions_response(id, response).into_core(),
op: AppCommand::request_permissions_response(id, response),
});
}
pub(crate) fn patch_approval(&self, thread_id: ThreadId, id: String, decision: ReviewDecision) {
pub(crate) fn patch_approval(
&self,
thread_id: ThreadId,
id: String,
decision: FileChangeApprovalDecision,
) {
self.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::patch_approval(id, decision).into_core(),
op: AppCommand::patch_approval(id, decision),
});
}
@@ -113,8 +121,7 @@ impl AppEventSender {
) {
self.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::resolve_elicitation(server_name, request_id, decision, content, meta)
.into_core(),
op: AppCommand::resolve_elicitation(server_name, request_id, decision, content, meta),
});
}
}
@@ -1,11 +1,11 @@
use crate::diff_model::FileChange;
use codex_app_server_protocol::AdditionalNetworkPermissions;
use codex_app_server_protocol::FileUpdateChange;
use codex_app_server_protocol::GrantedPermissionProfile;
use codex_app_server_protocol::NetworkApprovalContext;
use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext;
use codex_app_server_protocol::NetworkApprovalProtocol;
use codex_app_server_protocol::PatchChangeKind;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::NetworkApprovalContext;
use codex_protocol::protocol::NetworkApprovalProtocol;
use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile;
use std::collections::HashMap;
use std::path::PathBuf;
@@ -72,17 +72,11 @@ mod tests {
use super::file_update_changes_to_core;
use super::granted_permission_profile_from_request;
use super::network_approval_context_to_core;
use crate::diff_model::FileChange;
use codex_app_server_protocol::FileUpdateChange;
use codex_app_server_protocol::NetworkApprovalContext;
use codex_app_server_protocol::NetworkApprovalProtocol;
use codex_app_server_protocol::PatchChangeKind;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::NetworkApprovalContext;
use codex_protocol::protocol::NetworkApprovalProtocol;
use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
@@ -127,15 +121,19 @@ mod tests {
#[test]
fn converts_request_permissions_into_granted_permissions() {
assert_eq!(
granted_permission_profile_from_request(CoreRequestPermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path("/tmp/read-only")]),
Some(vec![absolute_path("/tmp/write")]),
)),
}),
granted_permission_profile_from_request(CoreRequestPermissionProfile::from(
codex_app_server_protocol::RequestPermissionProfile {
network: Some(codex_app_server_protocol::AdditionalNetworkPermissions {
enabled: Some(true),
}),
file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions {
read: Some(vec![absolute_path("/tmp/read-only")]),
write: Some(vec![absolute_path("/tmp/write")]),
glob_scan_max_depth: None,
entries: None,
}),
}
)),
codex_app_server_protocol::GrantedPermissionProfile {
network: Some(codex_app_server_protocol::AdditionalNetworkPermissions {
enabled: Some(true),
@@ -166,18 +164,22 @@ mod tests {
#[test]
fn converts_request_permissions_into_canonical_granted_permissions() {
assert_eq!(
granted_permission_profile_from_request(CoreRequestPermissionProfile {
file_system: Some(FileSystemPermissions {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
}],
glob_scan_max_depth: None,
}),
..Default::default()
}),
granted_permission_profile_from_request(CoreRequestPermissionProfile::from(
codex_app_server_protocol::RequestPermissionProfile {
network: None,
file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions {
read: None,
write: None,
glob_scan_max_depth: None,
entries: Some(vec![codex_app_server_protocol::FileSystemSandboxEntry {
path: codex_app_server_protocol::FileSystemPath::Special {
value: codex_app_server_protocol::FileSystemSpecialPath::Root,
},
access: codex_app_server_protocol::FileSystemAccessMode::Write,
}]),
}),
}
)),
codex_app_server_protocol::GrantedPermissionProfile {
network: None,
file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions {
+68 -161
View File
@@ -4,6 +4,7 @@ use crate::legacy_core::append_message_history_entry;
use crate::legacy_core::config::Config;
use crate::legacy_core::message_history_metadata;
use crate::permission_compat::legacy_compatible_permission_profile;
use crate::session_state::ThreadSessionState;
use crate::status::StatusAccountDisplay;
use crate::status::plan_type_display_name;
use codex_app_server_client::AppServerClient;
@@ -11,6 +12,7 @@ use codex_app_server_client::AppServerEvent;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigBatchWriteParams;
@@ -29,10 +31,12 @@ use codex_app_server_protocol::MemoryResetResponse;
use codex_app_server_protocol::Model as ApiModel;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewDelivery;
use codex_app_server_protocol::ReviewStartParams;
use codex_app_server_protocol::ReviewStartResponse;
use codex_app_server_protocol::ReviewTarget;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::Thread;
@@ -64,8 +68,7 @@ use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadRealtimeAppendAudioParams;
use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse;
use codex_app_server_protocol::ThreadRealtimeAppendTextParams;
use codex_app_server_protocol::ThreadRealtimeAppendTextResponse;
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
use codex_app_server_protocol::ThreadRealtimeStartParams;
use codex_app_server_protocol::ThreadRealtimeStartResponse;
use codex_app_server_protocol::ThreadRealtimeStartTransport;
@@ -93,24 +96,13 @@ use codex_app_server_protocol::TurnSteerParams;
use codex_app_server_protocol::TurnSteerResponse;
use codex_otel::TelemetryAuthMode;
use codex_protocol::ThreadId;
use codex_protocol::approvals::GuardianAssessmentEvent;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelAvailabilityNux;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::ConversationStartTransport;
use codex_protocol::protocol::ConversationTextParams;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::GuardianAssessmentEvent;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget as CoreReviewTarget;
use codex_protocol::protocol::SessionNetworkProxyRuntime;
use codex_utils_absolute_path::AbsolutePathBuf;
use color_eyre::eyre::ContextCompat;
use color_eyre::eyre::Result;
@@ -145,30 +137,6 @@ pub(crate) struct AppServerSession {
remote_cwd_override: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ThreadSessionState {
pub(crate) thread_id: ThreadId,
pub(crate) forked_from_id: Option<ThreadId>,
pub(crate) fork_parent_title: Option<String>,
pub(crate) thread_name: Option<String>,
pub(crate) model: String,
pub(crate) model_provider_id: String,
pub(crate) service_tier: Option<codex_protocol::config_types::ServiceTier>,
pub(crate) approval_policy: AskForApproval,
pub(crate) approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
/// Canonical active permissions for this session. Legacy app-server
/// responses are converted to a profile at ingestion time using the
/// response cwd so cached sessions do not reinterpret cwd-bound grants.
pub(crate) permission_profile: PermissionProfile,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) instruction_source_paths: Vec<AbsolutePathBuf>,
pub(crate) reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
pub(crate) history_log_id: u64,
pub(crate) history_entry_count: u64,
pub(crate) network_proxy: Option<SessionNetworkProxyRuntime>,
pub(crate) rollout_path: Option<PathBuf>,
}
#[derive(Clone, Copy)]
enum ThreadParamsMode {
Embedded,
@@ -571,7 +539,7 @@ impl AppServerSession {
responsesapi_client_metadata: None,
environments: None,
cwd: Some(cwd),
approval_policy: Some(approval_policy.into()),
approval_policy: Some(approval_policy),
approvals_reviewer: Some(approvals_reviewer.into()),
sandbox_policy,
permission_profile,
@@ -862,7 +830,7 @@ impl AppServerSession {
pub(crate) async fn review_start(
&mut self,
thread_id: ThreadId,
review_request: ReviewRequest,
target: ReviewTarget,
) -> Result<ReviewStartResponse> {
let request_id = self.next_request_id();
self.client
@@ -870,7 +838,7 @@ impl AppServerSession {
request_id,
params: ReviewStartParams {
thread_id: thread_id.to_string(),
target: review_target_to_app_server(review_request.target),
target,
delivery: Some(ReviewDelivery::Inline),
},
})
@@ -910,29 +878,14 @@ impl AppServerSession {
pub(crate) async fn thread_realtime_start(
&mut self,
thread_id: ThreadId,
params: ConversationStartParams,
transport: Option<ThreadRealtimeStartTransport>,
voice: Option<serde_json::Value>,
) -> Result<()> {
let request_id = self.next_request_id();
let params = thread_realtime_start_params(thread_id, transport, voice)?;
let _: ThreadRealtimeStartResponse = self
.client
.request_typed(ClientRequest::ThreadRealtimeStart {
request_id,
params: ThreadRealtimeStartParams {
thread_id: thread_id.to_string(),
output_modality: params.output_modality,
prompt: params.prompt,
session_id: params.session_id,
voice: params.voice,
transport: params.transport.map(|transport| match transport {
ConversationStartTransport::Websocket => {
ThreadRealtimeStartTransport::Websocket
}
ConversationStartTransport::Webrtc { sdp } => {
ThreadRealtimeStartTransport::Webrtc { sdp }
}
}),
},
})
.request_typed(ClientRequest::ThreadRealtimeStart { request_id, params })
.await
.wrap_err("thread/realtime/start failed in TUI")?;
Ok(())
@@ -941,7 +894,7 @@ impl AppServerSession {
pub(crate) async fn thread_realtime_audio(
&mut self,
thread_id: ThreadId,
params: ConversationAudioParams,
frame: ThreadRealtimeAudioChunk,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadRealtimeAppendAudioResponse = self
@@ -950,7 +903,7 @@ impl AppServerSession {
request_id,
params: ThreadRealtimeAppendAudioParams {
thread_id: thread_id.to_string(),
audio: params.frame.into(),
audio: frame,
},
})
.await
@@ -958,26 +911,6 @@ impl AppServerSession {
Ok(())
}
pub(crate) async fn thread_realtime_text(
&mut self,
thread_id: ThreadId,
params: ConversationTextParams,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadRealtimeAppendTextResponse = self
.client
.request_typed(ClientRequest::ThreadRealtimeAppendText {
request_id,
params: ThreadRealtimeAppendTextParams {
thread_id: thread_id.to_string(),
text: params.text,
},
})
.await
.wrap_err("thread/realtime/appendText failed in TUI")?;
Ok(())
}
pub(crate) async fn thread_realtime_stop(&mut self, thread_id: ThreadId) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadRealtimeStopResponse = self
@@ -1024,6 +957,34 @@ impl AppServerSession {
}
}
fn thread_realtime_start_params(
thread_id: ThreadId,
transport: Option<ThreadRealtimeStartTransport>,
voice: Option<serde_json::Value>,
) -> Result<ThreadRealtimeStartParams> {
let mut value = serde_json::Map::new();
value.insert(
"threadId".to_string(),
serde_json::Value::String(thread_id.to_string()),
);
value.insert(
"outputModality".to_string(),
serde_json::Value::String("audio".to_string()),
);
if let Some(transport) = transport {
value.insert(
"transport".to_string(),
serde_json::to_value(transport).wrap_err("serializing realtime transport")?,
);
}
if let Some(voice) = voice {
value.insert("voice".to_string(), voice);
}
serde_json::from_value(serde_json::Value::Object(value))
.wrap_err("mapping TUI realtime start params to app-server params")
}
pub(crate) fn status_account_display_from_auth_mode(
auth_mode: Option<AuthMode>,
plan_type: Option<codex_protocol::account::PlanType>,
@@ -1299,7 +1260,7 @@ async fn thread_session_state_from_thread_start_response(
response.model.clone(),
response.model_provider.clone(),
response.service_tier,
response.approval_policy.to_core(),
response.approval_policy,
response.approvals_reviewer.to_core(),
response
.permission_profile
@@ -1331,7 +1292,7 @@ async fn thread_session_state_from_thread_resume_response(
response.model.clone(),
response.model_provider.clone(),
response.service_tier,
response.approval_policy.to_core(),
response.approval_policy,
response.approvals_reviewer.to_core(),
response
.permission_profile
@@ -1363,7 +1324,7 @@ async fn thread_session_state_from_thread_fork_response(
response.model.clone(),
response.model_provider.clone(),
response.service_tier,
response.approval_policy.to_core(),
response.approval_policy,
response.approvals_reviewer.to_core(),
response
.permission_profile
@@ -1383,25 +1344,6 @@ async fn thread_session_state_from_thread_fork_response(
.await
}
fn review_target_to_app_server(
target: CoreReviewTarget,
) -> codex_app_server_protocol::ReviewTarget {
match target {
CoreReviewTarget::UncommittedChanges => {
codex_app_server_protocol::ReviewTarget::UncommittedChanges
}
CoreReviewTarget::BaseBranch { branch } => {
codex_app_server_protocol::ReviewTarget::BaseBranch { branch }
}
CoreReviewTarget::Commit { sha, title } => {
codex_app_server_protocol::ReviewTarget::Commit { sha, title }
}
CoreReviewTarget::Custom { instructions } => {
codex_app_server_protocol::ReviewTarget::Custom { instructions }
}
}
}
#[expect(
clippy::too_many_arguments,
reason = "session mapping keeps explicit fields"
@@ -1452,68 +1394,31 @@ async fn thread_session_state_from_thread_response(
})
}
pub(crate) fn app_server_rate_limit_snapshots_to_core(
pub(crate) fn app_server_rate_limit_snapshots(
response: GetAccountRateLimitsResponse,
) -> Vec<RateLimitSnapshot> {
let mut snapshots = Vec::new();
snapshots.push(app_server_rate_limit_snapshot_to_core(response.rate_limits));
snapshots.push(response.rate_limits);
if let Some(by_limit_id) = response.rate_limits_by_limit_id {
snapshots.extend(
by_limit_id
.into_values()
.map(app_server_rate_limit_snapshot_to_core),
);
snapshots.extend(by_limit_id.into_values());
}
snapshots
}
pub(crate) fn app_server_rate_limit_snapshot_to_core(
snapshot: codex_app_server_protocol::RateLimitSnapshot,
) -> RateLimitSnapshot {
RateLimitSnapshot {
limit_id: snapshot.limit_id,
limit_name: snapshot.limit_name,
primary: snapshot.primary.map(app_server_rate_limit_window_to_core),
secondary: snapshot.secondary.map(app_server_rate_limit_window_to_core),
credits: snapshot.credits.map(app_server_credits_snapshot_to_core),
plan_type: snapshot.plan_type,
rate_limit_reached_type: snapshot.rate_limit_reached_type.map(Into::into),
}
}
fn app_server_rate_limit_window_to_core(
window: codex_app_server_protocol::RateLimitWindow,
) -> RateLimitWindow {
RateLimitWindow {
used_percent: window.used_percent as f64,
window_minutes: window.window_duration_mins,
resets_at: window.resets_at,
}
}
fn app_server_credits_snapshot_to_core(
snapshot: codex_app_server_protocol::CreditsSnapshot,
) -> CreditsSnapshot {
CreditsSnapshot {
has_credits: snapshot.has_credits,
unlimited: snapshot.unlimited,
balance: snapshot.balance,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::legacy_core::config::ConfigBuilder;
use codex_app_server_protocol::FileSystemAccessMode;
use codex_app_server_protocol::FileSystemPath;
use codex_app_server_protocol::FileSystemSandboxEntry;
use codex_app_server_protocol::FileSystemSpecialPath;
use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile;
use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStatus;
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
@@ -1610,8 +1515,9 @@ mod tests {
fn sandbox_mode_does_not_project_non_cwd_write_roots_for_remote_sessions() {
let cwd = test_path_buf("/workspace/project").abs();
let extra_root = test_path_buf("/workspace/cache").abs();
let permission_profile = PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Restricted {
let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
file_system: PermissionProfileFileSystemPermissions::Restricted {
entries: vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
@@ -1626,8 +1532,8 @@ mod tests {
],
glob_scan_max_depth: None,
},
network: NetworkSandboxPolicy::Restricted,
};
}
.into();
assert_eq!(
sandbox_mode_from_permission_profile(&permission_profile, cwd.as_path()),
@@ -1638,8 +1544,9 @@ mod tests {
#[test]
fn sandbox_mode_projects_cwd_write_for_remote_sessions() {
let cwd = test_path_buf("/workspace/project").abs();
let permission_profile = PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Restricted {
let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
file_system: PermissionProfileFileSystemPermissions::Restricted {
entries: vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
@@ -1656,8 +1563,8 @@ mod tests {
],
glob_scan_max_depth: None,
},
network: NetworkSandboxPolicy::Restricted,
};
}
.into();
assert_eq!(
sandbox_mode_from_permission_profile(&permission_profile, cwd.as_path()),
@@ -1751,7 +1658,7 @@ mod tests {
path: None,
cwd: test_path_buf("/tmp/project").abs(),
cli_version: "0.0.0".to_string(),
source: codex_protocol::protocol::SessionSource::Cli.into(),
source: codex_app_server_protocol::SessionSource::Cli,
agent_nickname: None,
agent_role: None,
git_info: None,
@@ -1785,7 +1692,7 @@ mod tests {
service_tier: None,
cwd: test_path_buf("/tmp/project").abs(),
instruction_sources: vec![test_path_buf("/tmp/project/AGENTS.md").abs()],
approval_policy: codex_protocol::protocol::AskForApproval::Never.into(),
approval_policy: codex_app_server_protocol::AskForApproval::Never,
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User,
sandbox: read_only_profile
.to_legacy_sandbox_policy(test_path_buf("/tmp/project").as_path())
+45
View File
@@ -0,0 +1,45 @@
//! Display-level approval decisions used by the TUI approval overlay.
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyAmendment;
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ReviewDecision {
Approved,
ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: ExecPolicyAmendment,
},
ApprovedForSession,
NetworkPolicyAmendment {
network_policy_amendment: NetworkPolicyAmendment,
},
#[default]
Denied,
TimedOut,
Abort,
}
impl From<ReviewDecision> for CommandExecutionApprovalDecision {
fn from(value: ReviewDecision) -> Self {
match value {
ReviewDecision::Approved => Self::Accept,
ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment,
} => Self::AcceptWithExecpolicyAmendment {
execpolicy_amendment: proposed_execpolicy_amendment.into(),
},
ReviewDecision::ApprovedForSession => Self::AcceptForSession,
ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment,
} => Self::ApplyNetworkPolicyAmendment {
network_policy_amendment: network_policy_amendment.into(),
},
ReviewDecision::Denied | ReviewDecision::TimedOut => Self::Decline,
ReviewDecision::Abort => Self::Cancel,
}
}
}
+105
View File
@@ -0,0 +1,105 @@
//! TUI-owned approval request models used while rendering and queueing prompts.
use std::collections::HashMap;
use std::path::PathBuf;
use crate::approval_display::ReviewDecision;
use crate::diff_model::FileChange;
use codex_app_server_protocol::NetworkApprovalContext;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyRuleAction;
use codex_protocol::parse_command::ParsedCommand;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ExecApprovalRequestEvent {
pub(crate) call_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) approval_id: Option<String>,
#[serde(default)]
pub(crate) turn_id: String,
pub(crate) command: Vec<String>,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) proposed_network_policy_amendments: Option<Vec<NetworkPolicyAmendment>>,
#[serde(default)]
pub(crate) available_decisions: Option<Vec<ReviewDecision>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) network_approval_context: Option<NetworkApprovalContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) additional_permissions: Option<codex_protocol::models::AdditionalPermissionProfile>,
pub(crate) parsed_cmd: Vec<ParsedCommand>,
}
impl ExecApprovalRequestEvent {
pub(crate) fn effective_approval_id(&self) -> String {
self.approval_id
.clone()
.unwrap_or_else(|| self.call_id.clone())
}
pub(crate) fn effective_available_decisions(&self) -> Vec<ReviewDecision> {
match &self.available_decisions {
Some(decisions) => decisions.clone(),
None => Self::default_available_decisions(
self.network_approval_context.as_ref(),
self.proposed_execpolicy_amendment.as_ref(),
self.proposed_network_policy_amendments.as_deref(),
self.additional_permissions.as_ref(),
),
}
}
pub(crate) fn default_available_decisions(
network_approval_context: Option<&NetworkApprovalContext>,
proposed_execpolicy_amendment: Option<&ExecPolicyAmendment>,
proposed_network_policy_amendments: Option<&[NetworkPolicyAmendment]>,
additional_permissions: Option<&codex_protocol::models::AdditionalPermissionProfile>,
) -> Vec<ReviewDecision> {
if network_approval_context.is_some() {
let mut decisions = vec![ReviewDecision::Approved, ReviewDecision::ApprovedForSession];
if let Some(amendment) = proposed_network_policy_amendments.and_then(|amendments| {
amendments
.iter()
.find(|amendment| amendment.action == NetworkPolicyRuleAction::Allow)
}) {
decisions.push(ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment: amendment.clone(),
});
}
decisions.push(ReviewDecision::Abort);
return decisions;
}
if additional_permissions.is_some() {
return vec![ReviewDecision::Approved, ReviewDecision::Abort];
}
let mut decisions = vec![ReviewDecision::Approved];
if let Some(prefix) = proposed_execpolicy_amendment {
decisions.push(ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: prefix.clone(),
});
}
decisions.push(ReviewDecision::Abort);
decisions
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ApplyPatchApprovalRequestEvent {
pub(crate) call_id: String,
#[serde(default)]
pub(crate) turn_id: String,
pub(crate) changes: HashMap<PathBuf, FileChange>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) grant_root: Option<PathBuf>,
}
+4 -4
View File
@@ -1,8 +1,8 @@
use std::collections::VecDeque;
use codex_protocol::protocol::GuardianAssessmentAction;
use codex_protocol::protocol::GuardianAssessmentEvent;
use codex_protocol::protocol::GuardianAssessmentStatus;
use codex_protocol::approvals::GuardianAssessmentAction;
use codex_protocol::approvals::GuardianAssessmentEvent;
use codex_protocol::approvals::GuardianAssessmentStatus;
const MAX_RECENT_DENIALS: usize = 10;
@@ -76,7 +76,7 @@ pub(crate) fn action_summary(action: &GuardianAssessmentAction) -> String {
#[cfg(test)]
mod tests {
use codex_protocol::protocol::GuardianCommandSource;
use codex_protocol::approvals::GuardianCommandSource;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
@@ -1,8 +1,8 @@
#[cfg(test)]
use crate::app_command::AppCommand as Op;
use codex_protocol::ThreadId;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::mcp::RequestId as McpRequestId;
#[cfg(test)]
use codex_protocol::protocol::Op;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
+199 -111
View File
@@ -15,14 +15,18 @@ use std::collections::HashMap;
use std::path::PathBuf;
use crate::app::app_server_requests::ResolvedAppServerRequest;
#[cfg(test)]
use crate::app_command::AppCommand as Op;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::approval_display::ReviewDecision;
use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::list_selection_view::ListSelectionView;
use crate::bottom_pane::list_selection_view::SelectionItem;
use crate::bottom_pane::list_selection_view::SelectionViewParams;
use crate::bottom_pane::popup_consts::accept_cancel_hint_line;
use crate::diff_model::FileChange;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
use crate::key_hint;
@@ -34,21 +38,19 @@ use crate::keymap::primary_binding;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use codex_app_server_protocol::AdditionalPermissionProfile;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileSystemAccessMode;
use codex_app_server_protocol::FileSystemPath;
use codex_app_server_protocol::FileSystemSandboxEntry;
use codex_app_server_protocol::FileSystemSpecialPath;
use codex_app_server_protocol::NetworkApprovalContext;
use codex_app_server_protocol::NetworkPolicyRuleAction;
use codex_features::Features;
use codex_protocol::ThreadId;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::mcp::RequestId;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::ElicitationAction;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::NetworkApprovalContext;
use codex_protocol::protocol::NetworkPolicyRuleAction;
#[cfg(test)]
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -73,7 +75,7 @@ pub(crate) enum ApprovalRequest {
id: String,
command: Vec<String>,
reason: Option<String>,
available_decisions: Vec<ReviewDecision>,
available_decisions: Vec<CommandExecutionApprovalDecision>,
network_approval_context: Option<NetworkApprovalContext>,
additional_permissions: Option<AdditionalPermissionProfile>,
},
@@ -304,7 +306,10 @@ impl ApprovalOverlay {
};
if let Some(request) = self.current_request.as_ref() {
match (request, &option.decision) {
(ApprovalRequest::Exec { id, command, .. }, ApprovalDecision::Review(decision)) => {
(
ApprovalRequest::Exec { id, command, .. },
ApprovalDecision::Command(decision),
) => {
self.handle_exec_decision(id, command, decision.clone());
}
(
@@ -315,7 +320,10 @@ impl ApprovalOverlay {
},
ApprovalDecision::Permissions(decision),
) => self.handle_permissions_decision(call_id, permissions, *decision),
(ApprovalRequest::ApplyPatch { id, .. }, ApprovalDecision::Review(decision)) => {
(
ApprovalRequest::ApplyPatch { id, .. },
ApprovalDecision::FileChange(decision),
) => {
self.handle_patch_decision(id, decision.clone());
}
(
@@ -336,14 +344,19 @@ impl ApprovalOverlay {
self.advance_queue();
}
fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) {
fn handle_exec_decision(
&self,
id: &str,
command: &[String],
decision: CommandExecutionApprovalDecision,
) {
let Some(request) = self.current_request.as_ref() else {
return;
};
if request.thread_label().is_none() {
let cell = history_cell::new_approval_decision_cell(
command.to_vec(),
decision.clone(),
command_decision_to_review_decision(&decision),
history_cell::ApprovalDecisionActor::User,
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
@@ -403,7 +416,7 @@ impl ApprovalOverlay {
);
}
fn handle_patch_decision(&self, id: &str, decision: ReviewDecision) {
fn handle_patch_decision(&self, id: &str, decision: FileChangeApprovalDecision) {
let Some(thread_id) = self
.current_request
.as_ref()
@@ -455,7 +468,11 @@ impl ApprovalOverlay {
{
match request {
ApprovalRequest::Exec { id, command, .. } => {
self.handle_exec_decision(id, command, ReviewDecision::Abort);
self.handle_exec_decision(
id,
command,
CommandExecutionApprovalDecision::Cancel,
);
}
ApprovalRequest::Permissions {
call_id,
@@ -469,7 +486,7 @@ impl ApprovalOverlay {
);
}
ApprovalRequest::ApplyPatch { id, .. } => {
self.handle_patch_decision(id, ReviewDecision::Abort);
self.handle_patch_decision(id, FileChangeApprovalDecision::Cancel);
}
ApprovalRequest::McpElicitation {
server_name,
@@ -728,7 +745,8 @@ fn build_header(request: &ApprovalRequest) -> Box<dyn Renderable> {
#[derive(Clone)]
enum ApprovalDecision {
Review(ReviewDecision),
Command(CommandExecutionApprovalDecision),
FileChange(FileChangeApprovalDecision),
Permissions(PermissionsDecision),
McpElicitation(ElicitationAction),
}
@@ -748,8 +766,29 @@ struct ApprovalOption {
shortcuts: Vec<KeyBinding>,
}
fn command_decision_to_review_decision(
decision: &CommandExecutionApprovalDecision,
) -> ReviewDecision {
match decision {
CommandExecutionApprovalDecision::Accept => ReviewDecision::Approved,
CommandExecutionApprovalDecision::AcceptForSession => ReviewDecision::ApprovedForSession,
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
execpolicy_amendment,
} => ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: execpolicy_amendment.clone().into_core(),
},
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment,
} => ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment: network_policy_amendment.clone().into_core(),
},
CommandExecutionApprovalDecision::Decline => ReviewDecision::Denied,
CommandExecutionApprovalDecision::Cancel => ReviewDecision::Abort,
}
}
fn exec_options(
available_decisions: &[ReviewDecision],
available_decisions: &[CommandExecutionApprovalDecision],
network_approval_context: Option<&NetworkApprovalContext>,
additional_permissions: Option<&AdditionalPermissionProfile>,
keymap: &ApprovalKeymap,
@@ -757,20 +796,19 @@ fn exec_options(
available_decisions
.iter()
.filter_map(|decision| match decision {
ReviewDecision::Approved => Some(ApprovalOption {
CommandExecutionApprovalDecision::Accept => Some(ApprovalOption {
label: if network_approval_context.is_some() {
"Yes, just this once".to_string()
} else {
"Yes, proceed".to_string()
},
decision: ApprovalDecision::Review(ReviewDecision::Approved),
decision: ApprovalDecision::Command(CommandExecutionApprovalDecision::Accept),
shortcuts: keymap.approve.clone(),
}),
ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment,
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
execpolicy_amendment,
} => {
let rendered_prefix =
strip_bash_lc_and_escape(proposed_execpolicy_amendment.command());
let rendered_prefix = strip_bash_lc_and_escape(&execpolicy_amendment.command);
if rendered_prefix.contains('\n') || rendered_prefix.contains('\r') {
return None;
}
@@ -779,15 +817,15 @@ fn exec_options(
label: format!(
"Yes, and don't ask again for commands that start with `{rendered_prefix}`"
),
decision: ApprovalDecision::Review(
ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: proposed_execpolicy_amendment.clone(),
decision: ApprovalDecision::Command(
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
execpolicy_amendment: execpolicy_amendment.clone(),
},
),
shortcuts: keymap.approve_for_prefix.clone(),
})
}
ReviewDecision::ApprovedForSession => Some(ApprovalOption {
CommandExecutionApprovalDecision::AcceptForSession => Some(ApprovalOption {
label: if network_approval_context.is_some() {
"Yes, and allow this host for this conversation".to_string()
} else if additional_permissions.is_some() {
@@ -795,10 +833,12 @@ fn exec_options(
} else {
"Yes, and don't ask again for this command in this session".to_string()
},
decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession),
decision: ApprovalDecision::Command(
CommandExecutionApprovalDecision::AcceptForSession,
),
shortcuts: keymap.approve_for_session.clone(),
}),
ReviewDecision::NetworkPolicyAmendment {
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment,
} => {
let (label, shortcuts) = match network_policy_amendment.action {
@@ -813,21 +853,22 @@ fn exec_options(
};
Some(ApprovalOption {
label,
decision: ApprovalDecision::Review(ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment: network_policy_amendment.clone(),
}),
decision: ApprovalDecision::Command(
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment: network_policy_amendment.clone(),
},
),
shortcuts,
})
}
ReviewDecision::Denied => Some(ApprovalOption {
CommandExecutionApprovalDecision::Decline => Some(ApprovalOption {
label: "No, continue without running it".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Denied),
decision: ApprovalDecision::Command(CommandExecutionApprovalDecision::Decline),
shortcuts: keymap.deny.clone(),
}),
ReviewDecision::TimedOut => None,
ReviewDecision::Abort => Some(ApprovalOption {
CommandExecutionApprovalDecision::Cancel => Some(ApprovalOption {
label: "No, and tell Codex what to do differently".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Abort),
decision: ApprovalDecision::Command(CommandExecutionApprovalDecision::Cancel),
shortcuts: keymap.decline.clone(),
}),
})
@@ -851,6 +892,7 @@ pub(crate) fn format_additional_permissions_rule(
file_system
.entries
.iter()
.flatten()
.filter(|entry| entry.access == FileSystemAccessMode::Read),
);
if !reads.is_empty() {
@@ -860,6 +902,7 @@ pub(crate) fn format_additional_permissions_rule(
file_system
.entries
.iter()
.flatten()
.filter(|entry| entry.access == FileSystemAccessMode::Write),
);
if !writes.is_empty() {
@@ -869,6 +912,7 @@ pub(crate) fn format_additional_permissions_rule(
file_system
.entries
.iter()
.flatten()
.filter(|entry| entry.access == FileSystemAccessMode::None),
);
if !denied_reads.is_empty() {
@@ -885,7 +929,14 @@ pub(crate) fn format_additional_permissions_rule(
pub(crate) fn format_requested_permissions_rule(
permissions: &RequestPermissionProfile,
) -> Option<String> {
format_additional_permissions_rule(&permissions.clone().into())
let permissions =
crate::app_server_approval_conversions::granted_permission_profile_from_request(
permissions.clone(),
);
format_additional_permissions_rule(&AdditionalPermissionProfile {
network: permissions.network,
file_system: permissions.file_system,
})
}
fn format_file_system_entry_paths<'a>(
@@ -923,17 +974,17 @@ fn patch_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Yes, proceed".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Approved),
decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::Accept),
shortcuts: keymap.approve.clone(),
},
ApprovalOption {
label: "Yes, and don't ask again for these files".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession),
decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::AcceptForSession),
shortcuts: keymap.approve_for_session.clone(),
},
ApprovalOption {
label: "No, and tell Codex what to do differently".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Abort),
decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::Cancel),
shortcuts: keymap.decline.clone(),
},
]
@@ -1018,14 +1069,13 @@ fn elicitation_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
mod tests {
use super::*;
use crate::app_event::AppEvent;
use codex_app_server_protocol::AdditionalFileSystemPermissions;
use codex_app_server_protocol::AdditionalNetworkPermissions;
use codex_app_server_protocol::ExecPolicyAmendment;
use codex_app_server_protocol::NetworkApprovalProtocol;
use codex_app_server_protocol::NetworkPolicyAmendment;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::ExecPolicyAmendment;
use codex_protocol::protocol::NetworkApprovalProtocol;
use codex_protocol::protocol::NetworkPolicyAmendment;
use codex_utils_absolute_path::AbsolutePathBuf;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
@@ -1101,7 +1151,10 @@ mod tests {
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
reason: Some("reason".to_string()),
available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort],
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: None,
}
@@ -1174,7 +1227,7 @@ mod tests {
break;
}
}
assert_eq!(decision, Some(ReviewDecision::Abort));
assert_eq!(decision, Some(CommandExecutionApprovalDecision::Cancel));
}
#[test]
@@ -1237,7 +1290,10 @@ mod tests {
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
reason: None,
available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Denied],
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Decline,
],
network_approval_context: None,
additional_permissions: None,
},
@@ -1254,7 +1310,7 @@ mod tests {
..
} = ev
{
assert_eq!(decision, ReviewDecision::Denied);
assert_eq!(decision, CommandExecutionApprovalDecision::Decline);
saw_denied = true;
break;
}
@@ -1278,8 +1334,8 @@ mod tests {
command: vec!["curl".to_string(), "https://example.com".to_string()],
reason: None,
available_decisions: vec![
ReviewDecision::Approved,
ReviewDecision::NetworkPolicyAmendment {
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment: amendment.clone(),
},
],
@@ -1304,7 +1360,7 @@ mod tests {
{
assert_eq!(
decision,
ReviewDecision::NetworkPolicyAmendment {
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment: amendment
}
);
@@ -1351,7 +1407,10 @@ mod tests {
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
reason: None,
available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort],
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: None,
},
@@ -1382,7 +1441,10 @@ mod tests {
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
reason: None,
available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort],
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: None,
},
@@ -1417,7 +1479,10 @@ mod tests {
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
reason: None,
available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort],
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: None,
},
@@ -1443,13 +1508,13 @@ mod tests {
command: vec!["echo".to_string()],
reason: None,
available_decisions: vec![
ReviewDecision::Approved,
ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec![
"echo".to_string(),
]),
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
execpolicy_amendment: ExecPolicyAmendment {
command: vec!["echo".to_string()],
},
},
ReviewDecision::Abort,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: None,
@@ -1467,10 +1532,10 @@ mod tests {
{
assert_eq!(
decision,
ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec![
"echo".to_string()
])
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
execpolicy_amendment: ExecPolicyAmendment {
command: vec!["echo".to_string()],
}
}
);
saw_op = true;
@@ -1495,15 +1560,15 @@ mod tests {
command: vec!["curl".to_string(), "https://example.com".to_string()],
reason: None,
available_decisions: vec![
ReviewDecision::Approved,
ReviewDecision::ApprovedForSession,
ReviewDecision::NetworkPolicyAmendment {
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::AcceptForSession,
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment: NetworkPolicyAmendment {
host: "example.com".to_string(),
action: NetworkPolicyRuleAction::Allow,
},
},
ReviewDecision::Abort,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: Some(NetworkApprovalContext {
host: "example.com".to_string(),
@@ -1533,7 +1598,10 @@ mod tests {
id: "test".into(),
command,
reason: None,
available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort],
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: None,
};
@@ -1569,15 +1637,15 @@ mod tests {
let keymap = crate::keymap::RuntimeKeymap::defaults();
let options = exec_options(
&[
ReviewDecision::Approved,
ReviewDecision::ApprovedForSession,
ReviewDecision::NetworkPolicyAmendment {
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::AcceptForSession,
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment: NetworkPolicyAmendment {
host: "example.com".to_string(),
action: NetworkPolicyRuleAction::Allow,
},
},
ReviewDecision::Abort,
CommandExecutionApprovalDecision::Cancel,
],
Some(&network_context),
/*additional_permissions*/ None,
@@ -1601,9 +1669,9 @@ mod tests {
let keymap = crate::keymap::RuntimeKeymap::defaults();
let options = exec_options(
&[
ReviewDecision::Approved,
ReviewDecision::ApprovedForSession,
ReviewDecision::Abort,
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::AcceptForSession,
CommandExecutionApprovalDecision::Cancel,
],
/*network_approval_context*/ None,
/*additional_permissions*/ None,
@@ -1625,14 +1693,20 @@ mod tests {
fn additional_permissions_exec_options_hide_execpolicy_amendment() {
let keymap = crate::keymap::RuntimeKeymap::defaults();
let additional_permissions = AdditionalPermissionProfile {
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path("/tmp/readme.txt")]),
Some(vec![absolute_path("/tmp/out.txt")]),
)),
..Default::default()
network: None,
file_system: Some(
FileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path("/tmp/readme.txt")]),
Some(vec![absolute_path("/tmp/out.txt")]),
)
.into(),
),
};
let options = exec_options(
&[ReviewDecision::Approved, ReviewDecision::Abort],
&[
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
/*network_approval_context*/ None,
Some(&additional_permissions),
&keymap.approval,
@@ -1669,8 +1743,11 @@ mod tests {
#[test]
fn additional_permissions_rule_shows_non_path_file_system_entries() {
let additional_permissions = AdditionalPermissionProfile {
file_system: Some(FileSystemPermissions {
entries: vec![
network: None,
file_system: Some(AdditionalFileSystemPermissions {
read: None,
write: None,
entries: Some(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
@@ -1683,10 +1760,9 @@ mod tests {
},
access: FileSystemAccessMode::None,
},
],
]),
glob_scan_max_depth: None,
}),
..Default::default()
};
assert_eq!(
@@ -1795,16 +1871,22 @@ mod tests {
id: "test".into(),
command: vec!["cat".into(), "/tmp/readme.txt".into()],
reason: None,
available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort],
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: Some(AdditionalPermissionProfile {
network: Some(NetworkPermissions {
network: Some(AdditionalNetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path("/tmp/readme.txt")]),
Some(vec![absolute_path("/tmp/out.txt")]),
)),
file_system: Some(
FileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path("/tmp/readme.txt")]),
Some(vec![absolute_path("/tmp/out.txt")]),
)
.into(),
),
}),
};
@@ -1845,16 +1927,22 @@ mod tests {
id: "test".into(),
command: vec!["cat".into(), "/tmp/readme.txt".into()],
reason: Some("need filesystem access".into()),
available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort],
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: Some(AdditionalPermissionProfile {
network: Some(NetworkPermissions {
network: Some(AdditionalNetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path("/tmp/readme.txt")]),
Some(vec![absolute_path("/tmp/out.txt")]),
)),
file_system: Some(
FileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path("/tmp/readme.txt")]),
Some(vec![absolute_path("/tmp/out.txt")]),
)
.into(),
),
}),
};
@@ -1920,15 +2008,15 @@ mod tests {
command: vec!["curl".into(), "https://example.com".into()],
reason: Some("network request blocked".into()),
available_decisions: vec![
ReviewDecision::Approved,
ReviewDecision::ApprovedForSession,
ReviewDecision::NetworkPolicyAmendment {
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::AcceptForSession,
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment: NetworkPolicyAmendment {
host: "example.com".to_string(),
action: NetworkPolicyRuleAction::Allow,
},
},
ReviewDecision::Abort,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: Some(NetworkApprovalContext {
host: "example.com".to_string(),
@@ -2130,6 +2218,6 @@ mod tests {
break;
}
}
assert_eq!(decision, Some(ReviewDecision::Approved));
assert_eq!(decision, Some(CommandExecutionApprovalDecision::Accept));
}
}
@@ -5679,7 +5679,7 @@ mod tests {
dependencies: None,
policy: None,
path_to_skills_md: skill_path.clone(),
scope: codex_protocol::protocol::SkillScope::User,
scope: crate::test_support::skill_scope_user(),
}]));
let ActivePopup::Skill(popup) = &composer.active_popup else {
@@ -5721,7 +5721,7 @@ mod tests {
dependencies: None,
policy: None,
path_to_skills_md: skill_path.clone(),
scope: codex_protocol::protocol::SkillScope::Repo,
scope: crate::test_support::skill_scope_repo(),
}]));
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
config_name: "google-calendar@debug".to_string(),
@@ -5812,7 +5812,7 @@ mod tests {
dependencies: None,
policy: None,
path_to_skills_md: test_path_buf("/tmp/repo/google-calendar/SKILL.md").abs(),
scope: codex_protocol::protocol::SkillScope::Repo,
scope: crate::test_support::skill_scope_repo(),
}]));
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
config_name: "google-calendar@debug".to_string(),
@@ -15,11 +15,11 @@ use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use crate::app_command::AppCommand as Op;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::MentionBinding;
use crate::mention_codec::decode_history_mentions;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::TextElement;
/// A composer history entry that can rehydrate draft state.
@@ -598,10 +598,7 @@ impl ChatComposerHistory {
boundary_if_exhausted,
});
}
app_event_tx.send(AppEvent::CodexOp(Op::GetHistoryEntryRequest {
offset,
log_id,
}));
app_event_tx.send(AppEvent::CodexOp(Op::history_lookup(offset, log_id)));
return HistorySearchResult::Pending;
}
@@ -719,10 +716,7 @@ impl ChatComposerHistory {
self.last_history_text = Some(entry.text.clone());
return Some(entry);
} else if let Some(log_id) = self.history_log_id {
app_event_tx.send(AppEvent::CodexOp(Op::GetHistoryEntryRequest {
offset: global_idx,
log_id,
}));
app_event_tx.send(AppEvent::CodexOp(Op::history_lookup(global_idx, log_id)));
}
None
}
@@ -2,6 +2,8 @@ use std::collections::HashSet;
use std::collections::VecDeque;
use std::path::PathBuf;
#[cfg(test)]
use crate::app_command::AppCommand as Op;
use codex_app_server_protocol::McpElicitationEnumSchema;
use codex_app_server_protocol::McpElicitationPrimitiveSchema;
use codex_app_server_protocol::McpElicitationSingleSelectEnumSchema;
@@ -12,8 +14,6 @@ use codex_protocol::approvals::ElicitationAction;
use codex_protocol::approvals::ElicitationRequest;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::mcp::RequestId as McpRequestId;
#[cfg(test)]
use codex_protocol::protocol::Op;
use codex_protocol::user_input::TextElement;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
+9 -7
View File
@@ -1567,14 +1567,13 @@ impl Renderable for BottomPane {
mod tests {
use super::*;
use crate::app::app_server_requests::ResolvedAppServerRequest;
use crate::app_command::AppCommand as Op;
use crate::app_event::AppEvent;
use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES;
use crate::status_indicator_widget::StatusDetailsCapitalization;
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SkillScope;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -1634,8 +1633,8 @@ mod tests {
command: vec!["echo".into(), "ok".into()],
reason: None,
available_decisions: vec![
codex_protocol::protocol::ReviewDecision::Approved,
codex_protocol::protocol::ReviewDecision::Abort,
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: None,
@@ -1897,7 +1896,10 @@ mod tests {
approval_decision = Some(decision);
}
}
assert_eq!(approval_decision, Some(ReviewDecision::Approved));
assert_eq!(
approval_decision,
Some(CommandExecutionApprovalDecision::Accept)
);
}
#[test]
@@ -2354,7 +2356,7 @@ mod tests {
dependencies: None,
policy: None,
path_to_skills_md: test_path_buf("/tmp/test-skill/SKILL.md").abs(),
scope: SkillScope::User,
scope: crate::test_support::skill_scope_user(),
}]),
});
@@ -32,7 +32,7 @@ use crate::history_cell;
use crate::render::renderable::Renderable;
#[cfg(test)]
use codex_protocol::protocol::Op;
use crate::app_command::AppCommand as Op;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::request_user_input::RequestUserInputResponse;
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -1,14 +1,14 @@
use std::collections::VecDeque;
use crate::app::app_server_requests::ResolvedAppServerRequest;
use crate::approval_events::ApplyPatchApprovalRequestEvent;
use crate::approval_events::ExecApprovalRequestEvent;
use crate::tool_activity::ExecCommandBeginEvent;
use crate::tool_activity::ExecCommandEndEvent;
use crate::tool_activity::McpToolCallBeginEvent;
use crate::tool_activity::McpToolCallEndEvent;
use crate::tool_activity::PatchApplyEndEvent;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::ExecApprovalRequestEvent;
use codex_protocol::protocol::ExecCommandBeginEvent;
use codex_protocol::protocol::ExecCommandEndEvent;
use codex_protocol::protocol::McpToolCallBeginEvent;
use codex_protocol::protocol::McpToolCallEndEvent;
use codex_protocol::protocol::PatchApplyEndEvent;
use codex_protocol::request_permissions::RequestPermissionsEvent;
use codex_protocol::request_user_input::RequestUserInputEvent;
@@ -148,9 +148,9 @@ impl QueuedInterrupt {
#[cfg(test)]
mod tests {
use codex_protocol::approvals::ExecApprovalRequestEvent;
use codex_protocol::protocol::ExecCommandBeginEvent;
use codex_protocol::protocol::ExecCommandSource;
use crate::approval_events::ExecApprovalRequestEvent;
use crate::tool_activity::ExecCommandBeginEvent;
use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
+275
View File
@@ -0,0 +1,275 @@
//! MCP startup state and status handling for the chat widget.
//!
//! The app server reports MCP server startup as per-server status updates. This
//! module keeps the TUI's buffered startup round state coherent and translates
//! those updates into status headers, warnings, and queued-input release points.
use std::collections::BTreeSet;
use codex_app_server_protocol::McpServerStartupState;
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
use serde::Deserialize;
use serde::Serialize;
use super::ChatWidget;
#[cfg(test)]
use super::test_events::McpStartupCompleteEvent;
#[cfg(test)]
use super::test_events::McpStartupUpdateEvent;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "state")]
pub(crate) enum McpStartupStatus {
Starting,
Ready,
Failed { error: String },
Cancelled,
}
impl ChatWidget {
/// Record one MCP startup update, promoting it into either the active startup
/// round or a buffered "next" round.
///
/// This path has to deal with lossy app-server delivery. After
/// `finish_mcp_startup()` or `finish_mcp_startup_after_lag()`, we briefly
/// ignore incoming updates so stale events from the just-finished round do not
/// reopen startup. While that guard is active we buffer updates for a possible
/// next round, and only reactivate once the buffered set is coherent enough to
/// treat as a fresh startup round.
fn update_mcp_startup_status(
&mut self,
server: String,
status: McpStartupStatus,
complete_when_settled: bool,
) {
let mut activated_pending_round = false;
let startup_status = if self.mcp_startup_ignore_updates_until_next_start {
// Ignore-mode buffers the next plausible round so stale post-finish
// updates cannot immediately reopen startup. A fresh `Starting`
// update resets the buffer only if we have not already seen a
// pending-round `Starting`; this preserves valid interleavings like
// `alpha: Starting -> alpha: Ready -> beta: Starting`.
if matches!(status, McpStartupStatus::Starting)
&& !self.mcp_startup_pending_next_round_saw_starting
{
self.mcp_startup_pending_next_round.clear();
self.mcp_startup_allow_terminal_only_next_round = false;
}
self.mcp_startup_pending_next_round_saw_starting |=
matches!(status, McpStartupStatus::Starting);
self.mcp_startup_pending_next_round.insert(server, status);
let Some(expected_servers) = &self.mcp_startup_expected_servers else {
return;
};
let saw_full_round = expected_servers.is_empty()
|| expected_servers
.iter()
.all(|name| self.mcp_startup_pending_next_round.contains_key(name));
let saw_starting = self
.mcp_startup_pending_next_round
.values()
.any(|state| matches!(state, McpStartupStatus::Starting));
if !(saw_full_round
&& (saw_starting || self.mcp_startup_allow_terminal_only_next_round))
{
return;
}
// The buffered map now looks like a complete next round, so promote it
// to the active round and resume normal completion tracking.
self.mcp_startup_ignore_updates_until_next_start = false;
self.mcp_startup_allow_terminal_only_next_round = false;
self.mcp_startup_pending_next_round_saw_starting = false;
activated_pending_round = true;
std::mem::take(&mut self.mcp_startup_pending_next_round)
} else {
// Normal path: fold the update into the active round and surface
// per-server failures immediately.
let mut startup_status = self.mcp_startup_status.take().unwrap_or_default();
if let McpStartupStatus::Failed { error } = &status {
self.on_warning(error);
}
startup_status.insert(server, status);
startup_status
};
if activated_pending_round {
// A promoted buffered round may already contain terminal failures.
for state in startup_status.values() {
if let McpStartupStatus::Failed { error } = state {
self.on_warning(error);
}
}
}
self.mcp_startup_status = Some(startup_status);
self.update_task_running_state();
// App-server-backed startup completes when every expected server has
// reported a non-Starting status. Lag handling can force an earlier
// settle via `finish_mcp_startup_after_lag()`.
if complete_when_settled
&& let Some(current) = &self.mcp_startup_status
&& let Some(expected_servers) = &self.mcp_startup_expected_servers
&& !current.is_empty()
&& expected_servers
.iter()
.all(|name| current.contains_key(name))
&& current
.values()
.all(|state| !matches!(state, McpStartupStatus::Starting))
{
let mut failed = Vec::new();
let mut cancelled = Vec::new();
for (name, state) in current {
match state {
McpStartupStatus::Ready => {}
McpStartupStatus::Failed { .. } => failed.push(name.clone()),
McpStartupStatus::Cancelled => cancelled.push(name.clone()),
McpStartupStatus::Starting => {}
}
}
failed.sort();
cancelled.sort();
self.finish_mcp_startup(failed, cancelled);
return;
}
if let Some(current) = &self.mcp_startup_status {
// Otherwise keep the status header focused on the remaining
// in-progress servers for the active round.
let total = current.len();
let mut starting: Vec<_> = current
.iter()
.filter_map(|(name, state)| {
if matches!(state, McpStartupStatus::Starting) {
Some(name)
} else {
None
}
})
.collect();
starting.sort();
if let Some(first) = starting.first() {
let completed = total.saturating_sub(starting.len());
let max_to_show = 3;
let mut to_show: Vec<String> = starting
.iter()
.take(max_to_show)
.map(ToString::to_string)
.collect();
if starting.len() > max_to_show {
to_show.push("…".to_string());
}
let header = if total > 1 {
format!(
"Starting MCP servers ({completed}/{total}): {}",
to_show.join(", ")
)
} else {
format!("Booting MCP server: {first}")
};
self.set_status_header(header);
}
}
self.request_redraw();
}
pub(crate) fn set_mcp_startup_expected_servers<I>(&mut self, server_names: I)
where
I: IntoIterator<Item = String>,
{
self.mcp_startup_expected_servers = Some(server_names.into_iter().collect());
}
#[cfg(test)]
pub(super) fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) {
self.update_mcp_startup_status(ev.server, ev.status, /*complete_when_settled*/ false);
}
pub(super) fn finish_mcp_startup(&mut self, failed: Vec<String>, cancelled: Vec<String>) {
if !cancelled.is_empty() {
self.on_warning(format!(
"MCP startup interrupted. The following servers were not initialized: {}",
cancelled.join(", ")
));
}
let mut parts = Vec::new();
if !failed.is_empty() {
parts.push(format!("failed: {}", failed.join(", ")));
}
if !parts.is_empty() {
self.on_warning(format!("MCP startup incomplete ({})", parts.join("; ")));
}
self.mcp_startup_status = None;
self.mcp_startup_ignore_updates_until_next_start = true;
self.mcp_startup_allow_terminal_only_next_round = false;
self.mcp_startup_pending_next_round.clear();
self.mcp_startup_pending_next_round_saw_starting = false;
self.update_task_running_state();
self.maybe_send_next_queued_input();
self.request_redraw();
}
pub(crate) fn finish_mcp_startup_after_lag(&mut self) {
if self.mcp_startup_ignore_updates_until_next_start {
if self.mcp_startup_pending_next_round.is_empty() {
self.mcp_startup_pending_next_round_saw_starting = false;
}
self.mcp_startup_allow_terminal_only_next_round = true;
}
let Some(current) = &self.mcp_startup_status else {
return;
};
let mut failed = Vec::new();
let mut cancelled = Vec::new();
let mut server_names: BTreeSet<String> = current.keys().cloned().collect();
if let Some(expected_servers) = &self.mcp_startup_expected_servers {
server_names.extend(expected_servers.iter().cloned());
}
for name in server_names {
match current.get(&name) {
Some(McpStartupStatus::Ready) => {}
Some(McpStartupStatus::Failed { .. }) => failed.push(name),
Some(McpStartupStatus::Cancelled | McpStartupStatus::Starting) | None => {
cancelled.push(name);
}
}
}
failed.sort();
failed.dedup();
cancelled.sort();
cancelled.dedup();
self.finish_mcp_startup(failed, cancelled);
}
#[cfg(test)]
pub(super) fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) {
let failed = ev.failed.into_iter().map(|f| f.server).collect();
self.finish_mcp_startup(failed, ev.cancelled);
}
pub(super) fn on_mcp_server_status_updated(
&mut self,
notification: McpServerStatusUpdatedNotification,
) {
let status = match notification.status {
McpServerStartupState::Starting => McpStartupStatus::Starting,
McpServerStartupState::Ready => McpStartupStatus::Ready,
McpServerStartupState::Failed => McpStartupStatus::Failed {
error: notification.error.unwrap_or_else(|| {
format!("MCP client for `{}` failed to start", notification.name)
}),
},
McpServerStartupState::Cancelled => McpStartupStatus::Cancelled,
};
self.update_mcp_startup_status(
notification.name,
status,
/*complete_when_settled*/ true,
);
}
}
+30 -177
View File
@@ -1,13 +1,11 @@
use super::*;
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
use codex_app_server_protocol::ThreadRealtimeClosedNotification;
use codex_app_server_protocol::ThreadRealtimeErrorNotification;
use codex_app_server_protocol::ThreadRealtimeOutputAudioDeltaNotification;
use codex_app_server_protocol::ThreadRealtimeStartTransport;
use codex_app_server_protocol::ThreadRealtimeStartedNotification;
use codex_config::config_toml::RealtimeTransport;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::ConversationStartTransport;
use codex_protocol::protocol::RealtimeAudioFrame;
use codex_protocol::protocol::RealtimeConversationClosedEvent;
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
use codex_protocol::protocol::RealtimeConversationStartedEvent;
use codex_protocol::protocol::RealtimeEvent;
use codex_protocol::protocol::RealtimeOutputModality;
use codex_realtime_webrtc::RealtimeWebrtcEvent;
use codex_realtime_webrtc::RealtimeWebrtcSession;
use codex_realtime_webrtc::RealtimeWebrtcSessionHandle;
@@ -66,118 +64,7 @@ impl RealtimeConversationUiState {
}
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct RenderedUserMessageEvent {
pub(super) message: String,
pub(super) remote_image_urls: Vec<String>,
pub(super) local_images: Vec<PathBuf>,
pub(super) text_elements: Vec<TextElement>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct PendingSteerCompareKey {
pub(super) message: String,
pub(super) image_count: usize,
}
impl ChatWidget {
pub(super) fn rendered_user_message_event_from_parts(
message: String,
text_elements: Vec<TextElement>,
local_images: Vec<PathBuf>,
remote_image_urls: Vec<String>,
) -> RenderedUserMessageEvent {
RenderedUserMessageEvent {
message,
remote_image_urls,
local_images,
text_elements,
}
}
pub(super) fn rendered_user_message_event_from_event(
event: &UserMessageEvent,
) -> RenderedUserMessageEvent {
Self::rendered_user_message_event_from_parts(
event.message.clone(),
event.text_elements.clone(),
event.local_images.clone(),
event.images.clone().unwrap_or_default(),
)
}
/// Build the compare key for a submitted pending steer without invoking the
/// expensive request-serialization path. Pending steers only need to match the
/// committed `ItemCompleted(UserMessage)` emitted after core drains input, which
/// preserves flattened text and total image count but not UI-only text ranges or
/// local image paths.
pub(super) fn pending_steer_compare_key_from_items(
items: &[UserInput],
) -> PendingSteerCompareKey {
let mut message = String::new();
let mut image_count = 0;
for item in items {
match item {
UserInput::Text { text, .. } => message.push_str(text),
UserInput::Image { .. } | UserInput::LocalImage { .. } => image_count += 1,
UserInput::Skill { .. } | UserInput::Mention { .. } => {}
_ => {}
}
}
PendingSteerCompareKey {
message,
image_count,
}
}
pub(super) fn pending_steer_compare_key_from_item(
item: &codex_protocol::items::UserMessageItem,
) -> PendingSteerCompareKey {
Self::pending_steer_compare_key_from_items(&item.content)
}
#[cfg(test)]
pub(super) fn rendered_user_message_event_from_inputs(
items: &[UserInput],
) -> RenderedUserMessageEvent {
let mut message = String::new();
let mut remote_image_urls = Vec::new();
let mut local_images = Vec::new();
let mut text_elements = Vec::new();
for item in items {
match item {
UserInput::Text {
text,
text_elements: current_text_elements,
} => append_text_with_rebased_elements(
&mut message,
&mut text_elements,
text,
current_text_elements.iter().map(|element| {
TextElement::new(
element.byte_range,
element.placeholder(text).map(str::to_string),
)
}),
),
UserInput::Image { image_url } => remote_image_urls.push(image_url.clone()),
UserInput::LocalImage { path } => local_images.push(path.clone()),
UserInput::Skill { .. } | UserInput::Mention { .. } => {}
_ => {}
}
}
Self::rendered_user_message_event_from_parts(
message,
text_elements,
local_images,
remote_image_urls,
)
}
#[cfg(test)]
pub(super) fn should_render_realtime_user_message_event(
&self,
@@ -232,16 +119,14 @@ impl ChatWidget {
fn submit_realtime_conversation_start(
&mut self,
transport: Option<ConversationStartTransport>,
transport: Option<ThreadRealtimeStartTransport>,
) {
self.submit_op(AppCommand::realtime_conversation_start(
ConversationStartParams {
output_modality: RealtimeOutputModality::Audio,
prompt: None,
session_id: None,
transport,
voice: self.config.realtime.voice,
},
transport,
self.config
.realtime
.voice
.and_then(|voice| serde_json::to_value(voice).ok()),
));
}
@@ -289,13 +174,13 @@ impl ChatWidget {
pub(super) fn on_realtime_conversation_started(
&mut self,
ev: RealtimeConversationStartedEvent,
notification: ThreadRealtimeStartedNotification,
) {
if !self.realtime_conversation_enabled() {
self.request_realtime_conversation_close(/*info_message*/ None);
return;
}
self.realtime_conversation.session_id = ev.session_id;
self.realtime_conversation.session_id = notification.session_id;
self.set_footer_hint_override(Some(Self::realtime_footer_hint_items()));
if self.realtime_conversation_uses_webrtc() {
self.realtime_conversation.phase = RealtimeConversationPhase::Starting;
@@ -306,55 +191,33 @@ impl ChatWidget {
self.request_redraw();
}
pub(super) fn on_realtime_conversation_realtime(
pub(super) fn on_realtime_output_audio_delta(
&mut self,
ev: RealtimeConversationRealtimeEvent,
notification: ThreadRealtimeOutputAudioDeltaNotification,
) {
if self.realtime_conversation_uses_webrtc()
&& matches!(
ev.payload,
RealtimeEvent::AudioOut(_)
| RealtimeEvent::InputAudioSpeechStarted(_)
| RealtimeEvent::ResponseCreated(_)
| RealtimeEvent::ResponseCancelled(_)
| RealtimeEvent::ResponseDone(_)
)
{
if self.realtime_conversation_uses_webrtc() {
return;
}
match ev.payload {
RealtimeEvent::SessionUpdated { session_id, .. } => {
self.realtime_conversation.session_id = Some(session_id);
}
RealtimeEvent::InputAudioSpeechStarted(_) => self.interrupt_realtime_audio_playback(),
RealtimeEvent::InputTranscriptDelta(_) => {}
RealtimeEvent::InputTranscriptDone(_) => {}
RealtimeEvent::OutputTranscriptDelta(_) => {}
RealtimeEvent::OutputTranscriptDone(_) => {}
RealtimeEvent::AudioOut(frame) => self.enqueue_realtime_audio_out(&frame),
RealtimeEvent::ResponseCreated(_) => {}
RealtimeEvent::ResponseCancelled(_) => self.interrupt_realtime_audio_playback(),
RealtimeEvent::ResponseDone(_) => {}
RealtimeEvent::ConversationItemAdded(_item) => {}
RealtimeEvent::ConversationItemDone { .. } => {}
RealtimeEvent::HandoffRequested(_) => {}
RealtimeEvent::NoopRequested(_) => {}
RealtimeEvent::Error(message) => {
self.fail_realtime_conversation(format!("Realtime voice error: {message}"));
}
}
self.enqueue_realtime_audio_out(&notification.audio);
}
pub(super) fn on_realtime_conversation_closed(&mut self, ev: RealtimeConversationClosedEvent) {
pub(super) fn on_realtime_error(&mut self, notification: ThreadRealtimeErrorNotification) {
self.fail_realtime_conversation(format!("Realtime voice error: {}", notification.message));
}
pub(super) fn on_realtime_conversation_closed(
&mut self,
notification: ThreadRealtimeClosedNotification,
) {
if self.realtime_conversation_uses_webrtc()
&& self.realtime_conversation.is_live()
&& ev.reason.as_deref() == Some("transport_closed")
&& notification.reason.as_deref() == Some("transport_closed")
{
return;
}
let requested = self.realtime_conversation.requested_close;
let reason = ev.reason;
let reason = notification.reason;
self.reset_realtime_conversation_state();
if !requested
&& let Some(reason) = reason
@@ -405,7 +268,7 @@ impl ChatWidget {
self.realtime_conversation.transport = RealtimeConversationUiTransport::Webrtc {
handle: Some(offer.handle),
};
self.submit_realtime_conversation_start(Some(ConversationStartTransport::Webrtc {
self.submit_realtime_conversation_start(Some(ThreadRealtimeStartTransport::Webrtc {
sdp: offer.offer_sdp,
}));
self.request_redraw();
@@ -477,7 +340,7 @@ impl ChatWidget {
}
}
fn enqueue_realtime_audio_out(&mut self, frame: &RealtimeAudioFrame) {
fn enqueue_realtime_audio_out(&mut self, frame: &ThreadRealtimeAudioChunk) {
#[cfg(not(target_os = "linux"))]
{
if self.realtime_conversation.audio_player.is_none() {
@@ -496,16 +359,6 @@ impl ChatWidget {
}
}
#[cfg(not(target_os = "linux"))]
fn interrupt_realtime_audio_playback(&mut self) {
if let Some(player) = &self.realtime_conversation.audio_player {
player.clear();
}
}
#[cfg(target_os = "linux")]
fn interrupt_realtime_audio_playback(&mut self) {}
#[cfg(not(target_os = "linux"))]
fn start_realtime_local_audio(&mut self) {
if self.realtime_conversation.capture_stop_flag.is_some() {
+8 -6
View File
@@ -11,14 +11,14 @@ use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::skills_helpers::skill_description;
use crate::skills_helpers::skill_display_name;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::SkillMetadata as ProtocolSkillMetadata;
use codex_app_server_protocol::SkillsListEntry;
use codex_app_server_protocol::SkillsListResponse;
use codex_core_skills::model::SkillDependencies;
use codex_core_skills::model::SkillInterface;
use codex_core_skills::model::SkillMetadata;
use codex_core_skills::model::SkillToolDependency;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::ListSkillsResponseEvent;
use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata;
use codex_protocol::protocol::SkillsListEntry;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;
@@ -135,8 +135,8 @@ impl ChatWidget {
);
}
pub(crate) fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) {
let skills = skills_for_cwd(&self.config.cwd, &response.skills);
pub(crate) fn set_skills_from_response(&mut self, response: &SkillsListResponse) {
let skills = skills_for_cwd(&self.config.cwd, &response.data);
self.skills_all = skills;
self.set_skills(Some(enabled_skills_for_mentions(&self.skills_all)));
}
@@ -222,7 +222,9 @@ fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata {
}),
policy: None,
path_to_skills_md: skill.path.clone(),
scope: skill.scope,
scope: serde_json::to_value(skill.scope)
.and_then(serde_json::from_value)
.unwrap_or_else(|err| panic!("skill scope should map to core skill scope: {err}")),
}
}
@@ -409,8 +409,8 @@ impl ChatWidget {
SlashCommand::TestApproval => {
use std::collections::HashMap;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::FileChange;
use crate::approval_events::ApplyPatchApprovalRequestEvent;
use crate::diff_model::FileChange;
self.on_apply_patch_approval_request(
"1".to_string(),
@@ -705,9 +705,8 @@ impl ChatWidget {
self.request_side_conversation(parent_thread_id, Some(user_message));
}
SlashCommand::Review if !trimmed.is_empty() => {
self.submit_op(AppCommand::review(ReviewRequest {
target: ReviewTarget::Custom { instructions: args },
user_facing_hint: None,
self.submit_op(AppCommand::review(ReviewTarget::Custom {
instructions: args,
}));
}
SlashCommand::Resume if !trimmed.is_empty() => {
+331
View File
@@ -0,0 +1,331 @@
//! Test-only event fixtures for legacy-style ChatWidget coverage.
#![allow(dead_code)]
use std::collections::HashMap;
use std::path::PathBuf;
use crate::approval_events::ApplyPatchApprovalRequestEvent;
use crate::approval_events::ExecApprovalRequestEvent;
use crate::session_state::SessionNetworkProxyRuntime;
use crate::session_state::ThreadSessionState;
use crate::token_usage::TokenCountEvent;
use crate::tool_activity::ExecCommandBeginEvent;
use crate::tool_activity::ExecCommandEndEvent;
use crate::tool_activity::ExecCommandOutputDeltaEvent;
use crate::tool_activity::HookCompletedEvent;
use crate::tool_activity::HookStartedEvent;
use crate::tool_activity::ImageGenerationBeginEvent;
use crate::tool_activity::ImageGenerationEndEvent;
use crate::tool_activity::McpToolCallBeginEvent;
use crate::tool_activity::McpToolCallEndEvent;
use crate::tool_activity::PatchApplyBeginEvent;
use crate::tool_activity::PatchApplyEndEvent;
use crate::tool_activity::TerminalInteractionEvent;
use crate::tool_activity::ViewImageToolCallEvent;
use crate::tool_activity::WebSearchBeginEvent;
use crate::tool_activity::WebSearchEndEvent;
use crate::turn_state::TurnAbortReason;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::CodexErrorInfo;
use codex_app_server_protocol::McpAuthStatus;
use codex_app_server_protocol::ModelVerification;
use codex_protocol::ThreadId;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::approvals::GuardianAssessmentEvent;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::items::TurnItem;
use codex_protocol::mcp::Resource as McpResource;
use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate;
use codex_protocol::mcp::Tool as McpTool;
use codex_protocol::memory_citation::MemoryCitation;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::request_permissions::RequestPermissionsEvent;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use super::mcp_startup::McpStartupStatus;
use super::user_messages::UserMessageEvent;
#[derive(Debug, Clone)]
pub(crate) struct Event {
pub(crate) id: String,
pub(crate) msg: EventMsg,
}
#[derive(Debug, Clone)]
pub(crate) struct SessionConfiguredEvent {
pub(crate) session_id: ThreadId,
pub(crate) forked_from_id: Option<ThreadId>,
pub(crate) thread_name: Option<String>,
pub(crate) model: String,
#[allow(dead_code)]
pub(crate) model_provider_id: String,
pub(crate) service_tier: Option<ServiceTier>,
pub(crate) approval_policy: AskForApproval,
pub(crate) approvals_reviewer: ApprovalsReviewer,
pub(crate) permission_profile: PermissionProfile,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) reasoning_effort: Option<ReasoningEffortConfig>,
pub(crate) history_log_id: u64,
pub(crate) history_entry_count: usize,
pub(crate) initial_messages: Option<Vec<EventMsg>>,
pub(crate) network_proxy: Option<SessionNetworkProxyRuntime>,
pub(crate) rollout_path: Option<PathBuf>,
}
impl SessionConfiguredEvent {
pub(crate) fn into_session(self) -> (ThreadSessionState, Option<Vec<EventMsg>>) {
(
ThreadSessionState {
thread_id: self.session_id,
forked_from_id: self.forked_from_id,
fork_parent_title: None,
thread_name: self.thread_name,
model: self.model,
model_provider_id: self.model_provider_id,
service_tier: self.service_tier,
approval_policy: self.approval_policy,
approvals_reviewer: self.approvals_reviewer,
permission_profile: self.permission_profile,
cwd: self.cwd,
instruction_source_paths: Vec::new(),
reasoning_effort: self.reasoning_effort,
history_log_id: self.history_log_id,
history_entry_count: u64::try_from(self.history_entry_count).unwrap_or(u64::MAX),
network_proxy: self.network_proxy,
rollout_path: self.rollout_path,
},
self.initial_messages,
)
}
}
#[derive(Debug, Clone)]
#[allow(clippy::large_enum_variant)]
pub(crate) enum EventMsg {
Error(ErrorEvent),
Warning(WarningEvent),
GuardianWarning(WarningEvent),
ModelVerification(ModelVerificationEvent),
ThreadRolledBack(ThreadRolledBackEvent),
TurnStarted(TurnStartedEvent),
TurnComplete(TurnCompleteEvent),
TokenCount(TokenCountEvent),
AgentMessage(AgentMessageEvent),
UserMessage(UserMessageEvent),
AgentMessageDelta(AgentMessageDeltaEvent),
AgentReasoning(AgentReasoningEvent),
AgentReasoningDelta(AgentReasoningDeltaEvent),
AgentReasoningRawContent(AgentReasoningRawContentEvent),
AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent),
SessionConfigured(SessionConfiguredEvent),
ThreadNameUpdated(ThreadNameUpdatedEvent),
McpStartupUpdate(McpStartupUpdateEvent),
McpStartupComplete(McpStartupCompleteEvent),
McpToolCallBegin(McpToolCallBeginEvent),
McpToolCallEnd(McpToolCallEndEvent),
WebSearchBegin(WebSearchBeginEvent),
WebSearchEnd(WebSearchEndEvent),
ImageGenerationBegin(ImageGenerationBeginEvent),
ImageGenerationEnd(ImageGenerationEndEvent),
ExecCommandBegin(ExecCommandBeginEvent),
ExecCommandOutputDelta(ExecCommandOutputDeltaEvent),
TerminalInteraction(TerminalInteractionEvent),
ExecCommandEnd(ExecCommandEndEvent),
ViewImageToolCall(ViewImageToolCallEvent),
ExecApprovalRequest(ExecApprovalRequestEvent),
RequestPermissions(RequestPermissionsEvent),
RequestUserInput(RequestUserInputEvent),
ElicitationRequest(ElicitationRequestEvent),
ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent),
GuardianAssessment(GuardianAssessmentEvent),
DeprecationNotice(DeprecationNoticeEvent),
BackgroundEvent(BackgroundEventEvent),
UndoStarted(UndoStartedEvent),
UndoCompleted(UndoCompletedEvent),
StreamError(StreamErrorEvent),
PatchApplyBegin(PatchApplyBeginEvent),
PatchApplyEnd(PatchApplyEndEvent),
TurnDiff(TurnDiffEvent),
McpListToolsResponse(McpListToolsResponseEvent),
ListSkillsResponse(codex_app_server_protocol::SkillsListResponse),
SkillsUpdateAvailable,
PlanUpdate(UpdatePlanArgs),
TurnAborted(TurnAbortedEvent),
ShutdownComplete,
EnteredReviewMode(String),
ExitedReviewMode(ExitedReviewModeEvent),
ItemCompleted(ItemCompletedEvent),
HookStarted(HookStartedEvent),
HookCompleted(HookCompletedEvent),
PlanDelta(PlanDeltaEvent),
}
#[derive(Debug, Clone)]
pub(crate) struct ErrorEvent {
pub(crate) message: String,
pub(crate) codex_error_info: Option<CodexErrorInfo>,
}
#[derive(Debug, Clone)]
pub(crate) struct WarningEvent {
pub(crate) message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ModelVerificationEvent {
pub(crate) verifications: Vec<ModelVerification>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct TurnCompleteEvent {
pub(crate) turn_id: String,
pub(crate) last_agent_message: Option<String>,
pub(crate) completed_at: Option<i64>,
pub(crate) duration_ms: Option<i64>,
pub(crate) time_to_first_token_ms: Option<i64>,
}
#[derive(Debug, Clone)]
pub(crate) struct TurnStartedEvent {
pub(crate) turn_id: String,
pub(crate) started_at: Option<i64>,
pub(crate) model_context_window: Option<i64>,
pub(crate) collaboration_mode_kind: codex_protocol::config_types::ModeKind,
}
#[derive(Debug, Clone)]
pub(crate) struct AgentMessageEvent {
pub(crate) message: String,
pub(crate) phase: Option<MessagePhase>,
pub(crate) memory_citation: Option<MemoryCitation>,
}
#[derive(Debug, Clone)]
pub(crate) struct AgentMessageDeltaEvent {
pub(crate) delta: String,
}
#[derive(Debug, Clone)]
pub(crate) struct AgentReasoningEvent {
pub(crate) text: String,
}
#[derive(Debug, Clone)]
pub(crate) struct AgentReasoningRawContentEvent {
pub(crate) text: String,
}
#[derive(Debug, Clone)]
pub(crate) struct AgentReasoningRawContentDeltaEvent {
pub(crate) delta: String,
}
#[derive(Debug, Clone)]
pub(crate) struct AgentReasoningDeltaEvent {
pub(crate) delta: String,
}
#[derive(Debug, Clone)]
pub(crate) struct BackgroundEventEvent {
pub(crate) message: String,
}
#[derive(Debug, Clone)]
pub(crate) struct DeprecationNoticeEvent {
pub(crate) summary: String,
pub(crate) details: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct UndoStartedEvent {
pub(crate) message: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct UndoCompletedEvent {
pub(crate) success: bool,
pub(crate) message: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct ThreadRolledBackEvent {
pub(crate) num_turns: u32,
}
#[derive(Debug, Clone)]
pub(crate) struct StreamErrorEvent {
pub(crate) message: String,
pub(crate) codex_error_info: Option<CodexErrorInfo>,
pub(crate) additional_details: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct TurnDiffEvent {
pub(crate) unified_diff: String,
}
#[derive(Debug, Clone)]
pub(crate) struct McpListToolsResponseEvent {
pub(crate) tools: HashMap<String, McpTool>,
pub(crate) resources: HashMap<String, Vec<McpResource>>,
pub(crate) resource_templates: HashMap<String, Vec<McpResourceTemplate>>,
pub(crate) auth_statuses: HashMap<String, McpAuthStatus>,
}
#[derive(Debug, Clone)]
pub(crate) struct McpStartupUpdateEvent {
pub(crate) server: String,
pub(crate) status: McpStartupStatus,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct McpStartupCompleteEvent {
pub(crate) ready: Vec<String>,
pub(crate) failed: Vec<McpStartupFailure>,
pub(crate) cancelled: Vec<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct McpStartupFailure {
pub(crate) server: String,
pub(crate) error: String,
}
#[derive(Debug, Clone)]
pub(crate) struct ThreadNameUpdatedEvent {
pub(crate) thread_id: ThreadId,
pub(crate) thread_name: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct TurnAbortedEvent {
pub(crate) turn_id: Option<String>,
pub(crate) reason: TurnAbortReason,
pub(crate) completed_at: Option<i64>,
pub(crate) duration_ms: Option<i64>,
}
#[derive(Debug, Clone)]
pub(crate) struct ItemCompletedEvent {
pub(crate) thread_id: ThreadId,
pub(crate) turn_id: String,
pub(crate) item: TurnItem,
}
#[derive(Debug, Clone)]
pub(crate) struct PlanDeltaEvent {
pub(crate) thread_id: String,
pub(crate) turn_id: String,
pub(crate) item_id: String,
pub(crate) delta: String,
}
#[derive(Debug, Clone)]
pub(crate) struct ExitedReviewModeEvent;
+60 -67
View File
@@ -1,19 +1,45 @@
//! Exercises `ChatWidget` event handling and rendering invariants.
//!
//! These tests treat the widget as the adapter between `codex_protocol::protocol::EventMsg` inputs and
//! the TUI output. Many assertions are snapshot-based so that layout regressions and status/header
//! changes show up as stable, reviewable diffs.
//! These tests cover both app-server-native inputs and focused widget helpers. Many assertions are
//! snapshot-based so that layout regressions and status/header changes show up as stable,
//! reviewable diffs.
pub(super) use super::mcp_startup::McpStartupStatus;
pub(super) use super::*;
pub(super) use crate::app_command::AppCommand as Op;
pub(super) use crate::app_event::AppEvent;
pub(super) use crate::app_event::ExitMode;
#[cfg(not(target_os = "linux"))]
pub(super) use crate::app_event::RealtimeAudioDeviceKind;
pub(super) use crate::app_event_sender::AppEventSender;
pub(super) use crate::approval_events::ApplyPatchApprovalRequestEvent;
pub(super) use crate::approval_events::ExecApprovalRequestEvent;
pub(super) use crate::bottom_pane::LocalImageAttachment;
pub(super) use crate::bottom_pane::MentionBinding;
pub(super) use crate::bottom_pane::QueuedInputAction;
pub(super) use crate::chatwidget::realtime::RealtimeConversationPhase;
pub(super) use crate::chatwidget::test_events::AgentMessageDeltaEvent;
pub(super) use crate::chatwidget::test_events::AgentMessageEvent;
pub(super) use crate::chatwidget::test_events::AgentReasoningDeltaEvent;
pub(super) use crate::chatwidget::test_events::AgentReasoningEvent;
pub(super) use crate::chatwidget::test_events::BackgroundEventEvent;
pub(super) use crate::chatwidget::test_events::ErrorEvent;
pub(super) use crate::chatwidget::test_events::Event;
pub(super) use crate::chatwidget::test_events::EventMsg;
pub(super) use crate::chatwidget::test_events::ExitedReviewModeEvent;
pub(super) use crate::chatwidget::test_events::ItemCompletedEvent;
pub(super) use crate::chatwidget::test_events::McpStartupCompleteEvent;
pub(super) use crate::chatwidget::test_events::McpStartupUpdateEvent;
pub(super) use crate::chatwidget::test_events::ModelVerificationEvent;
pub(super) use crate::chatwidget::test_events::SessionConfiguredEvent;
pub(super) use crate::chatwidget::test_events::StreamErrorEvent;
pub(super) use crate::chatwidget::test_events::ThreadRolledBackEvent;
pub(super) use crate::chatwidget::test_events::TurnCompleteEvent;
pub(super) use crate::chatwidget::test_events::TurnStartedEvent;
pub(super) use crate::chatwidget::test_events::UndoCompletedEvent;
pub(super) use crate::chatwidget::test_events::UndoStartedEvent;
pub(super) use crate::chatwidget::test_events::WarningEvent;
pub(super) use crate::diff_model::FileChange;
pub(super) use crate::history_cell::UserHistoryCell;
pub(super) use crate::legacy_core::config::Config;
pub(super) use crate::legacy_core::config::ConfigBuilder;
@@ -24,6 +50,16 @@ pub(super) use crate::test_backend::VT100Backend;
pub(super) use crate::test_support::PathBufExt;
pub(super) use crate::test_support::test_path_buf;
pub(super) use crate::test_support::test_path_display;
pub(super) use crate::token_usage::TokenCountEvent;
pub(super) use crate::token_usage::TokenUsage;
pub(super) use crate::token_usage::TokenUsageInfo;
pub(super) use crate::tool_activity::ExecCommandBeginEvent;
pub(super) use crate::tool_activity::ExecCommandEndEvent;
pub(super) use crate::tool_activity::ImageGenerationEndEvent;
pub(super) use crate::tool_activity::PatchApplyBeginEvent;
pub(super) use crate::tool_activity::PatchApplyEndEvent;
pub(super) use crate::tool_activity::TerminalInteractionEvent;
pub(super) use crate::tool_activity::ViewImageToolCallEvent;
pub(super) use crate::tui::FrameRequester;
pub(super) use assert_matches::assert_matches;
pub(super) use codex_app_server_protocol::AddCreditsNudgeCreditType;
@@ -33,15 +69,19 @@ pub(super) use codex_app_server_protocol::AdditionalNetworkPermissions as AppSer
pub(super) use codex_app_server_protocol::AdditionalPermissionProfile as AppServerAdditionalPermissionProfile;
pub(super) use codex_app_server_protocol::AppSummary;
pub(super) use codex_app_server_protocol::AutoReviewDecisionSource as AppServerGuardianApprovalReviewDecisionSource;
pub(super) use codex_app_server_protocol::CodexErrorInfo;
pub(super) use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState;
pub(super) use codex_app_server_protocol::CollabAgentStatus as AppServerCollabAgentStatus;
pub(super) use codex_app_server_protocol::CollabAgentTool as AppServerCollabAgentTool;
pub(super) use codex_app_server_protocol::CollabAgentToolCallStatus as AppServerCollabAgentToolCallStatus;
pub(super) use codex_app_server_protocol::CommandAction as AppServerCommandAction;
pub(super) use codex_app_server_protocol::CommandExecutionRequestApprovalParams as AppServerCommandExecutionRequestApprovalParams;
pub(super) use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;
pub(super) use codex_app_server_protocol::CommandExecutionSource as AppServerCommandExecutionSource;
pub(super) use codex_app_server_protocol::CommandExecutionStatus as CoreExecCommandStatus;
pub(super) use codex_app_server_protocol::CommandExecutionStatus as AppServerCommandExecutionStatus;
pub(super) use codex_app_server_protocol::ConfigWarningNotification;
pub(super) use codex_app_server_protocol::CreditsSnapshot;
pub(super) use codex_app_server_protocol::ErrorNotification;
pub(super) use codex_app_server_protocol::FileUpdateChange;
pub(super) use codex_app_server_protocol::GuardianApprovalReview;
@@ -70,8 +110,11 @@ pub(super) use codex_app_server_protocol::MarketplaceInterface;
pub(super) use codex_app_server_protocol::McpServerStartupState;
pub(super) use codex_app_server_protocol::McpServerStatusDetail;
pub(super) use codex_app_server_protocol::McpServerStatusUpdatedNotification;
pub(super) use codex_app_server_protocol::ModelVerification as CoreModelVerification;
pub(super) use codex_app_server_protocol::ModelVerification as AppServerModelVerification;
pub(super) use codex_app_server_protocol::ModelVerificationNotification;
pub(super) use codex_app_server_protocol::NonSteerableTurnKind;
pub(super) use codex_app_server_protocol::PatchApplyStatus as CorePatchApplyStatus;
pub(super) use codex_app_server_protocol::PatchApplyStatus as AppServerPatchApplyStatus;
pub(super) use codex_app_server_protocol::PatchChangeKind;
pub(super) use codex_app_server_protocol::PermissionsRequestApprovalParams as AppServerPermissionsRequestApprovalParams;
@@ -84,11 +127,17 @@ pub(super) use codex_app_server_protocol::PluginMarketplaceEntry;
pub(super) use codex_app_server_protocol::PluginReadResponse;
pub(super) use codex_app_server_protocol::PluginSource;
pub(super) use codex_app_server_protocol::PluginSummary;
pub(super) use codex_app_server_protocol::RateLimitReachedType;
pub(super) use codex_app_server_protocol::RateLimitSnapshot;
pub(super) use codex_app_server_protocol::RateLimitWindow;
pub(super) use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
pub(super) use codex_app_server_protocol::ReviewTarget;
pub(super) use codex_app_server_protocol::ServerNotification;
pub(super) use codex_app_server_protocol::SkillSummary;
pub(super) use codex_app_server_protocol::ThreadClosedNotification;
pub(super) use codex_app_server_protocol::ThreadItem as AppServerThreadItem;
pub(super) use codex_app_server_protocol::ThreadRealtimeClosedNotification;
pub(super) use codex_app_server_protocol::ThreadRealtimeErrorNotification;
pub(super) use codex_app_server_protocol::Turn as AppServerTurn;
pub(super) use codex_app_server_protocol::TurnCompletedNotification;
pub(super) use codex_app_server_protocol::TurnError as AppServerTurnError;
@@ -116,6 +165,14 @@ pub(super) use codex_otel::RuntimeMetricsSummary;
pub(super) use codex_otel::SessionTelemetry;
pub(super) use codex_protocol::ThreadId;
pub(super) use codex_protocol::account::PlanType;
pub(super) use codex_protocol::approvals::ExecPolicyAmendment;
pub(super) use codex_protocol::approvals::GuardianAssessmentAction;
pub(super) use codex_protocol::approvals::GuardianAssessmentDecisionSource;
pub(super) use codex_protocol::approvals::GuardianAssessmentEvent;
pub(super) use codex_protocol::approvals::GuardianAssessmentStatus;
pub(super) use codex_protocol::approvals::GuardianCommandSource;
pub(super) use codex_protocol::approvals::GuardianRiskLevel;
pub(super) use codex_protocol::approvals::GuardianUserAuthorization;
pub(super) use codex_protocol::config_types::CollaborationMode;
pub(super) use codex_protocol::config_types::ModeKind;
pub(super) use codex_protocol::config_types::Personality;
@@ -139,70 +196,6 @@ pub(super) use codex_protocol::parse_command::ParsedCommand;
pub(super) use codex_protocol::plan_tool::PlanItemArg;
pub(super) use codex_protocol::plan_tool::StepStatus;
pub(super) use codex_protocol::plan_tool::UpdatePlanArgs;
pub(super) use codex_protocol::protocol::AgentMessageDeltaEvent;
pub(super) use codex_protocol::protocol::AgentMessageEvent;
pub(super) use codex_protocol::protocol::AgentReasoningDeltaEvent;
pub(super) use codex_protocol::protocol::AgentReasoningEvent;
pub(super) use codex_protocol::protocol::AgentStatus;
pub(super) use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
pub(super) use codex_protocol::protocol::BackgroundEventEvent;
pub(super) use codex_protocol::protocol::CodexErrorInfo;
pub(super) use codex_protocol::protocol::CollabAgentSpawnBeginEvent;
pub(super) use codex_protocol::protocol::CollabAgentSpawnEndEvent;
pub(super) use codex_protocol::protocol::CreditsSnapshot;
pub(super) use codex_protocol::protocol::ErrorEvent;
pub(super) use codex_protocol::protocol::Event;
pub(super) use codex_protocol::protocol::EventMsg;
pub(super) use codex_protocol::protocol::ExecApprovalRequestEvent;
pub(super) use codex_protocol::protocol::ExecCommandBeginEvent;
pub(super) use codex_protocol::protocol::ExecCommandEndEvent;
pub(super) use codex_protocol::protocol::ExecCommandSource;
pub(super) use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus;
pub(super) use codex_protocol::protocol::ExecPolicyAmendment;
pub(super) use codex_protocol::protocol::ExitedReviewModeEvent;
pub(super) use codex_protocol::protocol::FileChange;
pub(super) use codex_protocol::protocol::GuardianAssessmentAction;
pub(super) use codex_protocol::protocol::GuardianAssessmentDecisionSource;
pub(super) use codex_protocol::protocol::GuardianAssessmentEvent;
pub(super) use codex_protocol::protocol::GuardianAssessmentStatus;
pub(super) use codex_protocol::protocol::GuardianCommandSource;
pub(super) use codex_protocol::protocol::GuardianRiskLevel;
pub(super) use codex_protocol::protocol::GuardianUserAuthorization;
pub(super) use codex_protocol::protocol::ImageGenerationEndEvent;
pub(super) use codex_protocol::protocol::ItemCompletedEvent;
pub(super) use codex_protocol::protocol::McpStartupCompleteEvent;
pub(super) use codex_protocol::protocol::McpStartupStatus;
pub(super) use codex_protocol::protocol::McpStartupUpdateEvent;
pub(super) use codex_protocol::protocol::ModelVerification as CoreModelVerification;
pub(super) use codex_protocol::protocol::ModelVerificationEvent;
pub(super) use codex_protocol::protocol::NonSteerableTurnKind;
pub(super) use codex_protocol::protocol::Op;
pub(super) use codex_protocol::protocol::PatchApplyBeginEvent;
pub(super) use codex_protocol::protocol::PatchApplyEndEvent;
pub(super) use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus;
pub(super) use codex_protocol::protocol::RateLimitReachedType;
pub(super) use codex_protocol::protocol::RateLimitSnapshot;
pub(super) use codex_protocol::protocol::RateLimitWindow;
pub(super) use codex_protocol::protocol::RealtimeConversationClosedEvent;
pub(super) use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
pub(super) use codex_protocol::protocol::RealtimeEvent;
pub(super) use codex_protocol::protocol::ReviewRequest;
pub(super) use codex_protocol::protocol::ReviewTarget;
pub(super) use codex_protocol::protocol::SessionConfiguredEvent;
pub(super) use codex_protocol::protocol::SessionSource;
pub(super) use codex_protocol::protocol::SkillScope;
pub(super) use codex_protocol::protocol::StreamErrorEvent;
pub(super) use codex_protocol::protocol::TerminalInteractionEvent;
pub(super) use codex_protocol::protocol::ThreadRolledBackEvent;
pub(super) use codex_protocol::protocol::TokenCountEvent;
pub(super) use codex_protocol::protocol::TokenUsage;
pub(super) use codex_protocol::protocol::TokenUsageInfo;
pub(super) use codex_protocol::protocol::TurnCompleteEvent;
pub(super) use codex_protocol::protocol::TurnStartedEvent;
pub(super) use codex_protocol::protocol::UndoCompletedEvent;
pub(super) use codex_protocol::protocol::UndoStartedEvent;
pub(super) use codex_protocol::protocol::ViewImageToolCallEvent;
pub(super) use codex_protocol::protocol::WarningEvent;
pub(super) use codex_protocol::request_permissions::RequestPermissionProfile;
pub(super) use codex_protocol::request_user_input::RequestUserInputEvent;
pub(super) use codex_protocol::request_user_input::RequestUserInputQuestion;
+48 -25
View File
@@ -6,31 +6,54 @@ async fn collab_spawn_end_shows_requested_model_and_effort() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
let sender_thread_id = ThreadId::new();
let spawned_thread_id = ThreadId::new();
chat.set_collab_agent_metadata(
spawned_thread_id,
Some("Robie".to_string()),
Some("explorer".to_string()),
);
chat.handle_codex_event(Event {
id: "spawn-begin".into(),
msg: EventMsg::CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent {
call_id: "call-spawn".to_string(),
sender_thread_id,
prompt: "Explore the repo".to_string(),
model: "gpt-5".to_string(),
reasoning_effort: ReasoningEffortConfig::High,
chat.handle_server_notification(
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item: AppServerThreadItem::CollabAgentToolCall {
id: "call-spawn".to_string(),
tool: AppServerCollabAgentTool::SpawnAgent,
status: AppServerCollabAgentToolCallStatus::InProgress,
sender_thread_id: sender_thread_id.to_string(),
receiver_thread_ids: Vec::new(),
prompt: Some("Explore the repo".to_string()),
model: Some("gpt-5".to_string()),
reasoning_effort: Some(ReasoningEffortConfig::High),
agents_states: HashMap::new(),
},
}),
});
chat.handle_codex_event(Event {
id: "spawn-end".into(),
msg: EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent {
call_id: "call-spawn".to_string(),
sender_thread_id,
new_thread_id: Some(spawned_thread_id),
new_agent_nickname: Some("Robie".to_string()),
new_agent_role: Some("explorer".to_string()),
prompt: "Explore the repo".to_string(),
model: "gpt-5".to_string(),
reasoning_effort: ReasoningEffortConfig::High,
status: AgentStatus::PendingInit,
/*replay_kind*/ None,
);
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item: AppServerThreadItem::CollabAgentToolCall {
id: "call-spawn".to_string(),
tool: AppServerCollabAgentTool::SpawnAgent,
status: AppServerCollabAgentToolCallStatus::Completed,
sender_thread_id: sender_thread_id.to_string(),
receiver_thread_ids: vec![spawned_thread_id.to_string()],
prompt: Some("Explore the repo".to_string()),
model: None,
reasoning_effort: None,
agents_states: HashMap::from([(
spawned_thread_id.to_string(),
AppServerCollabAgentState {
status: AppServerCollabAgentStatus::PendingInit,
message: None,
},
)]),
},
}),
});
/*replay_kind*/ None,
);
let cells = drain_insert_history(&mut rx);
let rendered = cells
@@ -586,7 +609,7 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() {
ServerNotification::Error(ErrorNotification {
error: AppServerTurnError {
message: "Reconnecting... 1/5".to_string(),
codex_error_info: Some(CodexErrorInfo::Other.into()),
codex_error_info: Some(CodexErrorInfo::Other),
additional_details: None,
},
will_retry: true,
@@ -643,7 +666,7 @@ async fn live_app_server_server_overloaded_error_renders_warning() {
ServerNotification::Error(ErrorNotification {
error: AppServerTurnError {
message: "server overloaded".to_string(),
codex_error_info: Some(CodexErrorInfo::ServerOverloaded.into()),
codex_error_info: Some(CodexErrorInfo::ServerOverloaded),
additional_details: None,
},
will_retry: false,
@@ -684,7 +707,7 @@ async fn live_app_server_cyber_policy_error_renders_dedicated_notice() {
ServerNotification::Error(ErrorNotification {
error: AppServerTurnError {
message: "server fallback message".to_string(),
codex_error_info: Some(CodexErrorInfo::CyberPolicy.into()),
codex_error_info: Some(CodexErrorInfo::CyberPolicy),
additional_details: None,
},
will_retry: false,
@@ -126,9 +126,9 @@ fn app_server_exec_approval_request_preserves_permissions_context() {
assert_eq!(
request.network_approval_context,
Some(codex_protocol::protocol::NetworkApprovalContext {
Some(codex_app_server_protocol::NetworkApprovalContext {
host: "example.com".to_string(),
protocol: codex_protocol::protocol::NetworkApprovalProtocol::Socks5Tcp,
protocol: codex_app_server_protocol::NetworkApprovalProtocol::Socks5Tcp,
})
);
assert_eq!(
@@ -222,7 +222,10 @@ async fn exec_approval_uses_approval_id_when_present() {
} = app_ev
{
assert_eq!(id, "approval-subcommand");
assert_matches!(decision, codex_protocol::protocol::ReviewDecision::Approved);
assert_matches!(
decision,
codex_app_server_protocol::CommandExecutionApprovalDecision::Accept
);
found = true;
break;
}
@@ -1,4 +1,11 @@
use super::*;
use codex_app_server_protocol::FileSystemAccessMode;
use codex_app_server_protocol::FileSystemPath;
use codex_app_server_protocol::FileSystemSandboxEntry;
use codex_app_server_protocol::FileSystemSpecialPath;
use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile;
use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
use pretty_assertions::assert_eq;
#[tokio::test]
@@ -7,7 +14,7 @@ async fn submission_preserves_text_elements_and_local_images() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
@@ -91,27 +98,28 @@ async fn submission_includes_configured_permission_profile() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let expected_permission_profile = PermissionProfile::Managed {
network: codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
file_system: codex_protocol::models::ManagedFileSystemPermissions::Restricted {
let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
file_system: PermissionProfileFileSystemPermissions::Restricted {
entries: vec![
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: codex_protocol::permissions::FileSystemAccessMode::Read,
access: FileSystemAccessMode::Read,
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::GlobPattern {
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "/home/user/project/secrets/**".to_string(),
},
access: codex_protocol::permissions::FileSystemAccessMode::None,
access: FileSystemAccessMode::None,
},
],
glob_scan_max_depth: None,
},
};
let configured = codex_protocol::protocol::SessionConfiguredEvent {
}
.into();
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
@@ -148,7 +156,7 @@ async fn submission_includes_configured_permission_profile() {
} => permission_profile,
other => panic!("expected Op::UserTurn, got {other:?}"),
};
assert_eq!(permission_profile, Some(expected_permission_profile));
assert_eq!(permission_profile, expected_permission_profile);
}
#[tokio::test]
@@ -157,11 +165,12 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let expected_permission_profile = PermissionProfile::Managed {
network: codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
file_system: codex_protocol::models::ManagedFileSystemPermissions::Unrestricted,
};
let configured = codex_protocol::protocol::SessionConfiguredEvent {
let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
file_system: PermissionProfileFileSystemPermissions::Unrestricted,
}
.into();
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
@@ -195,7 +204,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() {
} => permission_profile,
other => panic!("expected Op::UserTurn, got {other:?}"),
};
assert_eq!(permission_profile, Some(expected_permission_profile));
assert_eq!(permission_profile, expected_permission_profile);
}
#[tokio::test]
@@ -204,7 +213,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
@@ -299,7 +308,7 @@ async fn enter_with_only_remote_images_submits_user_turn() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
@@ -364,7 +373,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
@@ -404,7 +413,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
@@ -444,7 +453,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
@@ -487,7 +496,7 @@ async fn submission_prefers_selected_duplicate_skill_path() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
@@ -522,7 +531,7 @@ async fn submission_prefers_selected_duplicate_skill_path() {
dependencies: None,
policy: None,
path_to_skills_md: repo_skill_path,
scope: SkillScope::Repo,
scope: crate::test_support::skill_scope_repo(),
},
SkillMetadata {
name: "figma".to_string(),
@@ -532,7 +541,7 @@ async fn submission_prefers_selected_duplicate_skill_path() {
dependencies: None,
policy: None,
path_to_skills_md: user_skill_path.clone(),
scope: SkillScope::User,
scope: crate::test_support::skill_scope_user(),
},
]));
@@ -731,7 +740,7 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() {
chat.handle_codex_event(Event {
id: "interrupt".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
@@ -1178,7 +1187,7 @@ async fn interrupt_restores_queued_messages_into_composer() {
// Deliver a TurnAborted event with Interrupted reason (as if Esc was pressed).
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
@@ -1219,7 +1228,7 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() {
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
+21 -12
View File
@@ -119,7 +119,10 @@ async fn exec_approval_uses_approval_id_when_present() {
} = app_ev
{
assert_eq!(id, "approval-subcommand");
assert_matches!(decision, codex_protocol::protocol::ReviewDecision::Approved);
assert_matches!(
decision,
codex_app_server_protocol::CommandExecutionApprovalDecision::Accept
);
found = true;
break;
}
@@ -1049,7 +1052,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
@@ -1183,7 +1186,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> {
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)?;
.set(AskForApproval::OnRequest.to_core())?;
// Inject an exec approval request to display the approval modal.
let ev = ExecApprovalRequestEvent {
call_id: "call-approve-cmd".into(),
@@ -1246,7 +1249,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> {
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)?;
.set(AskForApproval::OnRequest.to_core())?;
let ev = ExecApprovalRequestEvent {
call_id: "call-approve-cmd-noreason".into(),
@@ -1296,7 +1299,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot()
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)?;
.set(AskForApproval::OnRequest.to_core())?;
let script = "python - <<'PY'\nprint('hello')\nPY".to_string();
let command = vec!["bash".into(), "-lc".into(), script];
@@ -1344,7 +1347,7 @@ async fn approval_modal_patch_snapshot() -> anyhow::Result<()> {
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)?;
.set(AskForApproval::OnRequest.to_core())?;
// Build a small changeset and a reason/grant_root to exercise the prompt text.
let mut changes = HashMap::new();
@@ -1391,7 +1394,7 @@ async fn interrupt_preserves_unified_exec_processes() {
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
@@ -1439,7 +1442,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() {
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
@@ -1717,7 +1720,10 @@ async fn apply_patch_approval_sends_op_with_call_id() {
} = app_ev
{
assert_eq!(id, "call-999");
assert_matches!(decision, codex_protocol::protocol::ReviewDecision::Approved);
assert_matches!(
decision,
codex_app_server_protocol::FileChangeApprovalDecision::Accept
);
found = true;
break;
}
@@ -1765,7 +1771,10 @@ async fn apply_patch_full_flow_integration_like() {
match forwarded {
Op::PatchApproval { id, decision } => {
assert_eq!(id, "call-1");
assert_matches!(decision, codex_protocol::protocol::ReviewDecision::Approved);
assert_matches!(
decision,
codex_app_server_protocol::FileChangeApprovalDecision::Accept
);
}
other => panic!("unexpected op forwarded: {other:?}"),
}
@@ -1811,7 +1820,7 @@ async fn apply_patch_untrusted_shows_approval_modal() -> anyhow::Result<()> {
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)?;
.set(AskForApproval::OnRequest.to_core())?;
// Simulate a patch approval request from backend
let mut changes = HashMap::new();
@@ -1862,7 +1871,7 @@ async fn apply_patch_request_omits_diff_summary_from_modal() -> anyhow::Result<(
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)?;
.set(AskForApproval::OnRequest.to_core())?;
// Simulate backend asking to apply a patch adding two lines to README.md
let mut changes = HashMap::new();
+29 -29
View File
@@ -99,8 +99,8 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: percent,
window_minutes: Some(60),
used_percent: percent.round() as i32,
window_duration_mins: Some(60),
resets_at: None,
}),
secondary: None,
@@ -122,7 +122,7 @@ pub(super) fn test_session_telemetry(config: &Config, model: &str) -> SessionTel
"test_originator".to_string(),
/*log_user_prompts*/ false,
"test".to_string(),
SessionSource::Cli,
crate::test_support::session_source_cli(),
)
}
@@ -1016,7 +1016,7 @@ pub(super) fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) {
}
pub(super) async fn assert_hook_events_snapshot(
event_name: codex_protocol::protocol::HookEventName,
event_name: codex_app_server_protocol::HookEventName,
run_id: &str,
status_message: &str,
snapshot_name: &str,
@@ -1025,18 +1025,18 @@ pub(super) async fn assert_hook_events_snapshot(
chat.handle_codex_event(Event {
id: "hook-1".into(),
msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent {
msg: EventMsg::HookStarted(crate::tool_activity::HookStartedEvent {
turn_id: None,
run: codex_protocol::protocol::HookRunSummary {
run: codex_app_server_protocol::HookRunSummary {
id: run_id.to_string(),
event_name,
handler_type: codex_protocol::protocol::HookHandlerType::Command,
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
scope: codex_protocol::protocol::HookScope::Turn,
handler_type: codex_app_server_protocol::HookHandlerType::Command,
execution_mode: codex_app_server_protocol::HookExecutionMode::Sync,
scope: codex_app_server_protocol::HookScope::Turn,
source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(),
source: codex_protocol::protocol::HookSource::User,
source: codex_app_server_protocol::HookSource::User,
display_order: 0,
status: codex_protocol::protocol::HookRunStatus::Running,
status: codex_app_server_protocol::HookRunStatus::Running,
status_message: Some(status_message.to_string()),
started_at: 1,
completed_at: None,
@@ -1060,29 +1060,29 @@ pub(super) async fn assert_hook_events_snapshot(
chat.handle_codex_event(Event {
id: "hook-1".into(),
msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent {
msg: EventMsg::HookCompleted(crate::tool_activity::HookCompletedEvent {
turn_id: None,
run: codex_protocol::protocol::HookRunSummary {
run: codex_app_server_protocol::HookRunSummary {
id: run_id.to_string(),
event_name,
handler_type: codex_protocol::protocol::HookHandlerType::Command,
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
scope: codex_protocol::protocol::HookScope::Turn,
handler_type: codex_app_server_protocol::HookHandlerType::Command,
execution_mode: codex_app_server_protocol::HookExecutionMode::Sync,
scope: codex_app_server_protocol::HookScope::Turn,
source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(),
source: codex_protocol::protocol::HookSource::User,
source: codex_app_server_protocol::HookSource::User,
display_order: 0,
status: codex_protocol::protocol::HookRunStatus::Completed,
status: codex_app_server_protocol::HookRunStatus::Completed,
status_message: Some(status_message.to_string()),
started_at: 1,
completed_at: Some(11),
duration_ms: Some(10),
entries: vec![
codex_protocol::protocol::HookOutputEntry {
kind: codex_protocol::protocol::HookOutputEntryKind::Warning,
codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Warning,
text: "Heads up from the hook".to_string(),
},
codex_protocol::protocol::HookOutputEntry {
kind: codex_protocol::protocol::HookOutputEntryKind::Context,
codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Context,
text: "Remember the startup checklist.".to_string(),
},
],
@@ -1098,13 +1098,13 @@ pub(super) async fn assert_hook_events_snapshot(
assert_chatwidget_snapshot!(snapshot_name, combined);
}
fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str {
fn hook_event_label(event_name: codex_app_server_protocol::HookEventName) -> &'static str {
match event_name {
codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse",
codex_protocol::protocol::HookEventName::PermissionRequest => "PermissionRequest",
codex_protocol::protocol::HookEventName::PostToolUse => "PostToolUse",
codex_protocol::protocol::HookEventName::SessionStart => "SessionStart",
codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit",
codex_protocol::protocol::HookEventName::Stop => "Stop",
codex_app_server_protocol::HookEventName::PreToolUse => "PreToolUse",
codex_app_server_protocol::HookEventName::PermissionRequest => "PermissionRequest",
codex_app_server_protocol::HookEventName::PostToolUse => "PostToolUse",
codex_app_server_protocol::HookEventName::SessionStart => "SessionStart",
codex_app_server_protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit",
codex_app_server_protocol::HookEventName::Stop => "Stop",
}
}
@@ -1,11 +1,13 @@
use super::*;
use codex_protocol::protocol::FileSystemAccessMode;
use codex_protocol::protocol::FileSystemPath;
use codex_protocol::protocol::FileSystemSandboxEntry;
use codex_protocol::protocol::FileSystemSandboxKind;
use codex_protocol::protocol::FileSystemSandboxPolicy;
use codex_protocol::protocol::FileSystemSpecialPath;
use codex_protocol::protocol::NetworkSandboxPolicy;
use codex_app_server_protocol::FileSystemAccessMode;
use codex_app_server_protocol::FileSystemPath;
use codex_app_server_protocol::FileSystemSandboxEntry;
use codex_app_server_protocol::FileSystemSpecialPath;
use codex_app_server_protocol::NetworkAccess;
use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile;
use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
use codex_app_server_protocol::SandboxPolicy;
use pretty_assertions::assert_eq;
#[tokio::test]
@@ -14,7 +16,7 @@ async fn resumed_initial_messages_render_history() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
@@ -127,7 +129,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
@@ -188,7 +190,7 @@ async fn replayed_user_message_preserves_remote_image_urls() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
@@ -245,7 +247,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() {
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)
.set(AskForApproval::OnRequest.to_core())
.expect("set approval policy");
chat.config
.permissions
@@ -254,29 +256,33 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() {
chat.config.cwd = test_path_buf("/home/user/main").abs();
let expected_cwd = test_path_buf("/home/user/sub-agent").abs();
let expected_file_system_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
let expected_app_server_permission_profile = AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
file_system: PermissionProfileFileSystemPermissions::Restricted {
entries: vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "**/.secret".to_string(),
},
access: FileSystemAccessMode::None,
},
],
glob_scan_max_depth: None,
},
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "**/.secret".to_string(),
},
access: FileSystemAccessMode::None,
},
]);
let expected_permission_profile =
codex_protocol::models::PermissionProfile::from_runtime_permissions(
&expected_file_system_policy,
NetworkSandboxPolicy::Restricted,
);
let expected_sandbox = expected_permission_profile
};
let expected_permission_profile: PermissionProfile =
expected_app_server_permission_profile.clone().into();
let expected_core_sandbox = expected_permission_profile
.to_legacy_sandbox_policy(expected_cwd.as_path())
.expect("permission profile should project to legacy sandbox policy");
let configured = codex_protocol::protocol::SessionConfiguredEvent {
let expected_sandbox = SandboxPolicy::from(expected_core_sandbox);
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
@@ -285,7 +291,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() {
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: expected_permission_profile.clone(),
permission_profile: expected_permission_profile,
cwd: expected_cwd.clone(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
@@ -301,16 +307,14 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() {
});
assert_eq!(
chat.config_ref().permissions.approval_policy.value(),
AskForApproval::from(chat.config_ref().permissions.approval_policy.value()),
AskForApproval::Never
);
let actual_sandbox = SandboxPolicy::from(chat.config_ref().legacy_sandbox_policy());
assert_eq!(&actual_sandbox, &expected_sandbox);
assert_eq!(
&chat.config_ref().legacy_sandbox_policy(),
&expected_sandbox
);
assert_eq!(
chat.config_ref().permissions.permission_profile(),
expected_permission_profile
AppServerPermissionProfile::from(chat.config_ref().permissions.permission_profile()),
expected_app_server_permission_profile
);
assert_eq!(&chat.config_ref().cwd, &expected_cwd);
@@ -328,13 +332,15 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() {
async fn session_configured_external_sandbox_keeps_external_runtime_policy() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
let expected_permission_profile = PermissionProfile::External {
network: NetworkSandboxPolicy::Restricted,
let expected_app_server_permission_profile = AppServerPermissionProfile::External {
network: PermissionProfileNetworkPermissions { enabled: false },
};
let expected_sandbox = expected_permission_profile
.to_legacy_sandbox_policy(test_path_buf("/home/user/external").as_path())
.expect("external profile should project to legacy sandbox policy");
let configured = codex_protocol::protocol::SessionConfiguredEvent {
let expected_permission_profile: PermissionProfile =
expected_app_server_permission_profile.clone().into();
let expected_sandbox = SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
};
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
@@ -358,20 +364,11 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() {
msg: EventMsg::SessionConfigured(configured),
});
let actual_sandbox = SandboxPolicy::from(chat.config_ref().legacy_sandbox_policy());
assert_eq!(&actual_sandbox, &expected_sandbox);
assert_eq!(
&chat.config_ref().legacy_sandbox_policy(),
&expected_sandbox
);
assert_eq!(
chat.config_ref()
.permissions
.file_system_sandbox_policy()
.kind,
FileSystemSandboxKind::ExternalSandbox,
);
assert_eq!(
chat.config_ref().permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Restricted,
AppServerPermissionProfile::from(chat.config_ref().permissions.permission_profile()),
expected_app_server_permission_profile
);
}
@@ -383,7 +380,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
@@ -436,7 +433,7 @@ async fn replayed_user_message_with_only_local_images_does_not_render_history_ce
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
@@ -625,10 +622,7 @@ async fn thread_snapshot_replay_preserves_agent_message_during_review_mode() {
chat.handle_codex_event_replay(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::UncommittedChanges,
user_facing_hint: None,
}),
msg: EventMsg::EnteredReviewMode("current changes".to_string()),
});
let _ = drain_insert_history(&mut rx);
@@ -1,12 +1,53 @@
use super::*;
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::NetworkSandboxPolicy;
use codex_app_server_protocol::FileSystemAccessMode;
use codex_app_server_protocol::FileSystemPath;
use codex_app_server_protocol::FileSystemSandboxEntry;
use codex_app_server_protocol::FileSystemSpecialPath;
use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile;
use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
use pretty_assertions::assert_eq;
fn app_server_workspace_write_profile(extra_root: AbsolutePathBuf) -> PermissionProfile {
AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
file_system: PermissionProfileFileSystemPermissions::Restricted {
entries: vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::ProjectRoots { subpath: None },
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::SlashTmp,
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: extra_root },
access: FileSystemAccessMode::Write,
},
],
glob_scan_max_depth: None,
},
}
.into()
}
#[tokio::test]
async fn approvals_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -57,12 +98,7 @@ async fn preset_matching_accepts_workspace_write_with_extra_roots() {
.into_iter()
.find(|p| p.id == "auto")
.expect("auto preset exists");
let current_profile = PermissionProfile::workspace_write_with(
&[test_path_buf("/tmp/extra").abs()],
NetworkSandboxPolicy::Restricted,
/*exclude_tmpdir_env_var*/ false,
/*exclude_slash_tmp*/ false,
);
let current_profile = app_server_workspace_write_profile(test_path_buf("/tmp/extra").abs());
let cwd = test_path_buf("/tmp/project").abs();
assert!(
@@ -91,8 +127,9 @@ async fn preset_matching_does_not_treat_non_cwd_writable_profile_as_read_only()
.into_iter()
.find(|p| p.id == "read-only")
.expect("read-only preset exists");
let current_profile = PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Restricted {
let current_profile: PermissionProfile = AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
file_system: PermissionProfileFileSystemPermissions::Restricted {
entries: vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
@@ -109,8 +146,8 @@ async fn preset_matching_does_not_treat_non_cwd_writable_profile_as_read_only()
],
glob_scan_max_depth: None,
},
network: NetworkSandboxPolicy::Restricted,
};
}
.into();
let cwd = test_path_buf("/tmp/project").abs();
assert!(
@@ -208,15 +245,17 @@ async fn startup_does_not_prompt_for_windows_sandbox_when_not_requested() {
async fn approvals_popup_shows_disabled_presets() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.permissions.approval_policy =
Constrained::new(AskForApproval::OnRequest, |candidate| match candidate {
chat.config.permissions.approval_policy = Constrained::new(
AskForApproval::OnRequest.to_core(),
|candidate| match AskForApproval::from(*candidate) {
AskForApproval::OnRequest => Ok(()),
_ => Err(invalid_value(
candidate.to_string(),
"this message should be printed in the description",
)),
})
.expect("construct constrained approval policy");
},
)
.expect("construct constrained approval policy");
chat.open_approvals_popup();
let width = 80;
@@ -245,12 +284,14 @@ async fn approvals_popup_navigation_skips_disabled() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ false);
chat.config.permissions.approval_policy =
Constrained::new(AskForApproval::OnRequest, |candidate| match candidate {
chat.config.permissions.approval_policy = Constrained::new(
AskForApproval::OnRequest.to_core(),
|candidate| match AskForApproval::from(*candidate) {
AskForApproval::OnRequest => Ok(()),
_ => Err(invalid_value(candidate.to_string(), "[on-request]")),
})
.expect("construct constrained approval policy");
},
)
.expect("construct constrained approval policy");
chat.open_approvals_popup();
let popup = render_bottom_popup(&chat, /*width*/ 80);
@@ -400,7 +441,7 @@ async fn permissions_selection_history_snapshot_full_access_to_default() {
chat.config
.permissions
.approval_policy
.set(AskForApproval::Never)
.set(AskForApproval::Never.to_core())
.expect("set approval policy");
chat.config
.permissions
@@ -442,7 +483,7 @@ async fn permissions_selection_emits_history_cell_when_current_is_selected() {
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)
.set(AskForApproval::OnRequest.to_core())
.expect("set approval policy");
chat.config
.permissions
@@ -500,7 +541,7 @@ async fn permissions_selection_hides_auto_review_when_feature_disabled_even_if_a
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)
.set(AskForApproval::OnRequest.to_core())
.expect("set approval policy");
chat.config
.permissions
@@ -577,12 +618,7 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w
let extra_root = test_path_buf("/tmp/guardian-approvals-extra").abs();
let cwd = test_project_path().abs();
let permission_profile = PermissionProfile::workspace_write_with(
&[extra_root],
NetworkSandboxPolicy::Restricted,
/*exclude_tmpdir_env_var*/ false,
/*exclude_slash_tmp*/ false,
);
let permission_profile = app_server_workspace_write_profile(extra_root);
chat.handle_codex_event(Event {
id: "session-configured-custom-workspace".to_string(),
@@ -628,7 +664,7 @@ async fn permissions_selection_can_disable_auto_review() {
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)
.set(AskForApproval::OnRequest.to_core())
.expect("set approval policy");
chat.config
.permissions
@@ -668,7 +704,7 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)
.set(AskForApproval::OnRequest.to_core())
.expect("set approval policy");
chat.config
.permissions
@@ -708,7 +744,6 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context
cwd: None,
approval_policy: Some(AskForApproval::OnRequest),
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
sandbox_policy: None,
permission_profile: Some(PermissionProfile::workspace_write()),
windows_sandbox_level: None,
model: None,
@@ -1186,7 +1186,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
@@ -1431,7 +1431,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
let configured = codex_protocol::protocol::SessionConfiguredEvent {
let configured = crate::chatwidget::test_events::SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
@@ -8,12 +8,14 @@ async fn realtime_error_closes_without_followup_closed_info() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.realtime_conversation.phase = RealtimeConversationPhase::Active;
chat.on_realtime_conversation_realtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::Error("boom".to_string()),
chat.on_realtime_error(ThreadRealtimeErrorNotification {
thread_id: ThreadId::new().to_string(),
message: "boom".to_string(),
});
next_realtime_close_op(&mut op_rx);
chat.on_realtime_conversation_closed(RealtimeConversationClosedEvent {
chat.on_realtime_conversation_closed(ThreadRealtimeClosedNotification {
thread_id: ThreadId::new().to_string(),
reason: Some("error".to_string()),
});
@@ -64,7 +64,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
// must be renumbered to match the combined local image list.
chat.handle_codex_event(Event {
id: "interrupt".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
@@ -113,12 +113,7 @@ async fn entered_review_mode_uses_request_hint() {
chat.handle_codex_event(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::BaseBranch {
branch: "feature".to_string(),
},
user_facing_hint: Some("feature branch".to_string()),
}),
msg: EventMsg::EnteredReviewMode("feature branch".to_string()),
});
let cells = drain_insert_history(&mut rx);
@@ -134,10 +129,7 @@ async fn entered_review_mode_defaults_to_current_changes_banner() {
chat.handle_codex_event(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::UncommittedChanges,
user_facing_hint: None,
}),
msg: EventMsg::EnteredReviewMode("current changes".to_string()),
});
let cells = drain_insert_history(&mut rx);
@@ -152,12 +144,7 @@ async fn live_core_review_prompt_item_is_not_rendered() {
chat.handle_codex_event(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::BaseBranch {
branch: "main".to_string(),
},
user_facing_hint: Some("changes against 'main'".to_string()),
}),
msg: EventMsg::EnteredReviewMode("changes against 'main'".to_string()),
});
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
@@ -235,12 +222,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages
});
chat.handle_codex_event(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::BaseBranch {
branch: "feature".to_string(),
},
user_facing_hint: Some("feature branch".to_string()),
}),
msg: EventMsg::EnteredReviewMode("feature branch".to_string()),
});
let _ = drain_insert_history(&mut rx);
chat.queued_user_messages
@@ -303,9 +285,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages
chat.handle_codex_event(Event {
id: "review-exit".into(),
msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent {
review_output: None,
}),
msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent),
});
chat.handle_codex_event(Event {
id: "turn-complete".into(),
@@ -358,10 +338,7 @@ async fn live_agent_message_renders_during_review_mode() {
chat.handle_codex_event(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::UncommittedChanges,
user_facing_hint: None,
}),
msg: EventMsg::EnteredReviewMode("current changes".to_string()),
});
let _ = drain_insert_history(&mut rx);
@@ -399,12 +376,7 @@ async fn review_restores_context_window_indicator() {
chat.handle_codex_event(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::BaseBranch {
branch: "feature".to_string(),
},
user_facing_hint: Some("feature branch".to_string()),
}),
msg: EventMsg::EnteredReviewMode("feature branch".to_string()),
});
chat.handle_codex_event(Event {
@@ -418,9 +390,7 @@ async fn review_restores_context_window_indicator() {
chat.handle_codex_event(Event {
id: "review-end".into(),
msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent {
review_output: None,
}),
msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent),
});
let _ = drain_insert_history(&mut rx);
@@ -1022,7 +992,7 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() {
chat.handle_codex_event(Event {
id: "replaced".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Replaced,
completed_at: None,
@@ -1224,14 +1194,11 @@ async fn custom_prompt_submit_sends_review_op() {
// Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt
let evt = rx.try_recv().expect("expected one app event");
match evt {
AppEvent::CodexOp(Op::Review { review_request }) => {
AppEvent::CodexOp(Op::Review { target }) => {
assert_eq!(
review_request,
ReviewRequest {
target: ReviewTarget::Custom {
instructions: "please audit dependencies".to_string(),
},
user_facing_hint: None,
target,
ReviewTarget::Custom {
instructions: "please audit dependencies".to_string(),
}
);
}
@@ -1265,7 +1232,7 @@ async fn interrupt_exec_marks_failed_snapshot() {
// cause the active exec cell to be finalized as failed and flushed.
chat.handle_codex_event(Event {
id: "call-int".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
@@ -1304,7 +1271,7 @@ async fn interrupted_turn_error_message_snapshot() {
// Abort the turn (like pressing Esc) and drain inserted history.
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
@@ -1400,7 +1367,7 @@ async fn direct_budget_limited_turn_uses_budget_message_snapshot() {
});
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::BudgetLimited,
completed_at: None,
@@ -1431,7 +1398,7 @@ async fn budget_limited_turn_restores_queued_input_without_submitting() {
});
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::BudgetLimited,
completed_at: None,
@@ -1469,7 +1436,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() {
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
@@ -1567,7 +1534,7 @@ async fn review_ended_keeps_unified_exec_processes() {
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::ReviewEnded,
completed_at: None,
@@ -1612,10 +1579,7 @@ async fn enter_submits_steer_while_review_is_running() {
chat.handle_codex_event(Event {
id: "review-1".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::UncommittedChanges,
user_facing_hint: Some("current changes".to_string()),
}),
msg: EventMsg::EnteredReviewMode("current changes".to_string()),
});
let _ = drain_insert_history(&mut rx);
@@ -1661,10 +1625,7 @@ async fn review_queues_user_messages_snapshot() {
chat.handle_codex_event(Event {
id: "review-1".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::UncommittedChanges,
user_facing_hint: Some("current changes".to_string()),
}),
msg: EventMsg::EnteredReviewMode("current changes".to_string()),
});
let _ = drain_insert_history(&mut rx);
@@ -131,24 +131,18 @@ async fn queued_slash_review_with_args_dispatches_after_active_turn() {
match op_rx.try_recv() {
Ok(Op::AddToHistory { .. }) => match op_rx.try_recv() {
Ok(Op::Review { review_request }) => assert_eq!(
review_request,
ReviewRequest {
target: ReviewTarget::Custom {
instructions: "check regressions".to_string(),
},
user_facing_hint: None,
Ok(Op::Review { target }) => assert_eq!(
target,
ReviewTarget::Custom {
instructions: "check regressions".to_string(),
}
),
other => panic!("expected queued /review to submit review op, got {other:?}"),
},
Ok(Op::Review { review_request }) => assert_eq!(
review_request,
ReviewRequest {
target: ReviewTarget::Custom {
instructions: "check regressions".to_string(),
},
user_facing_hint: None,
Ok(Op::Review { target }) => assert_eq!(
target,
ReviewTarget::Custom {
instructions: "check regressions".to_string(),
}
),
other => panic!("expected queued /review to submit review op, got {other:?}"),
@@ -453,7 +447,7 @@ async fn queued_bare_rename_drains_next_input_after_name_update() {
chat.handle_codex_event(Event {
id: "rename".into(),
msg: EventMsg::ThreadNameUpdated(codex_protocol::protocol::ThreadNameUpdatedEvent {
msg: EventMsg::ThreadNameUpdated(crate::chatwidget::test_events::ThreadNameUpdatedEvent {
thread_id,
thread_name: Some("Queued rename".to_string()),
}),
@@ -536,7 +530,7 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() {
chat.handle_codex_event(Event {
id: "rename".into(),
msg: EventMsg::ThreadNameUpdated(codex_protocol::protocol::ThreadNameUpdatedEvent {
msg: EventMsg::ThreadNameUpdated(crate::chatwidget::test_events::ThreadNameUpdatedEvent {
thread_id,
thread_name: Some("Queued rename".to_string()),
}),
@@ -279,8 +279,8 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 80.0,
window_minutes: Some(60),
used_percent: 80,
window_duration_mins: Some(60),
resets_at: Some(123),
}),
secondary: None,
@@ -314,13 +314,13 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 10.0,
window_minutes: Some(60),
used_percent: 10,
window_duration_mins: Some(60),
resets_at: None,
}),
secondary: Some(RateLimitWindow {
used_percent: 5.0,
window_minutes: Some(300),
used_percent: 5,
window_duration_mins: Some(300),
resets_at: None,
}),
credits: None,
@@ -333,13 +333,13 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 25.0,
window_minutes: Some(30),
used_percent: 25,
window_duration_mins: Some(30),
resets_at: Some(123),
}),
secondary: Some(RateLimitWindow {
used_percent: 15.0,
window_minutes: Some(300),
used_percent: 15,
window_duration_mins: Some(300),
resets_at: Some(234),
}),
credits: None,
@@ -352,13 +352,13 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 30.0,
window_minutes: Some(60),
used_percent: 30,
window_duration_mins: Some(60),
resets_at: Some(456),
}),
secondary: Some(RateLimitWindow {
used_percent: 18.0,
window_minutes: Some(300),
used_percent: 18,
window_duration_mins: Some(300),
resets_at: Some(567),
}),
credits: None,
@@ -376,8 +376,8 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
primary: Some(RateLimitWindow {
used_percent: 20.0,
window_minutes: Some(300),
used_percent: 20,
window_duration_mins: Some(300),
resets_at: Some(100),
}),
secondary: None,
@@ -394,8 +394,8 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
limit_id: Some("codex_other".to_string()),
limit_name: Some("codex_other".to_string()),
primary: Some(RateLimitWindow {
used_percent: 90.0,
window_minutes: Some(60),
used_percent: 90,
window_duration_mins: Some(60),
resets_at: Some(200),
}),
secondary: None,
@@ -447,8 +447,8 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() {
limit_id: Some("codex_other".to_string()),
limit_name: Some("codex_other".to_string()),
primary: Some(RateLimitWindow {
used_percent: 95.0,
window_minutes: Some(60),
used_percent: 95,
window_duration_mins: Some(60),
resets_at: None,
}),
secondary: None,
@@ -1129,7 +1129,7 @@ async fn ui_snapshots_small_heights_task_running() {
// task (status indicator active) while an approval request is shown.
#[tokio::test]
async fn status_widget_and_approval_modal_snapshot() {
use codex_protocol::protocol::ExecApprovalRequestEvent;
use crate::approval_events::ExecApprovalRequestEvent;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
// Begin a running task so the status indicator would be active.
@@ -1421,7 +1421,7 @@ async fn status_line_branch_refreshes_after_interrupt() {
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
msg: EventMsg::TurnAborted(crate::chatwidget::test_events::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
@@ -1592,7 +1592,7 @@ async fn renamed_thread_footer_title_snapshot() {
chat.thread_id = Some(thread_id);
chat.handle_codex_event(Event {
id: "rename".to_string(),
msg: EventMsg::ThreadNameUpdated(codex_protocol::protocol::ThreadNameUpdatedEvent {
msg: EventMsg::ThreadNameUpdated(crate::chatwidget::test_events::ThreadNameUpdatedEvent {
thread_id,
thread_name: Some("Roadmap cleanup".to_string()),
}),
@@ -2223,7 +2223,7 @@ async fn user_prompt_submit_app_server_hook_notifications_render_snapshot() {
#[tokio::test]
async fn pre_tool_use_hook_events_render_snapshot() {
assert_hook_events_snapshot(
codex_protocol::protocol::HookEventName::PreToolUse,
codex_app_server_protocol::HookEventName::PreToolUse,
"pre-tool-use:0:/tmp/hooks.json",
"warming the shell",
"pre_tool_use_hook_events_render_snapshot",
@@ -2234,7 +2234,7 @@ async fn pre_tool_use_hook_events_render_snapshot() {
#[tokio::test]
async fn post_tool_use_hook_events_render_snapshot() {
assert_hook_events_snapshot(
codex_protocol::protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookEventName::PostToolUse,
"post-tool-use:0:/tmp/hooks.json",
"warming the shell",
"post_tool_use_hook_events_render_snapshot",
@@ -2248,18 +2248,18 @@ async fn completed_hook_with_no_entries_stays_out_of_history() {
chat.handle_codex_event(Event {
id: "hook-1".into(),
msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent {
msg: EventMsg::HookStarted(crate::tool_activity::HookStartedEvent {
turn_id: None,
run: codex_protocol::protocol::HookRunSummary {
run: codex_app_server_protocol::HookRunSummary {
id: "post-tool-use:0:/tmp/hooks.json".to_string(),
event_name: codex_protocol::protocol::HookEventName::PostToolUse,
handler_type: codex_protocol::protocol::HookHandlerType::Command,
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
scope: codex_protocol::protocol::HookScope::Turn,
event_name: codex_app_server_protocol::HookEventName::PostToolUse,
handler_type: codex_app_server_protocol::HookHandlerType::Command,
execution_mode: codex_app_server_protocol::HookExecutionMode::Sync,
scope: codex_app_server_protocol::HookScope::Turn,
source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(),
source: codex_protocol::protocol::HookSource::User,
source: codex_app_server_protocol::HookSource::User,
display_order: 0,
status: codex_protocol::protocol::HookRunStatus::Running,
status: codex_app_server_protocol::HookRunStatus::Running,
status_message: None,
started_at: 1,
completed_at: None,
@@ -2274,18 +2274,18 @@ async fn completed_hook_with_no_entries_stays_out_of_history() {
chat.handle_codex_event(Event {
id: "hook-1".into(),
msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent {
msg: EventMsg::HookCompleted(crate::tool_activity::HookCompletedEvent {
turn_id: None,
run: codex_protocol::protocol::HookRunSummary {
run: codex_app_server_protocol::HookRunSummary {
id: "post-tool-use:0:/tmp/hooks.json".to_string(),
event_name: codex_protocol::protocol::HookEventName::PostToolUse,
handler_type: codex_protocol::protocol::HookHandlerType::Command,
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
scope: codex_protocol::protocol::HookScope::Turn,
event_name: codex_app_server_protocol::HookEventName::PostToolUse,
handler_type: codex_app_server_protocol::HookHandlerType::Command,
execution_mode: codex_app_server_protocol::HookExecutionMode::Sync,
scope: codex_app_server_protocol::HookScope::Turn,
source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(),
source: codex_protocol::protocol::HookSource::User,
source: codex_app_server_protocol::HookSource::User,
display_order: 0,
status: codex_protocol::protocol::HookRunStatus::Completed,
status: codex_app_server_protocol::HookRunStatus::Completed,
status_message: None,
started_at: 1,
completed_at: Some(2),
@@ -2312,7 +2312,7 @@ async fn quiet_hook_linger_starts_when_delayed_redraw_reveals_hook() {
chat.handle_codex_event(hook_started_event(
"post-tool-use:0:/tmp/hooks.json",
codex_protocol::protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookEventName::PostToolUse,
Some("checking output policy"),
));
assert!(drain_insert_history(&mut rx).is_empty());
@@ -2320,8 +2320,8 @@ async fn quiet_hook_linger_starts_when_delayed_redraw_reveals_hook() {
reveal_running_hooks_after_delayed_redraw(&mut chat);
chat.handle_codex_event(hook_completed_event(
"post-tool-use:0:/tmp/hooks.json",
codex_protocol::protocol::HookEventName::PostToolUse,
codex_protocol::protocol::HookRunStatus::Completed,
codex_app_server_protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookRunStatus::Completed,
Vec::new(),
));
@@ -2340,19 +2340,19 @@ async fn blocked_and_failed_hooks_render_feedback_and_errors() {
chat.handle_codex_event(hook_completed_event(
"pre-tool-use:0:/tmp/hooks.json",
codex_protocol::protocol::HookEventName::PreToolUse,
codex_protocol::protocol::HookRunStatus::Blocked,
vec![codex_protocol::protocol::HookOutputEntry {
kind: codex_protocol::protocol::HookOutputEntryKind::Feedback,
codex_app_server_protocol::HookEventName::PreToolUse,
codex_app_server_protocol::HookRunStatus::Blocked,
vec![codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Feedback,
text: "run tests before touching the fixture".to_string(),
}],
));
chat.handle_codex_event(hook_completed_event(
"post-tool-use:1:/tmp/hooks.json",
codex_protocol::protocol::HookEventName::PostToolUse,
codex_protocol::protocol::HookRunStatus::Failed,
vec![codex_protocol::protocol::HookOutputEntry {
kind: codex_protocol::protocol::HookOutputEntryKind::Error,
codex_app_server_protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookRunStatus::Failed,
vec![codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Error,
text: "hook exited with code 7".to_string(),
}],
));
@@ -2380,7 +2380,7 @@ async fn completed_hook_with_output_flushes_immediately() {
chat.handle_codex_event(hook_started_event(
"pre-tool-use:0:/tmp/hooks.json:tool-call-1",
codex_protocol::protocol::HookEventName::PreToolUse,
codex_app_server_protocol::HookEventName::PreToolUse,
Some("checking command"),
));
reveal_running_hooks(&mut chat);
@@ -2388,10 +2388,10 @@ async fn completed_hook_with_output_flushes_immediately() {
chat.handle_codex_event(hook_completed_event(
"pre-tool-use:0:/tmp/hooks.json:tool-call-1",
codex_protocol::protocol::HookEventName::PreToolUse,
codex_protocol::protocol::HookRunStatus::Blocked,
vec![codex_protocol::protocol::HookOutputEntry {
kind: codex_protocol::protocol::HookOutputEntryKind::Feedback,
codex_app_server_protocol::HookEventName::PreToolUse,
codex_app_server_protocol::HookRunStatus::Blocked,
vec![codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Feedback,
text: "command blocked by policy".to_string(),
}],
));
@@ -2413,17 +2413,17 @@ async fn completed_hook_output_precedes_following_assistant_message() {
chat.handle_codex_event(hook_started_event(
"pre-tool-use:0:/tmp/hooks.json:tool-call-1",
codex_protocol::protocol::HookEventName::PreToolUse,
codex_app_server_protocol::HookEventName::PreToolUse,
Some("checking command"),
));
reveal_running_hooks(&mut chat);
chat.handle_codex_event(hook_completed_event(
"pre-tool-use:0:/tmp/hooks.json:tool-call-1",
codex_protocol::protocol::HookEventName::PreToolUse,
codex_protocol::protocol::HookRunStatus::Blocked,
vec![codex_protocol::protocol::HookOutputEntry {
kind: codex_protocol::protocol::HookOutputEntryKind::Feedback,
codex_app_server_protocol::HookEventName::PreToolUse,
codex_app_server_protocol::HookRunStatus::Blocked,
vec![codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Feedback,
text: "command blocked by policy".to_string(),
}],
));
@@ -2465,22 +2465,22 @@ async fn completed_same_id_hook_output_survives_restart() {
chat.handle_codex_event(hook_started_event(
hook_id,
codex_protocol::protocol::HookEventName::Stop,
codex_app_server_protocol::HookEventName::Stop,
Some("checking stop condition"),
));
reveal_running_hooks(&mut chat);
chat.handle_codex_event(hook_completed_event(
hook_id,
codex_protocol::protocol::HookEventName::Stop,
codex_protocol::protocol::HookRunStatus::Stopped,
vec![codex_protocol::protocol::HookOutputEntry {
kind: codex_protocol::protocol::HookOutputEntryKind::Stop,
codex_app_server_protocol::HookEventName::Stop,
codex_app_server_protocol::HookRunStatus::Stopped,
vec![codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Stop,
text: "continue with more context".to_string(),
}],
));
chat.handle_codex_event(hook_started_event(
hook_id,
codex_protocol::protocol::HookEventName::Stop,
codex_app_server_protocol::HookEventName::Stop,
Some("checking stop condition"),
));
reveal_running_hooks(&mut chat);
@@ -2509,7 +2509,7 @@ async fn identical_parallel_running_hooks_collapse_to_count() {
for tool_call_id in ["tool-call-1", "tool-call-2", "tool-call-3"] {
chat.handle_codex_event(hook_started_event(
&format!("pre-tool-use:0:/tmp/hooks.json:{tool_call_id}"),
codex_protocol::protocol::HookEventName::PreToolUse,
codex_app_server_protocol::HookEventName::PreToolUse,
Some("checking command policy"),
));
}
@@ -2530,7 +2530,7 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() {
chat.handle_codex_event(hook_started_event(
"pre-tool-use:0:/tmp/hooks.json",
codex_protocol::protocol::HookEventName::PreToolUse,
codex_app_server_protocol::HookEventName::PreToolUse,
Some("checking command policy"),
));
assert_eq!(chat.current_status.header, "Thinking");
@@ -2539,7 +2539,7 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() {
chat.handle_codex_event(hook_started_event(
"post-tool-use:1:/tmp/hooks.json",
codex_protocol::protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookEventName::PostToolUse,
Some("checking output policy"),
));
assert_eq!(chat.current_status.header, "Thinking");
@@ -2548,8 +2548,8 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() {
chat.handle_codex_event(hook_completed_event(
"pre-tool-use:0:/tmp/hooks.json",
codex_protocol::protocol::HookEventName::PreToolUse,
codex_protocol::protocol::HookRunStatus::Completed,
codex_app_server_protocol::HookEventName::PreToolUse,
codex_app_server_protocol::HookRunStatus::Completed,
Vec::new(),
));
assert_eq!(chat.current_status.header, "Thinking");
@@ -2561,8 +2561,8 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() {
chat.handle_codex_event(hook_completed_event(
"post-tool-use:1:/tmp/hooks.json",
codex_protocol::protocol::HookEventName::PostToolUse,
codex_protocol::protocol::HookRunStatus::Completed,
codex_app_server_protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookRunStatus::Completed,
Vec::new(),
));
assert_eq!(chat.current_status.header, "Thinking");
@@ -2589,7 +2589,7 @@ async fn running_hook_does_not_displace_active_exec_cell() {
chat.handle_codex_event(hook_started_event(
"post-tool-use:0:/tmp/hooks.json",
codex_protocol::protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookEventName::PostToolUse,
Some("checking output policy"),
));
reveal_running_hooks(&mut chat);
@@ -2608,8 +2608,8 @@ async fn running_hook_does_not_displace_active_exec_cell() {
chat.handle_codex_event(hook_completed_event(
"post-tool-use:0:/tmp/hooks.json",
codex_protocol::protocol::HookEventName::PostToolUse,
codex_protocol::protocol::HookRunStatus::Completed,
codex_app_server_protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookRunStatus::Completed,
Vec::new(),
));
assert!(drain_insert_history(&mut rx).is_empty());
@@ -2637,7 +2637,7 @@ async fn hidden_active_hook_does_not_add_transcript_separator() {
chat.handle_codex_event(hook_started_event(
"post-tool-use:0:/tmp/hooks.json",
codex_protocol::protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookEventName::PostToolUse,
Some("checking output policy"),
));
let hidden_hook_transcript = chat
@@ -2672,17 +2672,17 @@ async fn hook_completed_before_reveal_renders_completed_without_running_flash()
chat.handle_codex_event(hook_started_event(
"session-start:0:/tmp/hooks.json",
codex_protocol::protocol::HookEventName::SessionStart,
codex_app_server_protocol::HookEventName::SessionStart,
Some("warming the shell"),
));
let started_hidden_snapshot = active_hook_blob(&chat);
chat.handle_codex_event(hook_completed_event(
"session-start:0:/tmp/hooks.json",
codex_protocol::protocol::HookEventName::SessionStart,
codex_protocol::protocol::HookRunStatus::Completed,
vec![codex_protocol::protocol::HookOutputEntry {
kind: codex_protocol::protocol::HookOutputEntryKind::Context,
codex_app_server_protocol::HookEventName::SessionStart,
codex_app_server_protocol::HookRunStatus::Completed,
vec![codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Context,
text: "session context".to_string(),
}],
));
@@ -2700,7 +2700,7 @@ async fn hook_completed_before_reveal_renders_completed_without_running_flash()
#[tokio::test]
async fn session_start_hook_events_render_snapshot() {
assert_hook_events_snapshot(
codex_protocol::protocol::HookEventName::SessionStart,
codex_app_server_protocol::HookEventName::SessionStart,
"session-start:0:/tmp/hooks.json",
"warming the shell",
"session_start_hook_events_render_snapshot",
@@ -2710,17 +2710,17 @@ async fn session_start_hook_events_render_snapshot() {
fn hook_started_event(
id: &str,
event_name: codex_protocol::protocol::HookEventName,
event_name: codex_app_server_protocol::HookEventName,
status_message: Option<&str>,
) -> Event {
Event {
id: id.to_string(),
msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent {
msg: EventMsg::HookStarted(crate::tool_activity::HookStartedEvent {
turn_id: None,
run: hook_run_summary(
id,
event_name,
codex_protocol::protocol::HookRunStatus::Running,
codex_app_server_protocol::HookRunStatus::Running,
status_message,
Vec::new(),
),
@@ -2730,13 +2730,13 @@ fn hook_started_event(
fn hook_completed_event(
id: &str,
event_name: codex_protocol::protocol::HookEventName,
status: codex_protocol::protocol::HookRunStatus,
entries: Vec<codex_protocol::protocol::HookOutputEntry>,
event_name: codex_app_server_protocol::HookEventName,
status: codex_app_server_protocol::HookRunStatus,
entries: Vec<codex_app_server_protocol::HookOutputEntry>,
) -> Event {
Event {
id: id.to_string(),
msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent {
msg: EventMsg::HookCompleted(crate::tool_activity::HookCompletedEvent {
turn_id: None,
run: hook_run_summary(
id, event_name, status, /*status_message*/ None, entries,
@@ -2747,25 +2747,25 @@ fn hook_completed_event(
fn hook_run_summary(
id: &str,
event_name: codex_protocol::protocol::HookEventName,
status: codex_protocol::protocol::HookRunStatus,
event_name: codex_app_server_protocol::HookEventName,
status: codex_app_server_protocol::HookRunStatus,
status_message: Option<&str>,
entries: Vec<codex_protocol::protocol::HookOutputEntry>,
) -> codex_protocol::protocol::HookRunSummary {
codex_protocol::protocol::HookRunSummary {
entries: Vec<codex_app_server_protocol::HookOutputEntry>,
) -> codex_app_server_protocol::HookRunSummary {
codex_app_server_protocol::HookRunSummary {
id: id.to_string(),
event_name,
handler_type: codex_protocol::protocol::HookHandlerType::Command,
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
scope: codex_protocol::protocol::HookScope::Turn,
handler_type: codex_app_server_protocol::HookHandlerType::Command,
execution_mode: codex_app_server_protocol::HookExecutionMode::Sync,
scope: codex_app_server_protocol::HookScope::Turn,
source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(),
source: codex_protocol::protocol::HookSource::User,
source: codex_app_server_protocol::HookSource::User,
display_order: 0,
status,
status_message: status_message.map(str::to_string),
started_at: 1,
completed_at: (status != codex_protocol::protocol::HookRunStatus::Running).then_some(2),
duration_ms: (status != codex_protocol::protocol::HookRunStatus::Running).then_some(1),
completed_at: (status != codex_app_server_protocol::HookRunStatus::Running).then_some(2),
duration_ms: (status != codex_app_server_protocol::HookRunStatus::Running).then_some(1),
entries,
}
}
@@ -0,0 +1,135 @@
//! User-message display models and helpers for the chat widget.
//!
//! App-server turn items and queued TUI submissions describe user input in
//! slightly different shapes. This module keeps the display-only representation
//! and comparison keys together so chat rendering can avoid duplicate user rows
//! without depending on core protocol event wrappers.
use std::path::PathBuf;
use codex_protocol::items::UserMessageItem;
use codex_protocol::user_input::TextElement;
use codex_protocol::user_input::UserInput;
use super::ChatWidget;
use super::append_text_with_rebased_elements;
#[derive(Debug, Clone)]
pub(crate) struct UserMessageEvent {
pub(super) message: String,
pub(super) images: Option<Vec<String>>,
pub(super) local_images: Vec<PathBuf>,
pub(super) text_elements: Vec<TextElement>,
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct RenderedUserMessageEvent {
pub(super) message: String,
pub(super) remote_image_urls: Vec<String>,
pub(super) local_images: Vec<PathBuf>,
pub(super) text_elements: Vec<TextElement>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct PendingSteerCompareKey {
pub(super) message: String,
pub(super) image_count: usize,
}
impl ChatWidget {
pub(super) fn rendered_user_message_event_from_parts(
message: String,
text_elements: Vec<TextElement>,
local_images: Vec<PathBuf>,
remote_image_urls: Vec<String>,
) -> RenderedUserMessageEvent {
RenderedUserMessageEvent {
message,
remote_image_urls,
local_images,
text_elements,
}
}
pub(super) fn rendered_user_message_event_from_event(
event: &UserMessageEvent,
) -> RenderedUserMessageEvent {
Self::rendered_user_message_event_from_parts(
event.message.clone(),
event.text_elements.clone(),
event.local_images.clone(),
event.images.clone().unwrap_or_default(),
)
}
/// Build the compare key for a submitted pending steer without invoking the
/// expensive request-serialization path. Pending steers only need to match the
/// committed `ItemCompleted(UserMessage)` emitted after core drains input, which
/// preserves flattened text and total image count but not UI-only text ranges or
/// local image paths.
pub(super) fn pending_steer_compare_key_from_items(
items: &[UserInput],
) -> PendingSteerCompareKey {
let mut message = String::new();
let mut image_count = 0;
for item in items {
match item {
UserInput::Text { text, .. } => message.push_str(text),
UserInput::Image { .. } | UserInput::LocalImage { .. } => image_count += 1,
UserInput::Skill { .. } | UserInput::Mention { .. } => {}
_ => {}
}
}
PendingSteerCompareKey {
message,
image_count,
}
}
pub(super) fn pending_steer_compare_key_from_item(
item: &UserMessageItem,
) -> PendingSteerCompareKey {
Self::pending_steer_compare_key_from_items(&item.content)
}
pub(super) fn rendered_user_message_event_from_inputs(
items: &[UserInput],
) -> RenderedUserMessageEvent {
let mut message = String::new();
let mut remote_image_urls = Vec::new();
let mut local_images = Vec::new();
let mut text_elements = Vec::new();
for item in items {
match item {
UserInput::Text {
text,
text_elements: current_text_elements,
} => append_text_with_rebased_elements(
&mut message,
&mut text_elements,
text,
current_text_elements.iter().map(|element| {
TextElement::new(
element.byte_range,
element.placeholder(text).map(str::to_string),
)
}),
),
UserInput::Image { image_url } => remote_image_urls.push(image_url.clone()),
UserInput::LocalImage { path } => local_images.push(path.clone()),
UserInput::Skill { .. } | UserInput::Mention { .. } => {}
_ => {}
}
}
Self::rendered_user_message_event_from_parts(
message,
text_elements,
local_images,
remote_image_urls,
)
}
}
+4 -4
View File
@@ -1,5 +1,6 @@
use crate::history_cell::PlainHistoryCell;
use crate::legacy_core::config::Config;
use crate::session_state::SessionNetworkProxyRuntime;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStack;
@@ -12,7 +13,6 @@ use codex_config::RequirementSource;
use codex_config::ResidencyRequirement;
use codex_config::SandboxModeRequirement;
use codex_config::WebSearchModeRequirement;
use codex_protocol::protocol::SessionNetworkProxyRuntime;
use ratatui::style::Stylize;
use ratatui::text::Line;
use toml::Value as TomlValue;
@@ -505,6 +505,7 @@ mod tests {
use super::render_debug_config_lines;
use super::session_all_proxy_url;
use crate::legacy_core::config::Constrained;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStack;
@@ -532,7 +533,6 @@ mod tests {
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_utils_absolute_path::AbsolutePathBuf;
use ratatui::text::Line;
use std::collections::BTreeMap;
@@ -615,7 +615,7 @@ mod tests {
let requirements = ConfigRequirements {
approval_policy: ConstrainedWithSource::new(
Constrained::allow_any(AskForApproval::OnRequest),
Constrained::allow_any(AskForApproval::OnRequest.to_core()),
Some(RequirementSource::CloudRequirements),
),
approvals_reviewer: ConstrainedWithSource::new(
@@ -679,7 +679,7 @@ mod tests {
};
let requirements_toml = ConfigRequirementsToml {
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
allowed_approval_policies: Some(vec![AskForApproval::OnRequest.to_core()]),
allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]),
allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]),
remote_sandbox_config: None,
+21
View File
@@ -0,0 +1,21 @@
//! Minimal file-change model used by TUI diff rendering and approval previews.
use std::path::PathBuf;
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum FileChange {
Add {
content: String,
},
Delete {
content: String,
},
Update {
unified_diff: String,
move_path: Option<PathBuf>,
},
}
+2 -2
View File
@@ -77,6 +77,7 @@ const LIGHT_256_GUTTER_FG_IDX: u8 = 236;
use crate::color::is_light;
use crate::color::perceptual_distance;
use crate::diff_model::FileChange;
use crate::exec_command::relativize_to_home;
use crate::render::Insets;
use crate::render::highlight::DiffScopeBackgroundRgbs;
@@ -94,7 +95,6 @@ use crate::terminal_palette::indexed_color;
use crate::terminal_palette::rgb_color;
use crate::terminal_palette::stdout_color_level;
use codex_git_utils::get_git_repo_root;
use codex_protocol::protocol::FileChange;
use codex_terminal_detection::TerminalName;
use codex_terminal_detection::terminal_info;
@@ -299,7 +299,7 @@ pub struct DiffSummary {
}
impl DiffSummary {
pub fn new(changes: HashMap<PathBuf, FileChange>, cwd: AbsolutePathBuf) -> Self {
pub(crate) fn new(changes: HashMap<PathBuf, FileChange>, cwd: AbsolutePathBuf) -> Self {
Self { changes, cwd }
}
}
+1 -1
View File
@@ -8,8 +8,8 @@
use std::time::Duration;
use std::time::Instant;
use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::ExecCommandSource;
#[derive(Clone, Debug, Default)]
pub(crate) struct CommandOutput {
+2 -2
View File
@@ -13,8 +13,8 @@ use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_line;
use crate::wrapping::adaptive_wrap_lines;
use codex_ansi_escape::ansi_escape_line;
use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::ExecCommandSource;
use codex_shell_command::bash::extract_bash_command;
use codex_utils_elapsed::format_duration;
use itertools::Itertools;
@@ -713,7 +713,7 @@ const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new(
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::protocol::ExecCommandSource;
use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;
use pretty_assertions::assert_eq;
fn render_line_text(line: &Line<'static>) -> String {
+71 -48
View File
@@ -10,6 +10,7 @@
//! bumps the active-cell revision tracked by `ChatWidget`, so the cache key changes whenever the
//! rendered transcript output can change.
use crate::diff_model::FileChange;
use crate::diff_render::create_diff_summary;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
@@ -27,6 +28,7 @@ use crate::render::line_utils::line_to_static;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
use crate::render::renderable::Renderable;
use crate::session_state::ThreadSessionState;
use crate::style::proposed_plan_style;
use crate::style::user_message_style;
#[cfg(test)]
@@ -35,6 +37,7 @@ use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use crate::text_formatting::format_and_truncate_tool_result;
use crate::text_formatting::truncate_text;
use crate::tool_activity::McpInvocation;
use crate::tooltips;
use crate::ui_consts::LIVE_PREFIX_COLS;
use crate::update_action::UpdateAction;
@@ -43,8 +46,13 @@ use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_line;
use crate::wrapping::adaptive_wrap_lines;
use base64::Engine;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::McpAuthStatus;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::McpServerStatusDetail;
use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile;
use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
use codex_config::types::McpServerTransportConfig;
#[cfg(test)]
use codex_mcp::qualified_mcp_tool_name_prefix;
@@ -54,7 +62,6 @@ use codex_protocol::account::PlanType;
use codex_protocol::mcp::Resource;
#[cfg(test)]
use codex_protocol::mcp::ResourceTemplate;
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::WebSearchAction;
use codex_protocol::models::local_image_label_text;
@@ -62,11 +69,6 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::McpAuthStatus;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputQuestion;
use codex_protocol::user_input::TextElement;
@@ -862,11 +864,11 @@ fn exec_snippet(command: &[String]) -> String {
pub fn new_approval_decision_cell(
command: Vec<String>,
decision: codex_protocol::protocol::ReviewDecision,
decision: crate::approval_display::ReviewDecision,
actor: ApprovalDecisionActor,
) -> Box<dyn HistoryCell> {
use codex_protocol::protocol::NetworkPolicyRuleAction;
use codex_protocol::protocol::ReviewDecision::*;
use crate::approval_display::ReviewDecision::*;
use codex_protocol::approvals::NetworkPolicyRuleAction;
let (symbol, summary): (Span<'static>, Vec<Span<'static>>) = match decision {
Approved => {
@@ -1231,28 +1233,24 @@ impl HistoryCell for SessionInfoCell {
pub(crate) fn new_session_info(
config: &Config,
requested_model: &str,
event: SessionConfiguredEvent,
session: &ThreadSessionState,
is_first_event: bool,
tooltip_override: Option<String>,
auth_plan: Option<PlanType>,
show_fast_status: bool,
) -> SessionInfoCell {
let SessionConfiguredEvent {
model,
reasoning_effort,
approval_policy,
permission_profile,
..
} = event;
// Header box rendered as history (so it appears at the very top)
let header = SessionHeaderHistoryCell::new(
model.clone(),
reasoning_effort,
session.model.clone(),
session.reasoning_effort,
show_fast_status,
config.cwd.to_path_buf(),
CODEX_CLI_VERSION,
)
.with_yolo_mode(has_yolo_permissions(approval_policy, &permission_profile));
.with_yolo_mode(has_yolo_permissions(
session.approval_policy,
&session.permission_profile,
));
let mut parts: Vec<Box<dyn HistoryCell>> = vec![Box::new(header)];
if is_first_event {
@@ -1298,11 +1296,11 @@ pub(crate) fn new_session_info(
{
parts.push(Box::new(tooltips));
}
if requested_model != model {
if requested_model != session.model.as_str() {
let lines = vec![
"model changed:".magenta().bold().into(),
format!("requested: {requested_model}").into(),
format!("used: {model}").into(),
format!("used: {}", session.model).into(),
];
parts.push(Box::new(PlainHistoryCell { lines }));
}
@@ -1313,7 +1311,7 @@ pub(crate) fn new_session_info(
pub(crate) fn is_yolo_mode(config: &Config) -> bool {
has_yolo_permissions(
config.permissions.approval_policy.value(),
AskForApproval::from(config.permissions.approval_policy.value()),
&config.permissions.permission_profile(),
)
}
@@ -1322,17 +1320,27 @@ fn has_yolo_permissions(
approval_policy: AskForApproval,
permission_profile: &PermissionProfile,
) -> bool {
let permission_profile = AppServerPermissionProfile::from(permission_profile.clone());
approval_policy == AskForApproval::Never
&& matches!(
permission_profile,
PermissionProfile::Disabled
| PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Unrestricted,
network: codex_protocol::protocol::NetworkSandboxPolicy::Enabled,
AppServerPermissionProfile::Disabled
| AppServerPermissionProfile::Managed {
file_system: PermissionProfileFileSystemPermissions::Unrestricted,
network: PermissionProfileNetworkPermissions { enabled: true },
}
)
}
fn mcp_auth_status_label(status: McpAuthStatus) -> &'static str {
match status {
McpAuthStatus::Unsupported => "Unsupported",
McpAuthStatus::NotLoggedIn => "Not logged in",
McpAuthStatus::BearerToken => "Bearer token",
McpAuthStatus::OAuth => "OAuth",
}
}
pub(crate) fn new_user_prompt(
message: String,
text_elements: Vec<TextElement>,
@@ -2041,7 +2049,13 @@ pub(crate) fn new_mcp_tools_output(
}
lines.push(header.into());
lines.push(vec![" • Status: ".into(), "enabled".green()].into());
lines.push(vec![" • Auth: ".into(), auth_status.to_string().into()].into());
lines.push(
vec![
" • Auth: ".into(),
mcp_auth_status_label(auth_status).into(),
]
.into(),
);
match &cfg.transport {
McpServerTransportConfig::Stdio {
@@ -2219,7 +2233,13 @@ pub(crate) fn new_mcp_tools_output_from_statuses(
codex_app_server_protocol::McpAuthStatus::OAuth => McpAuthStatus::OAuth,
})
.unwrap_or(McpAuthStatus::Unsupported);
lines.push(vec![" • Auth: ".into(), auth_status.to_string().into()].into());
lines.push(
vec![
" • Auth: ".into(),
mcp_auth_status_label(auth_status).into(),
]
.into(),
);
if let Some(cfg) = cfg {
match &cfg.transport {
@@ -2992,7 +3012,10 @@ mod tests {
use crate::exec_cell::ExecCell;
use crate::legacy_core::config::Config;
use crate::legacy_core::config::ConfigBuilder;
use crate::session_state::ThreadSessionState;
use crate::wrapping::word_wrap_lines;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::McpAuthStatus;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerDisabledReason;
use codex_otel::RuntimeMetricTotals;
@@ -3001,9 +3024,6 @@ mod tests {
use codex_protocol::account::PlanType;
use codex_protocol::models::WebSearchAction;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::McpAuthStatus;
use codex_protocol::protocol::SessionConfiguredEvent;
use dirs::home_dir;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
@@ -3012,9 +3032,9 @@ mod tests {
use std::collections::HashMap;
use std::path::PathBuf;
use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::mcp::Tool;
use codex_protocol::protocol::ExecCommandSource;
use rmcp::model::Content;
const SMALL_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
@@ -3183,10 +3203,11 @@ mod tests {
);
}
fn session_configured_event(model: &str) -> SessionConfiguredEvent {
SessionConfiguredEvent {
session_id: ThreadId::new(),
fn session_configured_event(model: &str) -> ThreadSessionState {
ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: model.to_string(),
model_provider_id: "test-provider".to_string(),
@@ -3195,10 +3216,10 @@ mod tests {
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
cwd: test_path_buf("/tmp/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}
@@ -3296,7 +3317,7 @@ mod tests {
let cell = new_session_info(
&config,
"gpt-5",
session_configured_event("gpt-5"),
&session_configured_event("gpt-5"),
/*is_first_event*/ false,
Some("Model just became available".to_string()),
Some(PlanType::Free),
@@ -3318,7 +3339,7 @@ mod tests {
let cell = new_session_info(
&config,
"gpt-5",
session_configured_event("gpt-5"),
&session_configured_event("gpt-5"),
/*is_first_event*/ false,
Some("Model just became available".to_string()),
Some(PlanType::Free),
@@ -3335,7 +3356,7 @@ mod tests {
let cell = new_session_info(
&config,
"gpt-5",
session_configured_event("gpt-5"),
&session_configured_event("gpt-5"),
/*is_first_event*/ true,
Some("Model just became available".to_string()),
Some(PlanType::Free),
@@ -3354,7 +3375,7 @@ mod tests {
let cell = new_session_info(
&config,
"gpt-5",
session_configured_event("gpt-5"),
&session_configured_event("gpt-5"),
/*is_first_event*/ false,
Some("Model just became available".to_string()),
Some(PlanType::Free),
@@ -4215,10 +4236,11 @@ mod tests {
#[test]
fn yolo_mode_includes_managed_full_access_profiles() {
let permission_profile = PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Unrestricted,
network: codex_protocol::protocol::NetworkSandboxPolicy::Enabled,
};
let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: true },
file_system: PermissionProfileFileSystemPermissions::Unrestricted,
}
.into();
assert!(has_yolo_permissions(
AskForApproval::Never,
@@ -4228,9 +4250,10 @@ mod tests {
#[test]
fn yolo_mode_excludes_external_sandbox_profiles() {
let permission_profile = PermissionProfile::External {
network: codex_protocol::protocol::NetworkSandboxPolicy::Enabled,
};
let permission_profile: PermissionProfile = AppServerPermissionProfile::External {
network: PermissionProfileNetworkPermissions { enabled: true },
}
.into();
assert!(!has_yolo_permissions(
AskForApproval::Never,
+9 -9
View File
@@ -14,11 +14,11 @@ use super::HistoryCell;
use crate::exec_cell::spinner;
use crate::render::renderable::Renderable;
use crate::shimmer::shimmer_spans;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use codex_app_server_protocol::HookEventName;
use codex_app_server_protocol::HookOutputEntry;
use codex_app_server_protocol::HookOutputEntryKind;
use codex_app_server_protocol::HookRunStatus;
use codex_app_server_protocol::HookRunSummary;
use ratatui::prelude::*;
use ratatui::style::Stylize;
use ratatui::widgets::Paragraph;
@@ -765,11 +765,11 @@ mod tests {
HookRunSummary {
id: id.to_string(),
event_name: HookEventName::PostToolUse,
handler_type: codex_protocol::protocol::HookHandlerType::Command,
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
scope: codex_protocol::protocol::HookScope::Turn,
handler_type: codex_app_server_protocol::HookHandlerType::Command,
execution_mode: codex_app_server_protocol::HookExecutionMode::Sync,
scope: codex_app_server_protocol::HookScope::Turn,
source_path: test_path_buf("/tmp/hooks.json").abs(),
source: codex_protocol::protocol::HookSource::User,
source: codex_app_server_protocol::HookSource::User,
display_order: 0,
status: HookRunStatus::Running,
status_message: Some("checking output policy".to_string()),
+119 -419
View File
@@ -12,6 +12,8 @@ use crate::legacy_core::config::load_config_as_toml_with_cli_overrides;
use crate::legacy_core::config::resolve_oss_provider;
use crate::legacy_core::format_exec_policy_error_with_source;
use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt;
use crate::session_resume::ResolveCwdOutcome;
use crate::session_resume::resolve_cwd_for_resume_or_fork;
use additional_dirs::add_dir_warning_message;
use app::App;
pub use app::AppExitInfo;
@@ -24,6 +26,7 @@ use codex_app_server_client::InProcessClientStartArgs;
use codex_app_server_client::RemoteAppServerClient;
use codex_app_server_client::RemoteAppServerConnectArgs;
use codex_app_server_protocol::Account as AppServerAccount;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::AuthMode as AppServerAuthMode;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::Thread as AppServerThread;
@@ -46,11 +49,6 @@ use codex_protocol::ThreadId;
use codex_protocol::config_types::AltScreenMode;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::TurnContextItem;
use codex_rollout::read_session_meta_line;
use codex_rollout::state_db::get_state_db;
use codex_state::log_db;
use codex_terminal_detection::terminal_info;
@@ -58,16 +56,17 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks;
use codex_utils_oss::ensure_oss_provider_ready;
use codex_utils_oss::get_default_model_for_oss_provider;
use codex_utils_path as path_utils;
use color_eyre::eyre::WrapErr;
use cwd_prompt::CwdPromptAction;
use cwd_prompt::CwdPromptOutcome;
use cwd_prompt::CwdSelection;
use std::fs::OpenOptions;
use std::future::Future;
use std::path::Path;
use std::path::PathBuf;
#[cfg(test)]
use std::pin::Pin;
use std::sync::Arc;
pub use token_usage::FinalOutput;
pub use token_usage::TokenUsage;
use tracing::Level;
use tracing::error;
use tracing::warn;
@@ -88,6 +87,8 @@ mod app_event;
mod app_event_sender;
mod app_server_approval_conversions;
mod app_server_session;
mod approval_display;
mod approval_events;
mod ascii_animation;
#[cfg(not(target_os = "linux"))]
mod audio_device;
@@ -117,6 +118,7 @@ pub use custom_terminal::Terminal;
mod auto_review_denials;
mod cwd_prompt;
mod debug_config;
mod diff_model;
mod diff_render;
mod exec_cell;
mod exec_command;
@@ -157,6 +159,8 @@ mod resize_reflow_cap;
mod resume_picker;
mod selection_list;
mod session_log;
mod session_resume;
mod session_state;
mod shimmer;
mod skills_helpers;
mod slash_command;
@@ -168,9 +172,12 @@ mod terminal_palette;
mod terminal_title;
mod text_formatting;
mod theme_picker;
mod token_usage;
mod tool_activity;
mod tooltips;
mod transcript_reflow;
mod tui;
mod turn_state;
mod ui_consts;
pub(crate) mod update_action;
pub use update_action::UpdateAction;
@@ -189,7 +196,7 @@ mod width;
mod voice {
use crate::app_event_sender::AppEventSender;
use crate::legacy_core::config::Config;
use codex_protocol::protocol::RealtimeAudioFrame;
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU16;
@@ -231,7 +238,10 @@ mod voice {
Err("voice output is unavailable in this build".to_string())
}
pub(crate) fn enqueue_frame(&self, _frame: &RealtimeAudioFrame) -> Result<(), String> {
pub(crate) fn enqueue_frame(
&self,
_frame: &ThreadRealtimeAudioChunk,
) -> Result<(), String> {
Err("voice output is unavailable in this build".to_string())
}
@@ -435,15 +445,17 @@ pub(crate) async fn start_app_server_for_picker(
}
#[cfg(test)]
pub(crate) async fn start_embedded_app_server_for_picker(
pub(crate) fn start_embedded_app_server_for_picker(
config: &Config,
) -> color_eyre::Result<AppServerSession> {
start_app_server_for_picker(
config,
&AppServerTarget::Embedded,
Arc::new(EnvironmentManager::default_for_tests()),
)
.await
) -> Pin<Box<dyn Future<Output = color_eyre::Result<AppServerSession>> + '_>> {
Box::pin(async move {
start_app_server_for_picker(
config,
&AppServerTarget::Embedded,
Arc::new(EnvironmentManager::default_for_tests()),
)
.await
})
}
#[allow(clippy::too_many_arguments)]
@@ -482,7 +494,8 @@ where
log_db,
environment_manager,
config_warnings,
session_source: codex_protocol::protocol::SessionSource::Cli,
session_source: serde_json::from_value(serde_json::json!("cli"))
.unwrap_or_else(|err| panic!("cli session source should deserialize: {err}")),
enable_codex_api_key_env: false,
client_name: "codex-tui".to_string(),
client_version: env!("CARGO_PKG_VERSION").to_string(),
@@ -700,7 +713,7 @@ pub async fn run_main(
let (sandbox_mode, approval_policy) = if cli.dangerously_bypass_approvals_and_sandbox {
(
Some(SandboxMode::DangerFullAccess),
Some(AskForApproval::Never),
Some(AskForApproval::Never.to_core()),
)
} else {
(
@@ -1078,7 +1091,7 @@ async fn run_ratatui_app(
UpdatePromptOutcome::RunUpdate(action) => {
terminal_restore_guard.restore()?;
return Ok(AppExitInfo {
token_usage: codex_protocol::protocol::TokenUsage::default(),
token_usage: crate::token_usage::TokenUsage::default(),
thread_id: None,
thread_name: None,
update_action: Some(action),
@@ -1155,7 +1168,7 @@ async fn run_ratatui_app(
session_log::log_session_end();
let _ = tui.terminal.clear();
return Ok(AppExitInfo {
token_usage: codex_protocol::protocol::TokenUsage::default(),
token_usage: crate::token_usage::TokenUsage::default(),
thread_id: None,
thread_name: None,
update_action: None,
@@ -1200,7 +1213,7 @@ async fn run_ratatui_app(
session_log::log_session_end();
let _ = tui.terminal.clear();
Ok(AppExitInfo {
token_usage: codex_protocol::protocol::TokenUsage::default(),
token_usage: crate::token_usage::TokenUsage::default(),
thread_id: None,
thread_name: None,
update_action: None,
@@ -1261,7 +1274,7 @@ async fn run_ratatui_app(
terminal_restore_guard.restore_silently();
session_log::log_session_end();
return Ok(AppExitInfo {
token_usage: codex_protocol::protocol::TokenUsage::default(),
token_usage: crate::token_usage::TokenUsage::default(),
thread_id: None,
thread_name: None,
update_action: None,
@@ -1322,7 +1335,7 @@ async fn run_ratatui_app(
terminal_restore_guard.restore_silently();
session_log::log_session_end();
return Ok(AppExitInfo {
token_usage: codex_protocol::protocol::TokenUsage::default(),
token_usage: crate::token_usage::TokenUsage::default(),
thread_id: None,
thread_name: None,
update_action: None,
@@ -1367,7 +1380,7 @@ async fn run_ratatui_app(
terminal_restore_guard.restore_silently();
session_log::log_session_end();
return Ok(AppExitInfo {
token_usage: codex_protocol::protocol::TokenUsage::default(),
token_usage: crate::token_usage::TokenUsage::default(),
thread_id: None,
thread_name: None,
update_action: None,
@@ -1472,123 +1485,6 @@ async fn run_ratatui_app(
app_result
}
pub(crate) async fn resolve_session_thread_id(
path: &Path,
id_str_if_uuid: Option<&str>,
) -> Option<ThreadId> {
match id_str_if_uuid {
Some(id_str) => ThreadId::from_string(id_str).ok(),
None => read_session_meta_line(path)
.await
.ok()
.map(|meta_line| meta_line.meta.id),
}
}
pub(crate) async fn read_session_cwd(
config: &Config,
thread_id: ThreadId,
path: Option<&Path>,
) -> Option<PathBuf> {
if let Some(state_db_ctx) = get_state_db(config).await
&& let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await
{
return Some(metadata.cwd);
}
// Prefer the latest TurnContext cwd so resume/fork reflects the most recent
// session directory (for the changed-cwd prompt) when DB data is unavailable.
// The alternative would be mutating the SessionMeta line when the session cwd
// changes, but the rollout is an append-only JSONL log and rewriting the head
// would be error-prone.
let path = path?;
if let Some(cwd) = read_latest_turn_context(path).await.map(|item| item.cwd) {
return Some(cwd);
}
match read_session_meta_line(path).await {
Ok(meta_line) => Some(meta_line.meta.cwd),
Err(err) => {
let rollout_path = path.display().to_string();
tracing::warn!(
%rollout_path,
%err,
"Failed to read session metadata from rollout"
);
None
}
}
}
pub(crate) async fn read_session_model(
config: &Config,
thread_id: ThreadId,
path: Option<&Path>,
) -> Option<String> {
if let Some(state_db_ctx) = get_state_db(config).await
&& let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await
&& let Some(model) = metadata.model
{
return Some(model);
}
let path = path?;
read_latest_turn_context(path).await.map(|item| item.model)
}
async fn read_latest_turn_context(path: &Path) -> Option<TurnContextItem> {
let text = tokio::fs::read_to_string(path).await.ok()?;
for line in text.lines().rev() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Ok(rollout_line) = serde_json::from_str::<RolloutLine>(trimmed) else {
continue;
};
if let RolloutItem::TurnContext(item) = rollout_line.item {
return Some(item);
}
}
None
}
pub(crate) fn cwds_differ(current_cwd: &Path, session_cwd: &Path) -> bool {
!path_utils::paths_match_after_normalization(current_cwd, session_cwd)
}
pub(crate) enum ResolveCwdOutcome {
Continue(Option<PathBuf>),
Exit,
}
pub(crate) async fn resolve_cwd_for_resume_or_fork(
tui: &mut Tui,
config: &Config,
current_cwd: &Path,
thread_id: ThreadId,
path: Option<&Path>,
action: CwdPromptAction,
allow_prompt: bool,
) -> color_eyre::Result<ResolveCwdOutcome> {
let Some(history_cwd) = read_session_cwd(config, thread_id, path).await else {
return Ok(ResolveCwdOutcome::Continue(None));
};
if allow_prompt && cwds_differ(current_cwd, &history_cwd) {
let selection_outcome =
cwd_prompt::run_cwd_selection_prompt(tui, action, current_cwd, &history_cwd).await?;
return Ok(match selection_outcome {
CwdPromptOutcome::Selection(CwdSelection::Current) => {
ResolveCwdOutcome::Continue(Some(current_cwd.to_path_buf()))
}
CwdPromptOutcome::Selection(CwdSelection::Session) => {
ResolveCwdOutcome::Continue(Some(history_cwd))
}
CwdPromptOutcome::Exit => ResolveCwdOutcome::Exit,
});
}
Ok(ResolveCwdOutcome::Continue(Some(history_cwd)))
}
#[expect(
clippy::print_stderr,
reason = "TUI should no longer be displayed, so we can write to stderr."
@@ -1762,23 +1658,29 @@ mod tests {
use super::*;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_config::config_toml::ProjectConfig;
use codex_features::Feature;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TurnContextItem;
use pretty_assertions::assert_eq;
use serial_test::serial;
use tempfile::TempDir;
fn run_large_stack_test<T>(body: impl FnOnce() -> T + Send + 'static) -> T
where
T: Send + 'static,
{
std::thread::Builder::new()
.name("codex-tui-lib-test".to_string())
.stack_size(8 * 1024 * 1024)
.spawn(body)
.expect("spawn large-stack test thread")
.join()
.expect("large-stack test thread panicked")
}
async fn build_config(temp_dir: &TempDir) -> std::io::Result<Config> {
ConfigBuilder::default()
.codex_home(temp_dir.path().to_path_buf())
@@ -2031,17 +1933,6 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn read_session_cwd_returns_none_without_sqlite_or_rollout_path() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let cwd = read_session_cwd(&config, ThreadId::new(), /*path*/ None).await;
assert_eq!(cwd, None);
Ok(())
}
#[tokio::test]
#[serial]
async fn windows_shows_trust_prompt_without_sandbox() -> std::io::Result<()> {
@@ -2079,62 +1970,74 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn lookup_session_target_by_name_uses_backend_title_search() -> color_eyre::Result<()> {
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let thread_id = ThreadId::new();
let rollout_path = temp_dir
.path()
.join("sessions/2025/02/01")
.join(format!("rollout-2025-02-01T10-00-00-{thread_id}.jsonl"));
let rollout_dir = rollout_path.parent().expect("rollout parent");
std::fs::create_dir_all(rollout_dir)?;
std::fs::write(&rollout_path, "")?;
#[test]
fn lookup_session_target_by_name_uses_backend_title_search() -> color_eyre::Result<()> {
run_large_stack_test(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime")
.block_on(async {
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let thread_id = ThreadId::new();
let rollout_path = temp_dir
.path()
.join("sessions/2025/02/01")
.join(format!("rollout-2025-02-01T10-00-00-{thread_id}.jsonl"));
let rollout_dir = rollout_path.parent().expect("rollout parent");
std::fs::create_dir_all(rollout_dir)?;
std::fs::write(&rollout_path, "")?;
let state_runtime = codex_state::StateRuntime::init(
config.codex_home.to_path_buf(),
config.model_provider_id.clone(),
)
.await
.map_err(std::io::Error::other)?;
state_runtime
.mark_backfill_complete(/*last_watermark*/ None)
.await
.map_err(std::io::Error::other)?;
let state_runtime = codex_state::StateRuntime::init(
config.codex_home.to_path_buf(),
config.model_provider_id.clone(),
)
.await
.map_err(std::io::Error::other)?;
state_runtime
.mark_backfill_complete(/*last_watermark*/ None)
.await
.map_err(std::io::Error::other)?;
let session_cwd = temp_dir.path().join("project");
std::fs::create_dir_all(&session_cwd)?;
let created_at = chrono::DateTime::parse_from_rfc3339("2025-02-01T10:00:00Z")
.expect("timestamp should parse")
.with_timezone(&chrono::Utc);
let mut builder = codex_state::ThreadMetadataBuilder::new(
thread_id,
rollout_path.clone(),
created_at,
SessionSource::Cli,
);
builder.cwd = session_cwd;
let mut metadata = builder.build(config.model_provider_id.as_str());
metadata.title = "saved-session".to_string();
metadata.first_user_message = Some("preview text".to_string());
state_runtime
.upsert_thread(&metadata)
.await
.map_err(std::io::Error::other)?;
let session_cwd = temp_dir.path().join("project");
std::fs::create_dir_all(&session_cwd)?;
let created_at = chrono::DateTime::parse_from_rfc3339("2025-02-01T10:00:00Z")
.expect("timestamp should parse")
.with_timezone(&chrono::Utc);
let mut builder = codex_state::ThreadMetadataBuilder::new(
thread_id,
rollout_path.clone(),
created_at,
serde_json::from_value(serde_json::json!("cli"))
.expect("cli session source should deserialize"),
);
builder.cwd = session_cwd;
let mut metadata = builder.build(config.model_provider_id.as_str());
metadata.title = "saved-session".to_string();
metadata.first_user_message = Some("preview text".to_string());
state_runtime
.upsert_thread(&metadata)
.await
.map_err(std::io::Error::other)?;
let mut app_server =
AppServerSession::new(codex_app_server_client::AppServerClient::InProcess(
start_test_embedded_app_server(config).await?,
));
let target =
lookup_session_target_by_name_with_app_server(&mut app_server, "saved-session").await?;
let target = target.expect("name lookup should find the saved thread");
assert_eq!(target.path, Some(rollout_path));
assert_eq!(target.thread_id, thread_id);
let mut app_server =
AppServerSession::new(codex_app_server_client::AppServerClient::InProcess(
start_test_embedded_app_server(config).await?,
));
let target = lookup_session_target_by_name_with_app_server(
&mut app_server,
"saved-session",
)
.await?;
let target = target.expect("name lookup should find the saved thread");
assert_eq!(target.path, Some(rollout_path));
assert_eq!(target.thread_id, thread_id);
app_server.shutdown().await?;
Ok(())
app_server.shutdown().await?;
Ok(())
})
})
}
#[tokio::test]
@@ -2204,118 +2107,6 @@ mod tests {
Ok(())
}
fn build_turn_context(config: &Config, cwd: PathBuf) -> TurnContextItem {
let model = config
.model
.clone()
.unwrap_or_else(|| "gpt-5.1".to_string());
let permission_profile = config.permissions.permission_profile();
let sandbox_policy = permission_profile
.to_legacy_sandbox_policy(config.cwd.as_path())
.expect("configured permissions must have a legacy compatibility projection");
TurnContextItem {
turn_id: None,
trace_id: None,
cwd,
current_date: None,
timezone: None,
approval_policy: config.permissions.approval_policy.value(),
sandbox_policy,
permission_profile: Some(permission_profile),
network: None,
file_system_sandbox_policy: None,
model,
personality: None,
collaboration_mode: None,
realtime_active: Some(false),
effort: config.model_reasoning_effort,
summary: config
.model_reasoning_summary
.unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto),
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: None,
}
}
#[tokio::test]
async fn read_session_cwd_prefers_latest_turn_context() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let first = temp_dir.path().join("first");
let second = temp_dir.path().join("second");
std::fs::create_dir_all(&first)?;
std::fs::create_dir_all(&second)?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
let lines = vec![
RolloutLine {
timestamp: "t0".to_string(),
item: RolloutItem::TurnContext(build_turn_context(&config, first)),
},
RolloutLine {
timestamp: "t1".to_string(),
item: RolloutItem::TurnContext(build_turn_context(&config, second.clone())),
},
];
let mut text = String::new();
for line in lines {
text.push_str(&serde_json::to_string(&line).expect("serialize rollout"));
text.push('\n');
}
std::fs::write(&rollout_path, text)?;
let cwd = read_session_cwd(&config, ThreadId::new(), Some(&rollout_path))
.await
.expect("expected cwd");
assert_eq!(cwd, second);
Ok(())
}
#[tokio::test]
async fn should_prompt_when_meta_matches_current_but_latest_turn_differs() -> std::io::Result<()>
{
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let current = temp_dir.path().join("current");
let latest = temp_dir.path().join("latest");
std::fs::create_dir_all(&current)?;
std::fs::create_dir_all(&latest)?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
let session_meta = SessionMeta {
cwd: current.clone(),
..SessionMeta::default()
};
let lines = vec![
RolloutLine {
timestamp: "t0".to_string(),
item: RolloutItem::SessionMeta(SessionMetaLine {
meta: session_meta,
git: None,
}),
},
RolloutLine {
timestamp: "t1".to_string(),
item: RolloutItem::TurnContext(build_turn_context(&config, latest.clone())),
},
];
let mut text = String::new();
for line in lines {
text.push_str(&serde_json::to_string(&line).expect("serialize rollout"));
text.push('\n');
}
std::fs::write(&rollout_path, text)?;
let session_cwd = read_session_cwd(&config, ThreadId::new(), Some(&rollout_path))
.await
.expect("expected cwd");
assert_eq!(session_cwd, latest);
assert!(cwds_differ(&current, &session_cwd));
Ok(())
}
#[tokio::test]
async fn config_rebuild_changes_trust_defaults_with_cwd() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
@@ -2349,7 +2140,7 @@ trust_level = "untrusted"
.build()
.await?;
assert_eq!(
trusted_config.permissions.approval_policy.value(),
AskForApproval::from(trusted_config.permissions.approval_policy.value()),
AskForApproval::OnRequest
);
@@ -2364,7 +2155,7 @@ trust_level = "untrusted"
.build()
.await?;
assert_eq!(
untrusted_config.permissions.approval_policy.value(),
AskForApproval::from(untrusted_config.permissions.approval_policy.value()),
AskForApproval::UnlessTrusted
);
Ok(())
@@ -2413,95 +2204,4 @@ trust_level = "untrusted"
);
Ok(())
}
#[tokio::test]
async fn read_session_cwd_falls_back_to_session_meta() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let session_cwd = temp_dir.path().join("session");
std::fs::create_dir_all(&session_cwd)?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
let session_meta = SessionMeta {
cwd: session_cwd.clone(),
..SessionMeta::default()
};
let meta_line = RolloutLine {
timestamp: "t0".to_string(),
item: RolloutItem::SessionMeta(SessionMetaLine {
meta: session_meta,
git: None,
}),
};
let text = format!(
"{}\n",
serde_json::to_string(&meta_line).expect("serialize meta")
);
std::fs::write(&rollout_path, text)?;
let cwd = read_session_cwd(&config, ThreadId::new(), Some(&rollout_path))
.await
.expect("expected cwd");
assert_eq!(cwd, session_cwd);
Ok(())
}
#[tokio::test]
async fn read_session_cwd_prefers_sqlite_when_thread_id_present() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let mut config = build_config(&temp_dir).await?;
config
.features
.enable(Feature::Sqlite)
.expect("test config should allow sqlite");
let thread_id = ThreadId::new();
let rollout_cwd = temp_dir.path().join("rollout-cwd");
let sqlite_cwd = temp_dir.path().join("sqlite-cwd");
std::fs::create_dir_all(&rollout_cwd)?;
std::fs::create_dir_all(&sqlite_cwd)?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
let rollout_line = RolloutLine {
timestamp: "t0".to_string(),
item: RolloutItem::TurnContext(build_turn_context(&config, rollout_cwd)),
};
std::fs::write(
&rollout_path,
format!(
"{}\n",
serde_json::to_string(&rollout_line).expect("serialize rollout")
),
)?;
let runtime = codex_state::StateRuntime::init(
config.codex_home.to_path_buf(),
config.model_provider_id.clone(),
)
.await
.map_err(std::io::Error::other)?;
runtime
.mark_backfill_complete(/*last_watermark*/ None)
.await
.map_err(std::io::Error::other)?;
let mut builder = codex_state::ThreadMetadataBuilder::new(
thread_id,
rollout_path.clone(),
chrono::Utc::now(),
SessionSource::Cli,
);
builder.cwd = sqlite_cwd.clone();
let metadata = builder.build(config.model_provider_id.as_str());
runtime
.upsert_thread(&metadata)
.await
.map_err(std::io::Error::other)?;
let cwd = read_session_cwd(&config, thread_id, Some(&rollout_path))
.await
.expect("expected cwd");
assert_eq!(cwd, sqlite_cwd);
Ok(())
}
}
+2 -1
View File
@@ -6,6 +6,7 @@ use codex_config::LoaderOverrides;
use codex_tui::AppExitInfo;
use codex_tui::Cli;
use codex_tui::ExitReason;
use codex_tui::FinalOutput;
use codex_tui::run_main;
use codex_utils_cli::CliConfigOverrides;
use supports_color::Stream;
@@ -19,7 +20,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
let mut lines = Vec::new();
if !token_usage.is_zero() {
lines.push(codex_protocol::protocol::FinalOutput::from(token_usage).to_string());
lines.push(FinalOutput::from(token_usage).to_string());
}
if let Some(resume_cmd) =
+402 -317
View File
@@ -7,18 +7,13 @@
use crate::history_cell::PlainHistoryCell;
use crate::render::line_utils::prefix_lines;
use crate::text_formatting::truncate_text;
use codex_app_server_protocol::CollabAgentState;
use codex_app_server_protocol::CollabAgentStatus;
use codex_app_server_protocol::CollabAgentTool;
use codex_app_server_protocol::CollabAgentToolCallStatus;
use codex_app_server_protocol::ThreadItem;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::CollabAgentInteractionEndEvent;
use codex_protocol::protocol::CollabAgentRef;
use codex_protocol::protocol::CollabAgentSpawnEndEvent;
use codex_protocol::protocol::CollabAgentStatusEntry;
use codex_protocol::protocol::CollabCloseEndEvent;
use codex_protocol::protocol::CollabResumeBeginEvent;
use codex_protocol::protocol::CollabResumeEndEvent;
use codex_protocol::protocol::CollabWaitingBeginEvent;
use codex_protocol::protocol::CollabWaitingEndEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
#[cfg(target_os = "macos")]
@@ -28,7 +23,6 @@ use crossterm::event::KeyModifiers;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use std::collections::HashMap;
use std::collections::HashSet;
const COLLAB_PROMPT_PREVIEW_GRAPHEMES: usize = 160;
@@ -45,6 +39,14 @@ pub(crate) struct AgentPickerThreadEntry {
pub(crate) is_closed: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct AgentMetadata {
/// Human-friendly nickname shown in rendered tool-call rows.
pub(crate) agent_nickname: Option<String>,
/// Agent type shown in brackets when present, for example `worker`.
pub(crate) agent_role: Option<String>,
}
#[derive(Clone, Copy)]
struct AgentLabel<'a> {
thread_id: Option<ThreadId>,
@@ -171,82 +173,153 @@ fn next_agent_word_motion_fallback(
false
}
pub(crate) fn spawn_end(
ev: CollabAgentSpawnEndEvent,
spawn_request: Option<&SpawnRequestSummary>,
) -> PlainHistoryCell {
let CollabAgentSpawnEndEvent {
call_id: _,
sender_thread_id: _,
new_thread_id,
new_agent_nickname,
new_agent_role,
prompt,
status: _,
..
} = ev;
pub(crate) fn spawn_request_summary(item: &ThreadItem) -> Option<SpawnRequestSummary> {
match item {
ThreadItem::CollabAgentToolCall {
tool: CollabAgentTool::SpawnAgent,
model: Some(model),
reasoning_effort: Some(reasoning_effort),
..
} => Some(SpawnRequestSummary {
model: model.clone(),
reasoning_effort: *reasoning_effort,
}),
_ => None,
}
}
pub(crate) fn tool_call_history_cell(
item: &ThreadItem,
cached_spawn_request: Option<&SpawnRequestSummary>,
mut agent_metadata: impl FnMut(ThreadId) -> AgentMetadata,
) -> Option<PlainHistoryCell> {
let ThreadItem::CollabAgentToolCall {
tool,
status,
receiver_thread_ids,
prompt,
agents_states,
..
} = item
else {
return None;
};
let first_receiver = receiver_thread_ids
.first()
.and_then(|id| parse_thread_id(id));
let prompt = prompt.as_deref().unwrap_or_default();
match tool {
CollabAgentTool::SpawnAgent => {
if matches!(status, CollabAgentToolCallStatus::InProgress) {
return None;
}
let fallback_spawn_request = spawn_request_summary(item);
let spawn_request = cached_spawn_request.or(fallback_spawn_request.as_ref());
Some(spawn_end(
first_receiver,
prompt,
spawn_request,
&mut agent_metadata,
))
}
CollabAgentTool::SendInput => {
if matches!(status, CollabAgentToolCallStatus::InProgress) {
return None;
}
first_receiver.map(|receiver_thread_id| {
interaction_end(receiver_thread_id, prompt, &mut agent_metadata)
})
}
CollabAgentTool::ResumeAgent => first_receiver.map(|receiver_thread_id| {
if matches!(status, CollabAgentToolCallStatus::InProgress) {
resume_begin(receiver_thread_id, &mut agent_metadata)
} else {
let state = first_agent_state(receiver_thread_ids, agents_states);
resume_end(
receiver_thread_id,
state,
"Agent resume failed",
&mut agent_metadata,
)
}
}),
CollabAgentTool::Wait => {
if matches!(status, CollabAgentToolCallStatus::InProgress) {
Some(waiting_begin(receiver_thread_ids, &mut agent_metadata))
} else {
Some(waiting_end(
receiver_thread_ids,
agents_states,
&mut agent_metadata,
))
}
}
CollabAgentTool::CloseAgent => {
if matches!(status, CollabAgentToolCallStatus::InProgress) {
return None;
}
first_receiver
.map(|receiver_thread_id| close_end(receiver_thread_id, &mut agent_metadata))
}
}
}
fn spawn_end(
new_thread_id: Option<ThreadId>,
prompt: &str,
spawn_request: Option<&SpawnRequestSummary>,
agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata,
) -> PlainHistoryCell {
let title = match new_thread_id {
Some(thread_id) => title_with_agent(
"Spawned",
AgentLabel {
thread_id: Some(thread_id),
nickname: new_agent_nickname.as_deref(),
role: new_agent_role.as_deref(),
},
agent_label(thread_id, &agent_metadata(thread_id)),
spawn_request,
),
None => title_text("Agent spawn failed"),
};
let mut details = Vec::new();
if let Some(line) = prompt_line(&prompt) {
if let Some(line) = prompt_line(prompt) {
details.push(line);
}
collab_event(title, details)
}
pub(crate) fn interaction_end(ev: CollabAgentInteractionEndEvent) -> PlainHistoryCell {
let CollabAgentInteractionEndEvent {
call_id: _,
sender_thread_id: _,
receiver_thread_id,
receiver_agent_nickname,
receiver_agent_role,
prompt,
status: _,
} = ev;
fn interaction_end(
receiver_thread_id: ThreadId,
prompt: &str,
agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata,
) -> PlainHistoryCell {
let title = title_with_agent(
"Sent input to",
AgentLabel {
thread_id: Some(receiver_thread_id),
nickname: receiver_agent_nickname.as_deref(),
role: receiver_agent_role.as_deref(),
},
agent_label(receiver_thread_id, &agent_metadata(receiver_thread_id)),
/*spawn_request*/ None,
);
let mut details = Vec::new();
if let Some(line) = prompt_line(&prompt) {
if let Some(line) = prompt_line(prompt) {
details.push(line);
}
collab_event(title, details)
}
pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell {
let CollabWaitingBeginEvent {
sender_thread_id: _,
receiver_thread_ids,
receiver_agents,
call_id: _,
} = ev;
let receiver_agents = merge_wait_receivers(&receiver_thread_ids, receiver_agents);
fn waiting_begin(
receiver_thread_ids: &[String],
agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata,
) -> PlainHistoryCell {
let receiver_agents = receiver_thread_ids
.iter()
.filter_map(|thread_id| parse_thread_id(thread_id))
.map(|thread_id| (thread_id, agent_metadata(thread_id)))
.collect::<Vec<_>>();
let title = match receiver_agents.as_slice() {
[receiver] => title_with_agent(
[(thread_id, metadata)] => title_with_agent(
"Waiting for",
agent_label_from_ref(receiver),
agent_label(*thread_id, metadata),
/*spawn_request*/ None,
),
[] => title_text("Waiting for agents"),
@@ -256,7 +329,7 @@ pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell {
let details = if receiver_agents.len() > 1 {
receiver_agents
.iter()
.map(|receiver| agent_label_line(agent_label_from_ref(receiver)))
.map(|(thread_id, metadata)| agent_label_line(agent_label(*thread_id, metadata)))
.collect()
} else {
Vec::new()
@@ -265,85 +338,56 @@ pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell {
collab_event(title, details)
}
pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell {
let CollabWaitingEndEvent {
call_id: _,
sender_thread_id: _,
agent_statuses,
statuses,
} = ev;
let details = wait_complete_lines(&statuses, &agent_statuses);
fn waiting_end(
receiver_thread_ids: &[String],
agents_states: &std::collections::HashMap<String, CollabAgentState>,
agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata,
) -> PlainHistoryCell {
let details = wait_complete_lines(receiver_thread_ids, agents_states, agent_metadata);
collab_event(title_text("Finished waiting"), details)
}
pub(crate) fn close_end(ev: CollabCloseEndEvent) -> PlainHistoryCell {
let CollabCloseEndEvent {
call_id: _,
sender_thread_id: _,
receiver_thread_id,
receiver_agent_nickname,
receiver_agent_role,
status: _,
} = ev;
fn close_end(
receiver_thread_id: ThreadId,
agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata,
) -> PlainHistoryCell {
collab_event(
title_with_agent(
"Closed",
AgentLabel {
thread_id: Some(receiver_thread_id),
nickname: receiver_agent_nickname.as_deref(),
role: receiver_agent_role.as_deref(),
},
agent_label(receiver_thread_id, &agent_metadata(receiver_thread_id)),
/*spawn_request*/ None,
),
Vec::new(),
)
}
pub(crate) fn resume_begin(ev: CollabResumeBeginEvent) -> PlainHistoryCell {
let CollabResumeBeginEvent {
call_id: _,
sender_thread_id: _,
receiver_thread_id,
receiver_agent_nickname,
receiver_agent_role,
} = ev;
fn resume_begin(
receiver_thread_id: ThreadId,
agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata,
) -> PlainHistoryCell {
collab_event(
title_with_agent(
"Resuming",
AgentLabel {
thread_id: Some(receiver_thread_id),
nickname: receiver_agent_nickname.as_deref(),
role: receiver_agent_role.as_deref(),
},
agent_label(receiver_thread_id, &agent_metadata(receiver_thread_id)),
/*spawn_request*/ None,
),
Vec::new(),
)
}
pub(crate) fn resume_end(ev: CollabResumeEndEvent) -> PlainHistoryCell {
let CollabResumeEndEvent {
call_id: _,
sender_thread_id: _,
receiver_thread_id,
receiver_agent_nickname,
receiver_agent_role,
status,
} = ev;
fn resume_end(
receiver_thread_id: ThreadId,
status: Option<&CollabAgentState>,
fallback_error: &str,
agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata,
) -> PlainHistoryCell {
collab_event(
title_with_agent(
"Resumed",
AgentLabel {
thread_id: Some(receiver_thread_id),
nickname: receiver_agent_nickname.as_deref(),
role: receiver_agent_role.as_deref(),
},
agent_label(receiver_thread_id, &agent_metadata(receiver_thread_id)),
/*spawn_request*/ None,
),
vec![status_summary_line(&status)],
vec![status_summary_line(status, fallback_error)],
)
}
@@ -377,11 +421,15 @@ fn title_spans_line(mut spans: Vec<Span<'static>>) -> Line<'static> {
title.into()
}
fn agent_label_from_ref(agent: &CollabAgentRef) -> AgentLabel<'_> {
fn parse_thread_id(thread_id: &str) -> Option<ThreadId> {
ThreadId::from_string(thread_id).ok()
}
fn agent_label(thread_id: ThreadId, metadata: &AgentMetadata) -> AgentLabel<'_> {
AgentLabel {
thread_id: Some(agent.thread_id),
nickname: agent.agent_nickname.as_deref(),
role: agent.agent_role.as_deref(),
thread_id: Some(thread_id),
nickname: metadata.agent_nickname.as_deref(),
role: metadata.agent_role.as_deref(),
}
}
@@ -444,113 +492,80 @@ fn prompt_line(prompt: &str) -> Option<Line<'static>> {
}
}
fn merge_wait_receivers(
receiver_thread_ids: &[ThreadId],
mut receiver_agents: Vec<CollabAgentRef>,
) -> Vec<CollabAgentRef> {
if receiver_agents.is_empty() {
return receiver_thread_ids
.iter()
.map(|thread_id| CollabAgentRef {
thread_id: *thread_id,
agent_nickname: None,
agent_role: None,
})
.collect();
}
let mut seen = receiver_agents
.iter()
.map(|agent| agent.thread_id)
.collect::<HashSet<_>>();
for thread_id in receiver_thread_ids {
if seen.insert(*thread_id) {
receiver_agents.push(CollabAgentRef {
thread_id: *thread_id,
agent_nickname: None,
agent_role: None,
});
}
}
receiver_agents
}
fn wait_complete_lines(
statuses: &HashMap<ThreadId, AgentStatus>,
agent_statuses: &[CollabAgentStatusEntry],
receiver_thread_ids: &[String],
agents_states: &std::collections::HashMap<String, CollabAgentState>,
agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata,
) -> Vec<Line<'static>> {
if statuses.is_empty() && agent_statuses.is_empty() {
return vec![Line::from(Span::from("No agents completed yet"))];
}
let entries = if agent_statuses.is_empty() {
let mut entries = statuses
.iter()
.map(|(thread_id, status)| CollabAgentStatusEntry {
thread_id: *thread_id,
agent_nickname: None,
agent_role: None,
status: status.clone(),
})
.collect::<Vec<_>>();
entries.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string()));
entries
} else {
let mut entries = agent_statuses.to_vec();
let seen = entries
.iter()
.map(|entry| entry.thread_id)
.collect::<HashSet<_>>();
let mut extras = statuses
.iter()
.filter(|(thread_id, _)| !seen.contains(thread_id))
.map(|(thread_id, status)| CollabAgentStatusEntry {
thread_id: *thread_id,
agent_nickname: None,
agent_role: None,
status: status.clone(),
})
.collect::<Vec<_>>();
extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string()));
entries.extend(extras);
entries
};
entries
.into_iter()
.map(|entry| {
let CollabAgentStatusEntry {
thread_id,
agent_nickname,
agent_role,
status,
} = entry;
let mut spans = agent_label_spans(AgentLabel {
thread_id: Some(thread_id),
nickname: agent_nickname.as_deref(),
role: agent_role.as_deref(),
});
spans.push(Span::from(": ").dim());
spans.extend(status_summary_spans(&status));
spans.into()
let mut seen = HashSet::new();
let mut entries = receiver_thread_ids
.iter()
.filter_map(|thread_id| {
let parsed_thread_id = parse_thread_id(thread_id)?;
let status = agents_states.get(thread_id)?;
seen.insert(parsed_thread_id);
Some((parsed_thread_id, agent_metadata(parsed_thread_id), status))
})
.collect()
.collect::<Vec<_>>();
let mut extras = agents_states
.iter()
.filter_map(|(thread_id, status)| {
let parsed_thread_id = parse_thread_id(thread_id)?;
(!seen.contains(&parsed_thread_id))
.then(|| (parsed_thread_id, agent_metadata(parsed_thread_id), status))
})
.collect::<Vec<_>>();
extras.sort_by(|left, right| left.0.to_string().cmp(&right.0.to_string()));
entries.extend(extras);
if entries.is_empty() {
vec![Line::from(Span::from("No agents completed yet"))]
} else {
entries
.into_iter()
.map(|(thread_id, metadata, status)| {
let mut spans = agent_label_spans(agent_label(thread_id, &metadata));
spans.push(Span::from(": ").dim());
spans.extend(status_summary_spans(status));
spans.into()
})
.collect()
}
}
fn status_summary_line(status: &AgentStatus) -> Line<'static> {
status_summary_spans(status).into()
fn first_agent_state<'a>(
receiver_thread_ids: &[String],
agents_states: &'a std::collections::HashMap<String, CollabAgentState>,
) -> Option<&'a CollabAgentState> {
receiver_thread_ids
.iter()
.find_map(|thread_id| agents_states.get(thread_id))
.or_else(|| {
agents_states
.iter()
.min_by(|left, right| left.0.cmp(right.0))
.map(|(_, status)| status)
})
}
fn status_summary_spans(status: &AgentStatus) -> Vec<Span<'static>> {
fn status_summary_line(status: Option<&CollabAgentState>, fallback_error: &str) -> Line<'static> {
match status {
AgentStatus::PendingInit => vec![Span::from("Pending init").cyan()],
AgentStatus::Running => vec![Span::from("Running").cyan().bold()],
Some(status) => status_summary_spans(status).into(),
None => error_summary_spans(fallback_error).into(),
}
}
fn status_summary_spans(status: &CollabAgentState) -> Vec<Span<'static>> {
match status.status {
CollabAgentStatus::PendingInit => vec![Span::from("Pending init").cyan()],
CollabAgentStatus::Running => vec![Span::from("Running").cyan().bold()],
// Allow `.yellow()`
#[allow(clippy::disallowed_methods)]
AgentStatus::Interrupted => vec![Span::from("Interrupted").yellow()],
AgentStatus::Completed(message) => {
CollabAgentStatus::Interrupted => vec![Span::from("Interrupted").yellow()],
CollabAgentStatus::Completed => {
let mut spans = vec![Span::from("Completed").green()];
if let Some(message) = message.as_ref() {
if let Some(message) = status.message.as_ref() {
let message_preview = truncate_text(
&message.split_whitespace().collect::<Vec<_>>().join(" "),
COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES,
@@ -562,23 +577,27 @@ fn status_summary_spans(status: &AgentStatus) -> Vec<Span<'static>> {
}
spans
}
AgentStatus::Errored(error) => {
let mut spans = vec![Span::from("Error").red()];
let error_preview = truncate_text(
&error.split_whitespace().collect::<Vec<_>>().join(" "),
COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES,
);
if !error_preview.is_empty() {
spans.push(Span::from(" - ").dim());
spans.push(Span::from(error_preview));
}
spans
CollabAgentStatus::Errored => {
error_summary_spans(status.message.as_deref().unwrap_or("Agent errored"))
}
AgentStatus::Shutdown => vec![Span::from("Shutdown")],
AgentStatus::NotFound => vec![Span::from("Not found").red()],
CollabAgentStatus::Shutdown => vec![Span::from("Shutdown")],
CollabAgentStatus::NotFound => vec![Span::from("Not found").red()],
}
}
fn error_summary_spans(error: &str) -> Vec<Span<'static>> {
let mut spans = vec![Span::from("Error").red()];
let error_preview = truncate_text(
&error.split_whitespace().collect::<Vec<_>>().join(" "),
COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES,
);
if !error_preview.is_empty() {
spans.push(Span::from(" - ").dim());
spans.push(Span::from(error_preview));
}
spans
}
#[cfg(test)]
mod tests {
use super::*;
@@ -591,6 +610,7 @@ mod tests {
use pretty_assertions::assert_eq;
use ratatui::style::Color;
use ratatui::style::Modifier;
use std::collections::HashMap;
#[test]
fn collab_events_snapshot() {
@@ -601,79 +621,108 @@ mod tests {
let bob_id = ThreadId::from_string("00000000-0000-0000-0000-000000000003")
.expect("valid bob thread id");
let spawn = spawn_end(
CollabAgentSpawnEndEvent {
call_id: "call-spawn".to_string(),
sender_thread_id,
new_thread_id: Some(robie_id),
new_agent_nickname: Some("Robie".to_string()),
new_agent_role: Some("explorer".to_string()),
prompt: "Compute 11! and reply with just the integer result.".to_string(),
model: "gpt-5".to_string(),
reasoning_effort: ReasoningEffortConfig::High,
status: AgentStatus::PendingInit,
let spawn = tool_call_history_cell(
&ThreadItem::CollabAgentToolCall {
id: "call-spawn".to_string(),
tool: CollabAgentTool::SpawnAgent,
status: CollabAgentToolCallStatus::Completed,
sender_thread_id: sender_thread_id.to_string(),
receiver_thread_ids: vec![robie_id.to_string()],
prompt: Some("Compute 11! and reply with just the integer result.".to_string()),
model: Some("gpt-5".to_string()),
reasoning_effort: Some(ReasoningEffortConfig::High),
agents_states: HashMap::from([(
robie_id.to_string(),
agent_state(CollabAgentStatus::PendingInit, None),
)]),
},
Some(&SpawnRequestSummary {
model: "gpt-5".to_string(),
reasoning_effort: ReasoningEffortConfig::High,
}),
);
/*cached_spawn_request*/ None,
|thread_id| metadata_for(thread_id, robie_id, bob_id),
)
.expect("spawn item renders");
let send = interaction_end(CollabAgentInteractionEndEvent {
call_id: "call-send".to_string(),
sender_thread_id,
receiver_thread_id: robie_id,
receiver_agent_nickname: Some("Robie".to_string()),
receiver_agent_role: Some("explorer".to_string()),
prompt: "Please continue and return the answer only.".to_string(),
status: AgentStatus::Running,
});
let send = tool_call_history_cell(
&ThreadItem::CollabAgentToolCall {
id: "call-send".to_string(),
tool: CollabAgentTool::SendInput,
status: CollabAgentToolCallStatus::Completed,
sender_thread_id: sender_thread_id.to_string(),
receiver_thread_ids: vec![robie_id.to_string()],
prompt: Some("Please continue and return the answer only.".to_string()),
model: None,
reasoning_effort: None,
agents_states: HashMap::from([(
robie_id.to_string(),
agent_state(CollabAgentStatus::Running, None),
)]),
},
/*cached_spawn_request*/ None,
|thread_id| metadata_for(thread_id, robie_id, bob_id),
)
.expect("send-input item renders");
let waiting = waiting_begin(CollabWaitingBeginEvent {
sender_thread_id,
receiver_thread_ids: vec![robie_id],
receiver_agents: vec![CollabAgentRef {
thread_id: robie_id,
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
}],
call_id: "call-wait".to_string(),
});
let waiting = tool_call_history_cell(
&ThreadItem::CollabAgentToolCall {
id: "call-wait".to_string(),
tool: CollabAgentTool::Wait,
status: CollabAgentToolCallStatus::InProgress,
sender_thread_id: sender_thread_id.to_string(),
receiver_thread_ids: vec![robie_id.to_string()],
prompt: None,
model: None,
reasoning_effort: None,
agents_states: HashMap::new(),
},
/*cached_spawn_request*/ None,
|thread_id| metadata_for(thread_id, robie_id, bob_id),
)
.expect("wait begin item renders");
let mut statuses = HashMap::new();
statuses.insert(
robie_id,
AgentStatus::Completed(Some("39916800".to_string())),
);
statuses.insert(bob_id, AgentStatus::Errored("tool timeout".to_string()));
let finished = waiting_end(CollabWaitingEndEvent {
sender_thread_id,
call_id: "call-wait".to_string(),
agent_statuses: vec![
CollabAgentStatusEntry {
thread_id: robie_id,
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
status: AgentStatus::Completed(Some("39916800".to_string())),
},
CollabAgentStatusEntry {
thread_id: bob_id,
agent_nickname: Some("Bob".to_string()),
agent_role: Some("worker".to_string()),
status: AgentStatus::Errored("tool timeout".to_string()),
},
],
statuses,
});
let finished = tool_call_history_cell(
&ThreadItem::CollabAgentToolCall {
id: "call-wait".to_string(),
tool: CollabAgentTool::Wait,
status: CollabAgentToolCallStatus::Completed,
sender_thread_id: sender_thread_id.to_string(),
receiver_thread_ids: vec![robie_id.to_string(), bob_id.to_string()],
prompt: None,
model: None,
reasoning_effort: None,
agents_states: HashMap::from([
(
robie_id.to_string(),
agent_state(CollabAgentStatus::Completed, Some("39916800")),
),
(
bob_id.to_string(),
agent_state(CollabAgentStatus::Errored, Some("tool timeout")),
),
]),
},
/*cached_spawn_request*/ None,
|thread_id| metadata_for(thread_id, robie_id, bob_id),
)
.expect("wait end item renders");
let close = close_end(CollabCloseEndEvent {
call_id: "call-close".to_string(),
sender_thread_id,
receiver_thread_id: robie_id,
receiver_agent_nickname: Some("Robie".to_string()),
receiver_agent_role: Some("explorer".to_string()),
status: AgentStatus::Completed(Some("39916800".to_string())),
});
let close = tool_call_history_cell(
&ThreadItem::CollabAgentToolCall {
id: "call-close".to_string(),
tool: CollabAgentTool::CloseAgent,
status: CollabAgentToolCallStatus::Completed,
sender_thread_id: sender_thread_id.to_string(),
receiver_thread_ids: vec![robie_id.to_string()],
prompt: None,
model: None,
reasoning_effort: None,
agents_states: HashMap::from([(
robie_id.to_string(),
agent_state(CollabAgentStatus::Completed, Some("39916800")),
)]),
},
/*cached_spawn_request*/ None,
|thread_id| metadata_for(thread_id, robie_id, bob_id),
)
.expect("close item renders");
let snapshot = [spawn, send, waiting, finished, close]
.iter()
@@ -739,23 +788,25 @@ mod tests {
.expect("valid sender thread id");
let robie_id = ThreadId::from_string("00000000-0000-0000-0000-000000000002")
.expect("valid robie thread id");
let cell = spawn_end(
CollabAgentSpawnEndEvent {
call_id: "call-spawn".to_string(),
sender_thread_id,
new_thread_id: Some(robie_id),
new_agent_nickname: Some("Robie".to_string()),
new_agent_role: Some("explorer".to_string()),
prompt: String::new(),
model: "gpt-5".to_string(),
reasoning_effort: ReasoningEffortConfig::High,
status: AgentStatus::PendingInit,
let cell = tool_call_history_cell(
&ThreadItem::CollabAgentToolCall {
id: "call-spawn".to_string(),
tool: CollabAgentTool::SpawnAgent,
status: CollabAgentToolCallStatus::Completed,
sender_thread_id: sender_thread_id.to_string(),
receiver_thread_ids: vec![robie_id.to_string()],
prompt: Some(String::new()),
model: Some("gpt-5".to_string()),
reasoning_effort: Some(ReasoningEffortConfig::High),
agents_states: HashMap::from([(
robie_id.to_string(),
agent_state(CollabAgentStatus::PendingInit, None),
)]),
},
Some(&SpawnRequestSummary {
model: "gpt-5".to_string(),
reasoning_effort: ReasoningEffortConfig::High,
}),
);
/*cached_spawn_request*/ None,
|thread_id| metadata_for(thread_id, robie_id, ThreadId::new()),
)
.expect("spawn item renders");
let lines = cell.display_lines(/*width*/ 200);
let title = &lines[0];
@@ -776,18 +827,52 @@ mod tests {
let robie_id = ThreadId::from_string("00000000-0000-0000-0000-000000000002")
.expect("valid robie thread id");
let cell = resume_end(CollabResumeEndEvent {
call_id: "call-resume".to_string(),
sender_thread_id,
receiver_thread_id: robie_id,
receiver_agent_nickname: Some("Robie".to_string()),
receiver_agent_role: Some("explorer".to_string()),
status: AgentStatus::Interrupted,
});
let cell = tool_call_history_cell(
&ThreadItem::CollabAgentToolCall {
id: "call-resume".to_string(),
tool: CollabAgentTool::ResumeAgent,
status: CollabAgentToolCallStatus::Completed,
sender_thread_id: sender_thread_id.to_string(),
receiver_thread_ids: vec![robie_id.to_string()],
prompt: None,
model: None,
reasoning_effort: None,
agents_states: HashMap::from([(
robie_id.to_string(),
agent_state(CollabAgentStatus::Interrupted, None),
)]),
},
/*cached_spawn_request*/ None,
|thread_id| metadata_for(thread_id, robie_id, ThreadId::new()),
)
.expect("resume item renders");
assert_snapshot!("collab_resume_interrupted", cell_to_text(&cell));
}
fn agent_state(status: CollabAgentStatus, message: Option<&str>) -> CollabAgentState {
CollabAgentState {
status,
message: message.map(str::to_string),
}
}
fn metadata_for(thread_id: ThreadId, robie_id: ThreadId, bob_id: ThreadId) -> AgentMetadata {
if thread_id == robie_id {
AgentMetadata {
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
}
} else if thread_id == bob_id {
AgentMetadata {
agent_nickname: Some("Bob".to_string()),
agent_role: Some("worker".to_string()),
}
} else {
AgentMetadata::default()
}
}
fn cell_to_text(cell: &PlainHistoryCell) -> String {
cell.display_lines(/*width*/ 200)
.iter()
+2 -2
View File
@@ -1016,7 +1016,6 @@ mod tests {
use codex_cloud_requirements::cloud_requirements_loader_for_storage;
use codex_config::types::AuthCredentialsStoreMode;
use codex_protocol::protocol::SessionSource;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use tempfile::TempDir;
@@ -1047,7 +1046,8 @@ mod tests {
codex_app_server_client::EnvironmentManager::default_for_tests(),
),
config_warnings: Vec::new(),
session_source: SessionSource::Cli,
session_source: serde_json::from_value(serde_json::json!("cli"))
.expect("cli session source should deserialize"),
enable_codex_api_key_env: false,
client_name: "test".to_string(),
client_version: "test".to_string(),
+3 -3
View File
@@ -912,8 +912,8 @@ fn render_offset_content(
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::protocol::ExecCommandSource;
use codex_protocol::protocol::ReviewDecision;
use crate::approval_display::ReviewDecision;
use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
@@ -921,12 +921,12 @@ mod tests {
use std::sync::Arc;
use std::time::Duration;
use crate::diff_model::FileChange;
use crate::exec_cell::CommandOutput;
use crate::history_cell;
use crate::history_cell::HistoryCell;
use crate::history_cell::new_patch_event;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::FileChange;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::text::Text;
+13 -23
View File
@@ -2,8 +2,6 @@
//! legacy shapes still required by older or remote app-server APIs.
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::path::Path;
@@ -16,18 +14,7 @@ pub(crate) fn legacy_compatible_permission_profile(
}
let file_system_policy = permission_profile.file_system_sandbox_policy();
compatibility_workspace_write_profile(
&file_system_policy,
permission_profile.network_sandbox_policy(),
cwd,
)
}
fn compatibility_workspace_write_profile(
file_system_policy: &FileSystemSandboxPolicy,
network_policy: NetworkSandboxPolicy,
cwd: &Path,
) -> PermissionProfile {
let network_policy = permission_profile.network_sandbox_policy();
let cwd_abs = AbsolutePathBuf::from_absolute_path(cwd).ok();
let writable_roots = file_system_policy
.get_writable_roots_with_cwd(cwd)
@@ -57,19 +44,22 @@ fn compatibility_workspace_write_profile(
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_app_server_protocol::FileSystemAccessMode;
use codex_app_server_protocol::FileSystemPath;
use codex_app_server_protocol::FileSystemSandboxEntry;
use codex_app_server_protocol::FileSystemSpecialPath;
use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile;
use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
use pretty_assertions::assert_eq;
#[test]
fn compatibility_profile_preserves_unbridgeable_write_roots() {
let cwd = AbsolutePathBuf::try_from("/workspace/project").expect("absolute cwd");
let extra_root = AbsolutePathBuf::try_from("/workspace/extra").expect("absolute root");
let permission_profile = PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Restricted {
let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
file_system: PermissionProfileFileSystemPermissions::Restricted {
entries: vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
@@ -86,8 +76,8 @@ mod tests {
],
glob_scan_max_depth: None,
},
network: NetworkSandboxPolicy::Restricted,
};
}
.into();
let compatibility_profile =
legacy_compatible_permission_profile(&permission_profile, cwd.as_path());
+3 -5
View File
@@ -7,6 +7,7 @@ use crate::app_server_session::AppServerSession;
use crate::diff_render::display_path_for;
use crate::key_hint;
use crate::legacy_core::config::Config;
use crate::session_resume::resolve_session_thread_id;
use crate::text_formatting::truncate_text;
use crate::tui::FrameRequester;
use crate::tui::Tui;
@@ -561,11 +562,8 @@ impl PickerState {
Some(thread_id) => Some(thread_id),
None => match path.as_ref() {
Some(path) => {
crate::resolve_session_thread_id(
path.as_path(),
/*id_str_if_uuid*/ None,
)
.await
resolve_session_thread_id(path.as_path(), /*id_str_if_uuid*/ None)
.await
}
None => None,
},
+432
View File
@@ -0,0 +1,432 @@
//! Resolve saved-session state needed before resuming or forking a thread.
//!
//! The app-server API owns normal thread lifecycle data. This module handles the local fallback
//! path used before a thread has been resumed, where TUI may need the saved cwd or model from the
//! local rollout JSONL to rebuild config or render an inactive session accurately.
use std::io;
use std::path::Path;
use std::path::PathBuf;
use crate::cwd_prompt;
use crate::cwd_prompt::CwdPromptAction;
use crate::cwd_prompt::CwdPromptOutcome;
use crate::cwd_prompt::CwdSelection;
use crate::legacy_core::config::Config;
use crate::tui::Tui;
use codex_protocol::ThreadId;
use codex_rollout::state_db::get_state_db;
use codex_utils_path as path_utils;
use serde::Deserialize;
use serde_json::Value;
use tokio::io::AsyncBufReadExt;
#[derive(Deserialize)]
struct SessionMetadata {
id: ThreadId,
cwd: PathBuf,
}
#[derive(Deserialize)]
struct LatestTurnContext {
cwd: PathBuf,
model: String,
}
#[derive(Deserialize)]
struct RawRecord {
#[serde(rename = "type")]
item_type: String,
payload: Option<Value>,
}
pub(crate) enum ResolveCwdOutcome {
Continue(Option<PathBuf>),
Exit,
}
pub(crate) async fn resolve_session_thread_id(
path: &Path,
id_str_if_uuid: Option<&str>,
) -> Option<ThreadId> {
match id_str_if_uuid {
Some(id_str) => ThreadId::from_string(id_str).ok(),
None => read_session_metadata(path)
.await
.ok()
.map(|metadata| metadata.id),
}
}
pub(crate) async fn read_session_model(
config: &Config,
thread_id: ThreadId,
path: Option<&Path>,
) -> Option<String> {
if let Some(state_db_ctx) = get_state_db(config).await
&& let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await
&& let Some(model) = metadata.model
{
return Some(model);
}
let path = path?;
read_latest_turn_context(path).await.map(|item| item.model)
}
pub(crate) async fn resolve_cwd_for_resume_or_fork(
tui: &mut Tui,
config: &Config,
current_cwd: &Path,
thread_id: ThreadId,
path: Option<&Path>,
action: CwdPromptAction,
allow_prompt: bool,
) -> color_eyre::Result<ResolveCwdOutcome> {
let Some(history_cwd) = read_session_cwd(config, thread_id, path).await else {
return Ok(ResolveCwdOutcome::Continue(None));
};
if allow_prompt && cwds_differ(current_cwd, &history_cwd) {
let selection_outcome =
cwd_prompt::run_cwd_selection_prompt(tui, action, current_cwd, &history_cwd).await?;
return Ok(match selection_outcome {
CwdPromptOutcome::Selection(CwdSelection::Current) => {
ResolveCwdOutcome::Continue(Some(current_cwd.to_path_buf()))
}
CwdPromptOutcome::Selection(CwdSelection::Session) => {
ResolveCwdOutcome::Continue(Some(history_cwd))
}
CwdPromptOutcome::Exit => ResolveCwdOutcome::Exit,
});
}
Ok(ResolveCwdOutcome::Continue(Some(history_cwd)))
}
async fn read_session_cwd(
config: &Config,
thread_id: ThreadId,
path: Option<&Path>,
) -> Option<PathBuf> {
if let Some(state_db_ctx) = get_state_db(config).await
&& let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await
{
return Some(metadata.cwd);
}
// Prefer the latest TurnContext cwd so resume/fork reflects the most recent
// session directory (for the changed-cwd prompt) when DB data is unavailable.
// The alternative would be mutating the session metadata line when the session cwd
// changes, but the rollout is an append-only JSONL log and rewriting the head
// would be error-prone.
let path = path?;
if let Some(cwd) = read_latest_turn_context(path).await.map(|item| item.cwd) {
return Some(cwd);
}
match read_session_metadata(path).await {
Ok(metadata) => Some(metadata.cwd),
Err(err) => {
let rollout_path = path.display().to_string();
tracing::warn!(
%rollout_path,
%err,
"Failed to read session metadata from rollout"
);
None
}
}
}
pub(crate) fn cwds_differ(current_cwd: &Path, session_cwd: &Path) -> bool {
!path_utils::paths_match_after_normalization(current_cwd, session_cwd)
}
async fn read_session_metadata(path: &Path) -> io::Result<SessionMetadata> {
let file = tokio::fs::File::open(path).await?;
let reader = tokio::io::BufReader::new(file);
let mut lines = reader.lines();
while let Some(line) = lines.next_line().await? {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let record = serde_json::from_str::<RawRecord>(trimmed).map_err(|err| {
io::Error::other(format!(
"failed to parse rollout line in {}: {err}",
path.display()
))
})?;
if record.item_type != "session_meta" {
return Err(io::Error::other(format!(
"rollout at {} does not start with session metadata",
path.display()
)));
}
let payload = record.payload.ok_or_else(|| {
io::Error::other(format!(
"session metadata in {} is missing a payload",
path.display()
))
})?;
return serde_json::from_value(payload).map_err(|err| {
io::Error::other(format!(
"failed to parse session metadata in {}: {err}",
path.display()
))
});
}
Err(io::Error::other(format!(
"rollout at {} is empty",
path.display()
)))
}
async fn read_latest_turn_context(path: &Path) -> Option<LatestTurnContext> {
let text = tokio::fs::read_to_string(path).await.ok()?;
for line in text.lines().rev() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Ok(record) = serde_json::from_str::<RawRecord>(trimmed) else {
continue;
};
if record.item_type != "turn_context" {
continue;
}
let Some(payload) = record.payload else {
continue;
};
if let Ok(item) = serde_json::from_value(payload) {
return Some(item);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::legacy_core::config::ConfigBuilder;
use codex_features::Feature;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
async fn build_config(temp_dir: &TempDir) -> std::io::Result<Config> {
ConfigBuilder::default()
.codex_home(temp_dir.path().to_path_buf())
.build()
.await
}
fn rollout_line(
timestamp: &str,
item_type: &str,
payload: serde_json::Value,
) -> serde_json::Value {
serde_json::json!({
"timestamp": timestamp,
"type": item_type,
"payload": payload,
})
}
fn turn_context_line(config: &Config, cwd: PathBuf, timestamp: &str) -> serde_json::Value {
let model = config
.model
.clone()
.unwrap_or_else(|| "gpt-5.1".to_string());
rollout_line(
timestamp,
"turn_context",
serde_json::json!({
"cwd": cwd,
"model": model,
}),
)
}
fn session_meta_line(thread_id: ThreadId, cwd: PathBuf, timestamp: &str) -> serde_json::Value {
rollout_line(
timestamp,
"session_meta",
serde_json::json!({
"id": thread_id.to_string(),
"timestamp": timestamp,
"cwd": cwd,
"originator": "test",
"cli_version": "test",
}),
)
}
fn write_rollout_lines(path: &Path, lines: &[serde_json::Value]) -> std::io::Result<()> {
let mut text = String::new();
for line in lines {
text.push_str(&serde_json::to_string(line).expect("serialize rollout"));
text.push('\n');
}
std::fs::write(path, text)
}
#[tokio::test]
async fn read_session_cwd_returns_none_without_sqlite_or_rollout_path() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let cwd = read_session_cwd(&config, ThreadId::new(), /*path*/ None).await;
assert_eq!(cwd, None);
Ok(())
}
#[tokio::test]
async fn read_session_cwd_prefers_latest_turn_context() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let first = temp_dir.path().join("first");
let second = temp_dir.path().join("second");
std::fs::create_dir_all(&first)?;
std::fs::create_dir_all(&second)?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
write_rollout_lines(
&rollout_path,
&[
turn_context_line(&config, first, "t0"),
turn_context_line(&config, second.clone(), "t1"),
],
)?;
let cwd = read_session_cwd(&config, ThreadId::new(), Some(&rollout_path))
.await
.expect("expected cwd");
assert_eq!(cwd, second);
Ok(())
}
#[tokio::test]
async fn should_prompt_when_meta_matches_current_but_latest_turn_differs() -> std::io::Result<()>
{
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let current = temp_dir.path().join("current");
let latest = temp_dir.path().join("latest");
std::fs::create_dir_all(&current)?;
std::fs::create_dir_all(&latest)?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
write_rollout_lines(
&rollout_path,
&[
session_meta_line(ThreadId::new(), current.clone(), "t0"),
turn_context_line(&config, latest.clone(), "t1"),
],
)?;
let session_cwd = read_session_cwd(&config, ThreadId::new(), Some(&rollout_path))
.await
.expect("expected cwd");
assert_eq!(session_cwd, latest);
assert!(cwds_differ(&current, &session_cwd));
Ok(())
}
#[tokio::test]
async fn read_session_cwd_falls_back_to_session_meta() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let session_cwd = temp_dir.path().join("session");
std::fs::create_dir_all(&session_cwd)?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
write_rollout_lines(
&rollout_path,
&[session_meta_line(
ThreadId::new(),
session_cwd.clone(),
"t0",
)],
)?;
let cwd = read_session_cwd(&config, ThreadId::new(), Some(&rollout_path))
.await
.expect("expected cwd");
assert_eq!(cwd, session_cwd);
Ok(())
}
#[tokio::test]
async fn read_session_cwd_prefers_sqlite_when_thread_id_present() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let mut config = build_config(&temp_dir).await?;
config
.features
.enable(Feature::Sqlite)
.expect("test config should allow sqlite");
let thread_id = ThreadId::new();
let rollout_cwd = temp_dir.path().join("rollout-cwd");
let sqlite_cwd = temp_dir.path().join("sqlite-cwd");
std::fs::create_dir_all(&rollout_cwd)?;
std::fs::create_dir_all(&sqlite_cwd)?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
write_rollout_lines(
&rollout_path,
&[turn_context_line(&config, rollout_cwd, "t0")],
)?;
let runtime = codex_state::StateRuntime::init(
config.codex_home.to_path_buf(),
config.model_provider_id.clone(),
)
.await
.map_err(std::io::Error::other)?;
runtime
.mark_backfill_complete(/*last_watermark*/ None)
.await
.map_err(std::io::Error::other)?;
let mut builder = codex_state::ThreadMetadataBuilder::new(
thread_id,
rollout_path.clone(),
chrono::Utc::now(),
serde_json::from_value(serde_json::json!("cli"))
.expect("cli session source should deserialize"),
);
builder.cwd = sqlite_cwd.clone();
let metadata = builder.build(config.model_provider_id.as_str());
runtime
.upsert_thread(&metadata)
.await
.map_err(std::io::Error::other)?;
let cwd = read_session_cwd(&config, thread_id, Some(&rollout_path))
.await
.expect("expected cwd");
assert_eq!(cwd, sqlite_cwd);
Ok(())
}
#[tokio::test]
async fn resolve_session_thread_id_reads_minimal_session_metadata() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let thread_id = ThreadId::new();
let rollout_path = temp_dir.path().join("rollout.jsonl");
write_rollout_lines(
&rollout_path,
&[session_meta_line(
thread_id,
temp_dir.path().to_path_buf(),
"t0",
)],
)?;
let resolved = resolve_session_thread_id(&rollout_path, /*id_str_if_uuid*/ None).await;
assert_eq!(resolved, Some(thread_id));
Ok(())
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Canonical TUI session state shared across app-server routing, chat display, and status UI.
//!
//! The app-server API is the boundary for session lifecycle events. Once those responses enter
//! TUI, this module holds the small internal state shape used by app orchestration and widgets.
use std::path::PathBuf;
use codex_app_server_protocol::AskForApproval;
use codex_protocol::ThreadId;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct SessionNetworkProxyRuntime {
pub(crate) http_addr: String,
pub(crate) socks_addr: String,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ThreadSessionState {
pub(crate) thread_id: ThreadId,
pub(crate) forked_from_id: Option<ThreadId>,
pub(crate) fork_parent_title: Option<String>,
pub(crate) thread_name: Option<String>,
pub(crate) model: String,
pub(crate) model_provider_id: String,
pub(crate) service_tier: Option<codex_protocol::config_types::ServiceTier>,
pub(crate) approval_policy: AskForApproval,
pub(crate) approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
/// Canonical active permissions for this session. Legacy app-server
/// responses are converted to a profile at ingestion time using the
/// response cwd so cached sessions do not reinterpret cwd-bound grants.
pub(crate) permission_profile: PermissionProfile,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) instruction_source_paths: Vec<AbsolutePathBuf>,
pub(crate) reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
pub(crate) history_log_id: u64,
pub(crate) history_entry_count: u64,
pub(crate) network_proxy: Option<SessionNetworkProxyRuntime>,
pub(crate) rollout_path: Option<PathBuf>,
}
+8 -10
View File
@@ -3,17 +3,17 @@ use crate::history_cell::HistoryCell;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::with_border_with_inner_width;
use crate::legacy_core::config::Config;
use crate::token_usage::TokenUsage;
use crate::token_usage::TokenUsageInfo;
use crate::version::CODEX_CLI_VERSION;
use chrono::DateTime;
use chrono::Local;
use codex_app_server_protocol::AskForApproval;
use codex_model_provider_info::WireApi;
use codex_protocol::ThreadId;
use codex_protocol::account::PlanType;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_utils_sandbox_summary::summarize_permission_profile;
use ratatui::prelude::*;
use ratatui::style::Stylize;
@@ -244,6 +244,8 @@ impl StatusHistoryCell {
agents_summary: String,
refreshing_rate_limits: bool,
) -> (Self, StatusHistoryHandle) {
let approval_policy = AskForApproval::from(config.permissions.approval_policy.value());
let permission_profile = config.permissions.permission_profile();
let mut config_entries = vec![
("workdir", config.cwd.display().to_string()),
("model", model_name.to_string()),
@@ -254,10 +256,7 @@ impl StatusHistoryCell {
),
(
"sandbox",
summarize_permission_profile(
&config.permissions.permission_profile(),
config.cwd.as_path(),
),
summarize_permission_profile(&permission_profile, config.cwd.as_path()),
),
];
if config.model_provider.wire_api == WireApi::Responses {
@@ -280,13 +279,12 @@ impl StatusHistoryCell {
.find(|(k, _)| *k == "approval")
.map(|(_, v)| v.clone())
.unwrap_or_else(|| "<unknown>".to_string());
let permission_profile = config.permissions.permission_profile();
let sandbox = status_permission_summary(&permission_profile, config.cwd.as_path());
let permissions = if config.permissions.approval_policy.value() == AskForApproval::OnRequest
let permissions = if approval_policy == AskForApproval::OnRequest
&& permission_profile == PermissionProfile::workspace_write()
{
"Default".to_string()
} else if config.permissions.approval_policy.value() == AskForApproval::Never
} else if approval_policy == AskForApproval::Never
&& permission_profile == PermissionProfile::Disabled
{
"Full Access".to_string()
+5 -5
View File
@@ -13,9 +13,9 @@ use chrono::DateTime;
use chrono::Duration as ChronoDuration;
use chrono::Local;
use chrono::Utc;
use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_app_server_protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
const STATUS_LIMIT_BAR_SEGMENTS: usize = 20;
const STATUS_LIMIT_BAR_FILLED: &str = "█";
@@ -79,9 +79,9 @@ impl RateLimitWindowDisplay {
let resets_at = resets_at_utc.map(|dt| format_reset_timestamp(dt, captured_at));
Self {
used_percent: window.used_percent,
used_percent: f64::from(window.used_percent),
resets_at,
window_minutes: window.window_minutes,
window_minutes: window.window_duration_mins,
}
}
}
+96 -53
View File
@@ -8,26 +8,69 @@ use crate::legacy_core::config::ConfigBuilder;
use crate::status::StatusAccountDisplay;
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use crate::token_usage::TokenUsage;
use crate::token_usage::TokenUsageInfo;
use chrono::Duration as ChronoDuration;
use chrono::TimeZone;
use chrono::Utc;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::CreditsSnapshot;
use codex_app_server_protocol::FileSystemAccessMode;
use codex_app_server_protocol::FileSystemPath;
use codex_app_server_protocol::FileSystemSandboxEntry;
use codex_app_server_protocol::FileSystemSpecialPath;
use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile;
use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::NetworkSandboxPolicy;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::prelude::*;
use tempfile::TempDir;
fn app_server_workspace_write_profile(network_enabled: bool) -> PermissionProfile {
AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions {
enabled: network_enabled,
},
file_system: PermissionProfileFileSystemPermissions::Restricted {
entries: vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::ProjectRoots { subpath: None },
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::SlashTmp,
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
},
access: FileSystemAccessMode::Write,
},
],
glob_scan_max_depth: None,
},
}
.into()
}
async fn test_config(temp_home: &TempDir) -> Config {
let mut config = ConfigBuilder::default()
.codex_home(temp_home.path().to_path_buf())
@@ -36,11 +79,8 @@ async fn test_config(temp_home: &TempDir) -> Config {
.expect("load config");
config
.permissions
.set_permission_profile(PermissionProfile::workspace_write_with(
&[],
NetworkSandboxPolicy::Enabled,
/*exclude_tmpdir_env_var*/ false,
/*exclude_slash_tmp*/ false,
.set_permission_profile(app_server_workspace_write_profile(
/*network_enabled*/ true,
))
.expect("set permission profile");
config
@@ -167,13 +207,13 @@ async fn status_snapshot_includes_reasoning_details() {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 72.5,
window_minutes: Some(300),
used_percent: 72,
window_duration_mins: Some(300),
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 600)),
}),
secondary: Some(RateLimitWindow {
used_percent: 45.0,
window_minutes: Some(10080),
used_percent: 45,
window_duration_mins: Some(10080),
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_200)),
}),
credits: None,
@@ -220,16 +260,13 @@ async fn status_permissions_non_default_workspace_write_is_custom() {
config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)
.set(AskForApproval::OnRequest.to_core())
.expect("set approval policy");
config.cwd = test_path_buf("/workspace/tests").abs();
config
.permissions
.set_permission_profile(PermissionProfile::workspace_write_with(
&[],
NetworkSandboxPolicy::Enabled,
/*exclude_tmpdir_env_var*/ false,
/*exclude_slash_tmp*/ false,
.set_permission_profile(app_server_workspace_write_profile(
/*network_enabled*/ true,
))
.expect("set permission profile");
@@ -246,14 +283,17 @@ async fn status_permissions_full_disk_managed_with_network_is_danger_full_access
config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)
.set(AskForApproval::OnRequest.to_core())
.expect("set approval policy");
config
.permissions
.set_permission_profile(PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Unrestricted,
network: NetworkSandboxPolicy::Enabled,
})
.set_permission_profile(
AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: true },
file_system: PermissionProfileFileSystemPermissions::Unrestricted,
}
.into(),
)
.expect("set permission profile");
assert_eq!(
@@ -269,14 +309,17 @@ async fn status_permissions_full_disk_managed_without_network_is_external_sandbo
config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)
.set(AskForApproval::OnRequest.to_core())
.expect("set approval policy");
config
.permissions
.set_permission_profile(PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Unrestricted,
network: NetworkSandboxPolicy::Restricted,
})
.set_permission_profile(
AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
file_system: PermissionProfileFileSystemPermissions::Unrestricted,
}
.into(),
)
.expect("set permission profile");
assert_eq!(
@@ -364,8 +407,8 @@ async fn status_snapshot_includes_monthly_limit() {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 12.0,
window_minutes: Some(43_200),
used_percent: 12,
window_duration_mins: Some(43_200),
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 86_400)),
}),
secondary: None,
@@ -670,8 +713,8 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 72.5,
window_minutes: Some(300),
used_percent: 72,
window_duration_mins: Some(300),
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 600)),
}),
secondary: None,
@@ -830,13 +873,13 @@ async fn status_snapshot_shows_refreshing_limits_notice() {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 45.0,
window_minutes: Some(300),
used_percent: 45,
window_duration_mins: Some(300),
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 900)),
}),
secondary: Some(RateLimitWindow {
used_percent: 30.0,
window_minutes: Some(10_080),
used_percent: 30,
window_duration_mins: Some(10_080),
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_700)),
}),
credits: None,
@@ -897,13 +940,13 @@ async fn status_snapshot_includes_credits_and_limits() {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 45.0,
window_minutes: Some(300),
used_percent: 45,
window_duration_mins: Some(300),
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 900)),
}),
secondary: Some(RateLimitWindow {
used_percent: 30.0,
window_minutes: Some(10_080),
used_percent: 30,
window_duration_mins: Some(10_080),
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_700)),
}),
credits: Some(CreditsSnapshot {
@@ -1083,13 +1126,13 @@ async fn status_snapshot_shows_stale_limits_message() {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 72.5,
window_minutes: Some(300),
used_percent: 72,
window_duration_mins: Some(300),
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 600)),
}),
secondary: Some(RateLimitWindow {
used_percent: 40.0,
window_minutes: Some(10_080),
used_percent: 40,
window_duration_mins: Some(10_080),
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_800)),
}),
credits: None,
@@ -1150,13 +1193,13 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 60.0,
window_minutes: Some(300),
used_percent: 60,
window_duration_mins: Some(300),
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_200)),
}),
secondary: Some(RateLimitWindow {
used_percent: 35.0,
window_minutes: Some(10_080),
used_percent: 35,
window_duration_mins: Some(10_080),
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_400)),
}),
credits: Some(CreditsSnapshot {
+34
View File
@@ -1,6 +1,40 @@
pub(crate) use codex_utils_absolute_path::test_support::PathBufExt;
pub(crate) use codex_utils_absolute_path::test_support::test_path_buf;
use serde::Serialize;
use serde::de::DeserializeOwned;
pub(crate) fn test_path_display(path: &str) -> String {
test_path_buf(path).display().to_string()
}
pub(crate) fn session_source_cli<T>() -> T
where
T: DeserializeOwned,
{
from_app_server_wire(codex_app_server_protocol::SessionSource::Cli)
}
pub(crate) fn skill_scope_user<T>() -> T
where
T: DeserializeOwned,
{
from_app_server_wire(codex_app_server_protocol::SkillScope::User)
}
pub(crate) fn skill_scope_repo<T>() -> T
where
T: DeserializeOwned,
{
from_app_server_wire(codex_app_server_protocol::SkillScope::Repo)
}
fn from_app_server_wire<T>(value: impl Serialize) -> T
where
T: DeserializeOwned,
{
serde_json::to_value(value)
.and_then(serde_json::from_value)
.unwrap_or_else(|err| {
panic!("app-server wire value should map to legacy helper type: {err}")
})
}
+119
View File
@@ -0,0 +1,119 @@
//! TUI token usage and final-output formatting models.
use std::fmt;
use serde::Deserialize;
use serde::Serialize;
const BASELINE_TOKENS: i64 = 12000;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenUsage {
pub input_tokens: i64,
pub cached_input_tokens: i64,
pub output_tokens: i64,
pub reasoning_output_tokens: i64,
pub total_tokens: i64,
}
impl TokenUsage {
pub fn is_zero(&self) -> bool {
self.total_tokens == 0
}
pub(crate) fn cached_input(&self) -> i64 {
self.cached_input_tokens.max(0)
}
pub(crate) fn non_cached_input(&self) -> i64 {
(self.input_tokens - self.cached_input()).max(0)
}
pub(crate) fn blended_total(&self) -> i64 {
(self.non_cached_input() + self.output_tokens.max(0)).max(0)
}
pub(crate) fn tokens_in_context_window(&self) -> i64 {
self.total_tokens
}
pub(crate) fn percent_of_context_window_remaining(&self, context_window: i64) -> i64 {
if context_window <= BASELINE_TOKENS {
return 0;
}
let effective_window = context_window - BASELINE_TOKENS;
let used = (self.tokens_in_context_window() - BASELINE_TOKENS).max(0);
let remaining = (effective_window - used).max(0);
((remaining as f64 / effective_window as f64) * 100.0)
.clamp(0.0, 100.0)
.round() as i64
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct TokenUsageInfo {
pub(crate) total_token_usage: TokenUsage,
pub(crate) last_token_usage: TokenUsage,
pub(crate) model_context_window: Option<i64>,
}
#[cfg(test)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct TokenCountEvent {
pub(crate) info: Option<TokenUsageInfo>,
pub(crate) rate_limits: Option<codex_app_server_protocol::RateLimitSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FinalOutput {
pub token_usage: TokenUsage,
}
impl From<TokenUsage> 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()
}
)
}
}
fn format_with_separators(value: i64) -> String {
let sign = if value < 0 { "-" } else { "" };
let digits = value.abs().to_string();
let mut out = String::new();
for (i, ch) in digits.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
out.push(',');
}
out.push(ch);
}
let grouped: String = out.chars().rev().collect();
format!("{sign}{grouped}")
}
+163
View File
@@ -0,0 +1,163 @@
//! Internal tool activity models used by chat cells and interrupt queueing.
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use crate::diff_model::FileChange;
use codex_app_server_protocol::CommandExecOutputStream;
use codex_app_server_protocol::CommandExecutionSource;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::HookRunSummary;
use codex_app_server_protocol::PatchApplyStatus;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::parse_command::ParsedCommand;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct McpInvocation {
pub(crate) server: String,
pub(crate) tool: String,
pub(crate) arguments: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct McpToolCallBeginEvent {
pub(crate) call_id: String,
pub(crate) invocation: McpInvocation,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) mcp_app_resource_uri: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct McpToolCallEndEvent {
pub(crate) call_id: String,
pub(crate) invocation: McpInvocation,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) mcp_app_resource_uri: Option<String>,
pub(crate) duration: Duration,
pub(crate) result: Result<CallToolResult, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct WebSearchBeginEvent {
pub(crate) call_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct WebSearchEndEvent {
pub(crate) call_id: String,
pub(crate) query: String,
pub(crate) action: codex_protocol::models::WebSearchAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ImageGenerationBeginEvent {
pub(crate) call_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ImageGenerationEndEvent {
pub(crate) call_id: String,
pub(crate) status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) revised_prompt: Option<String>,
pub(crate) result: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) saved_path: Option<AbsolutePathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ExecCommandBeginEvent {
pub(crate) call_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) process_id: Option<String>,
pub(crate) turn_id: String,
pub(crate) command: Vec<String>,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) parsed_cmd: Vec<ParsedCommand>,
#[serde(default)]
pub(crate) source: CommandExecutionSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) interaction_input: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ExecCommandEndEvent {
pub(crate) call_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) process_id: Option<String>,
pub(crate) turn_id: String,
pub(crate) command: Vec<String>,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) parsed_cmd: Vec<ParsedCommand>,
#[serde(default)]
pub(crate) source: CommandExecutionSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) interaction_input: Option<String>,
pub(crate) stdout: String,
pub(crate) stderr: String,
#[serde(default)]
pub(crate) aggregated_output: String,
pub(crate) exit_code: i32,
pub(crate) duration: Duration,
pub(crate) formatted_output: String,
pub(crate) status: CommandExecutionStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ViewImageToolCallEvent {
pub(crate) call_id: String,
pub(crate) path: AbsolutePathBuf,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct ExecCommandOutputDeltaEvent {
pub(crate) call_id: String,
pub(crate) stream: CommandExecOutputStream,
pub(crate) chunk: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct TerminalInteractionEvent {
pub(crate) call_id: String,
pub(crate) process_id: String,
pub(crate) stdin: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PatchApplyBeginEvent {
pub(crate) call_id: String,
#[serde(default)]
pub(crate) turn_id: String,
pub(crate) auto_approved: bool,
pub(crate) changes: HashMap<PathBuf, FileChange>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PatchApplyEndEvent {
pub(crate) call_id: String,
#[serde(default)]
pub(crate) turn_id: String,
pub(crate) stdout: String,
pub(crate) stderr: String,
pub(crate) success: bool,
#[serde(default)]
pub(crate) changes: HashMap<PathBuf, FileChange>,
pub(crate) status: PatchApplyStatus,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct HookStartedEvent {
pub(crate) turn_id: Option<String>,
pub(crate) run: HookRunSummary,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct HookCompletedEvent {
pub(crate) turn_id: Option<String>,
pub(crate) run: HookRunSummary,
}
+13
View File
@@ -0,0 +1,13 @@
//! Turn lifecycle state used by TUI interruption and completion handling.
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum TurnAbortReason {
Interrupted,
Replaced,
ReviewEnded,
BudgetLimited,
}
+8 -11
View File
@@ -1,8 +1,7 @@
use crate::app_event_sender::AppEventSender;
use crate::legacy_core::config::Config;
use base64::Engine;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::RealtimeAudioFrame;
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
use cpal::traits::DeviceTrait;
use cpal::traits::StreamTrait;
use std::collections::VecDeque;
@@ -219,14 +218,12 @@ fn send_realtime_audio_chunk(
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
let samples_per_channel = (samples.len() / usize::from(MODEL_AUDIO_CHANNELS)) as u32;
tx.realtime_conversation_audio(ConversationAudioParams {
frame: RealtimeAudioFrame {
data: encoded,
sample_rate: MODEL_AUDIO_SAMPLE_RATE,
num_channels: MODEL_AUDIO_CHANNELS,
samples_per_channel: Some(samples_per_channel),
item_id: None,
},
tx.realtime_conversation_audio(ThreadRealtimeAudioChunk {
data: encoded,
sample_rate: MODEL_AUDIO_SAMPLE_RATE,
num_channels: MODEL_AUDIO_CHANNELS,
samples_per_channel: Some(samples_per_channel),
item_id: None,
});
}
@@ -306,7 +303,7 @@ impl RealtimeAudioPlayer {
})
}
pub(crate) fn enqueue_frame(&self, frame: &RealtimeAudioFrame) -> Result<(), String> {
pub(crate) fn enqueue_frame(&self, frame: &ThreadRealtimeAudioChunk) -> Result<(), String> {
if frame.num_channels == 0 || frame.sample_rate == 0 {
return Err("invalid realtime audio frame format".to_string());
}