mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
Replace TUI core protocol imports
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(¶ms.thread_id).ok()
|
||||
}
|
||||
ServerRequest::FileChangeRequestApproval { params, .. } => {
|
||||
ThreadId::from_string(¶ms.thread_id).ok()
|
||||
}
|
||||
ServerRequest::ToolRequestUserInput { params, .. } => {
|
||||
ThreadId::from_string(¶ms.thread_id).ok()
|
||||
}
|
||||
ServerRequest::McpServerElicitationRequest { params, .. } => {
|
||||
ThreadId::from_string(¶ms.thread_id).ok()
|
||||
}
|
||||
ServerRequest::PermissionsRequestApproval { params, .. } => {
|
||||
ThreadId::from_string(¶ms.thread_id).ok()
|
||||
}
|
||||
ServerRequest::DynamicToolCall { params, .. } => {
|
||||
ThreadId::from_string(¶ms.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(¬ification);
|
||||
|
||||
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(¬ification);
|
||||
|
||||
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(¬ification);
|
||||
|
||||
assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id));
|
||||
}
|
||||
}
|
||||
@@ -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 ¬ification {
|
||||
ServerNotification::ServerRequestResolved(notification) => {
|
||||
if let Some(request) = self
|
||||
.pending_app_server_requests
|
||||
.resolve_notification(¬ification.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(¬ification) {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
¤t_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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+339
-1043
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(¬ification.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() {
|
||||
|
||||
@@ -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() => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(¤t)?;
|
||||
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(¤t, &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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(¤t)?;
|
||||
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(¤t, &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(())
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user