mirror of
https://github.com/openai/codex.git
synced 2026-11-08 08:37:30 +00:00
Remove core protocol dependency [2/2] (#20325)
## Why With the local model layer and app-server routing in place from PR1, this PR moves the active TUI runtime onto app-server notifications. The affected pieces share the same event flow, so the command surface, session state, bottom-pane prompts, chat rendering, history/status views, and tests move together to keep the stacked branch buildable. This PR also removes the obsolete compatibility surface that is no longer used after the migration. The proposed protocol-boundary verifier layer was dropped from the stack; enforcing that final boundary will be simpler once `codex-tui` no longer needs any `codex_protocol` references. This PR is part 2 of a 2-PR stack: 1. Add TUI-owned replacement models and extract app-server event routing. 2. Move the active TUI flow to app-server notifications and delete obsolete adapter code. ## What changed - Rewired app command and session handling to use app-server request and notification shapes. - Moved approval overlays, request-user-input flows, MCP elicitation, realtime events, and review commands onto the app-server-facing model surface. - Updated chat rendering, history cells, status views, multi-agent UI, replay state, and TUI tests to use app-server notifications plus the local models introduced in PR1. - Deleted `codex-rs/tui/src/app/app_server_adapter.rs` and the superseded `chatwidget/tests/background_events.rs` fixture path. ## Verification - `cargo check -p codex-tui --tests` - Top of stack: `cargo test -p codex-tui`
This commit is contained in:
1 parent
5cc5f12efc
commit
f2bc2f26a9
76 files changed
+5078
-9951
No files matched your search
@@ -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!(
|
||||
|
||||
+60
-147
@@ -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,18 @@ 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::TokenUsage;
|
||||
use crate::transcript_reflow::TranscriptReflowState;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
@@ -82,6 +81,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 +92,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 +105,11 @@ 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::RequestId;
|
||||
use codex_app_server_protocol::RateLimitSnapshot;
|
||||
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;
|
||||
@@ -126,7 +129,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;
|
||||
@@ -135,19 +137,8 @@ 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_protocol::permissions::FileSystemSandboxKind;
|
||||
use codex_terminal_detection::user_agent;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use color_eyre::eyre::Result;
|
||||
@@ -224,48 +215,6 @@ enum ThreadInteractiveRequest {
|
||||
McpServerElicitation(McpServerElicitationFormRequest),
|
||||
}
|
||||
|
||||
fn app_server_request_id_to_mcp_request_id(
|
||||
request_id: &codex_app_server_protocol::RequestId,
|
||||
) -> codex_protocol::mcp::RequestId {
|
||||
match request_id {
|
||||
codex_app_server_protocol::RequestId::String(value) => {
|
||||
codex_protocol::mcp::RequestId::String(value.clone())
|
||||
}
|
||||
codex_app_server_protocol::RequestId::Integer(value) => {
|
||||
codex_protocol::mcp::RequestId::Integer(*value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +240,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)]
|
||||
@@ -378,7 +361,7 @@ fn session_summary(
|
||||
thread_name: Option<String>,
|
||||
rollout_path: Option<&Path>,
|
||||
) -> Option<SessionSummary> {
|
||||
let usage_line = (!token_usage.is_zero()).then(|| FinalOutput::from(token_usage).to_string());
|
||||
let usage_line = (!token_usage.is_zero()).then(|| token_usage.to_string());
|
||||
let thread_id =
|
||||
resumable_thread(thread_id, thread_name, rollout_path).map(|thread| thread.thread_id);
|
||||
let resume_command =
|
||||
@@ -417,86 +400,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>,
|
||||
@@ -760,7 +672,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
@@ -7,11 +7,9 @@ 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::app_server_rate_limit_snapshot_to_core;
|
||||
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::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
|
||||
@@ -77,9 +75,8 @@ impl App {
|
||||
self.refresh_mcp_startup_expected_servers_from_config();
|
||||
}
|
||||
ServerNotification::AccountRateLimitsUpdated(notification) => {
|
||||
self.chat_widget.on_rate_limit_snapshot(Some(
|
||||
app_server_rate_limit_snapshot_to_core(notification.rate_limits.clone()),
|
||||
));
|
||||
self.chat_widget
|
||||
.on_rate_limit_snapshot(Some(notification.rate_limits.clone()));
|
||||
return;
|
||||
}
|
||||
ServerNotification::AccountUpdated(notification) => {
|
||||
@@ -186,23 +183,4 @@ impl App {
|
||||
tracing::warn!("failed to enqueue app-server request: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn reject_app_server_request(
|
||||
&self,
|
||||
app_server_client: &AppServerSession,
|
||||
request_id: codex_app_server_protocol::RequestId,
|
||||
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}"))
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,38 @@
|
||||
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::McpServerElicitationAction;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestResponse;
|
||||
use codex_app_server_protocol::PermissionsRequestApprovalResponse;
|
||||
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 {
|
||||
@@ -44,7 +62,7 @@ pub(crate) enum ResolvedAppServerRequest {
|
||||
},
|
||||
McpElicitation {
|
||||
server_name: String,
|
||||
request_id: McpRequestId,
|
||||
request_id: AppServerRequestId,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -54,7 +72,7 @@ pub(super) struct PendingAppServerRequests {
|
||||
file_change_approvals: HashMap<String, AppServerRequestId>,
|
||||
permissions_approvals: HashMap<String, AppServerRequestId>,
|
||||
user_inputs: HashMap<String, VecDeque<PendingUserInputRequest>>,
|
||||
mcp_requests: HashMap<McpLegacyRequestKey, AppServerRequestId>,
|
||||
mcp_requests: HashMap<McpRequestKey, AppServerRequestId>,
|
||||
}
|
||||
|
||||
impl PendingAppServerRequests {
|
||||
@@ -101,9 +119,9 @@ impl PendingAppServerRequests {
|
||||
}
|
||||
ServerRequest::McpServerElicitationRequest { request_id, params } => {
|
||||
self.mcp_requests.insert(
|
||||
McpLegacyRequestKey {
|
||||
McpRequestKey {
|
||||
server_name: params.server_name.clone(),
|
||||
request_id: app_server_request_id_to_mcp_request_id(request_id),
|
||||
request_id: request_id.clone(),
|
||||
},
|
||||
request_id.clone(),
|
||||
);
|
||||
@@ -141,30 +159,32 @@ 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}")
|
||||
format!(
|
||||
"failed to serialize command execution approval response: {err}"
|
||||
)
|
||||
})?,
|
||||
})
|
||||
})
|
||||
.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 +192,7 @@ impl PendingAppServerRequests {
|
||||
})
|
||||
})
|
||||
.transpose()?,
|
||||
AppCommandView::RequestPermissionsResponse { id, response } => self
|
||||
AppCommand::RequestPermissionsResponse { id, response } => self
|
||||
.permissions_approvals
|
||||
.remove(id)
|
||||
.map(|request_id| {
|
||||
@@ -191,30 +211,18 @@ 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 {
|
||||
request_id: pending.request_id,
|
||||
result: serde_json::to_value(
|
||||
serde_json::from_value::<ToolRequestUserInputResponse>(
|
||||
serde_json::to_value(response).map_err(|err| {
|
||||
format!("failed to encode request_user_input response: {err}")
|
||||
})?,
|
||||
)
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"failed to decode request_user_input response for app-server: {err}"
|
||||
)
|
||||
})?,
|
||||
)
|
||||
.map_err(|err| {
|
||||
result: serde_json::to_value(response).map_err(|err| {
|
||||
format!("failed to serialize request_user_input response: {err}")
|
||||
})?,
|
||||
})
|
||||
})
|
||||
.transpose()?,
|
||||
AppCommandView::ResolveElicitation {
|
||||
AppCommand::ResolveElicitation {
|
||||
server_name,
|
||||
request_id,
|
||||
decision,
|
||||
@@ -222,7 +230,7 @@ impl PendingAppServerRequests {
|
||||
meta,
|
||||
} => self
|
||||
.mcp_requests
|
||||
.remove(&McpLegacyRequestKey {
|
||||
.remove(&McpRequestKey {
|
||||
server_name: server_name.to_string(),
|
||||
request_id: request_id.clone(),
|
||||
})
|
||||
@@ -230,17 +238,7 @@ impl PendingAppServerRequests {
|
||||
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
|
||||
request_id,
|
||||
result: serde_json::to_value(McpServerElicitationRequestResponse {
|
||||
action: match decision {
|
||||
codex_protocol::approvals::ElicitationAction::Accept => {
|
||||
McpServerElicitationAction::Accept
|
||||
}
|
||||
codex_protocol::approvals::ElicitationAction::Decline => {
|
||||
McpServerElicitationAction::Decline
|
||||
}
|
||||
codex_protocol::approvals::ElicitationAction::Cancel => {
|
||||
McpServerElicitationAction::Cancel
|
||||
}
|
||||
},
|
||||
action: *decision,
|
||||
content: content.clone(),
|
||||
meta: meta.clone(),
|
||||
})
|
||||
@@ -383,44 +381,25 @@ struct PendingUserInputRequest {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct McpLegacyRequestKey {
|
||||
struct McpRequestKey {
|
||||
server_name: String,
|
||||
request_id: McpRequestId,
|
||||
}
|
||||
|
||||
fn app_server_request_id_to_mcp_request_id(request_id: &AppServerRequestId) -> McpRequestId {
|
||||
match request_id {
|
||||
AppServerRequestId::String(value) => McpRequestId::String(value.clone()),
|
||||
AppServerRequestId::Integer(value) => McpRequestId::Integer(*value),
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
request_id: AppServerRequestId,
|
||||
}
|
||||
|
||||
#[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;
|
||||
use codex_app_server_protocol::McpServerElicitationAction;
|
||||
use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use codex_app_server_protocol::PermissionGrantScope;
|
||||
@@ -431,13 +410,8 @@ mod tests {
|
||||
use codex_app_server_protocol::ToolRequestUserInputAnswer;
|
||||
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 +448,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");
|
||||
@@ -586,10 +560,10 @@ mod tests {
|
||||
let user_input = pending
|
||||
.take_resolution(&Op::UserInputAnswer {
|
||||
id: "turn-2".to_string(),
|
||||
response: codex_protocol::request_user_input::RequestUserInputResponse {
|
||||
response: ToolRequestUserInputResponse {
|
||||
answers: std::iter::once((
|
||||
"question".to_string(),
|
||||
codex_protocol::request_user_input::RequestUserInputAnswer {
|
||||
ToolRequestUserInputAnswer {
|
||||
answers: vec!["yes".to_string()],
|
||||
},
|
||||
))
|
||||
@@ -643,8 +617,8 @@ mod tests {
|
||||
let resolution = pending
|
||||
.take_resolution(&Op::ResolveElicitation {
|
||||
server_name: "example".to_string(),
|
||||
request_id: McpRequestId::Integer(12),
|
||||
decision: ElicitationAction::Accept,
|
||||
request_id: AppServerRequestId::Integer(12),
|
||||
decision: McpServerElicitationAction::Accept,
|
||||
content: Some(json!({ "answer": "yes" })),
|
||||
meta: Some(json!({ "source": "tui" })),
|
||||
})
|
||||
@@ -703,7 +677,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 +693,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]
|
||||
@@ -803,7 +771,7 @@ mod tests {
|
||||
pending.resolve_notification(&AppServerRequestId::Integer(12)),
|
||||
Some(ResolvedAppServerRequest::McpElicitation {
|
||||
server_name: "example".to_string(),
|
||||
request_id: McpRequestId::Integer(12),
|
||||
request_id: AppServerRequestId::Integer(12),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -844,7 +812,7 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
let response = codex_protocol::request_user_input::RequestUserInputResponse {
|
||||
let response = ToolRequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
};
|
||||
let first_response = pending
|
||||
|
||||
@@ -9,6 +9,7 @@ use codex_app_server_protocol::MarketplaceAddParams;
|
||||
use codex_app_server_protocol::MarketplaceAddResponse;
|
||||
use codex_app_server_protocol::MarketplaceRemoveParams;
|
||||
use codex_app_server_protocol::MarketplaceRemoveResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
impl App {
|
||||
@@ -483,7 +484,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
|
||||
@@ -493,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,9 +549,6 @@ mod tests {
|
||||
use crate::app::test_support::make_test_app;
|
||||
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;
|
||||
|
||||
@@ -634,11 +632,11 @@ mod tests {
|
||||
let next_cwd_tmp = tempdir()?;
|
||||
let next_cwd = next_cwd_tmp.path().to_path_buf();
|
||||
|
||||
app.chat_widget.handle_codex_event(Event {
|
||||
id: String::new(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: ThreadId::new(),
|
||||
app.chat_widget
|
||||
.handle_thread_session(crate::session_state::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(),
|
||||
@@ -648,14 +646,13 @@ mod tests {
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: next_cwd.clone().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()),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
assert_eq!(app.chat_widget.config_ref().cwd.to_path_buf(), next_cwd);
|
||||
assert_eq!(app.config.cwd, original_cwd);
|
||||
|
||||
@@ -1055,7 +1055,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")]
|
||||
@@ -1068,8 +1068,9 @@ impl App {
|
||||
/*personality*/ None,
|
||||
),
|
||||
));
|
||||
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(),
|
||||
));
|
||||
@@ -1317,10 +1318,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, /*depth*/ 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, /*depth*/ 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, /*depth*/ 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;
|
||||
@@ -10,11 +9,11 @@ use std::collections::HashSet;
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct ElicitationRequestKey {
|
||||
server_name: String,
|
||||
request_id: codex_protocol::mcp::RequestId,
|
||||
request_id: AppServerRequestId,
|
||||
}
|
||||
|
||||
impl ElicitationRequestKey {
|
||||
fn new(server_name: String, request_id: codex_protocol::mcp::RequestId) -> Self {
|
||||
fn new(server_name: String, request_id: AppServerRequestId) -> Self {
|
||||
Self {
|
||||
server_name,
|
||||
request_id,
|
||||
@@ -33,9 +32,9 @@ impl ElicitationRequestKey {
|
||||
// - buffer eviction (`note_evicted_event`)
|
||||
//
|
||||
// We keep both fast lookup sets (for snapshot filtering by call_id/request key) and
|
||||
// turn-indexed queues/vectors so `TurnComplete`/`TurnAborted` can clear stale prompts tied
|
||||
// to a turn. `request_user_input` removal is FIFO because the overlay answers queued prompts
|
||||
// in FIFO order for a shared `turn_id`.
|
||||
// turn-indexed queues/vectors so turn completion or interruption can clear
|
||||
// stale prompts tied to a turn. `request_user_input` removal is FIFO because
|
||||
// the overlay answers queued prompts in FIFO order for a shared `turn_id`.
|
||||
pub(super) struct PendingInteractiveReplayState {
|
||||
exec_approval_call_ids: HashSet<String>,
|
||||
exec_approval_call_ids_by_turn_id: HashMap<String, Vec<String>>,
|
||||
@@ -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(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -208,10 +207,8 @@ impl PendingInteractiveReplayState {
|
||||
);
|
||||
}
|
||||
ServerRequest::McpServerElicitationRequest { request_id, params } => {
|
||||
let key = ElicitationRequestKey::new(
|
||||
params.server_name.clone(),
|
||||
app_server_request_id_to_mcp_request_id(request_id),
|
||||
);
|
||||
let key =
|
||||
ElicitationRequestKey::new(params.server_name.clone(), request_id.clone());
|
||||
self.elicitation_requests.insert(key.clone());
|
||||
self.pending_requests_by_request_id.insert(
|
||||
request_id.clone(),
|
||||
@@ -311,7 +308,7 @@ impl PendingInteractiveReplayState {
|
||||
self.elicitation_requests
|
||||
.remove(&ElicitationRequestKey::new(
|
||||
params.server_name.clone(),
|
||||
app_server_request_id_to_mcp_request_id(request_id),
|
||||
request_id.clone(),
|
||||
));
|
||||
}
|
||||
ServerRequest::ToolRequestUserInput { params, .. } => {
|
||||
@@ -366,7 +363,7 @@ impl PendingInteractiveReplayState {
|
||||
.elicitation_requests
|
||||
.contains(&ElicitationRequestKey::new(
|
||||
params.server_name.clone(),
|
||||
app_server_request_id_to_mcp_request_id(request_id),
|
||||
request_id.clone(),
|
||||
)),
|
||||
ServerRequest::ToolRequestUserInput { params, .. } => {
|
||||
self.request_user_input_call_ids.contains(¶ms.item_id)
|
||||
@@ -549,10 +546,7 @@ impl PendingInteractiveReplayState {
|
||||
(
|
||||
PendingInteractiveRequest::Elicitation(key),
|
||||
ServerRequest::McpServerElicitationRequest { request_id, params },
|
||||
) => {
|
||||
key.server_name == params.server_name
|
||||
&& key.request_id == app_server_request_id_to_mcp_request_id(request_id)
|
||||
}
|
||||
) => key.server_name == params.server_name && key.request_id == *request_id,
|
||||
(
|
||||
PendingInteractiveRequest::RequestPermissions { turn_id, item_id },
|
||||
ServerRequest::PermissionsRequestApproval { params, .. },
|
||||
@@ -566,23 +560,17 @@ impl PendingInteractiveReplayState {
|
||||
}
|
||||
}
|
||||
|
||||
fn app_server_request_id_to_mcp_request_id(
|
||||
request_id: &AppServerRequestId,
|
||||
) -> codex_protocol::mcp::RequestId {
|
||||
match request_id {
|
||||
AppServerRequestId::String(value) => codex_protocol::mcp::RequestId::String(value.clone()),
|
||||
AppServerRequestId::Integer(value) => codex_protocol::mcp::RequestId::Integer(*value),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::ThreadBufferedEvent;
|
||||
use super::super::ThreadEventStore;
|
||||
use crate::app_command::AppCommand as Op;
|
||||
use codex_app_server_protocol::CommandExecutionApprovalDecision;
|
||||
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
|
||||
use codex_app_server_protocol::FileChangeRequestApprovalParams;
|
||||
use codex_app_server_protocol::McpElicitationObjectType;
|
||||
use codex_app_server_protocol::McpElicitationSchema;
|
||||
use codex_app_server_protocol::McpServerElicitationAction;
|
||||
use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use codex_app_server_protocol::RequestId as AppServerRequestId;
|
||||
@@ -591,11 +579,10 @@ mod tests {
|
||||
use codex_app_server_protocol::ServerRequestResolvedNotification;
|
||||
use codex_app_server_protocol::ThreadClosedNotification;
|
||||
use codex_app_server_protocol::ToolRequestUserInputParams;
|
||||
use codex_app_server_protocol::ToolRequestUserInputResponse;
|
||||
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;
|
||||
@@ -724,7 +711,7 @@ mod tests {
|
||||
|
||||
store.note_outbound_op(&Op::UserInputAnswer {
|
||||
id: "turn-1".to_string(),
|
||||
response: codex_protocol::request_user_input::RequestUserInputResponse {
|
||||
response: ToolRequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
},
|
||||
});
|
||||
@@ -767,7 +754,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: CommandExecutionApprovalDecision::Accept,
|
||||
});
|
||||
|
||||
let snapshot = store.snapshot();
|
||||
@@ -809,7 +796,7 @@ mod tests {
|
||||
|
||||
store.note_outbound_op(&Op::UserInputAnswer {
|
||||
id: "turn-1".to_string(),
|
||||
response: codex_protocol::request_user_input::RequestUserInputResponse {
|
||||
response: ToolRequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
},
|
||||
});
|
||||
@@ -833,7 +820,7 @@ mod tests {
|
||||
|
||||
store.note_outbound_op(&Op::UserInputAnswer {
|
||||
id: "turn-1".to_string(),
|
||||
response: codex_protocol::request_user_input::RequestUserInputResponse {
|
||||
response: ToolRequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
},
|
||||
});
|
||||
@@ -854,7 +841,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();
|
||||
@@ -888,13 +875,13 @@ mod tests {
|
||||
#[test]
|
||||
fn thread_event_snapshot_drops_resolved_elicitation_after_outbound_resolution() {
|
||||
let mut store = ThreadEventStore::new(/*capacity*/ 8);
|
||||
let request_id = codex_protocol::mcp::RequestId::String("request-1".to_string());
|
||||
let request_id = AppServerRequestId::String("request-1".to_string());
|
||||
store.push_request(elicitation_request("server-1", "request-1", "turn-1"));
|
||||
|
||||
store.note_outbound_op(&Op::ResolveElicitation {
|
||||
server_name: "server-1".to_string(),
|
||||
request_id,
|
||||
decision: codex_protocol::approvals::ElicitationAction::Accept,
|
||||
decision: McpServerElicitationAction::Accept,
|
||||
content: None,
|
||||
meta: None,
|
||||
});
|
||||
@@ -920,7 +907,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: CommandExecutionApprovalDecision::Accept,
|
||||
});
|
||||
|
||||
assert_eq!(store.has_pending_thread_approvals(), false);
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+264
-258
@@ -6,7 +6,6 @@ use super::*;
|
||||
use crate::app_backtrack::BacktrackSelection;
|
||||
use crate::app_backtrack::BacktrackState;
|
||||
use crate::app_backtrack::user_count;
|
||||
use crate::app_command::AppCommand;
|
||||
|
||||
use crate::chatwidget::ChatWidgetInit;
|
||||
use crate::chatwidget::create_initial_user_message;
|
||||
@@ -22,6 +21,8 @@ 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::diff_model::FileChange;
|
||||
use crate::legacy_core::config::ConfigBuilder;
|
||||
use crate::legacy_core::config::ConfigOverrides;
|
||||
use crate::legacy_core::config::TerminalResizeReflowMaxRows;
|
||||
@@ -29,6 +30,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;
|
||||
@@ -47,6 +49,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;
|
||||
@@ -60,6 +63,7 @@ use codex_app_server_protocol::TurnCompletedNotification;
|
||||
use codex_app_server_protocol::TurnError as AppServerTurnError;
|
||||
use codex_app_server_protocol::TurnStartedNotification;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_app_server_protocol::UserInput as AppServerUserInput;
|
||||
use codex_app_server_protocol::WarningNotification;
|
||||
use codex_otel::SessionTelemetry;
|
||||
@@ -68,24 +72,11 @@ 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;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
@@ -442,12 +433,12 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm
|
||||
}
|
||||
AppEvent::SubmitThreadOp {
|
||||
thread_id: op_thread_id,
|
||||
op: AppCommand::UserTurn { items, .. },
|
||||
op: Op::UserTurn { items, .. },
|
||||
} => {
|
||||
assert_eq!(op_thread_id, thread_id);
|
||||
submitted_items = Some(items);
|
||||
}
|
||||
AppEvent::CodexOp(AppCommand::UserTurn { items, .. }) => {
|
||||
AppEvent::CodexOp(Op::UserTurn { items, .. }) => {
|
||||
submitted_items = Some(items);
|
||||
}
|
||||
_ => {}
|
||||
@@ -514,8 +505,7 @@ async fn history_lookup_response_is_routed_to_requesting_thread() -> Result<()>
|
||||
&Op::GetHistoryEntryRequest {
|
||||
offset: 0,
|
||||
log_id: 1,
|
||||
}
|
||||
.into(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1338,32 +1328,43 @@ async fn open_agent_picker_marks_terminal_read_errors_closed() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_agent_picker_marks_loaded_threads_open() -> Result<()> {
|
||||
let mut app = Box::pin(make_test_app()).await;
|
||||
let mut app_server = Box::pin(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<()> {
|
||||
const WORKER_THREADS: usize = 1;
|
||||
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
Box::pin(app.open_agent_picker(&mut app_server)).await;
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(WORKER_THREADS)
|
||||
.thread_stack_size(TEST_STACK_SIZE_BYTES)
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
assert_eq!(
|
||||
app.agent_navigation.get(&thread_id),
|
||||
Some(&AgentPickerThreadEntry {
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
is_closed: false,
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
runtime.block_on(async {
|
||||
let mut app = Box::pin(make_test_app()).await;
|
||||
let mut app_server = Box::pin(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));
|
||||
|
||||
Box::pin(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(())
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1575,68 +1576,84 @@ async fn update_memory_settings_persists_and_updates_widget_config() -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_memory_settings_updates_current_thread_memory_mode() -> Result<()> {
|
||||
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
|
||||
let codex_home = tempdir()?;
|
||||
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
||||
// Seed the previous setting so this test exercises the thread-mode update path.
|
||||
app.config.memories.generate_memories = true;
|
||||
#[test]
|
||||
fn update_memory_settings_updates_current_thread_memory_mode() -> Result<()> {
|
||||
const WORKER_THREADS: usize = 1;
|
||||
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let started = app_server.start_thread(&app.config).await?;
|
||||
let thread_id = started.session.thread_id;
|
||||
app.active_thread_id = Some(thread_id);
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(WORKER_THREADS)
|
||||
.thread_stack_size(TEST_STACK_SIZE_BYTES)
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
Box::pin(app.update_memory_settings_with_app_server(
|
||||
&mut app_server,
|
||||
/*use_memories*/ true,
|
||||
/*generate_memories*/ false,
|
||||
))
|
||||
.await;
|
||||
runtime.block_on(async {
|
||||
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
|
||||
let codex_home = tempdir()?;
|
||||
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
||||
// Seed the previous setting so this test exercises the thread-mode update path.
|
||||
app.config.memories.generate_memories = true;
|
||||
|
||||
let state_db = codex_state::StateRuntime::init(
|
||||
codex_home.path().to_path_buf(),
|
||||
app.config.model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let memory_mode = state_db
|
||||
.get_thread_memory_mode(thread_id)
|
||||
let mut app_server =
|
||||
Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let started = app_server.start_thread(&app.config).await?;
|
||||
let thread_id = started.session.thread_id;
|
||||
app.active_thread_id = Some(thread_id);
|
||||
|
||||
Box::pin(app.update_memory_settings_with_app_server(
|
||||
&mut app_server,
|
||||
/*use_memories*/ true,
|
||||
/*generate_memories*/ false,
|
||||
))
|
||||
.await;
|
||||
|
||||
let state_db = codex_state::StateRuntime::init(
|
||||
codex_home.path().to_path_buf(),
|
||||
app.config.model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("thread memory mode should be readable");
|
||||
assert_eq!(memory_mode.as_deref(), Some("disabled"));
|
||||
.expect("state db should initialize");
|
||||
let memory_mode = state_db
|
||||
.get_thread_memory_mode(thread_id)
|
||||
.await
|
||||
.expect("thread memory mode should be readable");
|
||||
assert_eq!(memory_mode.as_deref(), Some("disabled"));
|
||||
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_memories_clears_local_memory_directories() -> Result<()> {
|
||||
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
|
||||
let codex_home = tempdir()?;
|
||||
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
||||
app.config.sqlite_home = codex_home.path().to_path_buf();
|
||||
Box::pin(async {
|
||||
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
|
||||
let codex_home = tempdir()?;
|
||||
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
||||
app.config.sqlite_home = codex_home.path().to_path_buf();
|
||||
|
||||
let memory_root = codex_home.path().join("memories");
|
||||
let extensions_root = memory_root.join("extensions");
|
||||
std::fs::create_dir_all(memory_root.join("rollout_summaries"))?;
|
||||
std::fs::create_dir_all(&extensions_root)?;
|
||||
std::fs::write(memory_root.join("MEMORY.md"), "stale memory\n")?;
|
||||
std::fs::write(
|
||||
memory_root.join("rollout_summaries").join("stale.md"),
|
||||
"stale summary\n",
|
||||
)?;
|
||||
std::fs::write(extensions_root.join("stale.txt"), "stale extension\n")?;
|
||||
let memory_root = codex_home.path().join("memories");
|
||||
let extensions_root = memory_root.join("extensions");
|
||||
std::fs::create_dir_all(memory_root.join("rollout_summaries"))?;
|
||||
std::fs::create_dir_all(&extensions_root)?;
|
||||
std::fs::write(memory_root.join("MEMORY.md"), "stale memory\n")?;
|
||||
std::fs::write(
|
||||
memory_root.join("rollout_summaries").join("stale.md"),
|
||||
"stale summary\n",
|
||||
)?;
|
||||
std::fs::write(extensions_root.join("stale.txt"), "stale extension\n")?;
|
||||
|
||||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let mut app_server =
|
||||
Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
|
||||
Box::pin(app.reset_memories_with_app_server(&mut app_server)).await;
|
||||
Box::pin(app.reset_memories_with_app_server(&mut app_server)).await;
|
||||
|
||||
assert_eq!(std::fs::read_dir(&memory_root)?.count(), 0);
|
||||
assert_eq!(std::fs::read_dir(&memory_root)?.count(), 0);
|
||||
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1661,15 +1678,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!(
|
||||
@@ -1694,7 +1713,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,
|
||||
@@ -1750,7 +1768,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())?;
|
||||
@@ -1771,7 +1789,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!(
|
||||
@@ -1785,7 +1803,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,
|
||||
@@ -1848,7 +1865,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!(
|
||||
@@ -1864,7 +1881,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,
|
||||
@@ -1922,7 +1938,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,
|
||||
@@ -1982,7 +1997,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,
|
||||
@@ -2070,7 +2084,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,
|
||||
@@ -2502,35 +2515,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,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -2775,35 +2790,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)?),
|
||||
@@ -3241,6 +3235,7 @@ async fn side_thread_snapshot_hides_forked_parent_transcript() {
|
||||
let mut store = ThreadEventStore::new(/*capacity*/ 4);
|
||||
let session = ThreadSessionState {
|
||||
forked_from_id: Some(parent_thread_id),
|
||||
fork_parent_title: None,
|
||||
..test_thread_session(side_thread_id, test_path_buf("/tmp/side"))
|
||||
};
|
||||
let parent_turn = test_turn(
|
||||
@@ -3303,6 +3298,7 @@ async fn side_thread_snapshot_skips_session_header_preamble() {
|
||||
let snapshot = ThreadEventSnapshot {
|
||||
session: Some(ThreadSessionState {
|
||||
forked_from_id: Some(parent_thread_id),
|
||||
fork_parent_title: None,
|
||||
..test_thread_session(side_thread_id, test_path_buf("/tmp/side"))
|
||||
}),
|
||||
turns: Vec::new(),
|
||||
@@ -3390,30 +3386,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]
|
||||
@@ -3598,9 +3597,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(),
|
||||
@@ -3610,17 +3610,17 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String {
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
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,
|
||||
@@ -4248,7 +4248,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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4351,9 +4351,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(),
|
||||
@@ -4363,17 +4364,17 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
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,
|
||||
@@ -4413,11 +4414,11 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
|
||||
assert_eq!(user_count(&app.transcript_cells), 2);
|
||||
|
||||
let base_id = ThreadId::new();
|
||||
app.chat_widget.handle_codex_event(Event {
|
||||
id: String::new(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: base_id,
|
||||
app.chat_widget
|
||||
.handle_thread_session(crate::session_state::ThreadSessionState {
|
||||
thread_id: base_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
@@ -4427,14 +4428,13 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
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()),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
app.backtrack.base_id = Some(base_id);
|
||||
app.backtrack.primed = true;
|
||||
@@ -4507,11 +4507,11 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() {
|
||||
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||
|
||||
let thread_id = ThreadId::new();
|
||||
app.chat_widget.handle_codex_event(Event {
|
||||
id: String::new(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: thread_id,
|
||||
app.chat_widget
|
||||
.handle_thread_session(crate::session_state::ThreadSessionState {
|
||||
thread_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
@@ -4521,14 +4521,13 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() {
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
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()),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
let data_image_url = "data:image/png;base64,abc123".to_string();
|
||||
app.transcript_cells = vec![Arc::new(UserHistoryCell {
|
||||
@@ -4564,7 +4563,7 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() {
|
||||
assert!(items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
UserInput::Image { image_url } if image_url == &data_image_url
|
||||
UserInput::Image { url } if url == &data_image_url
|
||||
)
|
||||
}));
|
||||
}
|
||||
@@ -4749,6 +4748,7 @@ async fn refreshed_snapshot_session_persists_resumed_turns() {
|
||||
)];
|
||||
let resumed_session = ThreadSessionState {
|
||||
cwd: test_path_buf("/tmp/refreshed").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
..initial_session.clone()
|
||||
};
|
||||
let mut snapshot = ThreadEventSnapshot {
|
||||
@@ -4873,7 +4873,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,
|
||||
@@ -4893,48 +4893,49 @@ async fn thread_rollback_response_discards_queued_active_thread_events() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_session_requests_shutdown_for_previous_conversation() {
|
||||
let (mut app, mut app_event_rx, mut op_rx) = Box::pin(make_test_app_with_channels()).await;
|
||||
Box::pin(async {
|
||||
let (mut app, mut app_event_rx, mut op_rx) = Box::pin(make_test_app_with_channels()).await;
|
||||
|
||||
let thread_id = ThreadId::new();
|
||||
let event = SessionConfiguredEvent {
|
||||
session_id: thread_id,
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(PathBuf::new()),
|
||||
};
|
||||
let thread_id = ThreadId::new();
|
||||
let event = crate::session_state::ThreadSessionState {
|
||||
thread_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(PathBuf::new()),
|
||||
};
|
||||
|
||||
app.chat_widget.handle_codex_event(Event {
|
||||
id: String::new(),
|
||||
msg: EventMsg::SessionConfigured(event),
|
||||
});
|
||||
app.chat_widget.handle_thread_session(event);
|
||||
|
||||
while app_event_rx.try_recv().is_ok() {}
|
||||
while op_rx.try_recv().is_ok() {}
|
||||
while app_event_rx.try_recv().is_ok() {}
|
||||
while op_rx.try_recv().is_ok() {}
|
||||
|
||||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||||
app.chat_widget.config_ref(),
|
||||
))
|
||||
.await
|
||||
.expect("embedded app server");
|
||||
Box::pin(app.shutdown_current_thread(&mut app_server)).await;
|
||||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||||
app.chat_widget.config_ref(),
|
||||
))
|
||||
.await
|
||||
.expect("embedded app server");
|
||||
Box::pin(app.shutdown_current_thread(&mut app_server)).await;
|
||||
|
||||
assert!(
|
||||
op_rx.try_recv().is_err(),
|
||||
"shutdown should not submit Op::Shutdown"
|
||||
);
|
||||
assert!(
|
||||
op_rx.try_recv().is_err(),
|
||||
"shutdown should not submit Op::Shutdown"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -4983,39 +4984,45 @@ async fn shutdown_first_exit_uses_app_server_shutdown_without_submitting_op() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupt_without_active_turn_is_treated_as_handled() {
|
||||
let mut app = make_test_app().await;
|
||||
let mut app_server = Box::pin(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())
|
||||
Box::pin(async {
|
||||
let mut app = make_test_app().await;
|
||||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||||
app.chat_widget.config_ref(),
|
||||
))
|
||||
.await
|
||||
.expect("thread/start should succeed");
|
||||
let thread_id = started.session.thread_id;
|
||||
app.enqueue_primary_thread_session(started.session, started.turns)
|
||||
.await
|
||||
.expect("primary thread should be registered");
|
||||
let op = AppCommand::interrupt();
|
||||
|
||||
let handled =
|
||||
Box::pin(app.try_submit_active_thread_op_via_app_server(&mut app_server, thread_id, &op))
|
||||
.expect("embedded app server");
|
||||
let started = app_server
|
||||
.start_thread(app.chat_widget.config_ref())
|
||||
.await
|
||||
.expect("interrupt submission should not fail");
|
||||
.expect("thread/start should succeed");
|
||||
let thread_id = started.session.thread_id;
|
||||
app.enqueue_primary_thread_session(started.session, started.turns)
|
||||
.await
|
||||
.expect("primary thread should be registered");
|
||||
let op = AppCommand::interrupt();
|
||||
|
||||
assert_eq!(handled, true);
|
||||
let handled = Box::pin(app.try_submit_active_thread_op_via_app_server(
|
||||
&mut app_server,
|
||||
thread_id,
|
||||
&op,
|
||||
))
|
||||
.await
|
||||
.expect("interrupt submission should not fail");
|
||||
|
||||
assert_eq!(handled, true);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_only_ui_reset_preserves_chat_session_state() {
|
||||
let mut app = make_test_app().await;
|
||||
let thread_id = ThreadId::new();
|
||||
app.chat_widget.handle_codex_event(Event {
|
||||
id: String::new(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: thread_id,
|
||||
app.chat_widget
|
||||
.handle_thread_session(crate::session_state::ThreadSessionState {
|
||||
thread_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: Some("keep me".to_string()),
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
@@ -5025,14 +5032,13 @@ async fn clear_only_ui_reset_preserves_chat_session_state() {
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
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()),
|
||||
}),
|
||||
});
|
||||
});
|
||||
app.chat_widget
|
||||
.apply_external_edit("draft prompt".to_string());
|
||||
app.transcript_cells = vec![Arc::new(UserHistoryCell {
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
@@ -278,14 +257,14 @@ impl App {
|
||||
changes: self
|
||||
.thread_file_change_changes(thread_id, ¶ms.turn_id, ¶ms.item_id)
|
||||
.await
|
||||
.map(crate::app_server_approval_conversions::file_update_changes_to_core)
|
||||
.map(crate::app_server_approval_conversions::file_update_changes_to_display)
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
),
|
||||
ServerRequest::McpServerElicitationRequest { request_id, params } => {
|
||||
if let Some(request) = McpServerElicitationFormRequest::from_app_server_request(
|
||||
thread_id,
|
||||
app_server_request_id_to_mcp_request_id(request_id),
|
||||
request_id.clone(),
|
||||
params.clone(),
|
||||
) {
|
||||
Some(ThreadInteractiveRequest::McpServerElicitation(request))
|
||||
@@ -295,7 +274,7 @@ impl App {
|
||||
thread_id,
|
||||
thread_label,
|
||||
server_name: params.server_name.clone(),
|
||||
request_id: app_server_request_id_to_mcp_request_id(request_id),
|
||||
request_id: request_id.clone(),
|
||||
message: match ¶ms.request {
|
||||
codex_app_server_protocol::McpServerElicitationRequest::Form {
|
||||
message,
|
||||
@@ -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,
|
||||
@@ -618,12 +597,12 @@ impl App {
|
||||
thread_id,
|
||||
items.to_vec(),
|
||||
cwd.clone(),
|
||||
approval_policy,
|
||||
*approval_policy,
|
||||
approvals_reviewer,
|
||||
permission_profile.clone(),
|
||||
active_permission_profile,
|
||||
model.to_string(),
|
||||
effort,
|
||||
*effort,
|
||||
*summary,
|
||||
*service_tier,
|
||||
collaboration_mode.clone(),
|
||||
@@ -634,12 +613,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,
|
||||
@@ -647,76 +626,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::ApproveGuardianDeniedAction { event }
|
||||
| AppCommandView::Other(Op::ApproveGuardianDeniedAction { event }) => {
|
||||
AppCommand::OverrideTurnContext { .. } => Ok(true),
|
||||
AppCommand::ApproveGuardianDeniedAction { event } => {
|
||||
app_server
|
||||
.thread_approve_guardian_denied_action(thread_id, event)
|
||||
.await?;
|
||||
@@ -1016,7 +986,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);
|
||||
@@ -1327,7 +1297,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::ActivePermissionProfile;
|
||||
@@ -12,7 +13,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
|
||||
@@ -63,7 +64,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(),
|
||||
active_permission_profile: active_permission_profile.clone(),
|
||||
@@ -118,15 +121,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;
|
||||
|
||||
@@ -189,7 +193,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());
|
||||
@@ -243,23 +247,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"))
|
||||
@@ -274,7 +282,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
|
||||
@@ -497,8 +497,8 @@ impl App {
|
||||
self.backtrack.pending_rollback = None;
|
||||
}
|
||||
|
||||
/// Apply rollback semantics for `ThreadRolledBack` events where this TUI does not have an
|
||||
/// in-flight backtrack request (`pending_rollback` is `None`).
|
||||
/// Apply rollback semantics for a confirmed rollback where this TUI does
|
||||
/// not have an in-flight backtrack request (`pending_rollback` is `None`).
|
||||
///
|
||||
/// Returns `true` when local transcript state changed.
|
||||
pub(crate) fn apply_non_pending_thread_rollback(&mut self, num_turns: u32) -> bool {
|
||||
|
||||
+57
-525
@@ -1,39 +1,38 @@
|
||||
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::McpServerElicitationAction;
|
||||
use codex_app_server_protocol::RequestId as AppServerRequestId;
|
||||
use codex_app_server_protocol::ReviewTarget;
|
||||
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
|
||||
use codex_app_server_protocol::ThreadRealtimeStartTransport;
|
||||
use codex_app_server_protocol::ToolRequestUserInputResponse;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
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;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
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;
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub(crate) enum AppCommand {
|
||||
Interrupt,
|
||||
CleanBackgroundTerminals,
|
||||
RealtimeConversationStart(ConversationStartParams),
|
||||
RealtimeConversationAudio(ConversationAudioParams),
|
||||
RealtimeConversationText(ConversationTextParams),
|
||||
RealtimeConversationStart {
|
||||
transport: Option<ThreadRealtimeStartTransport>,
|
||||
voice: Option<Value>,
|
||||
},
|
||||
RealtimeConversationAudio(ThreadRealtimeAudioChunk),
|
||||
RealtimeConversationClose,
|
||||
RunUserShellCommand {
|
||||
command: String,
|
||||
@@ -68,22 +67,22 @@ pub(crate) enum AppCommand {
|
||||
ExecApproval {
|
||||
id: String,
|
||||
turn_id: Option<String>,
|
||||
decision: ReviewDecision,
|
||||
decision: CommandExecutionApprovalDecision,
|
||||
},
|
||||
PatchApproval {
|
||||
id: String,
|
||||
decision: ReviewDecision,
|
||||
decision: FileChangeApprovalDecision,
|
||||
},
|
||||
ResolveElicitation {
|
||||
server_name: String,
|
||||
request_id: McpRequestId,
|
||||
decision: ElicitationAction,
|
||||
request_id: AppServerRequestId,
|
||||
decision: McpServerElicitationAction,
|
||||
content: Option<Value>,
|
||||
meta: Option<Value>,
|
||||
},
|
||||
UserInputAnswer {
|
||||
id: String,
|
||||
response: RequestUserInputResponse,
|
||||
response: ToolRequestUserInputResponse,
|
||||
},
|
||||
RequestPermissionsResponse {
|
||||
id: String,
|
||||
@@ -103,97 +102,18 @@ pub(crate) enum AppCommand {
|
||||
num_turns: u32,
|
||||
},
|
||||
Review {
|
||||
review_request: ReviewRequest,
|
||||
target: ReviewTarget,
|
||||
},
|
||||
AddToHistory {
|
||||
text: String,
|
||||
},
|
||||
GetHistoryEntryRequest {
|
||||
offset: usize,
|
||||
log_id: u64,
|
||||
},
|
||||
ApproveGuardianDeniedAction {
|
||||
event: GuardianAssessmentEvent,
|
||||
},
|
||||
Other(Op),
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) enum AppCommandView<'a> {
|
||||
Interrupt,
|
||||
CleanBackgroundTerminals,
|
||||
RealtimeConversationStart(&'a ConversationStartParams),
|
||||
RealtimeConversationAudio(&'a ConversationAudioParams),
|
||||
RealtimeConversationText(&'a ConversationTextParams),
|
||||
RealtimeConversationClose,
|
||||
RunUserShellCommand {
|
||||
command: &'a str,
|
||||
},
|
||||
UserTurn {
|
||||
items: &'a [UserInput],
|
||||
cwd: &'a PathBuf,
|
||||
approval_policy: AskForApproval,
|
||||
approvals_reviewer: &'a Option<ApprovalsReviewer>,
|
||||
permission_profile: &'a PermissionProfile,
|
||||
model: &'a str,
|
||||
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>,
|
||||
},
|
||||
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>,
|
||||
},
|
||||
ExecApproval {
|
||||
id: &'a str,
|
||||
turn_id: &'a Option<String>,
|
||||
decision: &'a ReviewDecision,
|
||||
},
|
||||
PatchApproval {
|
||||
id: &'a str,
|
||||
decision: &'a ReviewDecision,
|
||||
},
|
||||
ResolveElicitation {
|
||||
server_name: &'a str,
|
||||
request_id: &'a McpRequestId,
|
||||
decision: &'a ElicitationAction,
|
||||
content: &'a Option<Value>,
|
||||
meta: &'a Option<Value>,
|
||||
},
|
||||
UserInputAnswer {
|
||||
id: &'a str,
|
||||
response: &'a RequestUserInputResponse,
|
||||
},
|
||||
RequestPermissionsResponse {
|
||||
id: &'a str,
|
||||
response: &'a RequestPermissionsResponse,
|
||||
},
|
||||
ReloadUserConfig,
|
||||
ListSkills {
|
||||
cwds: &'a [PathBuf],
|
||||
force_reload: bool,
|
||||
},
|
||||
Compact,
|
||||
SetThreadName {
|
||||
name: &'a str,
|
||||
},
|
||||
Shutdown,
|
||||
ThreadRollback {
|
||||
num_turns: u32,
|
||||
},
|
||||
Review {
|
||||
review_request: &'a ReviewRequest,
|
||||
},
|
||||
ApproveGuardianDeniedAction {
|
||||
event: &'a GuardianAssessmentEvent,
|
||||
},
|
||||
Other(&'a Op),
|
||||
}
|
||||
|
||||
impl AppCommand {
|
||||
@@ -205,13 +125,16 @@ impl AppCommand {
|
||||
Self::CleanBackgroundTerminals
|
||||
}
|
||||
|
||||
pub(crate) fn realtime_conversation_start(params: ConversationStartParams) -> Self {
|
||||
Self::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::RealtimeConversationAudio(params)
|
||||
pub(crate) fn realtime_conversation_audio(frame: ThreadRealtimeAudioChunk) -> Self {
|
||||
Self::RealtimeConversationAudio(frame)
|
||||
}
|
||||
|
||||
pub(crate) fn realtime_conversation_close() -> Self {
|
||||
@@ -284,7 +207,7 @@ impl AppCommand {
|
||||
pub(crate) fn exec_approval(
|
||||
id: String,
|
||||
turn_id: Option<String>,
|
||||
decision: ReviewDecision,
|
||||
decision: CommandExecutionApprovalDecision,
|
||||
) -> Self {
|
||||
Self::ExecApproval {
|
||||
id,
|
||||
@@ -293,14 +216,14 @@ impl AppCommand {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn patch_approval(id: String, decision: ReviewDecision) -> Self {
|
||||
pub(crate) fn patch_approval(id: String, decision: FileChangeApprovalDecision) -> Self {
|
||||
Self::PatchApproval { id, decision }
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_elicitation(
|
||||
server_name: String,
|
||||
request_id: McpRequestId,
|
||||
decision: ElicitationAction,
|
||||
request_id: AppServerRequestId,
|
||||
decision: McpServerElicitationAction,
|
||||
content: Option<Value>,
|
||||
meta: Option<Value>,
|
||||
) -> Self {
|
||||
@@ -313,7 +236,7 @@ impl AppCommand {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn user_input_answer(id: String, response: RequestUserInputResponse) -> Self {
|
||||
pub(crate) fn user_input_answer(id: String, response: ToolRequestUserInputResponse) -> Self {
|
||||
Self::UserInputAnswer { id, response }
|
||||
}
|
||||
|
||||
@@ -340,419 +263,34 @@ impl AppCommand {
|
||||
Self::SetThreadName { name }
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn shutdown() -> Self {
|
||||
Self::Shutdown
|
||||
}
|
||||
|
||||
pub(crate) fn thread_rollback(num_turns: u32) -> Self {
|
||||
Self::ThreadRollback { num_turns }
|
||||
}
|
||||
|
||||
pub(crate) fn review(review_request: ReviewRequest) -> Self {
|
||||
Self::Review { review_request }
|
||||
pub(crate) fn review(target: ReviewTarget) -> Self {
|
||||
Self::Review { target }
|
||||
}
|
||||
|
||||
pub(crate) fn into_core(self) -> Op {
|
||||
match self {
|
||||
Self::Interrupt => Op::Interrupt,
|
||||
Self::CleanBackgroundTerminals => Op::CleanBackgroundTerminals,
|
||||
Self::RealtimeConversationStart(params) => Op::RealtimeConversationStart(params),
|
||||
Self::RealtimeConversationAudio(params) => Op::RealtimeConversationAudio(params),
|
||||
Self::RealtimeConversationText(params) => Op::RealtimeConversationText(params),
|
||||
Self::RealtimeConversationClose => Op::RealtimeConversationClose,
|
||||
Self::RunUserShellCommand { command } => Op::RunUserShellCommand { command },
|
||||
Self::UserTurn {
|
||||
items,
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
permission_profile,
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
final_output_json_schema,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
} => {
|
||||
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}"
|
||||
)
|
||||
});
|
||||
Op::UserTurn {
|
||||
items,
|
||||
environments: None,
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile: Some(permission_profile),
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
final_output_json_schema,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
}
|
||||
}
|
||||
Self::OverrideTurnContext {
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
permission_profile,
|
||||
windows_sandbox_level,
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
} => Op::OverrideTurnContext {
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy: None,
|
||||
permission_profile,
|
||||
windows_sandbox_level,
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
},
|
||||
Self::ExecApproval {
|
||||
id,
|
||||
turn_id,
|
||||
decision,
|
||||
} => Op::ExecApproval {
|
||||
id,
|
||||
turn_id,
|
||||
decision,
|
||||
},
|
||||
Self::PatchApproval { id, decision } => Op::PatchApproval { id, decision },
|
||||
Self::ResolveElicitation {
|
||||
server_name,
|
||||
request_id,
|
||||
decision,
|
||||
content,
|
||||
meta,
|
||||
} => Op::ResolveElicitation {
|
||||
server_name,
|
||||
request_id,
|
||||
decision,
|
||||
content,
|
||||
meta,
|
||||
},
|
||||
Self::UserInputAnswer { id, response } => Op::UserInputAnswer { id, response },
|
||||
Self::RequestPermissionsResponse { id, response } => {
|
||||
Op::RequestPermissionsResponse { id, response }
|
||||
}
|
||||
Self::ReloadUserConfig => Op::ReloadUserConfig,
|
||||
Self::ListSkills { cwds, force_reload } => Op::ListSkills { cwds, force_reload },
|
||||
Self::Compact => Op::Compact,
|
||||
Self::SetThreadName { name } => Op::SetThreadName { name },
|
||||
Self::Shutdown => Op::Shutdown,
|
||||
Self::ThreadRollback { num_turns } => Op::ThreadRollback { num_turns },
|
||||
Self::Review { review_request } => Op::Review { review_request },
|
||||
Self::ApproveGuardianDeniedAction { event } => {
|
||||
Op::ApproveGuardianDeniedAction { event }
|
||||
}
|
||||
Self::Other(op) => op,
|
||||
}
|
||||
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, Self::Review { .. })
|
||||
}
|
||||
|
||||
pub(crate) fn view(&self) -> AppCommandView<'_> {
|
||||
match self {
|
||||
Self::Interrupt => AppCommandView::Interrupt,
|
||||
Self::CleanBackgroundTerminals => AppCommandView::CleanBackgroundTerminals,
|
||||
Self::RealtimeConversationStart(params) => {
|
||||
AppCommandView::RealtimeConversationStart(params)
|
||||
}
|
||||
Self::RealtimeConversationAudio(params) => {
|
||||
AppCommandView::RealtimeConversationAudio(params)
|
||||
}
|
||||
Self::RealtimeConversationText(params) => {
|
||||
AppCommandView::RealtimeConversationText(params)
|
||||
}
|
||||
Self::RealtimeConversationClose => AppCommandView::RealtimeConversationClose,
|
||||
Self::RunUserShellCommand { command } => {
|
||||
AppCommandView::RunUserShellCommand { command }
|
||||
}
|
||||
Self::UserTurn {
|
||||
items,
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
permission_profile,
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
final_output_json_schema,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
} => AppCommandView::UserTurn {
|
||||
items,
|
||||
cwd,
|
||||
approval_policy: *approval_policy,
|
||||
approvals_reviewer,
|
||||
permission_profile,
|
||||
model,
|
||||
effort: *effort,
|
||||
summary,
|
||||
service_tier,
|
||||
final_output_json_schema,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
},
|
||||
Self::OverrideTurnContext {
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
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,
|
||||
},
|
||||
Self::ExecApproval {
|
||||
id,
|
||||
turn_id,
|
||||
decision,
|
||||
} => AppCommandView::ExecApproval {
|
||||
id,
|
||||
turn_id,
|
||||
decision,
|
||||
},
|
||||
Self::PatchApproval { id, decision } => AppCommandView::PatchApproval { id, decision },
|
||||
Self::ResolveElicitation {
|
||||
server_name,
|
||||
request_id,
|
||||
decision,
|
||||
content,
|
||||
meta,
|
||||
} => AppCommandView::ResolveElicitation {
|
||||
server_name,
|
||||
request_id,
|
||||
decision,
|
||||
content,
|
||||
meta,
|
||||
},
|
||||
Self::UserInputAnswer { id, response } => {
|
||||
AppCommandView::UserInputAnswer { id, response }
|
||||
}
|
||||
Self::RequestPermissionsResponse { id, response } => {
|
||||
AppCommandView::RequestPermissionsResponse { id, response }
|
||||
}
|
||||
Self::ReloadUserConfig => AppCommandView::ReloadUserConfig,
|
||||
Self::ListSkills { cwds, force_reload } => AppCommandView::ListSkills {
|
||||
cwds,
|
||||
force_reload: *force_reload,
|
||||
},
|
||||
Self::Compact => AppCommandView::Compact,
|
||||
Self::SetThreadName { name } => AppCommandView::SetThreadName { name },
|
||||
Self::Shutdown => AppCommandView::Shutdown,
|
||||
Self::ThreadRollback { num_turns } => AppCommandView::ThreadRollback {
|
||||
num_turns: *num_turns,
|
||||
},
|
||||
Self::Review { review_request } => AppCommandView::Review { review_request },
|
||||
Self::ApproveGuardianDeniedAction { event } => {
|
||||
AppCommandView::ApproveGuardianDeniedAction { event }
|
||||
}
|
||||
Self::Other(op) => AppCommandView::Other(op),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Op> for AppCommand {
|
||||
fn from(value: Op) -> Self {
|
||||
match value {
|
||||
Op::Interrupt => Self::Interrupt,
|
||||
Op::CleanBackgroundTerminals => Self::CleanBackgroundTerminals,
|
||||
Op::RealtimeConversationStart(params) => Self::RealtimeConversationStart(params),
|
||||
Op::RealtimeConversationAudio(params) => Self::RealtimeConversationAudio(params),
|
||||
Op::RealtimeConversationText(params) => Self::RealtimeConversationText(params),
|
||||
Op::RealtimeConversationClose => Self::RealtimeConversationClose,
|
||||
Op::RunUserShellCommand { command } => Self::RunUserShellCommand { command },
|
||||
Op::UserTurn {
|
||||
items,
|
||||
environments,
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile: Some(permission_profile),
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
final_output_json_schema,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
} => {
|
||||
if environments.is_none()
|
||||
&& legacy_compatible_permission_profile(&permission_profile, cwd.as_path())
|
||||
.to_legacy_sandbox_policy(cwd.as_path())
|
||||
.is_ok_and(|compatible_policy| compatible_policy == sandbox_policy)
|
||||
{
|
||||
Self::UserTurn {
|
||||
items,
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
permission_profile,
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
final_output_json_schema,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
}
|
||||
} else {
|
||||
Self::Other(Op::UserTurn {
|
||||
items,
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile: Some(permission_profile),
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
final_output_json_schema,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
environments,
|
||||
})
|
||||
}
|
||||
}
|
||||
Op::OverrideTurnContext {
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
windows_sandbox_level,
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
} => {
|
||||
if sandbox_policy.is_none() {
|
||||
Self::OverrideTurnContext {
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
permission_profile,
|
||||
windows_sandbox_level,
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
}
|
||||
} else {
|
||||
Self::Other(Op::OverrideTurnContext {
|
||||
cwd,
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
windows_sandbox_level,
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
service_tier,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
})
|
||||
}
|
||||
}
|
||||
Op::ExecApproval {
|
||||
id,
|
||||
turn_id,
|
||||
decision,
|
||||
} => Self::ExecApproval {
|
||||
id,
|
||||
turn_id,
|
||||
decision,
|
||||
},
|
||||
Op::PatchApproval { id, decision } => Self::PatchApproval { id, decision },
|
||||
Op::ResolveElicitation {
|
||||
server_name,
|
||||
request_id,
|
||||
decision,
|
||||
content,
|
||||
meta,
|
||||
} => Self::ResolveElicitation {
|
||||
server_name,
|
||||
request_id,
|
||||
decision,
|
||||
content,
|
||||
meta,
|
||||
},
|
||||
Op::UserInputAnswer { id, response } => Self::UserInputAnswer { id, response },
|
||||
Op::RequestPermissionsResponse { id, response } => {
|
||||
Self::RequestPermissionsResponse { id, response }
|
||||
}
|
||||
Op::ReloadUserConfig => Self::ReloadUserConfig,
|
||||
Op::ListSkills { cwds, force_reload } => Self::ListSkills { cwds, force_reload },
|
||||
Op::Compact => Self::Compact,
|
||||
Op::SetThreadName { name } => Self::SetThreadName { name },
|
||||
Op::Shutdown => Self::Shutdown,
|
||||
Op::ThreadRollback { num_turns } => Self::ThreadRollback { num_turns },
|
||||
Op::Review { review_request } => Self::Review { review_request },
|
||||
Op::ApproveGuardianDeniedAction { event } => {
|
||||
Self::ApproveGuardianDeniedAction { event }
|
||||
}
|
||||
op => Self::Other(op),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<Op> for AppCommand {
|
||||
fn eq(&self, other: &Op) -> bool {
|
||||
self.clone().into_core() == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<AppCommand> for Op {
|
||||
fn eq(&self, other: &AppCommand) -> bool {
|
||||
*self == other.clone().into_core()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Op> for AppCommand {
|
||||
fn from(value: &Op) -> Self {
|
||||
Self::from(value.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&AppCommand> for AppCommand {
|
||||
@@ -760,9 +298,3 @@ impl From<&AppCommand> for AppCommand {
|
||||
value.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppCommand> for Op {
|
||||
fn from(value: AppCommand) -> Self {
|
||||
value.into_core()
|
||||
}
|
||||
}
|
||||
@@ -22,13 +22,13 @@ 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::RateLimitSnapshot;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_approval_presets::ApprovalPreset;
|
||||
|
||||
@@ -37,6 +37,7 @@ 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;
|
||||
@@ -45,7 +46,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;
|
||||
|
||||
@@ -63,6 +63,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 {
|
||||
@@ -139,7 +146,7 @@ pub(crate) enum AppEvent {
|
||||
/// Deliver a synthetic history lookup response to a specific thread channel.
|
||||
ThreadHistoryEntryResponse {
|
||||
thread_id: ThreadId,
|
||||
event: GetHistoryEntryResponseEvent,
|
||||
event: HistoryLookupResponse,
|
||||
},
|
||||
|
||||
/// Start a new session.
|
||||
@@ -181,7 +188,7 @@ 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(AppCommand),
|
||||
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
//! Convenience sender for app events and common outbound TUI commands.
|
||||
//!
|
||||
//! This wraps the raw channel so call sites can submit typed `AppCommand`s
|
||||
//! without duplicating event construction or session logging behavior.
|
||||
|
||||
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::McpServerElicitationAction;
|
||||
use codex_app_server_protocol::RequestId as AppServerRequestId;
|
||||
use codex_app_server_protocol::ReviewTarget;
|
||||
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
|
||||
use codex_app_server_protocol::ToolRequestUserInputResponse;
|
||||
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;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
@@ -49,8 +55,8 @@ impl AppEventSender {
|
||||
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)));
|
||||
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) {
|
||||
@@ -61,19 +67,24 @@ impl AppEventSender {
|
||||
}
|
||||
|
||||
#[cfg_attr(target_os = "linux", allow(dead_code))]
|
||||
pub(crate) fn realtime_conversation_audio(&self, params: ConversationAudioParams) {
|
||||
pub(crate) fn realtime_conversation_audio(&self, frame: ThreadRealtimeAudioChunk) {
|
||||
self.send(AppEvent::CodexOp(AppCommand::realtime_conversation_audio(
|
||||
params,
|
||||
frame,
|
||||
)));
|
||||
}
|
||||
|
||||
pub(crate) fn user_input_answer(&self, id: String, response: RequestUserInputResponse) {
|
||||
pub(crate) fn user_input_answer(&self, id: String, response: ToolRequestUserInputResponse) {
|
||||
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),
|
||||
@@ -92,7 +103,12 @@ impl AppEventSender {
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -103,8 +119,8 @@ impl AppEventSender {
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
server_name: String,
|
||||
request_id: McpRequestId,
|
||||
decision: ElicitationAction,
|
||||
request_id: AppServerRequestId,
|
||||
decision: McpServerElicitationAction,
|
||||
content: Option<serde_json::Value>,
|
||||
meta: Option<serde_json::Value>,
|
||||
) {
|
||||
|
||||
@@ -1,37 +1,19 @@
|
||||
//! Narrow conversion helpers for approval-related app-server payloads.
|
||||
//!
|
||||
//! The TUI mostly keeps app-server approval types intact. These helpers cover
|
||||
//! the remaining cases where the UI consumes a private file-change display
|
||||
//! model or needs to translate a granted permission response for outbound
|
||||
//! submission.
|
||||
|
||||
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 as AppServerNetworkApprovalContext;
|
||||
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;
|
||||
|
||||
pub(crate) fn network_approval_context_to_core(
|
||||
value: AppServerNetworkApprovalContext,
|
||||
) -> NetworkApprovalContext {
|
||||
NetworkApprovalContext {
|
||||
host: value.host,
|
||||
protocol: match value.protocol {
|
||||
codex_app_server_protocol::NetworkApprovalProtocol::Http => {
|
||||
NetworkApprovalProtocol::Http
|
||||
}
|
||||
codex_app_server_protocol::NetworkApprovalProtocol::Https => {
|
||||
NetworkApprovalProtocol::Https
|
||||
}
|
||||
codex_app_server_protocol::NetworkApprovalProtocol::Socks5Tcp => {
|
||||
NetworkApprovalProtocol::Socks5Tcp
|
||||
}
|
||||
codex_app_server_protocol::NetworkApprovalProtocol::Socks5Udp => {
|
||||
NetworkApprovalProtocol::Socks5Udp
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn granted_permission_profile_from_request(
|
||||
value: CoreRequestPermissionProfile,
|
||||
) -> GrantedPermissionProfile {
|
||||
@@ -43,7 +25,7 @@ pub(crate) fn granted_permission_profile_from_request(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn file_update_changes_to_core(
|
||||
pub(crate) fn file_update_changes_to_display(
|
||||
changes: Vec<FileUpdateChange>,
|
||||
) -> HashMap<PathBuf, FileChange> {
|
||||
changes
|
||||
@@ -69,20 +51,11 @@ pub(crate) fn file_update_changes_to_core(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::file_update_changes_to_core;
|
||||
use super::file_update_changes_to_display;
|
||||
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::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;
|
||||
@@ -94,23 +67,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_app_server_network_approval_context_to_core() {
|
||||
fn converts_file_update_changes_to_display() {
|
||||
assert_eq!(
|
||||
network_approval_context_to_core(codex_app_server_protocol::NetworkApprovalContext {
|
||||
host: "example.com".to_string(),
|
||||
protocol: codex_app_server_protocol::NetworkApprovalProtocol::Socks5Tcp,
|
||||
}),
|
||||
NetworkApprovalContext {
|
||||
host: "example.com".to_string(),
|
||||
protocol: NetworkApprovalProtocol::Socks5Tcp,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_file_update_changes_to_core() {
|
||||
assert_eq!(
|
||||
file_update_changes_to_core(vec![FileUpdateChange {
|
||||
file_update_changes_to_display(vec![FileUpdateChange {
|
||||
path: "foo.txt".to_string(),
|
||||
kind: PatchChangeKind::Add,
|
||||
diff: "hello\n".to_string(),
|
||||
@@ -127,15 +86,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 +129,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 {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
//! App-server session facade used by the TUI event loop.
|
||||
//!
|
||||
//! This module owns the typed JSON-RPC calls needed by the TUI and keeps
|
||||
//! request/response plumbing out of `App` and `ChatWidget`.
|
||||
|
||||
use crate::bottom_pane::FeedbackAudience;
|
||||
#[cfg(test)]
|
||||
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 +17,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;
|
||||
@@ -31,10 +38,12 @@ use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::ModelListResponse;
|
||||
use codex_app_server_protocol::PermissionProfileModificationParams;
|
||||
use codex_app_server_protocol::PermissionProfileSelectionParams;
|
||||
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;
|
||||
@@ -66,8 +75,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,8 +101,10 @@ use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnSteerParams;
|
||||
use codex_app_server_protocol::TurnSteerResponse;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_otel::TelemetryAuthMode;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::approvals::GuardianAssessmentEvent;
|
||||
use codex_protocol::models::ActivePermissionProfile;
|
||||
use codex_protocol::models::ActivePermissionProfileModification;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
@@ -103,18 +113,6 @@ 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;
|
||||
@@ -149,37 +147,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.
|
||||
///
|
||||
/// App-server responses may echo the experimental profile payload; when
|
||||
/// they do, the TUI uses that exact runtime profile. Older/missing
|
||||
/// responses fall back to the local profile for embedded sessions or to a
|
||||
/// response-cwd legacy sandbox projection for remote sessions so cached
|
||||
/// sessions do not reinterpret cwd-bound grants.
|
||||
pub(crate) permission_profile: PermissionProfile,
|
||||
/// Named or implicit built-in profile that produced `permission_profile`,
|
||||
/// when the server knows it.
|
||||
pub(crate) active_permission_profile: Option<ActivePermissionProfile>,
|
||||
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,
|
||||
@@ -542,7 +509,7 @@ impl AppServerSession {
|
||||
pub(crate) async fn turn_start(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
items: Vec<codex_protocol::user_input::UserInput>,
|
||||
items: Vec<UserInput>,
|
||||
cwd: PathBuf,
|
||||
approval_policy: AskForApproval,
|
||||
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
|
||||
@@ -568,11 +535,11 @@ impl AppServerSession {
|
||||
request_id,
|
||||
params: TurnStartParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
input: items.into_iter().map(Into::into).collect(),
|
||||
input: items,
|
||||
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,
|
||||
permissions,
|
||||
@@ -617,7 +584,7 @@ impl AppServerSession {
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
turn_id: String,
|
||||
items: Vec<codex_protocol::user_input::UserInput>,
|
||||
items: Vec<UserInput>,
|
||||
) -> std::result::Result<TurnSteerResponse, TypedRequestError> {
|
||||
let request_id = self.next_request_id();
|
||||
self.client
|
||||
@@ -625,7 +592,7 @@ impl AppServerSession {
|
||||
request_id,
|
||||
params: TurnSteerParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
input: items.into_iter().map(Into::into).collect(),
|
||||
input: items,
|
||||
responsesapi_client_metadata: None,
|
||||
expected_turn_id: turn_id,
|
||||
},
|
||||
@@ -863,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
|
||||
@@ -871,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),
|
||||
},
|
||||
})
|
||||
@@ -911,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,
|
||||
realtime_session_id: params.realtime_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(())
|
||||
@@ -942,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
|
||||
@@ -951,7 +903,7 @@ impl AppServerSession {
|
||||
request_id,
|
||||
params: ThreadRealtimeAppendAudioParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
audio: params.frame.into(),
|
||||
audio: frame,
|
||||
},
|
||||
})
|
||||
.await
|
||||
@@ -959,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
|
||||
@@ -1025,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>,
|
||||
@@ -1363,7 +1323,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(),
|
||||
permission_profile,
|
||||
response.active_permission_profile.clone().map(Into::into),
|
||||
@@ -1395,7 +1355,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(),
|
||||
permission_profile,
|
||||
response.active_permission_profile.clone().map(Into::into),
|
||||
@@ -1427,7 +1387,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(),
|
||||
permission_profile,
|
||||
response.active_permission_profile.clone().map(Into::into),
|
||||
@@ -1457,25 +1417,6 @@ fn permission_profile_from_thread_response(
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -1528,69 +1469,32 @@ 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 crate::legacy_core::config::ConfigOverrides;
|
||||
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;
|
||||
@@ -1756,8 +1660,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 {
|
||||
@@ -1772,8 +1677,8 @@ mod tests {
|
||||
],
|
||||
glob_scan_max_depth: None,
|
||||
},
|
||||
network: NetworkSandboxPolicy::Restricted,
|
||||
};
|
||||
}
|
||||
.into();
|
||||
|
||||
assert_eq!(
|
||||
sandbox_mode_from_permission_profile(&permission_profile, cwd.as_path()),
|
||||
@@ -1784,8 +1689,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 {
|
||||
@@ -1802,8 +1708,8 @@ mod tests {
|
||||
],
|
||||
glob_scan_max_depth: None,
|
||||
},
|
||||
network: NetworkSandboxPolicy::Restricted,
|
||||
};
|
||||
}
|
||||
.into();
|
||||
|
||||
assert_eq!(
|
||||
sandbox_mode_from_permission_profile(&permission_profile, cwd.as_path()),
|
||||
@@ -1897,7 +1803,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,
|
||||
@@ -1931,7 +1837,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())
|
||||
@@ -1968,8 +1874,8 @@ mod tests {
|
||||
.to_legacy_sandbox_policy(cwd.as_path())
|
||||
.expect("read-only profile must be legacy-compatible")
|
||||
.into();
|
||||
let split_profile = PermissionProfile::Managed {
|
||||
file_system: ManagedFileSystemPermissions::Restricted {
|
||||
let response_profile = AppServerPermissionProfile::Managed {
|
||||
file_system: PermissionProfileFileSystemPermissions::Restricted {
|
||||
entries: vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
@@ -1988,9 +1894,9 @@ mod tests {
|
||||
],
|
||||
glob_scan_max_depth: None,
|
||||
},
|
||||
network: NetworkSandboxPolicy::Restricted,
|
||||
network: PermissionProfileNetworkPermissions { enabled: false },
|
||||
};
|
||||
let response_profile = split_profile.clone().into();
|
||||
let split_profile: PermissionProfile = response_profile.clone().into();
|
||||
|
||||
assert_eq!(
|
||||
permission_profile_from_thread_response(
|
||||
|
||||
@@ -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 @@
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::approvals::ElicitationAction;
|
||||
use codex_protocol::mcp::RequestId as McpRequestId;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::protocol::Op;
|
||||
use crate::app_command::AppCommand as Op;
|
||||
use codex_app_server_protocol::McpServerElicitationAction;
|
||||
use codex_app_server_protocol::RequestId as AppServerRequestId;
|
||||
use codex_protocol::ThreadId;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
@@ -50,7 +50,7 @@ pub(crate) enum AppLinkSuggestionType {
|
||||
pub(crate) struct AppLinkElicitationTarget {
|
||||
pub(crate) thread_id: ThreadId,
|
||||
pub(crate) server_name: String,
|
||||
pub(crate) request_id: McpRequestId,
|
||||
pub(crate) request_id: AppServerRequestId,
|
||||
}
|
||||
|
||||
pub(crate) struct AppLinkViewParams {
|
||||
@@ -148,7 +148,7 @@ impl AppLinkView {
|
||||
self.elicitation_target.is_some()
|
||||
}
|
||||
|
||||
fn resolve_elicitation(&self, decision: ElicitationAction) {
|
||||
fn resolve_elicitation(&self, decision: McpServerElicitationAction) {
|
||||
let Some(target) = self.elicitation_target.as_ref() else {
|
||||
return;
|
||||
};
|
||||
@@ -163,7 +163,7 @@ impl AppLinkView {
|
||||
}
|
||||
|
||||
fn decline_tool_suggestion(&mut self) {
|
||||
self.resolve_elicitation(ElicitationAction::Decline);
|
||||
self.resolve_elicitation(McpServerElicitationAction::Decline);
|
||||
self.complete = true;
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ impl AppLinkView {
|
||||
force_refetch: true,
|
||||
});
|
||||
if self.is_tool_suggestion() {
|
||||
self.resolve_elicitation(ElicitationAction::Accept);
|
||||
self.resolve_elicitation(McpServerElicitationAction::Accept);
|
||||
}
|
||||
self.complete = true;
|
||||
}
|
||||
@@ -199,7 +199,7 @@ impl AppLinkView {
|
||||
enabled: self.is_enabled,
|
||||
});
|
||||
if self.is_tool_suggestion() {
|
||||
self.resolve_elicitation(ElicitationAction::Accept);
|
||||
self.resolve_elicitation(McpServerElicitationAction::Accept);
|
||||
self.complete = true;
|
||||
}
|
||||
}
|
||||
@@ -466,7 +466,7 @@ impl BottomPaneView for AppLinkView {
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
if self.is_tool_suggestion() {
|
||||
self.resolve_elicitation(ElicitationAction::Decline);
|
||||
self.resolve_elicitation(McpServerElicitationAction::Decline);
|
||||
}
|
||||
self.complete = true;
|
||||
CancellationEvent::Handled
|
||||
@@ -582,7 +582,7 @@ mod tests {
|
||||
thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000001")
|
||||
.expect("valid thread id"),
|
||||
server_name: "codex_apps".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
request_id: AppServerRequestId::String("request-1".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,8 +856,8 @@ mod tests {
|
||||
op,
|
||||
Op::ResolveElicitation {
|
||||
server_name: "codex_apps".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
decision: ElicitationAction::Accept,
|
||||
request_id: AppServerRequestId::String("request-1".to_string()),
|
||||
decision: McpServerElicitationAction::Accept,
|
||||
content: None,
|
||||
meta: None,
|
||||
}
|
||||
@@ -898,8 +898,8 @@ mod tests {
|
||||
op,
|
||||
Op::ResolveElicitation {
|
||||
server_name: "codex_apps".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
decision: ElicitationAction::Decline,
|
||||
request_id: AppServerRequestId::String("request-1".to_string()),
|
||||
decision: McpServerElicitationAction::Decline,
|
||||
content: None,
|
||||
meta: None,
|
||||
}
|
||||
@@ -948,8 +948,8 @@ mod tests {
|
||||
op,
|
||||
Op::ResolveElicitation {
|
||||
server_name: "codex_apps".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
decision: ElicitationAction::Accept,
|
||||
request_id: AppServerRequestId::String("request-1".to_string()),
|
||||
decision: McpServerElicitationAction::Accept,
|
||||
content: None,
|
||||
meta: None,
|
||||
}
|
||||
@@ -984,7 +984,7 @@ mod tests {
|
||||
assert!(
|
||||
view.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation {
|
||||
server_name: "codex_apps".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
request_id: AppServerRequestId::String("request-1".to_string()),
|
||||
})
|
||||
);
|
||||
assert!(view.is_complete());
|
||||
@@ -1013,7 +1013,7 @@ mod tests {
|
||||
assert!(
|
||||
!view.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation {
|
||||
server_name: "other_server".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
request_id: AppServerRequestId::String("request-1".to_string()),
|
||||
})
|
||||
);
|
||||
assert!(!view.is_complete());
|
||||
|
||||
@@ -15,6 +15,8 @@ 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::bottom_pane::BottomPaneView;
|
||||
@@ -23,8 +25,10 @@ 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::history_cell::ReviewDecision;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
@@ -34,19 +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::McpServerElicitationAction;
|
||||
use codex_app_server_protocol::NetworkApprovalContext;
|
||||
use codex_app_server_protocol::NetworkPolicyRuleAction;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_features::Features;
|
||||
use codex_protocol::ThreadId;
|
||||
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;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::request_permissions::PermissionGrantScope;
|
||||
use codex_protocol::request_permissions::RequestPermissionProfile;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -71,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>,
|
||||
},
|
||||
@@ -302,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());
|
||||
}
|
||||
(
|
||||
@@ -313,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());
|
||||
}
|
||||
(
|
||||
@@ -334,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));
|
||||
@@ -401,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()
|
||||
@@ -417,7 +432,7 @@ impl ApprovalOverlay {
|
||||
&self,
|
||||
server_name: &str,
|
||||
request_id: &RequestId,
|
||||
decision: ElicitationAction,
|
||||
decision: McpServerElicitationAction,
|
||||
) {
|
||||
let Some(thread_id) = self
|
||||
.current_request
|
||||
@@ -453,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,
|
||||
@@ -467,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,
|
||||
@@ -477,7 +496,7 @@ impl ApprovalOverlay {
|
||||
self.handle_elicitation_decision(
|
||||
server_name,
|
||||
request_id,
|
||||
ElicitationAction::Cancel,
|
||||
McpServerElicitationAction::Cancel,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -726,9 +745,10 @@ fn build_header(request: &ApprovalRequest) -> Box<dyn Renderable> {
|
||||
|
||||
#[derive(Clone)]
|
||||
enum ApprovalDecision {
|
||||
Review(ReviewDecision),
|
||||
Command(CommandExecutionApprovalDecision),
|
||||
FileChange(FileChangeApprovalDecision),
|
||||
Permissions(PermissionsDecision),
|
||||
McpElicitation(ElicitationAction),
|
||||
McpElicitation(McpServerElicitationAction),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -746,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,
|
||||
@@ -755,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;
|
||||
}
|
||||
@@ -777,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() {
|
||||
@@ -793,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 {
|
||||
@@ -811,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(),
|
||||
}),
|
||||
})
|
||||
@@ -849,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() {
|
||||
@@ -858,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() {
|
||||
@@ -867,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() {
|
||||
@@ -883,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>(
|
||||
@@ -921,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(),
|
||||
},
|
||||
]
|
||||
@@ -996,17 +1049,17 @@ fn elicitation_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
|
||||
vec![
|
||||
ApprovalOption {
|
||||
label: "Yes, provide the requested info".to_string(),
|
||||
decision: ApprovalDecision::McpElicitation(ElicitationAction::Accept),
|
||||
decision: ApprovalDecision::McpElicitation(McpServerElicitationAction::Accept),
|
||||
shortcuts: keymap.approve.clone(),
|
||||
},
|
||||
ApprovalOption {
|
||||
label: "No, but continue without it".to_string(),
|
||||
decision: ApprovalDecision::McpElicitation(ElicitationAction::Decline),
|
||||
decision: ApprovalDecision::McpElicitation(McpServerElicitationAction::Decline),
|
||||
shortcuts: decline_shortcuts,
|
||||
},
|
||||
ApprovalOption {
|
||||
label: "Cancel this request".to_string(),
|
||||
decision: ApprovalDecision::McpElicitation(ElicitationAction::Cancel),
|
||||
decision: ApprovalDecision::McpElicitation(McpServerElicitationAction::Cancel),
|
||||
shortcuts: cancel_shortcuts,
|
||||
},
|
||||
]
|
||||
@@ -1015,16 +1068,14 @@ fn elicitation_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_command::AppCommand;
|
||||
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;
|
||||
@@ -1100,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,
|
||||
}
|
||||
@@ -1165,7 +1219,7 @@ mod tests {
|
||||
let mut decision = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::SubmitThreadOp {
|
||||
op: AppCommand::ExecApproval { decision: d, .. },
|
||||
op: Op::ExecApproval { decision: d, .. },
|
||||
..
|
||||
} = ev
|
||||
{
|
||||
@@ -1173,7 +1227,7 @@ mod tests {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(decision, Some(ReviewDecision::Abort));
|
||||
assert_eq!(decision, Some(CommandExecutionApprovalDecision::Cancel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1196,7 +1250,7 @@ mod tests {
|
||||
let mut decision = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::SubmitThreadOp {
|
||||
op: AppCommand::ResolveElicitation { decision: d, .. },
|
||||
op: Op::ResolveElicitation { decision: d, .. },
|
||||
..
|
||||
} = ev
|
||||
{
|
||||
@@ -1204,7 +1258,7 @@ mod tests {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(decision, Some(ElicitationAction::Cancel));
|
||||
assert_eq!(decision, Some(McpServerElicitationAction::Cancel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1236,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,
|
||||
},
|
||||
@@ -1249,11 +1306,11 @@ mod tests {
|
||||
let mut saw_denied = false;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::SubmitThreadOp {
|
||||
op: AppCommand::ExecApproval { decision, .. },
|
||||
op: Op::ExecApproval { decision, .. },
|
||||
..
|
||||
} = ev
|
||||
{
|
||||
assert_eq!(decision, ReviewDecision::Denied);
|
||||
assert_eq!(decision, CommandExecutionApprovalDecision::Decline);
|
||||
saw_denied = true;
|
||||
break;
|
||||
}
|
||||
@@ -1277,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(),
|
||||
},
|
||||
],
|
||||
@@ -1297,13 +1354,13 @@ mod tests {
|
||||
let mut saw_deny = false;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::SubmitThreadOp {
|
||||
op: AppCommand::ExecApproval { decision, .. },
|
||||
op: Op::ExecApproval { decision, .. },
|
||||
..
|
||||
} = ev
|
||||
{
|
||||
assert_eq!(
|
||||
decision,
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
|
||||
network_policy_amendment: amendment
|
||||
}
|
||||
);
|
||||
@@ -1350,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,
|
||||
},
|
||||
@@ -1381,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,
|
||||
},
|
||||
@@ -1416,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,
|
||||
},
|
||||
@@ -1442,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,
|
||||
@@ -1460,16 +1526,16 @@ mod tests {
|
||||
let mut saw_op = false;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::SubmitThreadOp {
|
||||
op: AppCommand::ExecApproval { decision, .. },
|
||||
op: Op::ExecApproval { decision, .. },
|
||||
..
|
||||
} = ev
|
||||
{
|
||||
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;
|
||||
@@ -1494,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(),
|
||||
@@ -1532,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,
|
||||
};
|
||||
@@ -1568,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,
|
||||
@@ -1600,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,
|
||||
@@ -1624,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,
|
||||
@@ -1668,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,
|
||||
@@ -1682,10 +1760,9 @@ mod tests {
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
},
|
||||
],
|
||||
]),
|
||||
glob_scan_max_depth: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -1705,7 +1782,7 @@ mod tests {
|
||||
let mut saw_op = false;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::SubmitThreadOp {
|
||||
op: AppCommand::RequestPermissionsResponse { response, .. },
|
||||
op: Op::RequestPermissionsResponse { response, .. },
|
||||
..
|
||||
} = ev
|
||||
{
|
||||
@@ -1740,7 +1817,7 @@ mod tests {
|
||||
let mut saw_op = false;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::SubmitThreadOp {
|
||||
op: AppCommand::RequestPermissionsResponse { response, .. },
|
||||
op: Op::RequestPermissionsResponse { response, .. },
|
||||
..
|
||||
} = ev
|
||||
{
|
||||
@@ -1768,7 +1845,7 @@ mod tests {
|
||||
let mut saw_op = false;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::SubmitThreadOp {
|
||||
op: AppCommand::RequestPermissionsResponse { response, .. },
|
||||
op: Op::RequestPermissionsResponse { response, .. },
|
||||
..
|
||||
} = ev
|
||||
{
|
||||
@@ -1794,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(),
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1844,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(),
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1919,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(),
|
||||
@@ -2031,7 +2120,7 @@ mod tests {
|
||||
let mut decision = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::SubmitThreadOp {
|
||||
op: AppCommand::ResolveElicitation { decision: d, .. },
|
||||
op: Op::ResolveElicitation { decision: d, .. },
|
||||
..
|
||||
} = ev
|
||||
{
|
||||
@@ -2039,7 +2128,7 @@ mod tests {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(decision, Some(ElicitationAction::Cancel));
|
||||
assert_eq!(decision, Some(McpServerElicitationAction::Cancel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2065,7 +2154,7 @@ mod tests {
|
||||
let mut esc_decision = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::SubmitThreadOp {
|
||||
op: AppCommand::ResolveElicitation { decision, .. },
|
||||
op: Op::ResolveElicitation { decision, .. },
|
||||
..
|
||||
} = ev
|
||||
{
|
||||
@@ -2073,7 +2162,7 @@ mod tests {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(esc_decision, Some(ElicitationAction::Cancel));
|
||||
assert_eq!(esc_decision, Some(McpServerElicitationAction::Cancel));
|
||||
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
@@ -2095,7 +2184,7 @@ mod tests {
|
||||
let mut n_decision = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::SubmitThreadOp {
|
||||
op: AppCommand::ResolveElicitation { decision, .. },
|
||||
op: Op::ResolveElicitation { decision, .. },
|
||||
..
|
||||
} = ev
|
||||
{
|
||||
@@ -2103,7 +2192,7 @@ mod tests {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(n_decision, Some(ElicitationAction::Decline));
|
||||
assert_eq!(n_decision, Some(McpServerElicitationAction::Decline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2121,7 +2210,7 @@ mod tests {
|
||||
let mut decision = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::SubmitThreadOp {
|
||||
op: AppCommand::ExecApproval { decision: d, .. },
|
||||
op: Op::ExecApproval { decision: d, .. },
|
||||
..
|
||||
} = ev
|
||||
{
|
||||
@@ -2129,6 +2218,6 @@ mod tests {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(decision, Some(ReviewDecision::Approved));
|
||||
assert_eq!(decision, Some(CommandExecutionApprovalDecision::Accept));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use crate::app::app_server_requests::ResolvedAppServerRequest;
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::bottom_pane::McpServerElicitationFormRequest;
|
||||
use crate::render::renderable::Renderable;
|
||||
use codex_protocol::request_user_input::RequestUserInputEvent;
|
||||
use codex_app_server_protocol::ToolRequestUserInputParams;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
use super::CancellationEvent;
|
||||
@@ -101,8 +101,8 @@ pub(crate) trait BottomPaneView: Renderable {
|
||||
/// consumed.
|
||||
fn try_consume_user_input_request(
|
||||
&mut self,
|
||||
request: RequestUserInputEvent,
|
||||
) -> Option<RequestUserInputEvent> {
|
||||
request: ToolRequestUserInputParams,
|
||||
) -> Option<ToolRequestUserInputParams> {
|
||||
Some(request)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,12 +15,11 @@ use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app_command::AppCommand;
|
||||
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.
|
||||
@@ -599,9 +598,7 @@ impl ChatComposerHistory {
|
||||
boundary_if_exhausted,
|
||||
});
|
||||
}
|
||||
app_event_tx.send(AppEvent::CodexOp(AppCommand::from(
|
||||
Op::GetHistoryEntryRequest { offset, log_id },
|
||||
)));
|
||||
app_event_tx.send(AppEvent::CodexOp(Op::history_lookup(offset, log_id)));
|
||||
return HistorySearchResult::Pending;
|
||||
}
|
||||
|
||||
@@ -719,12 +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(AppCommand::from(
|
||||
Op::GetHistoryEntryRequest {
|
||||
offset: global_idx,
|
||||
log_id,
|
||||
},
|
||||
)));
|
||||
app_event_tx.send(AppEvent::CodexOp(Op::history_lookup(global_idx, log_id)));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -2,18 +2,16 @@ 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;
|
||||
use codex_app_server_protocol::McpServerElicitationAction;
|
||||
use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use codex_app_server_protocol::RequestId as AppServerRequestId;
|
||||
use codex_protocol::ThreadId;
|
||||
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;
|
||||
@@ -166,7 +164,7 @@ struct McpToolApprovalDisplayParam {
|
||||
pub(crate) struct McpServerElicitationFormRequest {
|
||||
thread_id: ThreadId,
|
||||
server_name: String,
|
||||
request_id: McpRequestId,
|
||||
request_id: AppServerRequestId,
|
||||
message: String,
|
||||
approval_display_params: Vec<McpToolApprovalDisplayParam>,
|
||||
response_mode: McpServerElicitationResponseMode,
|
||||
@@ -206,7 +204,7 @@ impl FooterTip {
|
||||
impl McpServerElicitationFormRequest {
|
||||
pub(crate) fn from_app_server_request(
|
||||
thread_id: ThreadId,
|
||||
request_id: McpRequestId,
|
||||
request_id: AppServerRequestId,
|
||||
request: McpServerElicitationRequestParams,
|
||||
) -> Option<Self> {
|
||||
let McpServerElicitationRequestParams {
|
||||
@@ -234,33 +232,10 @@ impl McpServerElicitationFormRequest {
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn from_event(
|
||||
thread_id: ThreadId,
|
||||
request: ElicitationRequestEvent,
|
||||
) -> Option<Self> {
|
||||
let ElicitationRequest::Form {
|
||||
meta,
|
||||
message,
|
||||
requested_schema,
|
||||
} = request.request
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Self::from_parts(
|
||||
thread_id,
|
||||
request.server_name,
|
||||
request.id,
|
||||
meta,
|
||||
message,
|
||||
requested_schema,
|
||||
)
|
||||
}
|
||||
|
||||
fn from_parts(
|
||||
thread_id: ThreadId,
|
||||
server_name: String,
|
||||
request_id: McpRequestId,
|
||||
request_id: AppServerRequestId,
|
||||
meta: Option<Value>,
|
||||
message: String,
|
||||
requested_schema: Value,
|
||||
@@ -388,7 +363,7 @@ impl McpServerElicitationFormRequest {
|
||||
self.server_name.as_str()
|
||||
}
|
||||
|
||||
pub(crate) fn request_id(&self) -> &McpRequestId {
|
||||
pub(crate) fn request_id(&self) -> &AppServerRequestId {
|
||||
&self.request_id
|
||||
}
|
||||
}
|
||||
@@ -1140,7 +1115,7 @@ impl McpServerElicitationOverlay {
|
||||
self.request.thread_id,
|
||||
self.request.server_name.clone(),
|
||||
self.request.request_id.clone(),
|
||||
ElicitationAction::Cancel,
|
||||
McpServerElicitationAction::Cancel,
|
||||
/*content*/ None,
|
||||
/*meta*/ None,
|
||||
);
|
||||
@@ -1167,22 +1142,22 @@ impl McpServerElicitationOverlay {
|
||||
if self.request.response_mode == McpServerElicitationResponseMode::ApprovalAction {
|
||||
let (decision, meta) =
|
||||
match self.field_value(/*idx*/ 0).as_ref().and_then(Value::as_str) {
|
||||
Some(APPROVAL_ACCEPT_ONCE_VALUE) => (ElicitationAction::Accept, None),
|
||||
Some(APPROVAL_ACCEPT_ONCE_VALUE) => (McpServerElicitationAction::Accept, None),
|
||||
Some(APPROVAL_ACCEPT_SESSION_VALUE) => (
|
||||
ElicitationAction::Accept,
|
||||
McpServerElicitationAction::Accept,
|
||||
Some(serde_json::json!({
|
||||
APPROVAL_PERSIST_KEY: APPROVAL_PERSIST_SESSION_VALUE,
|
||||
})),
|
||||
),
|
||||
Some(APPROVAL_ACCEPT_ALWAYS_VALUE) => (
|
||||
ElicitationAction::Accept,
|
||||
McpServerElicitationAction::Accept,
|
||||
Some(serde_json::json!({
|
||||
APPROVAL_PERSIST_KEY: APPROVAL_PERSIST_ALWAYS_VALUE,
|
||||
})),
|
||||
),
|
||||
Some(APPROVAL_DECLINE_VALUE) => (ElicitationAction::Decline, None),
|
||||
Some(APPROVAL_CANCEL_VALUE) => (ElicitationAction::Cancel, None),
|
||||
_ => (ElicitationAction::Cancel, None),
|
||||
Some(APPROVAL_DECLINE_VALUE) => (McpServerElicitationAction::Decline, None),
|
||||
Some(APPROVAL_CANCEL_VALUE) => (McpServerElicitationAction::Cancel, None),
|
||||
_ => (McpServerElicitationAction::Cancel, None),
|
||||
};
|
||||
self.app_event_tx.resolve_elicitation(
|
||||
self.request.thread_id,
|
||||
@@ -1206,7 +1181,7 @@ impl McpServerElicitationOverlay {
|
||||
self.request.thread_id,
|
||||
self.request.server_name.clone(),
|
||||
self.request.request_id.clone(),
|
||||
ElicitationAction::Accept,
|
||||
McpServerElicitationAction::Accept,
|
||||
Some(Value::Object(content)),
|
||||
/*meta*/ None,
|
||||
);
|
||||
@@ -1732,19 +1707,35 @@ mod tests {
|
||||
message: &str,
|
||||
requested_schema: Value,
|
||||
meta: Option<Value>,
|
||||
) -> ElicitationRequestEvent {
|
||||
ElicitationRequestEvent {
|
||||
) -> McpServerElicitationRequestParams {
|
||||
McpServerElicitationRequestParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
server_name: "server-1".to_string(),
|
||||
id: McpRequestId::String("request-1".to_string()),
|
||||
request: ElicitationRequest::Form {
|
||||
request: McpServerElicitationRequest::Form {
|
||||
meta,
|
||||
message: message.to_string(),
|
||||
requested_schema,
|
||||
requested_schema: serde_json::from_value(requested_schema)
|
||||
.expect("test schema should deserialize"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn request_id(value: &str) -> AppServerRequestId {
|
||||
AppServerRequestId::String(value.to_string())
|
||||
}
|
||||
|
||||
fn from_form_request(
|
||||
thread_id: ThreadId,
|
||||
request: McpServerElicitationRequestParams,
|
||||
) -> Option<McpServerElicitationFormRequest> {
|
||||
McpServerElicitationFormRequest::from_app_server_request(
|
||||
thread_id,
|
||||
request_id("request-1"),
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
fn empty_object_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
@@ -1816,7 +1807,7 @@ mod tests {
|
||||
#[test]
|
||||
fn parses_boolean_form_request() {
|
||||
let thread_id = ThreadId::default();
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
let request = from_form_request(
|
||||
thread_id,
|
||||
form_request(
|
||||
"Allow this request?",
|
||||
@@ -1841,7 +1832,7 @@ mod tests {
|
||||
McpServerElicitationFormRequest {
|
||||
thread_id,
|
||||
server_name: "server-1".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
request_id: request_id("request-1"),
|
||||
message: "Allow this request?".to_string(),
|
||||
approval_display_params: Vec::new(),
|
||||
response_mode: McpServerElicitationResponseMode::FormContent,
|
||||
@@ -1873,7 +1864,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unsupported_numeric_form_falls_back() {
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
let request = from_form_request(
|
||||
ThreadId::default(),
|
||||
form_request(
|
||||
"Pick a number",
|
||||
@@ -1894,11 +1885,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_schema_uses_approval_actions() {
|
||||
fn empty_object_schema_uses_approval_actions() {
|
||||
let thread_id = ThreadId::default();
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
let request = from_form_request(
|
||||
thread_id,
|
||||
form_request("Allow this request?", Value::Null, /*meta*/ None),
|
||||
form_request(
|
||||
"Allow this request?",
|
||||
empty_object_schema(),
|
||||
/*meta*/ None,
|
||||
),
|
||||
)
|
||||
.expect("expected approval fallback");
|
||||
|
||||
@@ -1907,7 +1902,7 @@ mod tests {
|
||||
McpServerElicitationFormRequest {
|
||||
thread_id,
|
||||
server_name: "server-1".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
request_id: request_id("request-1"),
|
||||
message: "Allow this request?".to_string(),
|
||||
approval_display_params: Vec::new(),
|
||||
response_mode: McpServerElicitationResponseMode::ApprovalAction,
|
||||
@@ -1945,7 +1940,7 @@ mod tests {
|
||||
#[test]
|
||||
fn empty_tool_approval_schema_uses_approval_actions() {
|
||||
let thread_id = ThreadId::default();
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
let request = from_form_request(
|
||||
thread_id,
|
||||
form_request(
|
||||
"Allow this request?",
|
||||
@@ -1964,7 +1959,7 @@ mod tests {
|
||||
McpServerElicitationFormRequest {
|
||||
thread_id,
|
||||
server_name: "server-1".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
request_id: request_id("request-1"),
|
||||
message: "Allow this request?".to_string(),
|
||||
approval_display_params: Vec::new(),
|
||||
response_mode: McpServerElicitationResponseMode::ApprovalAction,
|
||||
@@ -1996,7 +1991,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_suggestion_meta_is_parsed_into_request_payload() {
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
let request = from_form_request(
|
||||
ThreadId::default(),
|
||||
form_request(
|
||||
"Suggest Google Calendar",
|
||||
@@ -2029,7 +2024,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn plugin_tool_suggestion_meta_without_install_url_is_parsed_into_request_payload() {
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
let request = from_form_request(
|
||||
ThreadId::default(),
|
||||
form_request(
|
||||
"Suggest Slack",
|
||||
@@ -2061,7 +2056,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_approval_display_params_prefer_explicit_display_order() {
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
let request = from_form_request(
|
||||
ThreadId::default(),
|
||||
form_request(
|
||||
"Allow Calendar to create an event",
|
||||
@@ -2110,7 +2105,7 @@ mod tests {
|
||||
fn submit_sends_accept_with_typed_content() {
|
||||
let (tx, mut rx) = test_sender();
|
||||
let thread_id = ThreadId::default();
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
let request = from_form_request(
|
||||
thread_id,
|
||||
form_request(
|
||||
"Allow this request?",
|
||||
@@ -2150,8 +2145,8 @@ mod tests {
|
||||
op,
|
||||
Op::ResolveElicitation {
|
||||
server_name: "server-1".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
decision: ElicitationAction::Accept,
|
||||
request_id: request_id("request-1"),
|
||||
decision: McpServerElicitationAction::Accept,
|
||||
content: Some(serde_json::json!({
|
||||
"confirmed": true,
|
||||
})),
|
||||
@@ -2164,7 +2159,7 @@ mod tests {
|
||||
fn empty_tool_approval_schema_session_choice_sets_persist_meta() {
|
||||
let (tx, mut rx) = test_sender();
|
||||
let thread_id = ThreadId::default();
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
let request = from_form_request(
|
||||
thread_id,
|
||||
form_request(
|
||||
"Allow this request?",
|
||||
@@ -2204,8 +2199,8 @@ mod tests {
|
||||
op,
|
||||
Op::ResolveElicitation {
|
||||
server_name: "server-1".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
decision: ElicitationAction::Accept,
|
||||
request_id: request_id("request-1"),
|
||||
decision: McpServerElicitationAction::Accept,
|
||||
content: None,
|
||||
meta: Some(serde_json::json!({
|
||||
APPROVAL_PERSIST_KEY: APPROVAL_PERSIST_SESSION_VALUE,
|
||||
@@ -2218,7 +2213,7 @@ mod tests {
|
||||
fn empty_tool_approval_schema_always_allow_sets_persist_meta() {
|
||||
let (tx, mut rx) = test_sender();
|
||||
let thread_id = ThreadId::default();
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
let request = from_form_request(
|
||||
thread_id,
|
||||
form_request(
|
||||
"Allow this request?",
|
||||
@@ -2258,8 +2253,8 @@ mod tests {
|
||||
op,
|
||||
Op::ResolveElicitation {
|
||||
server_name: "server-1".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
decision: ElicitationAction::Accept,
|
||||
request_id: request_id("request-1"),
|
||||
decision: McpServerElicitationAction::Accept,
|
||||
content: None,
|
||||
meta: Some(serde_json::json!({
|
||||
APPROVAL_PERSIST_KEY: APPROVAL_PERSIST_ALWAYS_VALUE,
|
||||
@@ -2272,7 +2267,7 @@ mod tests {
|
||||
fn ctrl_c_cancels_elicitation() {
|
||||
let (tx, mut rx) = test_sender();
|
||||
let thread_id = ThreadId::default();
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
let request = from_form_request(
|
||||
thread_id,
|
||||
form_request(
|
||||
"Allow this request?",
|
||||
@@ -2311,8 +2306,8 @@ mod tests {
|
||||
op,
|
||||
Op::ResolveElicitation {
|
||||
server_name: "server-1".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
decision: ElicitationAction::Cancel,
|
||||
request_id: request_id("request-1"),
|
||||
decision: McpServerElicitationAction::Cancel,
|
||||
content: None,
|
||||
meta: None,
|
||||
}
|
||||
@@ -2322,7 +2317,7 @@ mod tests {
|
||||
#[test]
|
||||
fn queues_requests_fifo() {
|
||||
let (tx, _rx) = test_sender();
|
||||
let first = McpServerElicitationFormRequest::from_event(
|
||||
let first = from_form_request(
|
||||
ThreadId::default(),
|
||||
form_request(
|
||||
"First",
|
||||
@@ -2339,7 +2334,7 @@ mod tests {
|
||||
),
|
||||
)
|
||||
.expect("expected supported form");
|
||||
let second = McpServerElicitationFormRequest::from_event(
|
||||
let second = from_form_request(
|
||||
ThreadId::default(),
|
||||
form_request(
|
||||
"Second",
|
||||
@@ -2356,7 +2351,7 @@ mod tests {
|
||||
),
|
||||
)
|
||||
.expect("expected supported form");
|
||||
let third = McpServerElicitationFormRequest::from_event(
|
||||
let third = from_form_request(
|
||||
ThreadId::default(),
|
||||
form_request(
|
||||
"Third",
|
||||
@@ -2405,7 +2400,7 @@ mod tests {
|
||||
},
|
||||
});
|
||||
let mut overlay = McpServerElicitationOverlay::new(
|
||||
McpServerElicitationFormRequest::from_event(
|
||||
from_form_request(
|
||||
thread_id,
|
||||
form_request("First", supported_form_schema.clone(), /*meta*/ None),
|
||||
)
|
||||
@@ -2416,16 +2411,18 @@ mod tests {
|
||||
/*disable_paste_burst*/ false,
|
||||
);
|
||||
overlay.try_consume_mcp_server_elicitation_request(
|
||||
McpServerElicitationFormRequest::from_event(
|
||||
McpServerElicitationFormRequest::from_app_server_request(
|
||||
thread_id,
|
||||
ElicitationRequestEvent {
|
||||
request_id("request-2"),
|
||||
McpServerElicitationRequestParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: Some("turn-2".to_string()),
|
||||
server_name: "server-1".to_string(),
|
||||
id: McpRequestId::String("request-2".to_string()),
|
||||
request: ElicitationRequest::Form {
|
||||
request: McpServerElicitationRequest::Form {
|
||||
meta: None,
|
||||
message: "Second".to_string(),
|
||||
requested_schema: supported_form_schema,
|
||||
requested_schema: serde_json::from_value(supported_form_schema)
|
||||
.expect("test schema should deserialize"),
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -2435,7 +2432,7 @@ mod tests {
|
||||
assert!(
|
||||
overlay.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation {
|
||||
server_name: "server-1".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
request_id: request_id("request-1"),
|
||||
})
|
||||
);
|
||||
assert_eq!(overlay.request.message, "Second");
|
||||
@@ -2447,7 +2444,7 @@ mod tests {
|
||||
assert!(
|
||||
overlay.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation {
|
||||
server_name: "server-1".to_string(),
|
||||
request_id: McpRequestId::String("request-2".to_string()),
|
||||
request_id: request_id("request-2"),
|
||||
})
|
||||
);
|
||||
assert!(overlay.is_complete());
|
||||
@@ -2460,7 +2457,7 @@ mod tests {
|
||||
#[test]
|
||||
fn boolean_form_snapshot() {
|
||||
let (tx, _rx) = test_sender();
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
let request = from_form_request(
|
||||
ThreadId::default(),
|
||||
form_request(
|
||||
"Allow this request?",
|
||||
@@ -2493,7 +2490,7 @@ mod tests {
|
||||
#[test]
|
||||
fn approval_form_tool_approval_snapshot() {
|
||||
let (tx, _rx) = test_sender();
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
let request = from_form_request(
|
||||
ThreadId::default(),
|
||||
form_request(
|
||||
"Allow this request?",
|
||||
@@ -2520,7 +2517,7 @@ mod tests {
|
||||
#[test]
|
||||
fn message_only_form_snapshot() {
|
||||
let (tx, _rx) = test_sender();
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
let request = from_form_request(
|
||||
ThreadId::default(),
|
||||
form_request(
|
||||
"Boolean elicit MCP example: do you confirm?",
|
||||
@@ -2543,7 +2540,7 @@ mod tests {
|
||||
#[test]
|
||||
fn message_only_form_with_persist_options_snapshot() {
|
||||
let (tx, _rx) = test_sender();
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
let request = from_form_request(
|
||||
ThreadId::default(),
|
||||
form_request(
|
||||
"Boolean elicit MCP example: do you confirm?",
|
||||
@@ -2571,7 +2568,7 @@ mod tests {
|
||||
#[test]
|
||||
fn approval_form_tool_approval_with_persist_options_snapshot() {
|
||||
let (tx, _rx) = test_sender();
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
let request = from_form_request(
|
||||
ThreadId::default(),
|
||||
form_request(
|
||||
"Allow this request?",
|
||||
@@ -2601,7 +2598,7 @@ mod tests {
|
||||
#[test]
|
||||
fn approval_form_tool_approval_with_param_summary_snapshot() {
|
||||
let (tx, _rx) = test_sender();
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
let request = from_form_request(
|
||||
ThreadId::default(),
|
||||
form_request(
|
||||
"Allow Calendar to create an event",
|
||||
|
||||
@@ -31,11 +31,11 @@ use crate::render::renderable::RenderableItem;
|
||||
use crate::tui::FrameRequester;
|
||||
pub(crate) use bottom_pane_view::BottomPaneView;
|
||||
use bottom_pane_view::ViewCompletion;
|
||||
use codex_app_server_protocol::ToolRequestUserInputParams;
|
||||
use codex_core_skills::model::SkillMetadata;
|
||||
use codex_features::Features;
|
||||
use codex_file_search::FileMatch;
|
||||
use codex_plugin::PluginCapabilitySummary;
|
||||
use codex_protocol::request_user_input::RequestUserInputEvent;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -1205,7 +1205,7 @@ impl BottomPane {
|
||||
}
|
||||
|
||||
/// Called when the agent requests user input.
|
||||
pub fn push_user_input_request(&mut self, request: RequestUserInputEvent) {
|
||||
pub fn push_user_input_request(&mut self, request: ToolRequestUserInputParams) {
|
||||
let request = if let Some(view) = self.view_stack.last_mut() {
|
||||
match view.try_consume_user_input_request(request) {
|
||||
Some(request) => request,
|
||||
@@ -1567,14 +1567,13 @@ impl Renderable for BottomPane {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::app_server_requests::ResolvedAppServerRequest;
|
||||
use crate::app_command::AppCommand;
|
||||
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::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,
|
||||
@@ -1890,14 +1889,17 @@ mod tests {
|
||||
let mut approval_decision = None;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if let AppEvent::SubmitThreadOp {
|
||||
op: AppCommand::ExecApproval { decision, .. },
|
||||
op: Op::ExecApproval { decision, .. },
|
||||
..
|
||||
} = event
|
||||
{
|
||||
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(),
|
||||
}]),
|
||||
});
|
||||
|
||||
@@ -2371,7 +2373,7 @@ mod tests {
|
||||
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
assert!(
|
||||
!matches!(ev, AppEvent::CodexOp(AppCommand::Interrupt)),
|
||||
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
|
||||
"expected Esc to not send Op::Interrupt when dismissing skill popup"
|
||||
);
|
||||
}
|
||||
@@ -2409,7 +2411,7 @@ mod tests {
|
||||
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
assert!(
|
||||
!matches!(ev, AppEvent::CodexOp(AppCommand::Interrupt)),
|
||||
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
|
||||
"expected Esc to not send Op::Interrupt while command popup is active"
|
||||
);
|
||||
}
|
||||
@@ -2445,7 +2447,7 @@ mod tests {
|
||||
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
assert!(
|
||||
!matches!(ev, AppEvent::CodexOp(AppCommand::Interrupt)),
|
||||
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
|
||||
"expected Esc to not send Op::Interrupt while typing `/agent`"
|
||||
);
|
||||
}
|
||||
@@ -2490,7 +2492,7 @@ mod tests {
|
||||
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
assert!(
|
||||
!matches!(ev, AppEvent::CodexOp(AppCommand::Interrupt)),
|
||||
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
|
||||
"expected Esc release after dismissing agent picker to not interrupt"
|
||||
);
|
||||
}
|
||||
@@ -2520,7 +2522,7 @@ mod tests {
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(AppCommand::Interrupt))),
|
||||
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))),
|
||||
"expected Esc to send Op::Interrupt while a task is running"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,10 +32,13 @@ use crate::history_cell;
|
||||
use crate::render::renderable::Renderable;
|
||||
|
||||
#[cfg(test)]
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::request_user_input::RequestUserInputAnswer;
|
||||
use codex_protocol::request_user_input::RequestUserInputEvent;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use crate::app_command::AppCommand as Op;
|
||||
use codex_app_server_protocol::ToolRequestUserInputAnswer;
|
||||
#[cfg(test)]
|
||||
use codex_app_server_protocol::ToolRequestUserInputOption;
|
||||
use codex_app_server_protocol::ToolRequestUserInputParams;
|
||||
use codex_app_server_protocol::ToolRequestUserInputQuestion;
|
||||
use codex_app_server_protocol::ToolRequestUserInputResponse;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
@@ -122,9 +125,9 @@ impl FooterTip {
|
||||
|
||||
pub(crate) struct RequestUserInputOverlay {
|
||||
app_event_tx: AppEventSender,
|
||||
request: RequestUserInputEvent,
|
||||
request: ToolRequestUserInputParams,
|
||||
// Queue of incoming requests to process after the current one.
|
||||
queue: VecDeque<RequestUserInputEvent>,
|
||||
queue: VecDeque<ToolRequestUserInputParams>,
|
||||
// Reuse the shared chat composer so notes/freeform answers match the
|
||||
// primary input styling and behavior.
|
||||
composer: ChatComposer,
|
||||
@@ -139,7 +142,7 @@ pub(crate) struct RequestUserInputOverlay {
|
||||
|
||||
impl RequestUserInputOverlay {
|
||||
pub(crate) fn new(
|
||||
request: RequestUserInputEvent,
|
||||
request: ToolRequestUserInputParams,
|
||||
app_event_tx: AppEventSender,
|
||||
has_input_focus: bool,
|
||||
enhanced_keys_supported: bool,
|
||||
@@ -179,9 +182,7 @@ impl RequestUserInputOverlay {
|
||||
self.current_idx
|
||||
}
|
||||
|
||||
fn current_question(
|
||||
&self,
|
||||
) -> Option<&codex_protocol::request_user_input::RequestUserInputQuestion> {
|
||||
fn current_question(&self) -> Option<&ToolRequestUserInputQuestion> {
|
||||
self.request.questions.get(self.current_index())
|
||||
}
|
||||
|
||||
@@ -586,9 +587,7 @@ impl RequestUserInputOverlay {
|
||||
self.pending_submission_draft = None;
|
||||
}
|
||||
|
||||
fn options_len_for_question(
|
||||
question: &codex_protocol::request_user_input::RequestUserInputQuestion,
|
||||
) -> usize {
|
||||
fn options_len_for_question(question: &ToolRequestUserInputQuestion) -> usize {
|
||||
let options_len = question
|
||||
.options
|
||||
.as_ref()
|
||||
@@ -601,9 +600,7 @@ impl RequestUserInputOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
fn other_option_enabled_for_question(
|
||||
question: &codex_protocol::request_user_input::RequestUserInputQuestion,
|
||||
) -> bool {
|
||||
fn other_option_enabled_for_question(question: &ToolRequestUserInputQuestion) -> bool {
|
||||
question.is_other
|
||||
&& question
|
||||
.options
|
||||
@@ -612,7 +609,7 @@ impl RequestUserInputOverlay {
|
||||
}
|
||||
|
||||
fn option_label_for_index(
|
||||
question: &codex_protocol::request_user_input::RequestUserInputQuestion,
|
||||
question: &ToolRequestUserInputQuestion,
|
||||
idx: usize,
|
||||
) -> Option<String> {
|
||||
let options = question.options.as_ref()?;
|
||||
@@ -753,14 +750,14 @@ impl RequestUserInputOverlay {
|
||||
}
|
||||
answers.insert(
|
||||
question.id.clone(),
|
||||
RequestUserInputAnswer {
|
||||
ToolRequestUserInputAnswer {
|
||||
answers: answer_list,
|
||||
},
|
||||
);
|
||||
}
|
||||
self.app_event_tx.user_input_answer(
|
||||
self.request.turn_id.clone(),
|
||||
RequestUserInputResponse {
|
||||
ToolRequestUserInputResponse {
|
||||
answers: answers.clone(),
|
||||
},
|
||||
);
|
||||
@@ -781,8 +778,8 @@ impl RequestUserInputOverlay {
|
||||
|
||||
let queue_len = self.queue.len();
|
||||
self.queue
|
||||
.retain(|queued_request| queued_request.call_id != *call_id);
|
||||
if self.request.call_id == *call_id {
|
||||
.retain(|queued_request| queued_request.item_id != *call_id);
|
||||
if self.request.item_id == *call_id {
|
||||
self.advance_queue_or_complete();
|
||||
return true;
|
||||
}
|
||||
@@ -1294,8 +1291,8 @@ impl BottomPaneView for RequestUserInputOverlay {
|
||||
|
||||
fn try_consume_user_input_request(
|
||||
&mut self,
|
||||
request: RequestUserInputEvent,
|
||||
) -> Option<RequestUserInputEvent> {
|
||||
request: ToolRequestUserInputParams,
|
||||
) -> Option<ToolRequestUserInputParams> {
|
||||
self.queue.push_back(request);
|
||||
None
|
||||
}
|
||||
@@ -1308,12 +1305,9 @@ impl BottomPaneView for RequestUserInputOverlay {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_command::AppCommand;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::selection_popup_common::menu_surface_inset;
|
||||
use crate::render::renderable::Renderable;
|
||||
use codex_protocol::request_user_input::RequestUserInputQuestion;
|
||||
use codex_protocol::request_user_input::RequestUserInputQuestionOption;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
@@ -1341,23 +1335,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn question_with_options(id: &str, header: &str) -> RequestUserInputQuestion {
|
||||
RequestUserInputQuestion {
|
||||
fn question_with_options(id: &str, header: &str) -> ToolRequestUserInputQuestion {
|
||||
ToolRequestUserInputQuestion {
|
||||
id: id.to_string(),
|
||||
header: header.to_string(),
|
||||
question: "Choose an option.".to_string(),
|
||||
is_other: false,
|
||||
is_secret: false,
|
||||
options: Some(vec![
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Option 1".to_string(),
|
||||
description: "First choice.".to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Option 2".to_string(),
|
||||
description: "Second choice.".to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Option 3".to_string(),
|
||||
description: "Third choice.".to_string(),
|
||||
},
|
||||
@@ -1365,23 +1359,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn question_with_options_and_other(id: &str, header: &str) -> RequestUserInputQuestion {
|
||||
RequestUserInputQuestion {
|
||||
fn question_with_options_and_other(id: &str, header: &str) -> ToolRequestUserInputQuestion {
|
||||
ToolRequestUserInputQuestion {
|
||||
id: id.to_string(),
|
||||
header: header.to_string(),
|
||||
question: "Choose an option.".to_string(),
|
||||
is_other: true,
|
||||
is_secret: false,
|
||||
options: Some(vec![
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Option 1".to_string(),
|
||||
description: "First choice.".to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Option 2".to_string(),
|
||||
description: "Second choice.".to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Option 3".to_string(),
|
||||
description: "Third choice.".to_string(),
|
||||
},
|
||||
@@ -1389,27 +1383,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn question_with_wrapped_options(id: &str, header: &str) -> RequestUserInputQuestion {
|
||||
RequestUserInputQuestion {
|
||||
fn question_with_wrapped_options(id: &str, header: &str) -> ToolRequestUserInputQuestion {
|
||||
ToolRequestUserInputQuestion {
|
||||
id: id.to_string(),
|
||||
header: header.to_string(),
|
||||
question: "Choose the next step for this task.".to_string(),
|
||||
is_other: false,
|
||||
is_secret: false,
|
||||
options: Some(vec![
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Discuss a code change".to_string(),
|
||||
description:
|
||||
"Walk through a plan, then implement it together with careful checks."
|
||||
.to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Run targeted tests".to_string(),
|
||||
description:
|
||||
"Pick the most relevant crate and validate the current behavior first."
|
||||
.to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Review the diff".to_string(),
|
||||
description:
|
||||
"Summarize the changes and highlight the most important risks and gaps."
|
||||
@@ -1419,19 +1413,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn question_with_very_long_option_text(id: &str, header: &str) -> RequestUserInputQuestion {
|
||||
RequestUserInputQuestion {
|
||||
fn question_with_very_long_option_text(id: &str, header: &str) -> ToolRequestUserInputQuestion {
|
||||
ToolRequestUserInputQuestion {
|
||||
id: id.to_string(),
|
||||
header: header.to_string(),
|
||||
question: "Choose one option.".to_string(),
|
||||
is_other: false,
|
||||
is_secret: false,
|
||||
options: Some(vec![
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Job: running/completed/failed/expired; Run/Experiment: succeeded/failed/unknown (Recommended when triaging long-running background work and status transitions)".to_string(),
|
||||
description: "Keep async job statuses for progress tracking and include enough context for debugging retries, stale workers, and unexpected expiration paths.".to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Add a short status model".to_string(),
|
||||
description: "Simpler labels with less detail for quick rollouts.".to_string(),
|
||||
},
|
||||
@@ -1439,8 +1433,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn question_with_long_scroll_options(id: &str, header: &str) -> RequestUserInputQuestion {
|
||||
RequestUserInputQuestion {
|
||||
fn question_with_long_scroll_options(id: &str, header: &str) -> ToolRequestUserInputQuestion {
|
||||
ToolRequestUserInputQuestion {
|
||||
id: id.to_string(),
|
||||
header: header.to_string(),
|
||||
question:
|
||||
@@ -1449,19 +1443,19 @@ mod tests {
|
||||
is_other: false,
|
||||
is_secret: false,
|
||||
options: Some(vec![
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Use Detailed Hint A (Recommended)".to_string(),
|
||||
description: "Select this if you want a deliberately overextended explanatory hint that reads like a miniature specification, including context, rationale, expected behavior, and an explicit statement that this choice is mainly for testing how gracefully the interface wraps, truncates, and preserves readability under unusually verbose helper text conditions.".to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Use Detailed Hint B".to_string(),
|
||||
description: "Select this if you want an equally verbose but differently phrased guidance block that emphasizes user-facing clarity, spacing tolerance, multiline wrapping, visual hierarchy interactions, and whether long descriptive metadata remains understandable when scanned quickly in a constrained layout where cognitive load is already high.".to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Use Detailed Hint C".to_string(),
|
||||
description: "Select this when you specifically want to verify that navigating downward will keep the currently highlighted option visible, even when previous options consume many wrapped lines and would otherwise push the selection out of the viewport.".to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "None of the above".to_string(),
|
||||
description:
|
||||
"Use this only if the previous long-form options do not apply.".to_string(),
|
||||
@@ -1470,8 +1464,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn question_without_options(id: &str, header: &str) -> RequestUserInputQuestion {
|
||||
RequestUserInputQuestion {
|
||||
fn question_without_options(id: &str, header: &str) -> ToolRequestUserInputQuestion {
|
||||
ToolRequestUserInputQuestion {
|
||||
id: id.to_string(),
|
||||
header: header.to_string(),
|
||||
question: "Share details.".to_string(),
|
||||
@@ -1483,10 +1477,11 @@ mod tests {
|
||||
|
||||
fn request_event(
|
||||
turn_id: &str,
|
||||
questions: Vec<RequestUserInputQuestion>,
|
||||
) -> RequestUserInputEvent {
|
||||
RequestUserInputEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
questions: Vec<ToolRequestUserInputQuestion>,
|
||||
) -> ToolRequestUserInputParams {
|
||||
ToolRequestUserInputParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
item_id: "call-1".to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
questions,
|
||||
}
|
||||
@@ -1546,13 +1541,15 @@ mod tests {
|
||||
/*enhanced_keys_supported*/ false,
|
||||
/*disable_paste_burst*/ false,
|
||||
);
|
||||
overlay.try_consume_user_input_request(RequestUserInputEvent {
|
||||
call_id: "call-2".to_string(),
|
||||
overlay.try_consume_user_input_request(ToolRequestUserInputParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
item_id: "call-2".to_string(),
|
||||
turn_id: "turn-2".to_string(),
|
||||
questions: vec![question_with_options("q2", "Second")],
|
||||
});
|
||||
overlay.try_consume_user_input_request(RequestUserInputEvent {
|
||||
call_id: "call-3".to_string(),
|
||||
overlay.try_consume_user_input_request(ToolRequestUserInputParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
item_id: "call-3".to_string(),
|
||||
turn_id: "turn-3".to_string(),
|
||||
questions: vec![question_with_options("q3", "Third")],
|
||||
});
|
||||
@@ -1567,8 +1564,9 @@ mod tests {
|
||||
fn resolved_request_dismisses_overlay_without_emitting_events() {
|
||||
let (tx, mut rx) = test_sender();
|
||||
let mut overlay = RequestUserInputOverlay::new(
|
||||
RequestUserInputEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
ToolRequestUserInputParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
item_id: "call-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
questions: vec![question_with_options("q1", "First")],
|
||||
},
|
||||
@@ -1594,8 +1592,9 @@ mod tests {
|
||||
fn resolved_current_request_advances_to_next_same_turn_prompt() {
|
||||
let (tx, mut rx) = test_sender();
|
||||
let mut overlay = RequestUserInputOverlay::new(
|
||||
RequestUserInputEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
ToolRequestUserInputParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
item_id: "call-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
questions: vec![question_with_options("q1", "First")],
|
||||
},
|
||||
@@ -1604,8 +1603,9 @@ mod tests {
|
||||
/*enhanced_keys_supported*/ false,
|
||||
/*disable_paste_burst*/ false,
|
||||
);
|
||||
overlay.try_consume_user_input_request(RequestUserInputEvent {
|
||||
call_id: "call-2".to_string(),
|
||||
overlay.try_consume_user_input_request(ToolRequestUserInputParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
item_id: "call-2".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
questions: vec![question_with_options("q2", "Second")],
|
||||
});
|
||||
@@ -1617,7 +1617,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert!(!overlay.done, "newer same-turn prompt should stay pending");
|
||||
assert_eq!(overlay.request.call_id, "call-2");
|
||||
assert_eq!(overlay.request.item_id, "call-2");
|
||||
assert_eq!(overlay.request.turn_id, "turn-1");
|
||||
assert_eq!(overlay.request.questions[0].id, "q2");
|
||||
assert!(
|
||||
@@ -1630,8 +1630,9 @@ mod tests {
|
||||
fn resolved_queued_request_removes_only_that_prompt() {
|
||||
let (tx, mut rx) = test_sender();
|
||||
let mut overlay = RequestUserInputOverlay::new(
|
||||
RequestUserInputEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
ToolRequestUserInputParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
item_id: "call-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
questions: vec![question_with_options("q1", "First")],
|
||||
},
|
||||
@@ -1640,13 +1641,15 @@ mod tests {
|
||||
/*enhanced_keys_supported*/ false,
|
||||
/*disable_paste_burst*/ false,
|
||||
);
|
||||
overlay.try_consume_user_input_request(RequestUserInputEvent {
|
||||
call_id: "call-2".to_string(),
|
||||
overlay.try_consume_user_input_request(ToolRequestUserInputParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
item_id: "call-2".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
questions: vec![question_with_options("q2", "Second")],
|
||||
});
|
||||
overlay.try_consume_user_input_request(RequestUserInputEvent {
|
||||
call_id: "call-3".to_string(),
|
||||
overlay.try_consume_user_input_request(ToolRequestUserInputParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
item_id: "call-3".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
questions: vec![question_with_options("q3", "Third")],
|
||||
});
|
||||
@@ -1657,13 +1660,13 @@ mod tests {
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(overlay.request.call_id, "call-1");
|
||||
assert_eq!(overlay.request.item_id, "call-1");
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"dismissing a stale queued request should not emit an event"
|
||||
);
|
||||
overlay.submit_answers();
|
||||
assert_eq!(overlay.request.call_id, "call-3");
|
||||
assert_eq!(overlay.request.item_id, "call-3");
|
||||
assert_eq!(overlay.request.questions[0].id, "q3");
|
||||
assert!(
|
||||
rx.try_recv().is_ok(),
|
||||
@@ -1689,7 +1692,7 @@ mod tests {
|
||||
overlay.submit_answers();
|
||||
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(AppCommand::UserInputAnswer { id, response, .. }) = event else {
|
||||
let AppEvent::CodexOp(Op::UserInputAnswer { id, response, .. }) = event else {
|
||||
panic!("expected UserInputAnswer");
|
||||
};
|
||||
assert_eq!(id, "turn-1");
|
||||
@@ -1711,7 +1714,7 @@ mod tests {
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(AppCommand::UserInputAnswer { response, .. }) = event else {
|
||||
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
|
||||
panic!("expected UserInputAnswer");
|
||||
};
|
||||
let answer = response.answers.get("q1").expect("answer missing");
|
||||
@@ -1747,19 +1750,19 @@ mod tests {
|
||||
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(AppCommand::UserInputAnswer { response, .. }) = event else {
|
||||
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
|
||||
panic!("expected UserInputAnswer");
|
||||
};
|
||||
let mut expected = HashMap::new();
|
||||
expected.insert(
|
||||
"q1".to_string(),
|
||||
RequestUserInputAnswer {
|
||||
ToolRequestUserInputAnswer {
|
||||
answers: vec!["Option 1".to_string()],
|
||||
},
|
||||
);
|
||||
expected.insert(
|
||||
"q2".to_string(),
|
||||
RequestUserInputAnswer {
|
||||
ToolRequestUserInputAnswer {
|
||||
answers: vec!["Option 1".to_string()],
|
||||
},
|
||||
);
|
||||
@@ -1780,7 +1783,7 @@ mod tests {
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Char('2')));
|
||||
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(AppCommand::UserInputAnswer { response, .. }) = event else {
|
||||
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
|
||||
panic!("expected UserInputAnswer");
|
||||
};
|
||||
let answer = response.answers.get("q1").expect("answer missing");
|
||||
@@ -2030,7 +2033,7 @@ mod tests {
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(AppCommand::UserInputAnswer { response, .. }) = event else {
|
||||
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
|
||||
panic!("expected UserInputAnswer");
|
||||
};
|
||||
let answer = response.answers.get("q1").expect("answer missing");
|
||||
@@ -2330,7 +2333,7 @@ mod tests {
|
||||
overlay.submit_answers();
|
||||
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(AppCommand::UserInputAnswer { response, .. }) = event else {
|
||||
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
|
||||
panic!("expected UserInputAnswer");
|
||||
};
|
||||
let answer = response.answers.get("q1").expect("answer missing");
|
||||
@@ -2355,7 +2358,7 @@ mod tests {
|
||||
overlay.submit_answers();
|
||||
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(AppCommand::UserInputAnswer { response, .. }) = event else {
|
||||
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
|
||||
panic!("expected UserInputAnswer");
|
||||
};
|
||||
let answer = response.answers.get("q1").expect("answer missing");
|
||||
@@ -2398,7 +2401,7 @@ mod tests {
|
||||
overlay.submit_answers();
|
||||
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(AppCommand::UserInputAnswer { response, .. }) = event else {
|
||||
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
|
||||
panic!("expected UserInputAnswer");
|
||||
};
|
||||
let answer = response.answers.get("q1").expect("answer missing");
|
||||
@@ -2434,7 +2437,7 @@ mod tests {
|
||||
overlay.submit_answers();
|
||||
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(AppCommand::UserInputAnswer { response, .. }) = event else {
|
||||
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
|
||||
panic!("expected UserInputAnswer");
|
||||
};
|
||||
let answer = response.answers.get("q1").expect("answer missing");
|
||||
@@ -2519,7 +2522,7 @@ mod tests {
|
||||
overlay.submit_answers();
|
||||
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(AppCommand::UserInputAnswer { response, .. }) = event else {
|
||||
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
|
||||
panic!("expected UserInputAnswer");
|
||||
};
|
||||
let answer = response.answers.get("q1").expect("answer missing");
|
||||
@@ -2852,30 +2855,30 @@ mod tests {
|
||||
let mut overlay = RequestUserInputOverlay::new(
|
||||
request_event(
|
||||
"turn-1",
|
||||
vec![RequestUserInputQuestion {
|
||||
vec![ToolRequestUserInputQuestion {
|
||||
id: "q1".to_string(),
|
||||
header: "Next Step".to_string(),
|
||||
question: "What would you like to do next?".to_string(),
|
||||
is_other: false,
|
||||
is_secret: false,
|
||||
options: Some(vec![
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Discuss a code change (Recommended)".to_string(),
|
||||
description: "Walk through a plan and edit code together.".to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Run tests".to_string(),
|
||||
description: "Pick a crate and run its tests.".to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Review a diff".to_string(),
|
||||
description: "Summarize or review current changes.".to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Refactor".to_string(),
|
||||
description: "Tighten structure and remove dead code.".to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Ship it".to_string(),
|
||||
description: "Finalize and open a PR.".to_string(),
|
||||
},
|
||||
@@ -2904,30 +2907,30 @@ mod tests {
|
||||
let mut overlay = RequestUserInputOverlay::new(
|
||||
request_event(
|
||||
"turn-1",
|
||||
vec![RequestUserInputQuestion {
|
||||
vec![ToolRequestUserInputQuestion {
|
||||
id: "q1".to_string(),
|
||||
header: "Next Step".to_string(),
|
||||
question: "What would you like to do next?".to_string(),
|
||||
is_other: false,
|
||||
is_secret: false,
|
||||
options: Some(vec![
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Discuss a code change (Recommended)".to_string(),
|
||||
description: "Walk through a plan and edit code together.".to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Run tests".to_string(),
|
||||
description: "Pick a crate and run its tests.".to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Review a diff".to_string(),
|
||||
description: "Summarize or review current changes.".to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Refactor".to_string(),
|
||||
description: "Tighten structure and remove dead code.".to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
ToolRequestUserInputOption {
|
||||
label: "Ship it".to_string(),
|
||||
description: "Finalize and open a PR.".to_string(),
|
||||
},
|
||||
|
||||
+558
-1745
File diff suppressed because it is too large.
Load diff
@@ -1,16 +1,15 @@
|
||||
//! Queue prompt overlays and deferred tool activity while another interrupt is visible.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::app::app_server_requests::ResolvedAppServerRequest;
|
||||
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 crate::approval_events::ApplyPatchApprovalRequestEvent;
|
||||
use crate::approval_events::ExecApprovalRequestEvent;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use codex_app_server_protocol::RequestId as AppServerRequestId;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ToolRequestUserInputParams;
|
||||
use codex_protocol::request_permissions::RequestPermissionsEvent;
|
||||
use codex_protocol::request_user_input::RequestUserInputEvent;
|
||||
|
||||
use super::ChatWidget;
|
||||
|
||||
@@ -18,14 +17,14 @@ use super::ChatWidget;
|
||||
pub(crate) enum QueuedInterrupt {
|
||||
ExecApproval(ExecApprovalRequestEvent),
|
||||
ApplyPatchApproval(ApplyPatchApprovalRequestEvent),
|
||||
Elicitation(ElicitationRequestEvent),
|
||||
Elicitation {
|
||||
request_id: AppServerRequestId,
|
||||
params: McpServerElicitationRequestParams,
|
||||
},
|
||||
RequestPermissions(RequestPermissionsEvent),
|
||||
RequestUserInput(RequestUserInputEvent),
|
||||
ExecBegin(ExecCommandBeginEvent),
|
||||
ExecEnd(ExecCommandEndEvent),
|
||||
McpBegin(McpToolCallBeginEvent),
|
||||
McpEnd(McpToolCallEndEvent),
|
||||
PatchEnd(PatchApplyEndEvent),
|
||||
RequestUserInput(ToolRequestUserInputParams),
|
||||
ItemStarted(ThreadItem),
|
||||
ItemCompleted(ThreadItem),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -54,8 +53,13 @@ impl InterruptManager {
|
||||
.push_back(QueuedInterrupt::ApplyPatchApproval(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_elicitation(&mut self, ev: ElicitationRequestEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::Elicitation(ev));
|
||||
pub(crate) fn push_elicitation(
|
||||
&mut self,
|
||||
request_id: AppServerRequestId,
|
||||
params: McpServerElicitationRequestParams,
|
||||
) {
|
||||
self.queue
|
||||
.push_back(QueuedInterrupt::Elicitation { request_id, params });
|
||||
}
|
||||
|
||||
pub(crate) fn push_request_permissions(&mut self, ev: RequestPermissionsEvent) {
|
||||
@@ -63,28 +67,16 @@ impl InterruptManager {
|
||||
.push_back(QueuedInterrupt::RequestPermissions(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_user_input(&mut self, ev: RequestUserInputEvent) {
|
||||
pub(crate) fn push_user_input(&mut self, ev: ToolRequestUserInputParams) {
|
||||
self.queue.push_back(QueuedInterrupt::RequestUserInput(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::ExecBegin(ev));
|
||||
pub(crate) fn push_item_started(&mut self, item: ThreadItem) {
|
||||
self.queue.push_back(QueuedInterrupt::ItemStarted(item));
|
||||
}
|
||||
|
||||
pub(crate) fn push_exec_end(&mut self, ev: ExecCommandEndEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::ExecEnd(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_mcp_begin(&mut self, ev: McpToolCallBeginEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::McpBegin(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_mcp_end(&mut self, ev: McpToolCallEndEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::McpEnd(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_patch_end(&mut self, ev: PatchApplyEndEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::PatchEnd(ev));
|
||||
pub(crate) fn push_item_completed(&mut self, item: ThreadItem) {
|
||||
self.queue.push_back(QueuedInterrupt::ItemCompleted(item));
|
||||
}
|
||||
|
||||
pub(crate) fn remove_resolved_prompt(&mut self, request: &ResolvedAppServerRequest) -> bool {
|
||||
@@ -99,14 +91,15 @@ impl InterruptManager {
|
||||
match q {
|
||||
QueuedInterrupt::ExecApproval(ev) => chat.handle_exec_approval_now(ev),
|
||||
QueuedInterrupt::ApplyPatchApproval(ev) => chat.handle_apply_patch_approval_now(ev),
|
||||
QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev),
|
||||
QueuedInterrupt::Elicitation { request_id, params } => {
|
||||
chat.handle_elicitation_request_now(request_id, params);
|
||||
}
|
||||
QueuedInterrupt::RequestPermissions(ev) => chat.handle_request_permissions_now(ev),
|
||||
QueuedInterrupt::RequestUserInput(ev) => chat.handle_request_user_input_now(ev),
|
||||
QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev),
|
||||
QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev),
|
||||
QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev),
|
||||
QueuedInterrupt::McpEnd(ev) => chat.handle_mcp_end_now(ev),
|
||||
QueuedInterrupt::PatchEnd(ev) => chat.handle_patch_apply_end_now(ev),
|
||||
QueuedInterrupt::ItemStarted(item) => chat.handle_queued_item_started_now(item),
|
||||
QueuedInterrupt::ItemCompleted(item) => {
|
||||
chat.handle_queued_item_completed_now(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,11 +116,11 @@ impl QueuedInterrupt {
|
||||
matches!(request, ResolvedAppServerRequest::FileChangeApproval { id }
|
||||
if ev.call_id == id.as_str())
|
||||
}
|
||||
QueuedInterrupt::Elicitation(ev) => {
|
||||
QueuedInterrupt::Elicitation { request_id, params } => {
|
||||
matches!(request, ResolvedAppServerRequest::McpElicitation {
|
||||
server_name,
|
||||
request_id,
|
||||
} if ev.server_name == server_name.as_str() && &ev.id == request_id)
|
||||
request_id: resolved_request_id,
|
||||
} if params.server_name == server_name.as_str() && request_id == resolved_request_id)
|
||||
}
|
||||
QueuedInterrupt::RequestPermissions(ev) => {
|
||||
matches!(request, ResolvedAppServerRequest::PermissionsApproval { id }
|
||||
@@ -135,31 +128,28 @@ impl QueuedInterrupt {
|
||||
}
|
||||
QueuedInterrupt::RequestUserInput(ev) => {
|
||||
matches!(request, ResolvedAppServerRequest::UserInput { call_id }
|
||||
if ev.call_id == call_id.as_str())
|
||||
if ev.item_id == call_id.as_str())
|
||||
}
|
||||
QueuedInterrupt::ExecBegin(_)
|
||||
| QueuedInterrupt::ExecEnd(_)
|
||||
| QueuedInterrupt::McpBegin(_)
|
||||
| QueuedInterrupt::McpEnd(_)
|
||||
| QueuedInterrupt::PatchEnd(_) => false,
|
||||
QueuedInterrupt::ItemStarted(_) | QueuedInterrupt::ItemCompleted(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_protocol::approvals::ExecApprovalRequestEvent;
|
||||
use codex_protocol::protocol::ExecCommandBeginEvent;
|
||||
use codex_protocol::protocol::ExecCommandSource;
|
||||
use codex_protocol::request_user_input::RequestUserInputEvent;
|
||||
use crate::approval_events::ExecApprovalRequestEvent;
|
||||
use codex_app_server_protocol::CommandExecutionSource;
|
||||
use codex_app_server_protocol::CommandExecutionStatus;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn user_input(call_id: &str, turn_id: &str) -> RequestUserInputEvent {
|
||||
RequestUserInputEvent {
|
||||
call_id: call_id.to_string(),
|
||||
fn user_input(call_id: &str, turn_id: &str) -> ToolRequestUserInputParams {
|
||||
ToolRequestUserInputParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
item_id: call_id.to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
questions: Vec::new(),
|
||||
}
|
||||
@@ -178,20 +168,21 @@ mod tests {
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn exec_begin(call_id: &str) -> ExecCommandBeginEvent {
|
||||
ExecCommandBeginEvent {
|
||||
call_id: call_id.to_string(),
|
||||
process_id: None,
|
||||
turn_id: "turn".to_string(),
|
||||
command: vec!["true".to_string()],
|
||||
fn command_execution(call_id: &str) -> ThreadItem {
|
||||
ThreadItem::CommandExecution {
|
||||
id: call_id.to_string(),
|
||||
command: "true".to_string(),
|
||||
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
|
||||
parsed_cmd: Vec::new(),
|
||||
source: ExecCommandSource::Agent,
|
||||
interaction_input: None,
|
||||
process_id: None,
|
||||
source: CommandExecutionSource::Agent,
|
||||
status: CommandExecutionStatus::InProgress,
|
||||
command_actions: Vec::new(),
|
||||
aggregated_output: None,
|
||||
exit_code: None,
|
||||
duration_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +202,7 @@ mod tests {
|
||||
let Some(QueuedInterrupt::RequestUserInput(remaining)) = manager.queue.front() else {
|
||||
panic!("expected remaining queued user input");
|
||||
};
|
||||
assert_eq!(remaining.call_id, "call-a");
|
||||
assert_eq!(remaining.item_id, "call-a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -237,7 +228,7 @@ mod tests {
|
||||
#[test]
|
||||
fn remove_resolved_prompt_keeps_lifecycle_events() {
|
||||
let mut manager = InterruptManager::new();
|
||||
manager.push_exec_begin(exec_begin("call"));
|
||||
manager.push_item_started(command_execution("call"));
|
||||
|
||||
assert!(
|
||||
!manager.remove_resolved_prompt(&ResolvedAppServerRequest::ExecApproval {
|
||||
@@ -248,7 +239,7 @@ mod tests {
|
||||
assert_eq!(manager.queue.len(), 1);
|
||||
assert!(matches!(
|
||||
manager.queue.front(),
|
||||
Some(QueuedInterrupt::ExecBegin(_))
|
||||
Some(QueuedInterrupt::ItemStarted(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,24 @@
|
||||
//! MCP startup state and status handling for the chat widget.
|
||||
//!
|
||||
//! This module is a mechanical extraction from `chatwidget.rs`: it keeps the
|
||||
//! buffered startup-round bookkeeping together while preserving the existing
|
||||
//! event shapes and rendering behavior.
|
||||
//! 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;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::protocol::McpStartupCompleteEvent;
|
||||
use codex_protocol::protocol::McpStartupStatus;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::protocol::McpStartupUpdateEvent;
|
||||
|
||||
use super::ChatWidget;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
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.
|
||||
@@ -169,11 +172,6 @@ impl ChatWidget {
|
||||
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!(
|
||||
@@ -236,12 +234,6 @@ impl ChatWidget {
|
||||
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,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
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::ThreadRealtimeItemAddedNotification;
|
||||
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,130 +65,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,
|
||||
event: &UserMessageEvent,
|
||||
) -> bool {
|
||||
if !self.realtime_conversation.is_live() {
|
||||
return false;
|
||||
}
|
||||
let key = Self::rendered_user_message_event_from_event(event);
|
||||
self.last_rendered_user_message_event.as_ref() != Some(&key)
|
||||
}
|
||||
|
||||
fn realtime_footer_hint_items() -> Vec<(String, String)> {
|
||||
vec![("/realtime".to_string(), "stop live voice".to_string())]
|
||||
}
|
||||
@@ -232,16 +108,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,
|
||||
realtime_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 +163,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.realtime_session_id = ev.realtime_session_id;
|
||||
self.realtime_conversation.realtime_session_id = notification.realtime_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,58 +180,51 @@ 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 {
|
||||
realtime_session_id,
|
||||
..
|
||||
} => {
|
||||
self.realtime_conversation.realtime_session_id = Some(realtime_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_item_added(
|
||||
&mut self,
|
||||
notification: ThreadRealtimeItemAddedNotification,
|
||||
) {
|
||||
if self.realtime_conversation_uses_webrtc() {
|
||||
return;
|
||||
}
|
||||
if matches!(
|
||||
notification
|
||||
.item
|
||||
.get("type")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("input_audio_buffer.speech_started" | "response.cancelled")
|
||||
) {
|
||||
self.interrupt_realtime_audio_playback();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -408,7 +275,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();
|
||||
@@ -480,7 +347,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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -73,19 +73,19 @@ impl ChatWidget {
|
||||
let items: Vec<SkillsToggleItem> = self
|
||||
.skills_all
|
||||
.iter()
|
||||
.map(|skill| {
|
||||
let core_skill = protocol_skill_to_core(skill);
|
||||
.filter_map(|skill| {
|
||||
let core_skill = protocol_skill_to_core(skill)?;
|
||||
let display_name = skill_display_name(&core_skill);
|
||||
let description = skill_description(&core_skill).to_string();
|
||||
let name = core_skill.name.clone();
|
||||
let path = core_skill.path_to_skills_md;
|
||||
SkillsToggleItem {
|
||||
Some(SkillsToggleItem {
|
||||
name: display_name,
|
||||
skill_name: name,
|
||||
description,
|
||||
enabled: skill.enabled,
|
||||
path,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -186,12 +186,23 @@ fn enabled_skills_for_mentions(skills: &[ProtocolSkillMetadata]) -> Vec<SkillMet
|
||||
skills
|
||||
.iter()
|
||||
.filter(|skill| skill.enabled)
|
||||
.map(protocol_skill_to_core)
|
||||
.filter_map(protocol_skill_to_core)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata {
|
||||
SkillMetadata {
|
||||
fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> Option<SkillMetadata> {
|
||||
let scope = serde_json::to_value(skill.scope)
|
||||
.and_then(serde_json::from_value)
|
||||
.inspect_err(|err| {
|
||||
tracing::warn!(
|
||||
skill_name = %skill.name,
|
||||
%err,
|
||||
"Failed to map app-server skill scope"
|
||||
);
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
Some(SkillMetadata {
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
short_description: skill.short_description.clone(),
|
||||
@@ -222,8 +233,8 @@ fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata {
|
||||
}),
|
||||
policy: None,
|
||||
path_to_skills_md: skill.path.clone(),
|
||||
scope: skill.scope,
|
||||
}
|
||||
scope,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn collect_tool_mentions(
|
||||
|
||||
@@ -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() => {
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/status_and_layout.rs
|
||||
expression: combined
|
||||
---
|
||||
|
||||
• Here is the result.
|
||||
@@ -33,7 +33,6 @@ const TERMINAL_TITLE_ACTION_REQUIRED_PREFIX_HIDDEN: &str = "[ . ] Action Require
|
||||
pub(super) enum TerminalTitleStatusKind {
|
||||
Working,
|
||||
WaitingForBackgroundTerminal,
|
||||
Undoing,
|
||||
#[default]
|
||||
Thinking,
|
||||
}
|
||||
@@ -711,7 +710,6 @@ impl ChatWidget {
|
||||
}
|
||||
TerminalTitleStatusKind::Working => "Working".to_string(),
|
||||
TerminalTitleStatusKind::WaitingForBackgroundTerminal => "Waiting".to_string(),
|
||||
TerminalTitleStatusKind::Undoing => "Undoing".to_string(),
|
||||
TerminalTitleStatusKind::Thinking => "Thinking".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -748,9 +746,7 @@ impl ChatWidget {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.mcp_startup_status.is_some()
|
||||
|| self.bottom_pane.is_task_running()
|
||||
|| self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing
|
||||
self.mcp_startup_status.is_some() || self.bottom_pane.is_task_running()
|
||||
}
|
||||
|
||||
pub(super) fn should_animate_terminal_title_spinner(&self) -> bool {
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
//! 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::*;
|
||||
pub(super) use crate::app_command::AppCommand;
|
||||
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::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;
|
||||
@@ -25,6 +28,8 @@ 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::TokenUsage;
|
||||
pub(super) use crate::token_usage::TokenUsageInfo;
|
||||
pub(super) use crate::tui::FrameRequester;
|
||||
pub(super) use assert_matches::assert_matches;
|
||||
pub(super) use codex_app_server_protocol::AddCreditsNudgeCreditType;
|
||||
@@ -34,16 +39,20 @@ 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 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::ExecPolicyAmendment;
|
||||
pub(super) use codex_app_server_protocol::FileUpdateChange;
|
||||
pub(super) use codex_app_server_protocol::GuardianApprovalReview;
|
||||
pub(super) use codex_app_server_protocol::GuardianApprovalReviewAction as AppServerGuardianApprovalReviewAction;
|
||||
@@ -73,6 +82,7 @@ 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 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 AppServerPatchApplyStatus;
|
||||
pub(super) use codex_app_server_protocol::PatchChangeKind;
|
||||
pub(super) use codex_app_server_protocol::PermissionsRequestApprovalParams as AppServerPermissionsRequestApprovalParams;
|
||||
@@ -85,16 +95,26 @@ 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::ToolRequestUserInputOption;
|
||||
pub(super) use codex_app_server_protocol::ToolRequestUserInputParams;
|
||||
pub(super) use codex_app_server_protocol::ToolRequestUserInputQuestion;
|
||||
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;
|
||||
pub(super) use codex_app_server_protocol::TurnStartedNotification;
|
||||
pub(super) use codex_app_server_protocol::TurnStatus as AppServerTurnStatus;
|
||||
pub(super) use codex_app_server_protocol::UserInput;
|
||||
pub(super) use codex_app_server_protocol::UserInput as AppServerUserInput;
|
||||
pub(super) use codex_app_server_protocol::WarningNotification;
|
||||
pub(super) use codex_config::AppRequirementToml;
|
||||
@@ -116,16 +136,18 @@ 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::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;
|
||||
pub(super) use codex_protocol::config_types::ServiceTier;
|
||||
pub(super) use codex_protocol::config_types::Settings;
|
||||
pub(super) use codex_protocol::items::AgentMessageContent;
|
||||
pub(super) use codex_protocol::items::AgentMessageItem;
|
||||
pub(super) use codex_protocol::items::PlanItem;
|
||||
pub(super) use codex_protocol::items::TurnItem;
|
||||
pub(super) use codex_protocol::items::UserMessageItem;
|
||||
pub(super) use codex_protocol::models::FileSystemPermissions;
|
||||
pub(super) use codex_protocol::models::MessagePhase;
|
||||
pub(super) use codex_protocol::models::NetworkPermissions;
|
||||
@@ -139,73 +161,8 @@ 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::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;
|
||||
pub(super) use codex_protocol::request_user_input::RequestUserInputQuestionOption;
|
||||
pub(super) use codex_protocol::user_input::TextElement;
|
||||
pub(super) use codex_protocol::user_input::UserInput;
|
||||
pub(super) use codex_terminal_detection::Multiplexer;
|
||||
pub(super) use codex_terminal_detection::TerminalInfo;
|
||||
pub(super) use codex_terminal_detection::TerminalName;
|
||||
@@ -264,7 +221,6 @@ macro_rules! assert_chatwidget_snapshot {
|
||||
|
||||
mod app_server;
|
||||
mod approval_requests;
|
||||
mod background_events;
|
||||
mod composer_submission;
|
||||
mod exec_flow;
|
||||
mod goal_menu;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,12 +23,8 @@ async fn exec_approval_emits_proposed_command_and_decision_history() {
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-short".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(ev),
|
||||
});
|
||||
handle_exec_approval_request(&mut chat, "sub-short", ev);
|
||||
|
||||
let proposed_cells = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
@@ -126,21 +122,23 @@ 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!(
|
||||
request.additional_permissions,
|
||||
Some(codex_protocol::models::AdditionalPermissionProfile {
|
||||
network: Some(NetworkPermissions {
|
||||
Some(AppServerAdditionalPermissionProfile {
|
||||
network: Some(AppServerAdditionalNetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
file_system: Some(FileSystemPermissions::from_read_write_roots(
|
||||
Some(vec![read_path]),
|
||||
Some(vec![write_path]),
|
||||
)),
|
||||
file_system: Some(AppServerAdditionalFileSystemPermissions {
|
||||
read: Some(vec![read_path]),
|
||||
write: Some(vec![write_path]),
|
||||
glob_scan_max_depth: None,
|
||||
entries: None,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -192,9 +190,10 @@ fn app_server_request_permissions_preserves_file_system_permissions() {
|
||||
async fn exec_approval_uses_approval_id_when_present() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-short".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
handle_exec_approval_request(
|
||||
&mut chat,
|
||||
"sub-short",
|
||||
ExecApprovalRequestEvent {
|
||||
call_id: "call-parent".into(),
|
||||
approval_id: Some("approval-subcommand".into()),
|
||||
turn_id: "turn-short".into(),
|
||||
@@ -208,21 +207,23 @@ async fn exec_approval_uses_approval_id_when_present() {
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
|
||||
let mut found = false;
|
||||
while let Ok(app_ev) = rx.try_recv() {
|
||||
if let AppEvent::SubmitThreadOp {
|
||||
op: AppCommand::ExecApproval { id, decision, .. },
|
||||
op: Op::ExecApproval { id, decision, .. },
|
||||
..
|
||||
} = 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;
|
||||
}
|
||||
@@ -248,12 +249,8 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-multi".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(ev_multi),
|
||||
});
|
||||
handle_exec_approval_request(&mut chat, "sub-multi", ev_multi);
|
||||
let proposed_multi = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
proposed_multi.is_empty(),
|
||||
@@ -301,12 +298,8 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-long".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(ev_long),
|
||||
});
|
||||
handle_exec_approval_request(&mut chat, "sub-long", ev_long);
|
||||
let proposed_long = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
proposed_long.is_empty(),
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_event_updates_status_header() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "bg-1".into(),
|
||||
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
|
||||
message: "Waiting for `vim`".to_string(),
|
||||
}),
|
||||
});
|
||||
|
||||
assert!(chat.bottom_pane.status_indicator_visible());
|
||||
assert_eq!(chat.current_status.header, "Waiting for `vim`");
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
}
|
||||
@@ -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,9 +14,10 @@ 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 {
|
||||
session_id: conversation_id,
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
@@ -19,17 +27,14 @@ async fn submission_preserves_text_elements_and_local_images() {
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(configured);
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
let placeholder = "[Image #1]";
|
||||
@@ -59,7 +64,7 @@ async fn submission_preserves_text_elements_and_local_images() {
|
||||
items[1],
|
||||
UserInput::Text {
|
||||
text: text.clone(),
|
||||
text_elements: text_elements.clone(),
|
||||
text_elements: text_elements.clone().into_iter().map(Into::into).collect(),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -92,29 +97,31 @@ 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 {
|
||||
session_id: conversation_id,
|
||||
}
|
||||
.into();
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
@@ -124,17 +131,14 @@ async fn submission_includes_configured_permission_profile() {
|
||||
permission_profile: expected_permission_profile.clone(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(configured);
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
chat.bottom_pane.set_composer_text(
|
||||
@@ -150,7 +154,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]
|
||||
@@ -159,13 +163,15 @@ 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 {
|
||||
session_id: conversation_id,
|
||||
let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed {
|
||||
network: PermissionProfileNetworkPermissions { enabled: false },
|
||||
file_system: PermissionProfileFileSystemPermissions::Unrestricted,
|
||||
}
|
||||
.into();
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
@@ -175,17 +181,14 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() {
|
||||
permission_profile: expected_permission_profile.clone(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(configured);
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
chat.bottom_pane
|
||||
@@ -198,7 +201,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]
|
||||
@@ -207,9 +210,10 @@ 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 {
|
||||
session_id: conversation_id,
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
@@ -219,17 +223,14 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(configured);
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
let remote_url = "https://example.com/remote.png".to_string();
|
||||
@@ -256,7 +257,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi
|
||||
assert_eq!(
|
||||
items[0],
|
||||
UserInput::Image {
|
||||
image_url: remote_url.clone(),
|
||||
url: remote_url.clone(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -269,7 +270,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi
|
||||
items[2],
|
||||
UserInput::Text {
|
||||
text: text.clone(),
|
||||
text_elements: text_elements.clone(),
|
||||
text_elements: text_elements.clone().into_iter().map(Into::into).collect(),
|
||||
}
|
||||
);
|
||||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #2]"));
|
||||
@@ -303,9 +304,10 @@ 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 {
|
||||
session_id: conversation_id,
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
@@ -315,17 +317,14 @@ async fn enter_with_only_remote_images_submits_user_turn() {
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(configured);
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
let remote_url = "https://example.com/remote-only.png".to_string();
|
||||
@@ -341,7 +340,7 @@ async fn enter_with_only_remote_images_submits_user_turn() {
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![UserInput::Image {
|
||||
image_url: remote_url.clone(),
|
||||
url: remote_url.clone(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(summary, None);
|
||||
@@ -369,9 +368,10 @@ 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 {
|
||||
session_id: conversation_id,
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
@@ -381,17 +381,14 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() {
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(configured);
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
let remote_url = "https://example.com/remote-only.png".to_string();
|
||||
@@ -410,9 +407,10 @@ 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 {
|
||||
session_id: conversation_id,
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
@@ -422,17 +420,14 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() {
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(configured);
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
let remote_url = "https://example.com/remote-only.png".to_string();
|
||||
@@ -451,9 +446,10 @@ 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 {
|
||||
session_id: conversation_id,
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
@@ -463,17 +459,14 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() {
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(configured);
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
let remote_url = "https://example.com/remote-only.png".to_string();
|
||||
@@ -495,9 +488,10 @@ 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 {
|
||||
session_id: conversation_id,
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
@@ -507,17 +501,14 @@ async fn submission_prefers_selected_duplicate_skill_path() {
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(configured);
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
let repo_skill_path = test_path_buf("/tmp/repo/figma/SKILL.md").abs();
|
||||
@@ -531,7 +522,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(),
|
||||
@@ -541,7 +532,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(),
|
||||
},
|
||||
]));
|
||||
|
||||
@@ -738,15 +729,7 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() {
|
||||
);
|
||||
chat.refresh_pending_input_preview();
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "interrupt".into(),
|
||||
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_turn_interrupted(&mut chat, "turn-1");
|
||||
|
||||
assert_eq!(chat.bottom_pane.composer_text(), "Implement the plan.");
|
||||
assert!(chat.queued_user_messages.is_empty());
|
||||
@@ -1129,15 +1112,15 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_user_message_event_from_inputs_matches_flattened_user_message_shape() {
|
||||
fn user_message_display_from_inputs_matches_flattened_user_message_shape() {
|
||||
let local_image = PathBuf::from("/tmp/local.png");
|
||||
let rendered = ChatWidget::rendered_user_message_event_from_inputs(&[
|
||||
let rendered = ChatWidget::user_message_display_from_inputs(&[
|
||||
UserInput::Text {
|
||||
text: "hello ".to_string(),
|
||||
text_elements: vec![TextElement::new((0..5).into(), /*placeholder*/ None)],
|
||||
text_elements: vec![TextElement::new((0..5).into(), /*placeholder*/ None).into()],
|
||||
},
|
||||
UserInput::Image {
|
||||
image_url: "https://example.com/remote.png".to_string(),
|
||||
url: "https://example.com/remote.png".to_string(),
|
||||
},
|
||||
UserInput::LocalImage {
|
||||
path: local_image.clone(),
|
||||
@@ -1152,13 +1135,13 @@ fn rendered_user_message_event_from_inputs_matches_flattened_user_message_shape(
|
||||
},
|
||||
UserInput::Text {
|
||||
text: "world".to_string(),
|
||||
text_elements: vec![TextElement::new((0..5).into(), Some("planet".to_string()))],
|
||||
text_elements: vec![TextElement::new((0..5).into(), Some("planet".to_string())).into()],
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
rendered,
|
||||
ChatWidget::rendered_user_message_event_from_parts(
|
||||
ChatWidget::user_message_display_from_parts(
|
||||
"hello world".to_string(),
|
||||
vec![
|
||||
TextElement::new((0..5).into(), Some("hello".to_string())),
|
||||
@@ -1184,16 +1167,8 @@ async fn interrupt_restores_queued_messages_into_composer() {
|
||||
.push_back(UserMessage::from("second queued".to_string()).into());
|
||||
chat.refresh_pending_input_preview();
|
||||
|
||||
// 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 {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}),
|
||||
});
|
||||
// Deliver an interrupted turn notification as if Esc was pressed.
|
||||
handle_turn_interrupted(&mut chat, "turn-1");
|
||||
|
||||
// Composer should now contain the queued messages joined by newlines, in order.
|
||||
assert_eq!(
|
||||
@@ -1226,15 +1201,7 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() {
|
||||
.push_back(UserMessage::from("second queued".to_string()).into());
|
||||
chat.refresh_pending_input_preview();
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_turn_interrupted(&mut chat, "turn-1");
|
||||
|
||||
assert_eq!(
|
||||
chat.bottom_pane.composer_text(),
|
||||
|
||||
@@ -20,12 +20,8 @@ async fn exec_approval_emits_proposed_command_and_decision_history() {
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-short".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(ev),
|
||||
});
|
||||
handle_exec_approval_request(&mut chat, "sub-short", ev);
|
||||
|
||||
let proposed_cells = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
@@ -89,9 +85,10 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() {
|
||||
async fn exec_approval_uses_approval_id_when_present() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-short".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
handle_exec_approval_request(
|
||||
&mut chat,
|
||||
"sub-short",
|
||||
ExecApprovalRequestEvent {
|
||||
call_id: "call-parent".into(),
|
||||
approval_id: Some("approval-subcommand".into()),
|
||||
turn_id: "turn-short".into(),
|
||||
@@ -105,21 +102,23 @@ async fn exec_approval_uses_approval_id_when_present() {
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
|
||||
let mut found = false;
|
||||
while let Ok(app_ev) = rx.try_recv() {
|
||||
if let AppEvent::SubmitThreadOp {
|
||||
op: AppCommand::ExecApproval { id, decision, .. },
|
||||
op: Op::ExecApproval { id, decision, .. },
|
||||
..
|
||||
} = 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;
|
||||
}
|
||||
@@ -146,12 +145,8 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-multi".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(ev_multi),
|
||||
});
|
||||
handle_exec_approval_request(&mut chat, "sub-multi", ev_multi);
|
||||
let proposed_multi = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
proposed_multi.is_empty(),
|
||||
@@ -201,12 +196,8 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-long".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(ev_long),
|
||||
});
|
||||
handle_exec_approval_request(&mut chat, "sub-long", ev_long);
|
||||
let proposed_long = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
proposed_long.is_empty(),
|
||||
@@ -356,28 +347,26 @@ async fn exec_end_without_begin_uses_event_command() {
|
||||
"-lc".to_string(),
|
||||
"echo orphaned".to_string(),
|
||||
];
|
||||
let parsed_cmd = codex_shell_command::parse_command::parse_command(&command);
|
||||
let cwd = AbsolutePathBuf::current_dir().expect("current dir");
|
||||
chat.handle_codex_event(Event {
|
||||
id: "call-orphan".to_string(),
|
||||
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: "call-orphan".to_string(),
|
||||
process_id: None,
|
||||
turn_id: "turn-1".to_string(),
|
||||
command,
|
||||
let command_actions = codex_shell_command::parse_command::parse_command(&command)
|
||||
.into_iter()
|
||||
.map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd))
|
||||
.collect();
|
||||
let cwd = chat.config.cwd.clone();
|
||||
handle_exec_end(
|
||||
&mut chat,
|
||||
AppServerThreadItem::CommandExecution {
|
||||
id: "call-orphan".to_string(),
|
||||
command: codex_shell_command::parse_command::shlex_join(&command),
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
process_id: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
interaction_input: None,
|
||||
stdout: "done".to_string(),
|
||||
stderr: String::new(),
|
||||
aggregated_output: "done".to_string(),
|
||||
exit_code: 0,
|
||||
duration: std::time::Duration::from_millis(5),
|
||||
formatted_output: "done".to_string(),
|
||||
status: CoreExecCommandStatus::Completed,
|
||||
}),
|
||||
});
|
||||
status: AppServerCommandExecutionStatus::Completed,
|
||||
command_actions,
|
||||
aggregated_output: Some("done".to_string()),
|
||||
exit_code: Some(0),
|
||||
duration_ms: Some(5),
|
||||
},
|
||||
);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1, "expected finalized exec cell to flush");
|
||||
@@ -618,14 +607,7 @@ async fn unified_exec_interaction_after_task_complete_is_suppressed() {
|
||||
/*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,
|
||||
);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "call-1".to_string(),
|
||||
msg: EventMsg::TerminalInteraction(TerminalInteractionEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
process_id: "proc-1".to_string(),
|
||||
stdin: "ls\n".to_string(),
|
||||
}),
|
||||
});
|
||||
terminal_interaction(&mut chat, "call-1", "proc-1", "ls\n");
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
@@ -637,30 +619,13 @@ async fn unified_exec_interaction_after_task_complete_is_suppressed() {
|
||||
#[tokio::test]
|
||||
async fn unified_exec_wait_after_final_agent_message_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
begin_unified_exec_startup(&mut chat, "call-wait", "proc-1", "cargo test -p codex-core");
|
||||
terminal_interaction(&mut chat, "call-wait-stdin", "proc-1", "");
|
||||
|
||||
complete_assistant_message(&mut chat, "msg-1", "Final response.", /*phase*/ None);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: Some("Final response.".into()),
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let combined = cells
|
||||
@@ -673,15 +638,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() {
|
||||
#[tokio::test]
|
||||
async fn unified_exec_wait_before_streamed_agent_message_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
begin_unified_exec_startup(
|
||||
&mut chat,
|
||||
@@ -691,22 +648,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() {
|
||||
);
|
||||
terminal_interaction(&mut chat, "call-wait-stream-stdin", "proc-1", "");
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
delta: "Streaming response.".into(),
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_agent_message_delta(&mut chat, "Streaming response.");
|
||||
handle_turn_completed(&mut chat, "turn-wait-1", /*duration_ms*/ None);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let combined = cells
|
||||
@@ -719,15 +662,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() {
|
||||
#[tokio::test]
|
||||
async fn final_worked_for_uses_cumulative_turn_duration_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
let exec = begin_exec_with_source(
|
||||
&mut chat,
|
||||
@@ -743,16 +678,7 @@ async fn final_worked_for_uses_cumulative_turn_duration_snapshot() {
|
||||
"Final response.",
|
||||
Some(MessagePhase::FinalAnswer),
|
||||
);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: Some("Final response.".to_string()),
|
||||
completed_at: None,
|
||||
duration_ms: Some(125_000),
|
||||
time_to_first_token_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_turn_completed(&mut chat, "turn-1", Some(125_000));
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let combined = cells
|
||||
@@ -777,11 +703,7 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() {
|
||||
recent_chunks: Vec::new(),
|
||||
});
|
||||
|
||||
chat.on_terminal_interaction(TerminalInteractionEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
process_id: "proc-1".to_string(),
|
||||
stdin: String::new(),
|
||||
});
|
||||
terminal_interaction(&mut chat, "call-1", "proc-1", "");
|
||||
|
||||
assert!(chat.active_cell.is_none());
|
||||
assert_eq!(
|
||||
@@ -815,16 +737,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() {
|
||||
assert_eq!(status.header(), "Waiting for background terminal");
|
||||
assert_eq!(status.details(), Some("just fix"));
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-wait-1".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_turn_completed(&mut chat, "turn-wait-3", /*duration_ms*/ None);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let combined = cells
|
||||
@@ -896,16 +809,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() {
|
||||
.collect::<String>();
|
||||
assert_chatwidget_snapshot!("unified_exec_non_empty_then_empty_active", active_combined);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-wait-3".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
||||
|
||||
let post_cells = drain_insert_history(&mut rx);
|
||||
let mut combined = pre_cells
|
||||
@@ -928,13 +832,7 @@ async fn view_image_tool_call_adds_history_cell() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let image_path = chat.config.cwd.join("example.png");
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-image".into(),
|
||||
msg: EventMsg::ViewImageToolCall(ViewImageToolCallEvent {
|
||||
call_id: "call-image".into(),
|
||||
path: image_path,
|
||||
}),
|
||||
});
|
||||
handle_view_image_tool_call(&mut chat, "call-image", image_path);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1, "expected a single history cell");
|
||||
@@ -946,16 +844,12 @@ async fn view_image_tool_call_adds_history_cell() {
|
||||
async fn image_generation_call_adds_history_cell() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-image-generation".into(),
|
||||
msg: EventMsg::ImageGenerationEnd(ImageGenerationEndEvent {
|
||||
call_id: "call-image-generation".into(),
|
||||
status: "completed".into(),
|
||||
revised_prompt: Some("A tiny blue square".into()),
|
||||
result: "Zm9v".into(),
|
||||
saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()),
|
||||
}),
|
||||
});
|
||||
handle_image_generation_end(
|
||||
&mut chat,
|
||||
"call-image-generation",
|
||||
Some("A tiny blue square".into()),
|
||||
Some(test_path_buf("/tmp/ig-1.png").abs()),
|
||||
);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1, "expected a single history cell");
|
||||
@@ -1049,9 +943,10 @@ 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 {
|
||||
session_id: conversation_id,
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
@@ -1061,28 +956,17 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() {
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(configured);
|
||||
drain_insert_history(&mut rx);
|
||||
while op_rx.try_recv().is_ok() {}
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("!echo hi".to_string(), Vec::new(), Vec::new());
|
||||
@@ -1103,15 +987,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() {
|
||||
async fn user_message_during_user_shell_command_is_queued_not_steered() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
let begin = begin_exec_with_source(
|
||||
&mut chat,
|
||||
"user-shell-sleep",
|
||||
@@ -1128,16 +1004,13 @@ async fn user_message_during_user_shell_command_is_queued_not_steered() {
|
||||
assert_eq!(chat.queued_user_message_texts(), vec!["hi".to_string()]);
|
||||
|
||||
end_exec(&mut chat, begin, "", "", /*exit_code*/ 0);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-complete".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: Some("done".to_string()),
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
}),
|
||||
});
|
||||
complete_assistant_message(
|
||||
&mut chat,
|
||||
"msg-done",
|
||||
"done",
|
||||
Some(MessagePhase::FinalAnswer),
|
||||
);
|
||||
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
||||
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => assert_eq!(
|
||||
@@ -1174,7 +1047,7 @@ async fn disabled_slash_command_while_task_running_snapshot() {
|
||||
//
|
||||
// Snapshot test: command approval modal
|
||||
//
|
||||
// Synthesizes a Codex ExecApprovalRequest event to trigger the approval modal
|
||||
// Synthesizes an exec approval request to trigger the approval modal
|
||||
// and snapshots the visual output using the ratatui TestBackend.
|
||||
#[tokio::test]
|
||||
async fn approval_modal_exec_snapshot() -> anyhow::Result<()> {
|
||||
@@ -1184,7 +1057,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(),
|
||||
@@ -1196,20 +1069,14 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> {
|
||||
"this is a test reason such as one that would be produced by the model".into(),
|
||||
),
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
|
||||
"echo".into(),
|
||||
"hello".into(),
|
||||
"world".into(),
|
||||
])),
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment {
|
||||
command: vec!["echo".into(), "hello".into(), "world".into()],
|
||||
}),
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-approve".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(ev),
|
||||
});
|
||||
handle_exec_approval_request(&mut chat, "sub-approve", ev);
|
||||
// Render to a fixed-size test terminal and snapshot.
|
||||
// Call desired_height first and use that exact height for rendering.
|
||||
let width = 100;
|
||||
@@ -1247,7 +1114,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(),
|
||||
@@ -1257,20 +1124,14 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> {
|
||||
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
|
||||
reason: None,
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
|
||||
"echo".into(),
|
||||
"hello".into(),
|
||||
"world".into(),
|
||||
])),
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment {
|
||||
command: vec!["echo".into(), "hello".into(), "world".into()],
|
||||
}),
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-approve-noreason".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(ev),
|
||||
});
|
||||
handle_exec_approval_request(&mut chat, "sub-approve-noreason", ev);
|
||||
|
||||
let width = 100;
|
||||
let height = chat.desired_height(width);
|
||||
@@ -1297,7 +1158,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];
|
||||
@@ -1309,16 +1170,12 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot()
|
||||
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
|
||||
reason: None,
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment { command }),
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-approve-multiline-trunc".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(ev),
|
||||
});
|
||||
handle_exec_approval_request(&mut chat, "sub-approve-multiline-trunc", ev);
|
||||
|
||||
let width = 100;
|
||||
let height = chat.desired_height(width);
|
||||
@@ -1345,7 +1202,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();
|
||||
@@ -1362,10 +1219,7 @@ async fn approval_modal_patch_snapshot() -> anyhow::Result<()> {
|
||||
reason: Some("The model wants to apply changes".into()),
|
||||
grant_root: Some(PathBuf::from("/tmp")),
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-approve-patch".into(),
|
||||
msg: EventMsg::ApplyPatchApprovalRequest(ev),
|
||||
});
|
||||
handle_apply_patch_approval_request(&mut chat, "sub-approve-patch", ev);
|
||||
|
||||
// Render at the widget's desired height and snapshot.
|
||||
let height = chat.desired_height(/*width*/ 80);
|
||||
@@ -1390,15 +1244,7 @@ async fn interrupt_preserves_unified_exec_processes() {
|
||||
begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6");
|
||||
assert_eq!(chat.unified_exec_processes.len(), 2);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_turn_interrupted(&mut chat, "turn-1");
|
||||
|
||||
assert_eq!(chat.unified_exec_processes.len(), 2);
|
||||
|
||||
@@ -1425,28 +1271,12 @@ async fn interrupt_preserves_unified_exec_processes() {
|
||||
async fn interrupt_preserves_unified_exec_wait_streak_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
let begin = begin_unified_exec_startup(&mut chat, "call-1", "process-1", "just fix");
|
||||
terminal_interaction(&mut chat, "call-1a", "process-1", "");
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_turn_interrupted(&mut chat, "turn-1");
|
||||
|
||||
end_exec(&mut chat, begin, "", "", /*exit_code*/ 0);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
@@ -1467,16 +1297,7 @@ async fn turn_complete_keeps_unified_exec_processes() {
|
||||
begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6");
|
||||
assert_eq!(chat.unified_exec_processes.len(), 2);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
||||
|
||||
assert_eq!(chat.unified_exec_processes.len(), 2);
|
||||
|
||||
@@ -1518,10 +1339,7 @@ async fn apply_patch_events_emit_history_cells() {
|
||||
reason: None,
|
||||
grant_root: None,
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::ApplyPatchApprovalRequest(ev),
|
||||
});
|
||||
handle_apply_patch_approval_request(&mut chat, "s1", ev);
|
||||
assert!(
|
||||
drain_insert_history(&mut rx).is_empty(),
|
||||
"expected approval request to surface via modal without emitting history cells"
|
||||
@@ -1535,16 +1353,7 @@ async fn apply_patch_events_emit_history_cells() {
|
||||
content: "hello\n".to_string(),
|
||||
},
|
||||
);
|
||||
let begin = PatchApplyBeginEvent {
|
||||
call_id: "c1".into(),
|
||||
turn_id: "turn-c1".into(),
|
||||
auto_approved: true,
|
||||
changes: changes2,
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::PatchApplyBegin(begin),
|
||||
});
|
||||
handle_patch_apply_begin(&mut chat, "c1", "turn-c1", changes2);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(!cells.is_empty(), "expected apply block cell to be sent");
|
||||
let blob = lines_to_single_string(cells.last().unwrap());
|
||||
@@ -1561,19 +1370,13 @@ async fn apply_patch_events_emit_history_cells() {
|
||||
content: "hello\n".to_string(),
|
||||
},
|
||||
);
|
||||
let end = PatchApplyEndEvent {
|
||||
call_id: "c1".into(),
|
||||
turn_id: "turn-c1".into(),
|
||||
stdout: "ok\n".into(),
|
||||
stderr: String::new(),
|
||||
success: true,
|
||||
changes: end_changes,
|
||||
status: CorePatchApplyStatus::Completed,
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::PatchApplyEnd(end),
|
||||
});
|
||||
handle_patch_apply_end(
|
||||
&mut chat,
|
||||
"c1",
|
||||
"turn-c1",
|
||||
end_changes,
|
||||
AppServerPatchApplyStatus::Completed,
|
||||
);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
cells.is_empty(),
|
||||
@@ -1592,16 +1395,17 @@ async fn apply_patch_manual_approval_adjusts_header() {
|
||||
content: "hello\n".to_string(),
|
||||
},
|
||||
);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
|
||||
handle_apply_patch_approval_request(
|
||||
&mut chat,
|
||||
"s1",
|
||||
ApplyPatchApprovalRequestEvent {
|
||||
call_id: "c1".into(),
|
||||
turn_id: "turn-c1".into(),
|
||||
changes: proposed_changes,
|
||||
reason: None,
|
||||
grant_root: None,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
let mut apply_changes = HashMap::new();
|
||||
@@ -1611,15 +1415,7 @@ async fn apply_patch_manual_approval_adjusts_header() {
|
||||
content: "hello\n".to_string(),
|
||||
},
|
||||
);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||||
call_id: "c1".into(),
|
||||
turn_id: "turn-c1".into(),
|
||||
auto_approved: false,
|
||||
changes: apply_changes,
|
||||
}),
|
||||
});
|
||||
handle_patch_apply_begin(&mut chat, "c1", "turn-c1", apply_changes);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(!cells.is_empty(), "expected apply block cell to be sent");
|
||||
@@ -1641,16 +1437,17 @@ async fn apply_patch_manual_flow_snapshot() {
|
||||
content: "hello\n".to_string(),
|
||||
},
|
||||
);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
|
||||
handle_apply_patch_approval_request(
|
||||
&mut chat,
|
||||
"s1",
|
||||
ApplyPatchApprovalRequestEvent {
|
||||
call_id: "c1".into(),
|
||||
turn_id: "turn-c1".into(),
|
||||
changes: proposed_changes,
|
||||
reason: Some("Manual review required".into()),
|
||||
grant_root: None,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
let history_before_apply = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
history_before_apply.is_empty(),
|
||||
@@ -1664,15 +1461,7 @@ async fn apply_patch_manual_flow_snapshot() {
|
||||
content: "hello\n".to_string(),
|
||||
},
|
||||
);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||||
call_id: "c1".into(),
|
||||
turn_id: "turn-c1".into(),
|
||||
auto_approved: false,
|
||||
changes: apply_changes,
|
||||
}),
|
||||
});
|
||||
handle_patch_apply_begin(&mut chat, "c1", "turn-c1", apply_changes);
|
||||
let approved_lines = drain_insert_history(&mut rx)
|
||||
.pop()
|
||||
.expect("approved patch cell");
|
||||
@@ -1701,10 +1490,7 @@ async fn apply_patch_approval_sends_op_with_call_id() {
|
||||
reason: None,
|
||||
grant_root: None,
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-123".into(),
|
||||
msg: EventMsg::ApplyPatchApprovalRequest(ev),
|
||||
});
|
||||
handle_apply_patch_approval_request(&mut chat, "sub-123", ev);
|
||||
|
||||
// Approve via key press 'y'
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
@@ -1713,12 +1499,15 @@ async fn apply_patch_approval_sends_op_with_call_id() {
|
||||
let mut found = false;
|
||||
while let Ok(app_ev) = rx.try_recv() {
|
||||
if let AppEvent::SubmitThreadOp {
|
||||
op: AppCommand::PatchApproval { id, decision },
|
||||
op: Op::PatchApproval { id, decision },
|
||||
..
|
||||
} = 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;
|
||||
}
|
||||
@@ -1736,23 +1525,24 @@ async fn apply_patch_full_flow_integration_like() {
|
||||
PathBuf::from("pkg.rs"),
|
||||
FileChange::Add { content: "".into() },
|
||||
);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-xyz".into(),
|
||||
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
|
||||
handle_apply_patch_approval_request(
|
||||
&mut chat,
|
||||
"sub-xyz",
|
||||
ApplyPatchApprovalRequestEvent {
|
||||
call_id: "call-1".into(),
|
||||
turn_id: "turn-call-1".into(),
|
||||
changes,
|
||||
reason: None,
|
||||
grant_root: None,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// 2) User approves via 'y' and App receives a thread-scoped op
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
let mut maybe_op: Option<Op> = None;
|
||||
while let Ok(app_ev) = rx.try_recv() {
|
||||
if let AppEvent::SubmitThreadOp { op, .. } = app_ev {
|
||||
maybe_op = Some(op.into_core());
|
||||
maybe_op = Some(op);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1766,7 +1556,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:?}"),
|
||||
}
|
||||
@@ -1777,32 +1570,19 @@ async fn apply_patch_full_flow_integration_like() {
|
||||
PathBuf::from("pkg.rs"),
|
||||
FileChange::Add { content: "".into() },
|
||||
);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-xyz".into(),
|
||||
msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||||
call_id: "call-1".into(),
|
||||
turn_id: "turn-call-1".into(),
|
||||
auto_approved: false,
|
||||
changes: changes2,
|
||||
}),
|
||||
});
|
||||
handle_patch_apply_begin(&mut chat, "call-1", "turn-call-1", changes2);
|
||||
let mut end_changes = HashMap::new();
|
||||
end_changes.insert(
|
||||
PathBuf::from("pkg.rs"),
|
||||
FileChange::Add { content: "".into() },
|
||||
);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-xyz".into(),
|
||||
msg: EventMsg::PatchApplyEnd(PatchApplyEndEvent {
|
||||
call_id: "call-1".into(),
|
||||
turn_id: "turn-call-1".into(),
|
||||
stdout: String::from("ok"),
|
||||
stderr: String::new(),
|
||||
success: true,
|
||||
changes: end_changes,
|
||||
status: CorePatchApplyStatus::Completed,
|
||||
}),
|
||||
});
|
||||
handle_patch_apply_end(
|
||||
&mut chat,
|
||||
"call-1",
|
||||
"turn-call-1",
|
||||
end_changes,
|
||||
AppServerPatchApplyStatus::Completed,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1812,7 +1592,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();
|
||||
@@ -1820,16 +1600,17 @@ async fn apply_patch_untrusted_shows_approval_modal() -> anyhow::Result<()> {
|
||||
PathBuf::from("a.rs"),
|
||||
FileChange::Add { content: "".into() },
|
||||
);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-1".into(),
|
||||
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
|
||||
handle_apply_patch_approval_request(
|
||||
&mut chat,
|
||||
"sub-1",
|
||||
ApplyPatchApprovalRequestEvent {
|
||||
call_id: "call-1".into(),
|
||||
turn_id: "turn-call-1".into(),
|
||||
changes,
|
||||
reason: None,
|
||||
grant_root: None,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Render and ensure the approval modal title is present
|
||||
let area = Rect::new(0, 0, 80, 12);
|
||||
@@ -1863,7 +1644,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();
|
||||
@@ -1874,16 +1655,17 @@ async fn apply_patch_request_omits_diff_summary_from_modal() -> anyhow::Result<(
|
||||
content: "line one\nline two\n".into(),
|
||||
},
|
||||
);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-apply".into(),
|
||||
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
|
||||
handle_apply_patch_approval_request(
|
||||
&mut chat,
|
||||
"sub-apply",
|
||||
ApplyPatchApprovalRequestEvent {
|
||||
call_id: "call-apply".into(),
|
||||
turn_id: "turn-apply".into(),
|
||||
changes,
|
||||
reason: None,
|
||||
grant_root: None,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
assert!(
|
||||
drain_insert_history(&mut rx).is_empty(),
|
||||
|
||||
@@ -23,10 +23,7 @@ fn auto_review_denial_event() -> GuardianAssessmentEvent {
|
||||
async fn auto_review_denials_popup_lists_stored_auto_review_denials() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "guardian-assessment".into(),
|
||||
msg: EventMsg::GuardianAssessment(auto_review_denial_event()),
|
||||
});
|
||||
chat.on_guardian_assessment(auto_review_denial_event());
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
chat.open_auto_review_denials_popup();
|
||||
@@ -40,10 +37,7 @@ async fn approving_recent_denial_emits_structured_core_op_once() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "guardian-assessment".into(),
|
||||
msg: EventMsg::GuardianAssessment(auto_review_denial_event()),
|
||||
});
|
||||
chat.on_guardian_assessment(auto_review_denial_event());
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
chat.approve_recent_auto_review_denial(thread_id, "auto-review-recent-1".to_string());
|
||||
@@ -52,7 +46,7 @@ async fn approving_recent_denial_emits_structured_core_op_once() {
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::SubmitThreadOp {
|
||||
thread_id: submitted_thread_id,
|
||||
op: AppCommand::ApproveGuardianDeniedAction { event }
|
||||
op: Op::ApproveGuardianDeniedAction { event }
|
||||
}) if submitted_thread_id == thread_id
|
||||
&& event.id == "auto-review-recent-1"
|
||||
&& event.status == GuardianAssessmentStatus::Denied
|
||||
@@ -75,39 +69,28 @@ async fn guardian_denied_exec_renders_warning_and_denied_request() {
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
};
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "guardian-in-progress".into(),
|
||||
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
|
||||
id: "guardian-1".into(),
|
||||
target_item_id: Some("guardian-target-1".into()),
|
||||
turn_id: "turn-1".into(),
|
||||
status: GuardianAssessmentStatus::InProgress,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: None,
|
||||
decision_source: None,
|
||||
action: action.clone(),
|
||||
}),
|
||||
chat.on_guardian_assessment(GuardianAssessmentEvent {
|
||||
id: "guardian-1".into(),
|
||||
target_item_id: Some("guardian-target-1".into()),
|
||||
turn_id: "turn-1".into(),
|
||||
status: GuardianAssessmentStatus::InProgress,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: None,
|
||||
decision_source: None,
|
||||
action: action.clone(),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "guardian-warning".into(),
|
||||
msg: EventMsg::GuardianWarning(WarningEvent {
|
||||
message: "Automatic approval review denied (risk: high): The planned action would transmit the full contents of a workspace source file (`core/src/codex.rs`) to `https://example.com`, which is an external and untrusted endpoint.".into(),
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "guardian-assessment".into(),
|
||||
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
|
||||
id: "guardian-1".into(),
|
||||
target_item_id: Some("guardian-target-1".into()),
|
||||
turn_id: "turn-1".into(),
|
||||
status: GuardianAssessmentStatus::Denied,
|
||||
risk_level: Some(GuardianRiskLevel::High),
|
||||
user_authorization: Some(GuardianUserAuthorization::Low),
|
||||
rationale: Some("Would exfiltrate local source code.".into()),
|
||||
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
|
||||
action,
|
||||
}),
|
||||
chat.on_warning("Automatic approval review denied (risk: high): The planned action would transmit the full contents of a workspace source file (`core/src/codex.rs`) to `https://example.com`, which is an external and untrusted endpoint.");
|
||||
chat.on_guardian_assessment(GuardianAssessmentEvent {
|
||||
id: "guardian-1".into(),
|
||||
target_item_id: Some("guardian-target-1".into()),
|
||||
turn_id: "turn-1".into(),
|
||||
status: GuardianAssessmentStatus::Denied,
|
||||
risk_level: Some(GuardianRiskLevel::High),
|
||||
user_authorization: Some(GuardianUserAuthorization::Low),
|
||||
rationale: Some("Would exfiltrate local source code.".into()),
|
||||
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
|
||||
action,
|
||||
});
|
||||
|
||||
let width: u16 = 140;
|
||||
@@ -140,23 +123,20 @@ async fn guardian_approved_exec_renders_approved_request() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.show_welcome_banner = false;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "guardian-assessment".into(),
|
||||
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
|
||||
id: "thread:child-thread:guardian-1".into(),
|
||||
target_item_id: Some("guardian-approved-target".into()),
|
||||
turn_id: "turn-1".into(),
|
||||
status: GuardianAssessmentStatus::Approved,
|
||||
risk_level: Some(GuardianRiskLevel::Low),
|
||||
user_authorization: Some(GuardianUserAuthorization::High),
|
||||
rationale: Some("Narrowly scoped to the requested file.".into()),
|
||||
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
|
||||
action: GuardianAssessmentAction::Command {
|
||||
source: GuardianCommandSource::Shell,
|
||||
command: "rm -f /tmp/guardian-approved.sqlite".to_string(),
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
},
|
||||
}),
|
||||
chat.on_guardian_assessment(GuardianAssessmentEvent {
|
||||
id: "thread:child-thread:guardian-1".into(),
|
||||
target_item_id: Some("guardian-approved-target".into()),
|
||||
turn_id: "turn-1".into(),
|
||||
status: GuardianAssessmentStatus::Approved,
|
||||
risk_level: Some(GuardianRiskLevel::Low),
|
||||
user_authorization: Some(GuardianUserAuthorization::High),
|
||||
rationale: Some("Narrowly scoped to the requested file.".into()),
|
||||
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
|
||||
action: GuardianAssessmentAction::Command {
|
||||
source: GuardianCommandSource::Shell,
|
||||
command: "rm -f /tmp/guardian-approved.sqlite".to_string(),
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
},
|
||||
});
|
||||
|
||||
let width: u16 = 120;
|
||||
@@ -199,19 +179,16 @@ async fn guardian_approved_request_permissions_renders_request_summary() {
|
||||
},
|
||||
};
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "guardian-in-progress".into(),
|
||||
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
|
||||
id: "guardian-request-permissions".into(),
|
||||
target_item_id: None,
|
||||
turn_id: "turn-1".into(),
|
||||
status: GuardianAssessmentStatus::InProgress,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: None,
|
||||
decision_source: None,
|
||||
action: action.clone(),
|
||||
}),
|
||||
chat.on_guardian_assessment(GuardianAssessmentEvent {
|
||||
id: "guardian-request-permissions".into(),
|
||||
target_item_id: None,
|
||||
turn_id: "turn-1".into(),
|
||||
status: GuardianAssessmentStatus::InProgress,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: None,
|
||||
decision_source: None,
|
||||
action: action.clone(),
|
||||
});
|
||||
|
||||
let status = chat
|
||||
@@ -224,19 +201,16 @@ async fn guardian_approved_request_permissions_renders_request_summary() {
|
||||
Some("permission request: Need write access for generated report assets.")
|
||||
);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "guardian-assessment".into(),
|
||||
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
|
||||
id: "guardian-request-permissions".into(),
|
||||
target_item_id: None,
|
||||
turn_id: "turn-1".into(),
|
||||
status: GuardianAssessmentStatus::Approved,
|
||||
risk_level: Some(GuardianRiskLevel::Low),
|
||||
user_authorization: Some(GuardianUserAuthorization::High),
|
||||
rationale: Some("Request is scoped to report output.".into()),
|
||||
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
|
||||
action,
|
||||
}),
|
||||
chat.on_guardian_assessment(GuardianAssessmentEvent {
|
||||
id: "guardian-request-permissions".into(),
|
||||
target_item_id: None,
|
||||
turn_id: "turn-1".into(),
|
||||
status: GuardianAssessmentStatus::Approved,
|
||||
risk_level: Some(GuardianRiskLevel::Low),
|
||||
user_authorization: Some(GuardianUserAuthorization::High),
|
||||
rationale: Some("Request is scoped to report output.".into()),
|
||||
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
|
||||
action,
|
||||
});
|
||||
|
||||
let width: u16 = 110;
|
||||
@@ -275,43 +249,30 @@ async fn guardian_timed_out_exec_renders_warning_and_timed_out_request() {
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
};
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "guardian-in-progress".into(),
|
||||
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
|
||||
id: "guardian-1".into(),
|
||||
target_item_id: Some("guardian-target-1".into()),
|
||||
turn_id: "turn-1".into(),
|
||||
status: GuardianAssessmentStatus::InProgress,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: None,
|
||||
decision_source: None,
|
||||
action: action.clone(),
|
||||
}),
|
||||
chat.on_guardian_assessment(GuardianAssessmentEvent {
|
||||
id: "guardian-1".into(),
|
||||
target_item_id: Some("guardian-target-1".into()),
|
||||
turn_id: "turn-1".into(),
|
||||
status: GuardianAssessmentStatus::InProgress,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: None,
|
||||
decision_source: None,
|
||||
action: action.clone(),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "guardian-warning".into(),
|
||||
msg: EventMsg::GuardianWarning(WarningEvent {
|
||||
message: "Automatic approval review timed out while evaluating the requested approval."
|
||||
.into(),
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "guardian-assessment".into(),
|
||||
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
|
||||
id: "guardian-1".into(),
|
||||
target_item_id: Some("guardian-target-1".into()),
|
||||
turn_id: "turn-1".into(),
|
||||
status: GuardianAssessmentStatus::TimedOut,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: Some(
|
||||
"Automatic approval review timed out while evaluating the requested approval."
|
||||
.into(),
|
||||
),
|
||||
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
|
||||
action,
|
||||
}),
|
||||
chat.on_warning("Automatic approval review timed out while evaluating the requested approval.");
|
||||
chat.on_guardian_assessment(GuardianAssessmentEvent {
|
||||
id: "guardian-1".into(),
|
||||
target_item_id: Some("guardian-target-1".into()),
|
||||
turn_id: "turn-1".into(),
|
||||
status: GuardianAssessmentStatus::TimedOut,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: Some(
|
||||
"Automatic approval review timed out while evaluating the requested approval.".into(),
|
||||
),
|
||||
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
|
||||
action,
|
||||
});
|
||||
|
||||
let width: u16 = 140;
|
||||
@@ -541,23 +502,20 @@ async fn guardian_parallel_reviews_render_aggregate_status_snapshot() {
|
||||
("guardian-1", "rm -rf '/tmp/guardian target 1'"),
|
||||
("guardian-2", "rm -rf '/tmp/guardian target 2'"),
|
||||
] {
|
||||
chat.handle_codex_event(Event {
|
||||
id: format!("event-{id}"),
|
||||
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
|
||||
id: id.to_string(),
|
||||
target_item_id: Some(format!("{id}-target")),
|
||||
turn_id: "turn-1".to_string(),
|
||||
status: GuardianAssessmentStatus::InProgress,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: None,
|
||||
decision_source: None,
|
||||
action: GuardianAssessmentAction::Command {
|
||||
source: GuardianCommandSource::Shell,
|
||||
command: command.to_string(),
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
},
|
||||
}),
|
||||
chat.on_guardian_assessment(GuardianAssessmentEvent {
|
||||
id: id.to_string(),
|
||||
target_item_id: Some(format!("{id}-target")),
|
||||
turn_id: "turn-1".to_string(),
|
||||
status: GuardianAssessmentStatus::InProgress,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: None,
|
||||
decision_source: None,
|
||||
action: GuardianAssessmentAction::Command {
|
||||
source: GuardianCommandSource::Shell,
|
||||
command: command.to_string(),
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -573,59 +531,50 @@ async fn guardian_parallel_reviews_keep_remaining_review_visible_after_denial()
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.on_task_started();
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "event-guardian-1".into(),
|
||||
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
|
||||
id: "guardian-1".to_string(),
|
||||
target_item_id: Some("guardian-1-target".to_string()),
|
||||
turn_id: "turn-1".to_string(),
|
||||
status: GuardianAssessmentStatus::InProgress,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: None,
|
||||
decision_source: None,
|
||||
action: GuardianAssessmentAction::Command {
|
||||
source: GuardianCommandSource::Shell,
|
||||
command: "rm -rf '/tmp/guardian target 1'".to_string(),
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
},
|
||||
}),
|
||||
chat.on_guardian_assessment(GuardianAssessmentEvent {
|
||||
id: "guardian-1".to_string(),
|
||||
target_item_id: Some("guardian-1-target".to_string()),
|
||||
turn_id: "turn-1".to_string(),
|
||||
status: GuardianAssessmentStatus::InProgress,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: None,
|
||||
decision_source: None,
|
||||
action: GuardianAssessmentAction::Command {
|
||||
source: GuardianCommandSource::Shell,
|
||||
command: "rm -rf '/tmp/guardian target 1'".to_string(),
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
},
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "event-guardian-2".into(),
|
||||
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
|
||||
id: "guardian-2".to_string(),
|
||||
target_item_id: Some("guardian-2-target".to_string()),
|
||||
turn_id: "turn-1".to_string(),
|
||||
status: GuardianAssessmentStatus::InProgress,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: None,
|
||||
decision_source: None,
|
||||
action: GuardianAssessmentAction::Command {
|
||||
source: GuardianCommandSource::Shell,
|
||||
command: "rm -rf '/tmp/guardian target 2'".to_string(),
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
},
|
||||
}),
|
||||
chat.on_guardian_assessment(GuardianAssessmentEvent {
|
||||
id: "guardian-2".to_string(),
|
||||
target_item_id: Some("guardian-2-target".to_string()),
|
||||
turn_id: "turn-1".to_string(),
|
||||
status: GuardianAssessmentStatus::InProgress,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: None,
|
||||
decision_source: None,
|
||||
action: GuardianAssessmentAction::Command {
|
||||
source: GuardianCommandSource::Shell,
|
||||
command: "rm -rf '/tmp/guardian target 2'".to_string(),
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
},
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "event-guardian-1-denied".into(),
|
||||
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
|
||||
id: "guardian-1".to_string(),
|
||||
target_item_id: Some("guardian-1-target".to_string()),
|
||||
turn_id: "turn-1".to_string(),
|
||||
status: GuardianAssessmentStatus::Denied,
|
||||
risk_level: Some(GuardianRiskLevel::High),
|
||||
user_authorization: Some(GuardianUserAuthorization::Low),
|
||||
rationale: Some("Would delete important data.".to_string()),
|
||||
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
|
||||
action: GuardianAssessmentAction::Command {
|
||||
source: GuardianCommandSource::Shell,
|
||||
command: "rm -rf '/tmp/guardian target 1'".to_string(),
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
},
|
||||
}),
|
||||
chat.on_guardian_assessment(GuardianAssessmentEvent {
|
||||
id: "guardian-1".to_string(),
|
||||
target_item_id: Some("guardian-1-target".to_string()),
|
||||
turn_id: "turn-1".to_string(),
|
||||
status: GuardianAssessmentStatus::Denied,
|
||||
risk_level: Some(GuardianRiskLevel::High),
|
||||
user_authorization: Some(GuardianUserAuthorization::Low),
|
||||
rationale: Some("Would delete important data.".to_string()),
|
||||
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
|
||||
action: GuardianAssessmentAction::Command {
|
||||
source: GuardianCommandSource::Shell,
|
||||
command: "rm -rf '/tmp/guardian target 1'".to_string(),
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
},
|
||||
});
|
||||
|
||||
assert_eq!(chat.current_status.header, "Reviewing approval request");
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ pub(super) async fn make_chatwidget_manual(
|
||||
goal_status_active_turn_started_at: None,
|
||||
external_editor_state: ExternalEditorState::Closed,
|
||||
realtime_conversation: RealtimeConversationUiState::default(),
|
||||
last_rendered_user_message_event: None,
|
||||
last_rendered_user_message_display: None,
|
||||
last_non_retry_error: None,
|
||||
};
|
||||
widget.set_model(&resolved_model);
|
||||
@@ -483,35 +483,453 @@ pub(super) fn make_token_info(total_tokens: i64, context_window: i64) -> TokenUs
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_id(chat: &ChatWidget) -> String {
|
||||
chat.thread_id.map(|id| id.to_string()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn token_usage_breakdown(usage: TokenUsage) -> codex_app_server_protocol::TokenUsageBreakdown {
|
||||
codex_app_server_protocol::TokenUsageBreakdown {
|
||||
total_tokens: usage.total_tokens,
|
||||
input_tokens: usage.input_tokens,
|
||||
cached_input_tokens: usage.cached_input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
reasoning_output_tokens: usage.reasoning_output_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_token_count(chat: &mut ChatWidget, info: Option<TokenUsageInfo>) {
|
||||
match info {
|
||||
Some(info) => {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ThreadTokenUsageUpdated(
|
||||
codex_app_server_protocol::ThreadTokenUsageUpdatedNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: chat
|
||||
.last_turn_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "turn-1".to_string()),
|
||||
token_usage: codex_app_server_protocol::ThreadTokenUsage {
|
||||
total: token_usage_breakdown(info.total_token_usage),
|
||||
last: token_usage_breakdown(info.last_token_usage),
|
||||
model_context_window: info.model_context_window,
|
||||
},
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
None => chat.set_token_info(/*info*/ None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_error(
|
||||
chat: &mut ChatWidget,
|
||||
message: impl Into<String>,
|
||||
codex_error_info: Option<CodexErrorInfo>,
|
||||
) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::Error(ErrorNotification {
|
||||
error: AppServerTurnError {
|
||||
message: message.into(),
|
||||
codex_error_info,
|
||||
additional_details: None,
|
||||
},
|
||||
will_retry: false,
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: chat
|
||||
.last_turn_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "turn-1".to_string()),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_stream_error(
|
||||
chat: &mut ChatWidget,
|
||||
message: impl Into<String>,
|
||||
additional_details: Option<String>,
|
||||
) {
|
||||
handle_stream_error_with_replay(chat, message, additional_details, /*replay_kind*/ None);
|
||||
}
|
||||
|
||||
pub(super) fn handle_stream_error_with_replay(
|
||||
chat: &mut ChatWidget,
|
||||
message: impl Into<String>,
|
||||
additional_details: Option<String>,
|
||||
replay_kind: Option<ReplayKind>,
|
||||
) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::Error(ErrorNotification {
|
||||
error: AppServerTurnError {
|
||||
message: message.into(),
|
||||
codex_error_info: None,
|
||||
additional_details,
|
||||
},
|
||||
will_retry: true,
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: chat
|
||||
.last_turn_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "turn-1".to_string()),
|
||||
}),
|
||||
replay_kind,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_warning(chat: &mut ChatWidget, message: impl Into<String>) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::Warning(WarningNotification {
|
||||
thread_id: Some(thread_id(chat)),
|
||||
message: message.into(),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_model_verification(
|
||||
chat: &mut ChatWidget,
|
||||
verifications: Vec<AppServerModelVerification>,
|
||||
) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ModelVerification(ModelVerificationNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: chat
|
||||
.last_turn_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "turn-1".to_string()),
|
||||
verifications,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_agent_message_delta(chat: &mut ChatWidget, delta: impl Into<String>) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::AgentMessageDelta(
|
||||
codex_app_server_protocol::AgentMessageDeltaNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: chat
|
||||
.last_turn_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "turn-1".to_string()),
|
||||
item_id: "msg-1".to_string(),
|
||||
delta: delta.into(),
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_agent_reasoning_delta(chat: &mut ChatWidget, delta: impl Into<String>) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: chat
|
||||
.last_turn_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "turn-1".to_string()),
|
||||
item_id: "reasoning-1".to_string(),
|
||||
delta: delta.into(),
|
||||
summary_index: 0,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_agent_reasoning_final(chat: &mut ChatWidget) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: chat
|
||||
.last_turn_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "turn-1".to_string()),
|
||||
item: AppServerThreadItem::Reasoning {
|
||||
id: "reasoning-1".to_string(),
|
||||
summary: Vec::new(),
|
||||
content: Vec::new(),
|
||||
},
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_entered_review_mode(chat: &mut ChatWidget, review: impl Into<String>) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ItemStarted(ItemStartedNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: chat
|
||||
.last_turn_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "turn-1".to_string()),
|
||||
item: AppServerThreadItem::EnteredReviewMode {
|
||||
id: "review-start".to_string(),
|
||||
review: review.into(),
|
||||
},
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn replay_entered_review_mode(chat: &mut ChatWidget, review: impl Into<String>) {
|
||||
chat.replay_thread_item(
|
||||
AppServerThreadItem::EnteredReviewMode {
|
||||
id: "review-start".to_string(),
|
||||
review: review.into(),
|
||||
},
|
||||
"turn-1".to_string(),
|
||||
ReplayKind::ThreadSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_exited_review_mode(chat: &mut ChatWidget) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: chat
|
||||
.last_turn_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "turn-1".to_string()),
|
||||
item: AppServerThreadItem::ExitedReviewMode {
|
||||
id: "review-end".to_string(),
|
||||
review: String::new(),
|
||||
},
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_exec_approval_request(
|
||||
chat: &mut ChatWidget,
|
||||
id: impl Into<String>,
|
||||
event: ExecApprovalRequestEvent,
|
||||
) {
|
||||
chat.on_exec_approval_request(id.into(), event);
|
||||
}
|
||||
|
||||
pub(super) fn handle_apply_patch_approval_request(
|
||||
chat: &mut ChatWidget,
|
||||
id: impl Into<String>,
|
||||
event: ApplyPatchApprovalRequestEvent,
|
||||
) {
|
||||
chat.on_apply_patch_approval_request(id.into(), event);
|
||||
}
|
||||
|
||||
fn file_update_changes_from_tui(changes: HashMap<PathBuf, FileChange>) -> Vec<FileUpdateChange> {
|
||||
changes
|
||||
.into_iter()
|
||||
.map(|(path, change)| {
|
||||
let (kind, diff) = match change {
|
||||
FileChange::Add { content } => (PatchChangeKind::Add, content),
|
||||
FileChange::Delete { content } => (PatchChangeKind::Delete, content),
|
||||
FileChange::Update {
|
||||
unified_diff,
|
||||
move_path,
|
||||
} => (PatchChangeKind::Update { move_path }, unified_diff),
|
||||
};
|
||||
FileUpdateChange {
|
||||
path: path.display().to_string(),
|
||||
kind,
|
||||
diff,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn handle_patch_apply_begin(
|
||||
chat: &mut ChatWidget,
|
||||
call_id: impl Into<String>,
|
||||
turn_id: impl Into<String>,
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ItemStarted(ItemStartedNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: turn_id.into(),
|
||||
item: AppServerThreadItem::FileChange {
|
||||
id: call_id.into(),
|
||||
changes: file_update_changes_from_tui(changes),
|
||||
status: AppServerPatchApplyStatus::InProgress,
|
||||
},
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_patch_apply_end(
|
||||
chat: &mut ChatWidget,
|
||||
call_id: impl Into<String>,
|
||||
turn_id: impl Into<String>,
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
status: AppServerPatchApplyStatus,
|
||||
) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: turn_id.into(),
|
||||
item: AppServerThreadItem::FileChange {
|
||||
id: call_id.into(),
|
||||
changes: file_update_changes_from_tui(changes),
|
||||
status,
|
||||
},
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_view_image_tool_call(
|
||||
chat: &mut ChatWidget,
|
||||
call_id: impl Into<String>,
|
||||
path: AbsolutePathBuf,
|
||||
) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: "turn-1".to_string(),
|
||||
item: AppServerThreadItem::ImageView {
|
||||
id: call_id.into(),
|
||||
path,
|
||||
},
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_image_generation_end(
|
||||
chat: &mut ChatWidget,
|
||||
call_id: impl Into<String>,
|
||||
revised_prompt: Option<String>,
|
||||
saved_path: Option<AbsolutePathBuf>,
|
||||
) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: "turn-1".to_string(),
|
||||
item: AppServerThreadItem::ImageGeneration {
|
||||
id: call_id.into(),
|
||||
status: "completed".to_string(),
|
||||
revised_prompt,
|
||||
result: String::new(),
|
||||
saved_path,
|
||||
},
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn replay_user_message_inputs(
|
||||
chat: &mut ChatWidget,
|
||||
item_id: &str,
|
||||
content: Vec<AppServerUserInput>,
|
||||
replay_kind: ReplayKind,
|
||||
) {
|
||||
chat.replay_thread_item(
|
||||
AppServerThreadItem::UserMessage {
|
||||
id: item_id.to_string(),
|
||||
content,
|
||||
},
|
||||
"turn-1".to_string(),
|
||||
replay_kind,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn replay_user_message_text(
|
||||
chat: &mut ChatWidget,
|
||||
item_id: &str,
|
||||
text: impl Into<String>,
|
||||
replay_kind: ReplayKind,
|
||||
) {
|
||||
replay_user_message_inputs(
|
||||
chat,
|
||||
item_id,
|
||||
vec![AppServerUserInput::Text {
|
||||
text: text.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
replay_kind,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn replay_agent_message(
|
||||
chat: &mut ChatWidget,
|
||||
item_id: &str,
|
||||
text: impl Into<String>,
|
||||
replay_kind: ReplayKind,
|
||||
) {
|
||||
chat.replay_thread_item(
|
||||
AppServerThreadItem::AgentMessage {
|
||||
id: item_id.to_string(),
|
||||
text: text.into(),
|
||||
phase: Some(MessagePhase::FinalAnswer),
|
||||
memory_citation: None,
|
||||
},
|
||||
"turn-1".to_string(),
|
||||
replay_kind,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn replay_turn_started(chat: &mut ChatWidget, replay_kind: ReplayKind) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::TurnStarted(TurnStartedNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn: app_server_turn(
|
||||
"turn-1",
|
||||
AppServerTurnStatus::InProgress,
|
||||
/*duration_ms*/ None,
|
||||
/*error*/ None,
|
||||
),
|
||||
}),
|
||||
Some(replay_kind),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn replay_agent_message_delta(
|
||||
chat: &mut ChatWidget,
|
||||
delta: impl Into<String>,
|
||||
replay_kind: ReplayKind,
|
||||
) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::AgentMessageDelta(
|
||||
codex_app_server_protocol::AgentMessageDeltaNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: "turn-1".to_string(),
|
||||
item_id: "msg-1".to_string(),
|
||||
delta: delta.into(),
|
||||
},
|
||||
),
|
||||
Some(replay_kind),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Small helpers to tersely drive exec begin/end and snapshot active cell ---
|
||||
pub(super) fn begin_exec_with_source(
|
||||
chat: &mut ChatWidget,
|
||||
call_id: &str,
|
||||
raw_cmd: &str,
|
||||
source: ExecCommandSource,
|
||||
) -> ExecCommandBeginEvent {
|
||||
) -> AppServerThreadItem {
|
||||
// Build the full command vec and parse it using core's parser,
|
||||
// then convert to protocol variants for the event payload.
|
||||
let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()];
|
||||
let parsed_cmd: Vec<ParsedCommand> =
|
||||
codex_shell_command::parse_command::parse_command(&command);
|
||||
let cwd = AbsolutePathBuf::current_dir().expect("current dir");
|
||||
let interaction_input = None;
|
||||
let event = ExecCommandBeginEvent {
|
||||
call_id: call_id.to_string(),
|
||||
process_id: None,
|
||||
turn_id: "turn-1".to_string(),
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
source,
|
||||
interaction_input,
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
let command_actions = codex_shell_command::parse_command::parse_command(&command)
|
||||
.into_iter()
|
||||
.map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd))
|
||||
.collect();
|
||||
let item = AppServerThreadItem::CommandExecution {
|
||||
id: call_id.to_string(),
|
||||
msg: EventMsg::ExecCommandBegin(event.clone()),
|
||||
});
|
||||
event
|
||||
command: codex_shell_command::parse_command::shlex_join(&command),
|
||||
cwd: chat.config.cwd.clone(),
|
||||
process_id: None,
|
||||
source,
|
||||
status: AppServerCommandExecutionStatus::InProgress,
|
||||
command_actions,
|
||||
aggregated_output: None,
|
||||
exit_code: None,
|
||||
duration_ms: None,
|
||||
};
|
||||
handle_exec_begin(chat, item.clone());
|
||||
item
|
||||
}
|
||||
|
||||
pub(super) fn begin_unified_exec_startup(
|
||||
@@ -519,24 +937,36 @@ pub(super) fn begin_unified_exec_startup(
|
||||
call_id: &str,
|
||||
process_id: &str,
|
||||
raw_cmd: &str,
|
||||
) -> ExecCommandBeginEvent {
|
||||
) -> AppServerThreadItem {
|
||||
let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()];
|
||||
let cwd = AbsolutePathBuf::current_dir().expect("current dir");
|
||||
let event = ExecCommandBeginEvent {
|
||||
call_id: call_id.to_string(),
|
||||
process_id: Some(process_id.to_string()),
|
||||
turn_id: "turn-1".to_string(),
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd: Vec::new(),
|
||||
source: ExecCommandSource::UnifiedExecStartup,
|
||||
interaction_input: None,
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
let item = AppServerThreadItem::CommandExecution {
|
||||
id: call_id.to_string(),
|
||||
msg: EventMsg::ExecCommandBegin(event.clone()),
|
||||
});
|
||||
event
|
||||
command: codex_shell_command::parse_command::shlex_join(&command),
|
||||
cwd: chat.config.cwd.clone(),
|
||||
process_id: Some(process_id.to_string()),
|
||||
source: ExecCommandSource::UnifiedExecStartup,
|
||||
status: AppServerCommandExecutionStatus::InProgress,
|
||||
command_actions: Vec::new(),
|
||||
aggregated_output: None,
|
||||
exit_code: None,
|
||||
duration_ms: None,
|
||||
};
|
||||
handle_exec_begin(chat, item.clone());
|
||||
item
|
||||
}
|
||||
|
||||
pub(super) fn handle_exec_begin(chat: &mut ChatWidget, item: AppServerThreadItem) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ItemStarted(ItemStartedNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: chat
|
||||
.last_turn_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "turn-1".to_string()),
|
||||
item,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn terminal_interaction(
|
||||
@@ -545,14 +975,21 @@ pub(super) fn terminal_interaction(
|
||||
process_id: &str,
|
||||
stdin: &str,
|
||||
) {
|
||||
chat.handle_codex_event(Event {
|
||||
id: call_id.to_string(),
|
||||
msg: EventMsg::TerminalInteraction(TerminalInteractionEvent {
|
||||
call_id: call_id.to_string(),
|
||||
process_id: process_id.to_string(),
|
||||
stdin: stdin.to_string(),
|
||||
}),
|
||||
});
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::TerminalInteraction(
|
||||
codex_app_server_protocol::TerminalInteractionNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: chat
|
||||
.last_turn_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "turn-1".to_string()),
|
||||
item_id: call_id.to_string(),
|
||||
process_id: process_id.to_string(),
|
||||
stdin: stdin.to_string(),
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn complete_assistant_message(
|
||||
@@ -561,21 +998,19 @@ pub(super) fn complete_assistant_message(
|
||||
text: &str,
|
||||
phase: Option<MessagePhase>,
|
||||
) {
|
||||
chat.handle_codex_event(Event {
|
||||
id: format!("raw-{item_id}"),
|
||||
msg: EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||
thread_id: ThreadId::new(),
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
||||
thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
item: TurnItem::AgentMessage(AgentMessageItem {
|
||||
item: AppServerThreadItem::AgentMessage {
|
||||
id: item_id.to_string(),
|
||||
content: vec![AgentMessageContent::Text {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
text: text.to_string(),
|
||||
phase,
|
||||
memory_citation: None,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn pending_steer(text: &str) -> PendingSteer {
|
||||
@@ -605,30 +1040,101 @@ pub(super) fn complete_user_message_for_inputs(
|
||||
item_id: &str,
|
||||
content: Vec<UserInput>,
|
||||
) {
|
||||
chat.handle_codex_event(Event {
|
||||
id: format!("raw-{item_id}"),
|
||||
msg: EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||
thread_id: ThreadId::new(),
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
||||
thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
item: TurnItem::UserMessage(UserMessageItem {
|
||||
item: AppServerThreadItem::UserMessage {
|
||||
id: item_id.to_string(),
|
||||
content,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn app_server_turn(
|
||||
turn_id: &str,
|
||||
status: AppServerTurnStatus,
|
||||
duration_ms: Option<i64>,
|
||||
error: Option<AppServerTurnError>,
|
||||
) -> AppServerTurn {
|
||||
AppServerTurn {
|
||||
id: turn_id.to_string(),
|
||||
items: Vec::new(),
|
||||
status,
|
||||
error,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
duration_ms,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_turn_started(chat: &mut ChatWidget, turn_id: &str) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::TurnStarted(TurnStartedNotification {
|
||||
thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(),
|
||||
turn: app_server_turn(
|
||||
turn_id,
|
||||
AppServerTurnStatus::InProgress,
|
||||
/*duration_ms*/ None,
|
||||
/*error*/ None,
|
||||
),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_turn_completed(
|
||||
chat: &mut ChatWidget,
|
||||
turn_id: &str,
|
||||
duration_ms: Option<i64>,
|
||||
) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
||||
thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(),
|
||||
turn: app_server_turn(
|
||||
turn_id,
|
||||
AppServerTurnStatus::Completed,
|
||||
duration_ms,
|
||||
/*error*/ None,
|
||||
),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_turn_interrupted(chat: &mut ChatWidget, turn_id: &str) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
||||
thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(),
|
||||
turn: app_server_turn(
|
||||
turn_id,
|
||||
AppServerTurnStatus::Interrupted,
|
||||
/*duration_ms*/ None,
|
||||
/*error*/ None,
|
||||
),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_budget_limited_turn(chat: &mut ChatWidget, turn_id: &str) {
|
||||
chat.budget_limited_turn_ids.insert(turn_id.to_string());
|
||||
handle_turn_interrupted(chat, turn_id);
|
||||
}
|
||||
|
||||
pub(super) fn begin_exec(
|
||||
chat: &mut ChatWidget,
|
||||
call_id: &str,
|
||||
raw_cmd: &str,
|
||||
) -> ExecCommandBeginEvent {
|
||||
) -> AppServerThreadItem {
|
||||
begin_exec_with_source(chat, call_id, raw_cmd, ExecCommandSource::Agent)
|
||||
}
|
||||
|
||||
pub(super) fn end_exec(
|
||||
chat: &mut ChatWidget,
|
||||
begin_event: ExecCommandBeginEvent,
|
||||
begin_item: AppServerThreadItem,
|
||||
stdout: &str,
|
||||
stderr: &str,
|
||||
exit_code: i32,
|
||||
@@ -638,40 +1144,51 @@ pub(super) fn end_exec(
|
||||
} else {
|
||||
format!("{stdout}{stderr}")
|
||||
};
|
||||
let ExecCommandBeginEvent {
|
||||
call_id,
|
||||
turn_id,
|
||||
let AppServerThreadItem::CommandExecution {
|
||||
id,
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
source,
|
||||
interaction_input,
|
||||
process_id,
|
||||
} = begin_event;
|
||||
chat.handle_codex_event(Event {
|
||||
id: call_id.clone(),
|
||||
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id,
|
||||
process_id,
|
||||
turn_id,
|
||||
source,
|
||||
command_actions,
|
||||
..
|
||||
} = begin_item
|
||||
else {
|
||||
panic!("expected command execution item");
|
||||
};
|
||||
handle_exec_end(
|
||||
chat,
|
||||
AppServerThreadItem::CommandExecution {
|
||||
id,
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
process_id,
|
||||
source,
|
||||
interaction_input,
|
||||
stdout: stdout.to_string(),
|
||||
stderr: stderr.to_string(),
|
||||
aggregated_output: aggregated.clone(),
|
||||
exit_code,
|
||||
duration: std::time::Duration::from_millis(5),
|
||||
formatted_output: aggregated,
|
||||
status: if exit_code == 0 {
|
||||
CoreExecCommandStatus::Completed
|
||||
AppServerCommandExecutionStatus::Completed
|
||||
} else {
|
||||
CoreExecCommandStatus::Failed
|
||||
AppServerCommandExecutionStatus::Failed
|
||||
},
|
||||
command_actions,
|
||||
aggregated_output: (!aggregated.is_empty()).then_some(aggregated),
|
||||
exit_code: Some(exit_code),
|
||||
duration_ms: Some(5),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_exec_end(chat: &mut ChatWidget, item: AppServerThreadItem) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: chat
|
||||
.last_turn_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "turn-1".to_string()),
|
||||
item,
|
||||
}),
|
||||
});
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn active_blob(chat: &ChatWidget) -> String {
|
||||
@@ -1001,36 +1518,85 @@ pub(super) fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_hook_started(chat: &mut ChatWidget, run: AppServerHookRunSummary) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::HookStarted(AppServerHookStartedNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: None,
|
||||
run,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_hook_completed(chat: &mut ChatWidget, run: AppServerHookRunSummary) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::HookCompleted(AppServerHookCompletedNotification {
|
||||
thread_id: thread_id(chat),
|
||||
turn_id: None,
|
||||
run,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn hook_run(
|
||||
run_id: &str,
|
||||
event_name: codex_app_server_protocol::HookEventName,
|
||||
status: codex_app_server_protocol::HookRunStatus,
|
||||
status_message: &str,
|
||||
entries: Vec<codex_app_server_protocol::HookOutputEntry>,
|
||||
) -> codex_app_server_protocol::HookRunSummary {
|
||||
codex_app_server_protocol::HookRunSummary {
|
||||
id: run_id.to_string(),
|
||||
event_name,
|
||||
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_app_server_protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
status,
|
||||
status_message: Some(status_message.to_string()),
|
||||
started_at: 1,
|
||||
completed_at: matches!(
|
||||
status,
|
||||
codex_app_server_protocol::HookRunStatus::Completed
|
||||
| codex_app_server_protocol::HookRunStatus::Failed
|
||||
| codex_app_server_protocol::HookRunStatus::Blocked
|
||||
| codex_app_server_protocol::HookRunStatus::Stopped
|
||||
)
|
||||
.then_some(11),
|
||||
duration_ms: matches!(
|
||||
status,
|
||||
codex_app_server_protocol::HookRunStatus::Completed
|
||||
| codex_app_server_protocol::HookRunStatus::Failed
|
||||
| codex_app_server_protocol::HookRunStatus::Blocked
|
||||
| codex_app_server_protocol::HookRunStatus::Stopped
|
||||
)
|
||||
.then_some(10),
|
||||
entries,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "hook-1".into(),
|
||||
msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent {
|
||||
turn_id: None,
|
||||
run: codex_protocol::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,
|
||||
source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
status: codex_protocol::protocol::HookRunStatus::Running,
|
||||
status_message: Some(status_message.to_string()),
|
||||
started_at: 1,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
entries: vec![],
|
||||
},
|
||||
}),
|
||||
});
|
||||
handle_hook_started(
|
||||
&mut chat,
|
||||
hook_run(
|
||||
run_id,
|
||||
event_name,
|
||||
codex_app_server_protocol::HookRunStatus::Running,
|
||||
status_message,
|
||||
Vec::new(),
|
||||
),
|
||||
);
|
||||
assert!(
|
||||
drain_insert_history(&mut rx).is_empty(),
|
||||
"hook start should update the live hook cell instead of writing history"
|
||||
@@ -1044,37 +1610,25 @@ pub(super) async fn assert_hook_events_snapshot(
|
||||
"hook start should render in the live hook cell"
|
||||
);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "hook-1".into(),
|
||||
msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent {
|
||||
turn_id: None,
|
||||
run: codex_protocol::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,
|
||||
source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
status: codex_protocol::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,
|
||||
text: "Heads up from the hook".to_string(),
|
||||
},
|
||||
codex_protocol::protocol::HookOutputEntry {
|
||||
kind: codex_protocol::protocol::HookOutputEntryKind::Context,
|
||||
text: "Remember the startup checklist.".to_string(),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
handle_hook_completed(
|
||||
&mut chat,
|
||||
hook_run(
|
||||
run_id,
|
||||
event_name,
|
||||
codex_app_server_protocol::HookRunStatus::Completed,
|
||||
status_message,
|
||||
vec![
|
||||
codex_app_server_protocol::HookOutputEntry {
|
||||
kind: codex_app_server_protocol::HookOutputEntryKind::Warning,
|
||||
text: "Heads up from the hook".to_string(),
|
||||
},
|
||||
codex_app_server_protocol::HookOutputEntry {
|
||||
kind: codex_app_server_protocol::HookOutputEntryKind::Context,
|
||||
text: "Remember the startup checklist.".to_string(),
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let combined = cells
|
||||
@@ -1084,13 +1638,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,42 +16,40 @@ async fn resumed_initial_messages_render_history() {
|
||||
|
||||
let conversation_id = ThreadId::new();
|
||||
let rollout_file = NamedTempFile::new().unwrap();
|
||||
let configured = codex_protocol::protocol::SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
permission_profile: codex_protocol::models::PermissionProfile::read_only(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: Some(vec![
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "hello from user".to_string(),
|
||||
images: None,
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
}),
|
||||
EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "assistant reply".to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
}),
|
||||
]),
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(configured);
|
||||
replay_user_message_text(
|
||||
&mut chat,
|
||||
"user-1",
|
||||
"hello from user",
|
||||
ReplayKind::ResumeInitialMessages,
|
||||
);
|
||||
replay_agent_message(
|
||||
&mut chat,
|
||||
"assistant-1",
|
||||
"assistant reply",
|
||||
ReplayKind::ResumeInitialMessages,
|
||||
);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let mut merged_lines = Vec::new();
|
||||
@@ -73,47 +73,6 @@ async fn resumed_initial_messages_render_history() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_snapshot_replay_does_not_duplicate_agent_message_history() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event_replay(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||
thread_id: ThreadId::new(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
item: TurnItem::AgentMessage(AgentMessageItem {
|
||||
id: "msg-1".to_string(),
|
||||
content: vec![AgentMessageContent::Text {
|
||||
text: "assistant reply".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event_replay(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "assistant reply".to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(
|
||||
cells.len(),
|
||||
1,
|
||||
"expected replayed assistant message to render once"
|
||||
);
|
||||
let rendered = lines_to_single_string(&cells[0]);
|
||||
assert!(
|
||||
rendered.contains("assistant reply"),
|
||||
"expected replayed assistant message, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_user_message_preserves_text_elements_and_local_images() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -128,35 +87,42 @@ 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 {
|
||||
session_id: conversation_id,
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
permission_profile: codex_protocol::models::PermissionProfile::read_only(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent {
|
||||
message: message.clone(),
|
||||
images: None,
|
||||
text_elements: text_elements.clone(),
|
||||
local_images: local_images.clone(),
|
||||
})]),
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(configured);
|
||||
replay_user_message_inputs(
|
||||
&mut chat,
|
||||
"user-1",
|
||||
vec![
|
||||
AppServerUserInput::Text {
|
||||
text: message.clone(),
|
||||
text_elements: text_elements.clone().into_iter().map(Into::into).collect(),
|
||||
},
|
||||
AppServerUserInput::LocalImage {
|
||||
path: local_images[0].clone(),
|
||||
},
|
||||
],
|
||||
ReplayKind::ResumeInitialMessages,
|
||||
);
|
||||
|
||||
let mut user_cell = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
@@ -190,35 +156,42 @@ 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 {
|
||||
session_id: conversation_id,
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
permission_profile: codex_protocol::models::PermissionProfile::read_only(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent {
|
||||
message: message.clone(),
|
||||
images: Some(remote_image_urls.clone()),
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
})]),
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(configured);
|
||||
replay_user_message_inputs(
|
||||
&mut chat,
|
||||
"user-1",
|
||||
vec![
|
||||
AppServerUserInput::Text {
|
||||
text: message.clone(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
AppServerUserInput::Image {
|
||||
url: remote_image_urls[0].clone(),
|
||||
},
|
||||
],
|
||||
ReplayKind::ResumeInitialMessages,
|
||||
);
|
||||
|
||||
let mut user_cell = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
@@ -248,7 +221,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
|
||||
@@ -257,64 +230,64 @@ 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 {
|
||||
session_id: ThreadId::new(),
|
||||
let expected_sandbox = SandboxPolicy::from(expected_core_sandbox);
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
permission_profile: expected_permission_profile.clone(),
|
||||
permission_profile: expected_permission_profile,
|
||||
active_permission_profile: None,
|
||||
cwd: expected_cwd.clone(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: None,
|
||||
};
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "session-configured".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(configured);
|
||||
|
||||
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);
|
||||
|
||||
@@ -332,15 +305,18 @@ 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 {
|
||||
session_id: ThreadId::new(),
|
||||
let expected_permission_profile: PermissionProfile =
|
||||
expected_app_server_permission_profile.clone().into();
|
||||
let expected_sandbox = SandboxPolicy::ExternalSandbox {
|
||||
network_access: NetworkAccess::Restricted,
|
||||
};
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
@@ -350,33 +326,21 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() {
|
||||
permission_profile: expected_permission_profile,
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/external").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: None,
|
||||
};
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "session-configured".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -388,35 +352,36 @@ 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 {
|
||||
session_id: conversation_id,
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
permission_profile: codex_protocol::models::PermissionProfile::read_only(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent {
|
||||
message: String::new(),
|
||||
images: Some(remote_image_urls.clone()),
|
||||
text_elements: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
})]),
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(configured);
|
||||
replay_user_message_inputs(
|
||||
&mut chat,
|
||||
"user-1",
|
||||
vec![AppServerUserInput::Image {
|
||||
url: remote_image_urls[0].clone(),
|
||||
}],
|
||||
ReplayKind::ResumeInitialMessages,
|
||||
);
|
||||
|
||||
let mut user_cell = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
@@ -438,39 +403,40 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() {
|
||||
async fn replayed_user_message_with_only_local_images_does_not_render_history_cell() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
let local_images = vec![PathBuf::from("/tmp/replay-local-only.png")];
|
||||
let local_images = [PathBuf::from("/tmp/replay-local-only.png")];
|
||||
|
||||
let conversation_id = ThreadId::new();
|
||||
let rollout_file = NamedTempFile::new().unwrap();
|
||||
let configured = codex_protocol::protocol::SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
permission_profile: codex_protocol::models::PermissionProfile::read_only(),
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent {
|
||||
message: String::new(),
|
||||
images: None,
|
||||
text_elements: Vec::new(),
|
||||
local_images,
|
||||
})]),
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(configured);
|
||||
replay_user_message_inputs(
|
||||
&mut chat,
|
||||
"user-1",
|
||||
vec![AppServerUserInput::LocalImage {
|
||||
path: local_images[0].clone(),
|
||||
}],
|
||||
ReplayKind::ResumeInitialMessages,
|
||||
);
|
||||
|
||||
let mut found_user_history_cell = false;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
@@ -630,75 +596,21 @@ async fn app_server_forked_thread_history_line_without_app_server_name_ignores_l
|
||||
async fn thread_snapshot_replay_preserves_agent_message_during_review_mode() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event_replay(Event {
|
||||
id: "review-start".into(),
|
||||
msg: EventMsg::EnteredReviewMode(ReviewRequest {
|
||||
target: ReviewTarget::UncommittedChanges,
|
||||
user_facing_hint: None,
|
||||
}),
|
||||
});
|
||||
replay_entered_review_mode(&mut chat, "current changes");
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
|
||||
chat.handle_codex_event_replay(Event {
|
||||
id: "review-message".into(),
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "Review progress update".to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
}),
|
||||
});
|
||||
replay_agent_message(
|
||||
&mut chat,
|
||||
"review-message",
|
||||
"Review progress update",
|
||||
ReplayKind::ThreadSnapshot,
|
||||
);
|
||||
|
||||
let inserted = drain_insert_history(&mut rx);
|
||||
assert_eq!(inserted.len(), 1);
|
||||
assert!(lines_to_single_string(&inserted[0]).contains("Review progress update"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_thread_rollback_emits_ordered_app_event() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
|
||||
chat.replay_initial_messages(vec![EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns: 2,
|
||||
})]);
|
||||
|
||||
let mut saw = false;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if let AppEvent::ApplyThreadRollback { num_turns } = event {
|
||||
saw = true;
|
||||
assert_eq!(num_turns, 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(saw, "expected replay rollback app event");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_legacy_agent_message_after_item_completed_does_not_duplicate_assistant_message() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
complete_assistant_message(
|
||||
&mut chat,
|
||||
"msg-live",
|
||||
"hello",
|
||||
Some(MessagePhase::FinalAnswer),
|
||||
);
|
||||
let inserted = drain_insert_history(&mut rx);
|
||||
assert_eq!(inserted.len(), 1);
|
||||
assert!(lines_to_single_string(&inserted[0]).contains("hello"));
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "legacy-live".into(),
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "hello".into(),
|
||||
phase: Some(MessagePhase::FinalAnswer),
|
||||
memory_citation: None,
|
||||
}),
|
||||
});
|
||||
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_retryable_app_server_error_keeps_turn_running() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -762,27 +674,25 @@ async fn replayed_thread_closed_notification_does_not_exit_tui() {
|
||||
async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.config.show_raw_agent_reasoning = false;
|
||||
chat.handle_codex_event(Event {
|
||||
id: "configured".into(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
permission_profile: codex_protocol::models::PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_project_path().abs(),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: None,
|
||||
}),
|
||||
chat.handle_thread_session(crate::session_state::ThreadSessionState {
|
||||
thread_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_project_path().abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
network_proxy: None,
|
||||
rollout_path: None,
|
||||
});
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
|
||||
@@ -810,27 +720,25 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() {
|
||||
async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.config.show_raw_agent_reasoning = true;
|
||||
chat.handle_codex_event(Event {
|
||||
id: "configured".into(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
permission_profile: codex_protocol::models::PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_project_path().abs(),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: None,
|
||||
}),
|
||||
chat.handle_thread_session(crate::session_state::ThreadSessionState {
|
||||
thread_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_project_path().abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
network_proxy: None,
|
||||
rollout_path: None,
|
||||
});
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
|
||||
@@ -908,34 +816,11 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() {
|
||||
assert_eq!(rendered.matches("Summary only").count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_turn_started_does_not_mark_task_running() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
})]);
|
||||
|
||||
assert!(!chat.bottom_pane.is_task_running());
|
||||
assert!(chat.bottom_pane.status_widget().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_snapshot_replayed_turn_started_marks_task_running() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event_replay(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
replay_turn_started(&mut chat, ReplayKind::ThreadSnapshot);
|
||||
|
||||
drain_insert_history(&mut rx);
|
||||
assert!(chat.bottom_pane.is_task_running());
|
||||
@@ -977,11 +862,12 @@ async fn replayed_stream_error_does_not_set_retry_status_or_status_indicator() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_status_header("Idle".to_string());
|
||||
|
||||
chat.replay_initial_messages(vec![EventMsg::StreamError(StreamErrorEvent {
|
||||
message: "Reconnecting... 2/5".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
additional_details: Some("Idle timeout waiting for SSE".to_string()),
|
||||
})]);
|
||||
handle_stream_error_with_replay(
|
||||
&mut chat,
|
||||
"Reconnecting... 2/5",
|
||||
Some("Idle timeout waiting for SSE".to_string()),
|
||||
Some(ReplayKind::ResumeInitialMessages),
|
||||
);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
@@ -997,33 +883,18 @@ async fn replayed_stream_error_does_not_set_retry_status_or_status_indicator() {
|
||||
async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_header() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event_replay(Event {
|
||||
id: "task".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
replay_turn_started(&mut chat, ReplayKind::ThreadSnapshot);
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
chat.handle_codex_event_replay(Event {
|
||||
id: "retry".into(),
|
||||
msg: EventMsg::StreamError(StreamErrorEvent {
|
||||
message: "Reconnecting... 1/5".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
additional_details: None,
|
||||
}),
|
||||
});
|
||||
handle_stream_error_with_replay(
|
||||
&mut chat,
|
||||
"Reconnecting... 1/5",
|
||||
/*additional_details*/ None,
|
||||
Some(ReplayKind::ThreadSnapshot),
|
||||
);
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
chat.handle_codex_event_replay(Event {
|
||||
id: "delta".into(),
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
delta: "hello".to_string(),
|
||||
}),
|
||||
});
|
||||
replay_agent_message_delta(&mut chat, "hello", ReplayKind::ThreadSnapshot);
|
||||
|
||||
let status = chat
|
||||
.bottom_pane
|
||||
@@ -1034,93 +905,18 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade
|
||||
assert!(chat.retry_status_header.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_status_header("Idle".to_string());
|
||||
|
||||
chat.replay_initial_messages(vec![
|
||||
EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
EventMsg::StreamError(StreamErrorEvent {
|
||||
message: "Reconnecting... 1/5".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
additional_details: None,
|
||||
}),
|
||||
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
delta: "hello".to_string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
cells.is_empty(),
|
||||
"expected no history cells for replayed interrupted reconnect sequence"
|
||||
);
|
||||
assert!(!chat.bottom_pane.is_task_running());
|
||||
assert!(chat.bottom_pane.status_widget().is_none());
|
||||
assert_eq!(chat.current_status.header, "Idle");
|
||||
assert!(chat.retry_status_header.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_interrupted_reconnect_footer_row_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.replay_initial_messages(vec![
|
||||
EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
EventMsg::StreamError(StreamErrorEvent {
|
||||
message: "Reconnecting... 2/5".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
additional_details: Some("Idle timeout waiting for SSE".to_string()),
|
||||
}),
|
||||
]);
|
||||
|
||||
let header = render_bottom_first_row(&chat, /*width*/ 80);
|
||||
assert!(
|
||||
!header.contains("Reconnecting") && !header.contains("Working"),
|
||||
"expected replayed interrupted reconnect to avoid active status row, got {header:?}"
|
||||
);
|
||||
assert_chatwidget_snapshot!("replayed_interrupted_reconnect_footer_row", header);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_recovery_restores_previous_status_header() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
drain_insert_history(&mut rx);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "retry".into(),
|
||||
msg: EventMsg::StreamError(StreamErrorEvent {
|
||||
message: "Reconnecting... 1/5".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
additional_details: None,
|
||||
}),
|
||||
});
|
||||
handle_stream_error(
|
||||
&mut chat,
|
||||
"Reconnecting... 1/5",
|
||||
/*additional_details*/ None,
|
||||
);
|
||||
drain_insert_history(&mut rx);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "delta".into(),
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
delta: "hello".to_string(),
|
||||
}),
|
||||
});
|
||||
handle_agent_message_delta(&mut chat, "hello");
|
||||
|
||||
let status = chat
|
||||
.bottom_pane
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
use super::*;
|
||||
use codex_protocol::protocol::McpStartupCompleteEvent;
|
||||
use codex_protocol::protocol::McpStartupStatus;
|
||||
use codex_protocol::protocol::McpStartupUpdateEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn notify_mcp_status(chat: &mut ChatWidget, name: &str, status: McpServerStartupState) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: name.to_string(),
|
||||
status,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
fn notify_mcp_status_error(chat: &mut ChatWidget, name: &str, error: &str) {
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: name.to_string(),
|
||||
status: McpServerStartupState::Failed,
|
||||
error: Some(error.to_string()),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_startup_header_booting_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.show_welcome_banner = false;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "mcp-1".into(),
|
||||
msg: EventMsg::McpStartupUpdate(McpStartupUpdateEvent {
|
||||
server: "alpha".into(),
|
||||
status: McpStartupStatus::Starting,
|
||||
}),
|
||||
});
|
||||
notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting);
|
||||
|
||||
let height = chat.desired_height(/*width*/ 80);
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
|
||||
@@ -33,26 +46,14 @@ async fn mcp_startup_header_booting_snapshot() {
|
||||
async fn mcp_startup_complete_does_not_clear_running_task() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
assert!(chat.bottom_pane.is_task_running());
|
||||
assert!(chat.bottom_pane.status_indicator_visible());
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "mcp-1".into(),
|
||||
msg: EventMsg::McpStartupComplete(McpStartupCompleteEvent {
|
||||
ready: vec!["schaltwerk".into()],
|
||||
..Default::default()
|
||||
}),
|
||||
});
|
||||
chat.set_mcp_startup_expected_servers(["schaltwerk".to_string()]);
|
||||
notify_mcp_status(&mut chat, "schaltwerk", McpServerStartupState::Starting);
|
||||
notify_mcp_status(&mut chat, "schaltwerk", McpServerStartupState::Ready);
|
||||
|
||||
assert!(chat.bottom_pane.is_task_running());
|
||||
assert!(chat.bottom_pane.status_indicator_visible());
|
||||
@@ -64,25 +65,15 @@ async fn app_server_mcp_startup_failure_renders_warning_history() {
|
||||
chat.show_welcome_banner = false;
|
||||
chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]);
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "alpha".to_string(),
|
||||
status: McpServerStartupState::Starting,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting);
|
||||
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
assert!(chat.bottom_pane.is_task_running());
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "alpha".to_string(),
|
||||
status: McpServerStartupState::Failed,
|
||||
error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
notify_mcp_status_error(
|
||||
&mut chat,
|
||||
"alpha",
|
||||
"MCP client for `alpha` failed to start: handshake failed",
|
||||
);
|
||||
|
||||
let failure_cells = drain_insert_history(&mut rx);
|
||||
@@ -94,26 +85,12 @@ async fn app_server_mcp_startup_failure_renders_warning_history() {
|
||||
assert!(!failure_text.contains("MCP startup incomplete"));
|
||||
assert!(chat.bottom_pane.is_task_running());
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "beta".to_string(),
|
||||
status: McpServerStartupState::Starting,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting);
|
||||
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
assert!(chat.bottom_pane.is_task_running());
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "beta".to_string(),
|
||||
status: McpServerStartupState::Ready,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready);
|
||||
|
||||
let summary_cells = drain_insert_history(&mut rx);
|
||||
let summary_text = summary_cells
|
||||
@@ -154,30 +131,13 @@ async fn app_server_mcp_startup_lag_settles_startup_and_ignores_late_updates() {
|
||||
chat.show_welcome_banner = false;
|
||||
chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]);
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "alpha".to_string(),
|
||||
status: McpServerStartupState::Starting,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "alpha".to_string(),
|
||||
status: McpServerStartupState::Failed,
|
||||
error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "beta".to_string(),
|
||||
status: McpServerStartupState::Starting,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting);
|
||||
notify_mcp_status_error(
|
||||
&mut chat,
|
||||
"alpha",
|
||||
"MCP client for `alpha` failed to start: handshake failed",
|
||||
);
|
||||
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting);
|
||||
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
assert!(chat.bottom_pane.is_task_running());
|
||||
@@ -193,26 +153,12 @@ async fn app_server_mcp_startup_lag_settles_startup_and_ignores_late_updates() {
|
||||
assert!(summary_text.contains("MCP startup incomplete (failed: alpha)"));
|
||||
assert!(!chat.bottom_pane.is_task_running());
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "beta".to_string(),
|
||||
status: McpServerStartupState::Starting,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting);
|
||||
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
assert!(!chat.bottom_pane.is_task_running());
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "beta".to_string(),
|
||||
status: McpServerStartupState::Ready,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready);
|
||||
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
assert!(!chat.bottom_pane.is_task_running());
|
||||
@@ -226,13 +172,10 @@ async fn app_server_mcp_startup_after_lag_can_settle_without_starting_updates()
|
||||
|
||||
chat.finish_mcp_startup_after_lag();
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "alpha".to_string(),
|
||||
status: McpServerStartupState::Failed,
|
||||
error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
notify_mcp_status_error(
|
||||
&mut chat,
|
||||
"alpha",
|
||||
"MCP client for `alpha` failed to start: handshake failed",
|
||||
);
|
||||
|
||||
let failure_text = drain_insert_history(&mut rx)
|
||||
@@ -242,14 +185,7 @@ async fn app_server_mcp_startup_after_lag_can_settle_without_starting_updates()
|
||||
assert!(failure_text.contains("MCP client for `alpha` failed to start: handshake failed"));
|
||||
assert!(chat.bottom_pane.is_task_running());
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "beta".to_string(),
|
||||
status: McpServerStartupState::Ready,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready);
|
||||
|
||||
let summary_text = drain_insert_history(&mut rx)
|
||||
.iter()
|
||||
@@ -265,43 +201,23 @@ async fn app_server_mcp_startup_after_lag_preserves_partial_terminal_only_round(
|
||||
chat.show_welcome_banner = false;
|
||||
chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]);
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "alpha".to_string(),
|
||||
status: McpServerStartupState::Starting,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "alpha".to_string(),
|
||||
status: McpServerStartupState::Failed,
|
||||
error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "beta".to_string(),
|
||||
status: McpServerStartupState::Starting,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting);
|
||||
notify_mcp_status_error(
|
||||
&mut chat,
|
||||
"alpha",
|
||||
"MCP client for `alpha` failed to start: handshake failed",
|
||||
);
|
||||
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting);
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
|
||||
chat.finish_mcp_startup_after_lag();
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
assert!(!chat.bottom_pane.is_task_running());
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "alpha".to_string(),
|
||||
status: McpServerStartupState::Failed,
|
||||
error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
notify_mcp_status_error(
|
||||
&mut chat,
|
||||
"alpha",
|
||||
"MCP client for `alpha` failed to start: handshake failed",
|
||||
);
|
||||
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
@@ -309,14 +225,7 @@ async fn app_server_mcp_startup_after_lag_preserves_partial_terminal_only_round(
|
||||
|
||||
chat.finish_mcp_startup_after_lag();
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "beta".to_string(),
|
||||
status: McpServerStartupState::Ready,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready);
|
||||
|
||||
let summary_text = drain_insert_history(&mut rx)
|
||||
.iter()
|
||||
@@ -333,78 +242,35 @@ async fn app_server_mcp_startup_next_round_discards_stale_terminal_updates() {
|
||||
chat.show_welcome_banner = false;
|
||||
chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]);
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "alpha".to_string(),
|
||||
status: McpServerStartupState::Starting,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "alpha".to_string(),
|
||||
status: McpServerStartupState::Failed,
|
||||
error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "beta".to_string(),
|
||||
status: McpServerStartupState::Starting,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting);
|
||||
notify_mcp_status_error(
|
||||
&mut chat,
|
||||
"alpha",
|
||||
"MCP client for `alpha` failed to start: handshake failed",
|
||||
);
|
||||
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting);
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
|
||||
chat.finish_mcp_startup_after_lag();
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
assert!(!chat.bottom_pane.is_task_running());
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "alpha".to_string(),
|
||||
status: McpServerStartupState::Failed,
|
||||
error: Some(
|
||||
"MCP client for `alpha` failed to start: stale handshake failed".to_string(),
|
||||
),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
notify_mcp_status_error(
|
||||
&mut chat,
|
||||
"alpha",
|
||||
"MCP client for `alpha` failed to start: stale handshake failed",
|
||||
);
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "beta".to_string(),
|
||||
status: McpServerStartupState::Starting,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting);
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
assert!(!chat.bottom_pane.is_task_running());
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "alpha".to_string(),
|
||||
status: McpServerStartupState::Ready,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Ready);
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
assert!(chat.bottom_pane.is_task_running());
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "beta".to_string(),
|
||||
status: McpServerStartupState::Ready,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready);
|
||||
|
||||
let summary_text = drain_insert_history(&mut rx)
|
||||
.iter()
|
||||
@@ -422,23 +288,13 @@ async fn app_server_mcp_startup_next_round_keeps_terminal_statuses_after_startin
|
||||
|
||||
chat.finish_mcp_startup_after_lag();
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "alpha".to_string(),
|
||||
status: McpServerStartupState::Starting,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting);
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "alpha".to_string(),
|
||||
status: McpServerStartupState::Failed,
|
||||
error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
notify_mcp_status_error(
|
||||
&mut chat,
|
||||
"alpha",
|
||||
"MCP client for `alpha` failed to start: handshake failed",
|
||||
);
|
||||
|
||||
let failure_text = drain_insert_history(&mut rx)
|
||||
@@ -447,25 +303,11 @@ async fn app_server_mcp_startup_next_round_keeps_terminal_statuses_after_startin
|
||||
.collect::<String>();
|
||||
assert!(failure_text.contains("MCP client for `alpha` failed to start: handshake failed"));
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "beta".to_string(),
|
||||
status: McpServerStartupState::Starting,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting);
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
assert!(chat.bottom_pane.is_task_running());
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "beta".to_string(),
|
||||
status: McpServerStartupState::Ready,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready);
|
||||
|
||||
let summary_text = drain_insert_history(&mut rx)
|
||||
.iter()
|
||||
@@ -482,24 +324,14 @@ async fn app_server_mcp_startup_next_round_with_empty_expected_servers_reactivat
|
||||
chat.set_mcp_startup_expected_servers(std::iter::empty::<String>());
|
||||
chat.finish_mcp_startup(Vec::new(), Vec::new());
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "runtime".to_string(),
|
||||
status: McpServerStartupState::Starting,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
notify_mcp_status(&mut chat, "runtime", McpServerStartupState::Starting);
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
assert!(chat.bottom_pane.is_task_running());
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "runtime".to_string(),
|
||||
status: McpServerStartupState::Failed,
|
||||
error: Some("MCP client for `runtime` failed to start: handshake failed".to_string()),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
notify_mcp_status_error(
|
||||
&mut chat,
|
||||
"runtime",
|
||||
"MCP client for `runtime` failed to start: handshake failed",
|
||||
);
|
||||
|
||||
let summary_text = drain_insert_history(&mut rx)
|
||||
@@ -511,56 +343,17 @@ async fn app_server_mcp_startup_next_round_with_empty_expected_servers_reactivat
|
||||
assert!(!chat.bottom_pane.is_task_running());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_server_mcp_startup_after_lag_with_empty_expected_servers_preserves_failures() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.show_welcome_banner = false;
|
||||
chat.set_mcp_startup_expected_servers(std::iter::empty::<String>());
|
||||
|
||||
chat.on_mcp_startup_update(McpStartupUpdateEvent {
|
||||
server: "runtime".to_string(),
|
||||
status: McpStartupStatus::Starting,
|
||||
});
|
||||
chat.on_mcp_startup_update(McpStartupUpdateEvent {
|
||||
server: "runtime".to_string(),
|
||||
status: McpStartupStatus::Failed {
|
||||
error: "MCP client for `runtime` failed to start: handshake failed".to_string(),
|
||||
},
|
||||
});
|
||||
|
||||
let warning_text = drain_insert_history(&mut rx)
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<String>();
|
||||
assert!(warning_text.contains("MCP client for `runtime` failed to start: handshake failed"));
|
||||
assert!(chat.bottom_pane.is_task_running());
|
||||
|
||||
chat.finish_mcp_startup_after_lag();
|
||||
|
||||
let summary_text = drain_insert_history(&mut rx)
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<String>();
|
||||
assert!(summary_text.contains("MCP startup incomplete (failed: runtime)"));
|
||||
assert!(!chat.bottom_pane.is_task_running());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_server_mcp_startup_after_lag_includes_runtime_servers_with_expected_set() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.show_welcome_banner = false;
|
||||
chat.set_mcp_startup_expected_servers(["alpha".to_string()]);
|
||||
|
||||
chat.on_mcp_startup_update(McpStartupUpdateEvent {
|
||||
server: "alpha".to_string(),
|
||||
status: McpStartupStatus::Ready,
|
||||
});
|
||||
chat.on_mcp_startup_update(McpStartupUpdateEvent {
|
||||
server: "runtime".to_string(),
|
||||
status: McpStartupStatus::Failed {
|
||||
error: "MCP client for `runtime` failed to start: handshake failed".to_string(),
|
||||
},
|
||||
});
|
||||
notify_mcp_status_error(
|
||||
&mut chat,
|
||||
"runtime",
|
||||
"MCP client for `runtime` failed to start: handshake failed",
|
||||
);
|
||||
|
||||
let warning_text = drain_insert_history(&mut rx)
|
||||
.iter()
|
||||
@@ -585,57 +378,32 @@ async fn app_server_mcp_startup_next_round_after_lag_can_settle_without_starting
|
||||
chat.show_welcome_banner = false;
|
||||
chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]);
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "alpha".to_string(),
|
||||
status: McpServerStartupState::Starting,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "alpha".to_string(),
|
||||
status: McpServerStartupState::Failed,
|
||||
error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "beta".to_string(),
|
||||
status: McpServerStartupState::Starting,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting);
|
||||
notify_mcp_status_error(
|
||||
&mut chat,
|
||||
"alpha",
|
||||
"MCP client for `alpha` failed to start: handshake failed",
|
||||
);
|
||||
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting);
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
|
||||
chat.finish_mcp_startup_after_lag();
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
assert!(!chat.bottom_pane.is_task_running());
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "alpha".to_string(),
|
||||
status: McpServerStartupState::Failed,
|
||||
error: Some(
|
||||
"MCP client for `alpha` failed to start: stale handshake failed".to_string(),
|
||||
),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
notify_mcp_status_error(
|
||||
&mut chat,
|
||||
"alpha",
|
||||
"MCP client for `alpha` failed to start: stale handshake failed",
|
||||
);
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
|
||||
chat.finish_mcp_startup_after_lag();
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "alpha".to_string(),
|
||||
status: McpServerStartupState::Failed,
|
||||
error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
notify_mcp_status_error(
|
||||
&mut chat,
|
||||
"alpha",
|
||||
"MCP client for `alpha` failed to start: handshake failed",
|
||||
);
|
||||
|
||||
let failure_text = drain_insert_history(&mut rx)
|
||||
@@ -645,14 +413,7 @@ async fn app_server_mcp_startup_next_round_after_lag_can_settle_without_starting
|
||||
assert!(failure_text.is_empty());
|
||||
assert!(!chat.bottom_pane.is_task_running());
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
name: "beta".to_string(),
|
||||
status: McpServerStartupState::Ready,
|
||||
error: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready);
|
||||
|
||||
let summary_text = drain_insert_history(&mut rx)
|
||||
.iter()
|
||||
|
||||
@@ -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);
|
||||
@@ -315,7 +356,7 @@ async fn approvals_popup_navigation_skips_disabled() {
|
||||
assert!(
|
||||
app_events.iter().any(|ev| matches!(
|
||||
ev,
|
||||
AppEvent::CodexOp(AppCommand::OverrideTurnContext {
|
||||
AppEvent::CodexOp(Op::OverrideTurnContext {
|
||||
approval_policy: Some(AskForApproval::OnRequest),
|
||||
personality: None,
|
||||
..
|
||||
@@ -326,7 +367,7 @@ async fn approvals_popup_navigation_skips_disabled() {
|
||||
assert!(
|
||||
!app_events.iter().any(|ev| matches!(
|
||||
ev,
|
||||
AppEvent::CodexOp(AppCommand::OverrideTurnContext {
|
||||
AppEvent::CodexOp(Op::OverrideTurnContext {
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
personality: None,
|
||||
..
|
||||
@@ -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
|
||||
@@ -530,27 +571,25 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure
|
||||
.features
|
||||
.set_enabled(Feature::GuardianApproval, /*enabled*/ true);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "session-configured".to_string(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
approvals_reviewer: ApprovalsReviewer::AutoReview,
|
||||
permission_profile: PermissionProfile::workspace_write(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_project_path().abs(),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(PathBuf::new()),
|
||||
}),
|
||||
chat.handle_thread_session(crate::session_state::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(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
approvals_reviewer: ApprovalsReviewer::AutoReview,
|
||||
permission_profile: PermissionProfile::workspace_write(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_project_path().abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(PathBuf::new()),
|
||||
});
|
||||
|
||||
chat.open_permissions_popup();
|
||||
@@ -578,34 +617,27 @@ 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(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
approvals_reviewer: ApprovalsReviewer::AutoReview,
|
||||
permission_profile,
|
||||
active_permission_profile: None,
|
||||
cwd,
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(PathBuf::new()),
|
||||
}),
|
||||
chat.handle_thread_session(crate::session_state::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(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
approvals_reviewer: ApprovalsReviewer::AutoReview,
|
||||
permission_profile,
|
||||
active_permission_profile: None,
|
||||
cwd,
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(PathBuf::new()),
|
||||
});
|
||||
|
||||
chat.open_permissions_popup();
|
||||
@@ -630,7 +662,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
|
||||
@@ -670,7 +702,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
|
||||
@@ -699,7 +731,7 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context
|
||||
|
||||
let op = std::iter::from_fn(|| rx.try_recv().ok())
|
||||
.find_map(|event| match event {
|
||||
AppEvent::CodexOp(op @ AppCommand::OverrideTurnContext { .. }) => Some(op),
|
||||
AppEvent::CodexOp(op @ Op::OverrideTurnContext { .. }) => Some(op),
|
||||
_ => None,
|
||||
})
|
||||
.expect("expected OverrideTurnContext op");
|
||||
@@ -710,7 +742,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,
|
||||
|
||||
@@ -582,16 +582,17 @@ async fn request_user_input_notification_overrides_pending_agent_turn_complete_n
|
||||
chat.notify(Notification::AgentTurnComplete {
|
||||
response: "done".to_string(),
|
||||
});
|
||||
chat.handle_request_user_input_now(RequestUserInputEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
chat.handle_request_user_input_now(ToolRequestUserInputParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
item_id: "call-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
questions: vec![RequestUserInputQuestion {
|
||||
questions: vec![ToolRequestUserInputQuestion {
|
||||
id: "reasoning_scope".to_string(),
|
||||
header: "Reasoning scope".to_string(),
|
||||
question: "Which reasoning scope should I use?".to_string(),
|
||||
is_other: false,
|
||||
is_secret: false,
|
||||
options: Some(vec![RequestUserInputQuestionOption {
|
||||
options: Some(vec![ToolRequestUserInputOption {
|
||||
label: "Plan only".to_string(),
|
||||
description: "Update only Plan mode.".to_string(),
|
||||
}]),
|
||||
@@ -610,16 +611,17 @@ async fn handle_request_user_input_sets_pending_notification() {
|
||||
chat.config.tui_notifications.notifications =
|
||||
Notifications::Custom(vec!["plan-mode-prompt".to_string()]);
|
||||
|
||||
chat.handle_request_user_input_now(RequestUserInputEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
chat.handle_request_user_input_now(ToolRequestUserInputParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
item_id: "call-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
questions: vec![RequestUserInputQuestion {
|
||||
questions: vec![ToolRequestUserInputQuestion {
|
||||
id: "reasoning_scope".to_string(),
|
||||
header: "Reasoning scope".to_string(),
|
||||
question: "Which reasoning scope should I use?".to_string(),
|
||||
is_other: false,
|
||||
is_secret: false,
|
||||
options: Some(vec![RequestUserInputQuestionOption {
|
||||
options: Some(vec![ToolRequestUserInputOption {
|
||||
label: "Plan only".to_string(),
|
||||
description: "Update only Plan mode.".to_string(),
|
||||
}]),
|
||||
@@ -802,13 +804,23 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() {
|
||||
.expect("expected plan collaboration mask");
|
||||
chat.set_collaboration_mask(plan_mask);
|
||||
|
||||
chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: Some("Plan details".to_string()),
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
})]);
|
||||
chat.replay_thread_turns(
|
||||
vec![AppServerTurn {
|
||||
id: "turn-1".to_string(),
|
||||
items: vec![AppServerThreadItem::AgentMessage {
|
||||
id: "msg-plan".to_string(),
|
||||
text: "Plan details".to_string(),
|
||||
phase: Some(MessagePhase::FinalAnswer),
|
||||
memory_citation: None,
|
||||
}],
|
||||
status: AppServerTurnStatus::Completed,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}],
|
||||
ReplayKind::ResumeInitialMessages,
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert!(
|
||||
@@ -829,29 +841,36 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com
|
||||
chat.on_plan_delta("- Step 1\n- Step 2\n".to_string());
|
||||
chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string());
|
||||
|
||||
chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: Some("Plan details".to_string()),
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
})]);
|
||||
chat.replay_thread_turns(
|
||||
vec![AppServerTurn {
|
||||
id: "turn-1".to_string(),
|
||||
items: vec![AppServerThreadItem::AgentMessage {
|
||||
id: "msg-plan-replay".to_string(),
|
||||
text: "Plan details".to_string(),
|
||||
phase: Some(MessagePhase::FinalAnswer),
|
||||
memory_citation: None,
|
||||
}],
|
||||
status: AppServerTurnStatus::Completed,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}],
|
||||
ReplayKind::ResumeInitialMessages,
|
||||
);
|
||||
let replay_popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert!(
|
||||
!replay_popup.contains(PLAN_IMPLEMENTATION_TITLE),
|
||||
"expected no prompt for replayed turn completion, got {replay_popup:?}"
|
||||
);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "live-turn-complete-1".to_string(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: Some("Plan details".to_string()),
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
}),
|
||||
});
|
||||
complete_assistant_message(
|
||||
&mut chat,
|
||||
"msg-plan-live-1",
|
||||
"Plan details",
|
||||
Some(MessagePhase::FinalAnswer),
|
||||
);
|
||||
handle_turn_completed(&mut chat, "live-turn-complete-1", /*duration_ms*/ None);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert!(
|
||||
@@ -866,16 +885,13 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com
|
||||
"expected prompt to dismiss on Esc, got {dismissed_popup:?}"
|
||||
);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "live-turn-complete-2".to_string(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: Some("Plan details".to_string()),
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
}),
|
||||
});
|
||||
complete_assistant_message(
|
||||
&mut chat,
|
||||
"msg-plan-live-2",
|
||||
"Plan details",
|
||||
Some(MessagePhase::FinalAnswer),
|
||||
);
|
||||
handle_turn_completed(&mut chat, "live-turn-complete-2", /*duration_ms*/ None);
|
||||
let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert!(
|
||||
!duplicate_popup.contains(PLAN_IMPLEMENTATION_TITLE),
|
||||
@@ -1137,15 +1153,13 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() {
|
||||
other => panic!("expected running-turn compact steer submit, got {other:?}"),
|
||||
}
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "steer-rejected".into(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: "cannot steer a compact turn".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Compact,
|
||||
}),
|
||||
handle_error(
|
||||
&mut chat,
|
||||
"cannot steer a compact turn",
|
||||
Some(CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Compact,
|
||||
}),
|
||||
});
|
||||
);
|
||||
|
||||
assert!(chat.pending_steers.is_empty());
|
||||
assert_eq!(
|
||||
@@ -1186,9 +1200,10 @@ 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 {
|
||||
session_id: conversation_id,
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
@@ -1198,17 +1213,14 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() {
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(configured);
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
chat.bottom_pane
|
||||
.set_plugin_mentions(Some(vec![codex_plugin::PluginCapabilitySummary {
|
||||
@@ -1432,9 +1444,10 @@ 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 {
|
||||
session_id: ThreadId::new(),
|
||||
let configured = crate::session_state::ThreadSessionState {
|
||||
thread_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
fork_parent_title: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
@@ -1444,17 +1457,14 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
instruction_source_paths: Vec::new(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: None,
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "configured".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
chat.handle_thread_session(configured);
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("/plan build the plan".to_string(), Vec::new(), Vec::new());
|
||||
@@ -1654,10 +1664,7 @@ async fn plan_update_renders_history_cell() {
|
||||
},
|
||||
],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-1".into(),
|
||||
msg: EventMsg::PlanUpdate(update),
|
||||
});
|
||||
chat.on_plan_update(update);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(!cells.is_empty(), "expected plan update cell to be sent");
|
||||
let blob = lines_to_single_string(cells.last().unwrap());
|
||||
|
||||
@@ -9,12 +9,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()),
|
||||
});
|
||||
|
||||
@@ -2323,13 +2325,11 @@ async fn server_overloaded_error_does_not_switch_models() {
|
||||
while rx.try_recv().is_ok() {}
|
||||
while op_rx.try_recv().is_ok() {}
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "err-1".to_string(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: "server overloaded".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::ServerOverloaded),
|
||||
}),
|
||||
});
|
||||
handle_error(
|
||||
&mut chat,
|
||||
"server overloaded",
|
||||
Some(CodexErrorInfo::ServerOverloaded),
|
||||
);
|
||||
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if let AppEvent::UpdateModel(model) = event {
|
||||
|
||||
@@ -62,15 +62,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
|
||||
|
||||
// When interrupted, queued messages are merged into the composer; image placeholders
|
||||
// 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 {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_turn_interrupted(&mut chat, "turn-1");
|
||||
|
||||
let first = "[Image #1] first".to_string();
|
||||
let second = "[Image #2] second".to_string();
|
||||
@@ -111,15 +103,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
|
||||
async fn entered_review_mode_uses_request_hint() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
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()),
|
||||
}),
|
||||
});
|
||||
handle_entered_review_mode(&mut chat, "feature branch");
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let banner = lines_to_single_string(cells.last().expect("review banner"));
|
||||
@@ -132,13 +116,7 @@ async fn entered_review_mode_uses_request_hint() {
|
||||
async fn entered_review_mode_defaults_to_current_changes_banner() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "review-start".into(),
|
||||
msg: EventMsg::EnteredReviewMode(ReviewRequest {
|
||||
target: ReviewTarget::UncommittedChanges,
|
||||
user_facing_hint: None,
|
||||
}),
|
||||
});
|
||||
handle_entered_review_mode(&mut chat, "current changes");
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let banner = lines_to_single_string(cells.last().expect("review banner"));
|
||||
@@ -147,18 +125,10 @@ async fn entered_review_mode_defaults_to_current_changes_banner() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_core_review_prompt_item_is_not_rendered() {
|
||||
async fn live_review_prompt_item_is_not_rendered() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
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()),
|
||||
}),
|
||||
});
|
||||
handle_entered_review_mode(&mut chat, "changes against 'main'");
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1);
|
||||
assert!(lines_to_single_string(&cells[0]).contains("Code review started"));
|
||||
@@ -224,24 +194,8 @@ async fn live_app_server_review_prompt_item_is_not_rendered() {
|
||||
async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
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()),
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
handle_entered_review_mode(&mut chat, "feature branch");
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
chat.queued_user_messages
|
||||
.push_back(UserMessage::from("queued later").into());
|
||||
@@ -271,24 +225,20 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages
|
||||
other => panic!("expected second running-turn steer submit, got {other:?}"),
|
||||
}
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "steer-rejected-1".into(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: "cannot steer a review turn".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Review,
|
||||
}),
|
||||
handle_error(
|
||||
&mut chat,
|
||||
"cannot steer a review turn",
|
||||
Some(CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Review,
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "steer-rejected-2".into(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: "cannot steer a review turn".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Review,
|
||||
}),
|
||||
);
|
||||
handle_error(
|
||||
&mut chat,
|
||||
"cannot steer a review turn",
|
||||
Some(CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Review,
|
||||
}),
|
||||
});
|
||||
);
|
||||
|
||||
assert!(chat.pending_steers.is_empty());
|
||||
assert_eq!(
|
||||
@@ -301,22 +251,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages
|
||||
);
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "review-exit".into(),
|
||||
msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent {
|
||||
review_output: None,
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-complete".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_exited_review_mode(&mut chat);
|
||||
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
||||
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => assert_eq!(
|
||||
@@ -329,16 +265,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages
|
||||
other => panic!("expected merged rejected-steer follow-up submit, got {other:?}"),
|
||||
}
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-complete-2".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-2".to_string(),
|
||||
last_agent_message: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
||||
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => assert_eq!(
|
||||
@@ -356,23 +283,15 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages
|
||||
async fn live_agent_message_renders_during_review_mode() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "review-start".into(),
|
||||
msg: EventMsg::EnteredReviewMode(ReviewRequest {
|
||||
target: ReviewTarget::UncommittedChanges,
|
||||
user_facing_hint: None,
|
||||
}),
|
||||
});
|
||||
handle_entered_review_mode(&mut chat, "current changes");
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "review-message".into(),
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "Review progress update".to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
}),
|
||||
});
|
||||
complete_assistant_message(
|
||||
&mut chat,
|
||||
"review-message",
|
||||
"Review progress update",
|
||||
/*phase*/ None,
|
||||
);
|
||||
|
||||
let inserted = drain_insert_history(&mut rx);
|
||||
assert_eq!(inserted.len(), 1);
|
||||
@@ -388,40 +307,21 @@ async fn review_restores_context_window_indicator() {
|
||||
let pre_review_tokens = 12_700; // ~30% remaining after subtracting baseline.
|
||||
let review_tokens = 12_030; // ~97% remaining after subtracting baseline.
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "token-before".into(),
|
||||
msg: EventMsg::TokenCount(TokenCountEvent {
|
||||
info: Some(make_token_info(pre_review_tokens, context_window)),
|
||||
rate_limits: None,
|
||||
}),
|
||||
});
|
||||
handle_token_count(
|
||||
&mut chat,
|
||||
Some(make_token_info(pre_review_tokens, context_window)),
|
||||
);
|
||||
assert_eq!(chat.bottom_pane.context_window_percent(), Some(30));
|
||||
|
||||
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()),
|
||||
}),
|
||||
});
|
||||
handle_entered_review_mode(&mut chat, "feature branch");
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "token-review".into(),
|
||||
msg: EventMsg::TokenCount(TokenCountEvent {
|
||||
info: Some(make_token_info(review_tokens, context_window)),
|
||||
rate_limits: None,
|
||||
}),
|
||||
});
|
||||
handle_token_count(
|
||||
&mut chat,
|
||||
Some(make_token_info(review_tokens, context_window)),
|
||||
);
|
||||
assert_eq!(chat.bottom_pane.context_window_percent(), Some(97));
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "review-end".into(),
|
||||
msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent {
|
||||
review_output: None,
|
||||
}),
|
||||
});
|
||||
handle_exited_review_mode(&mut chat);
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
|
||||
assert_eq!(chat.bottom_pane.context_window_percent(), Some(30));
|
||||
@@ -651,7 +551,7 @@ async fn item_completed_pops_pending_steer_with_local_image_and_text_elements()
|
||||
"user-1",
|
||||
vec![
|
||||
UserInput::Image {
|
||||
image_url: "data:image/png;base64,placeholder".to_string(),
|
||||
url: "data:image/png;base64,placeholder".to_string(),
|
||||
},
|
||||
UserInput::Text {
|
||||
text,
|
||||
@@ -932,10 +832,9 @@ async fn manual_interrupt_restores_pending_steer_mention_bindings_to_composer()
|
||||
items,
|
||||
vec![UserInput::Text {
|
||||
text: "please use $figma".to_string(),
|
||||
text_elements: vec![TextElement::new(
|
||||
(11..17).into(),
|
||||
Some("$figma".to_string()),
|
||||
)],
|
||||
text_elements: vec![
|
||||
TextElement::new((11..17).into(), Some("$figma".to_string())).into()
|
||||
],
|
||||
}]
|
||||
),
|
||||
other => panic!("expected Op::UserTurn, got {other:?}"),
|
||||
@@ -990,61 +889,6 @@ queued draft"
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.on_task_started();
|
||||
chat.on_agent_message_delta(
|
||||
"Final answer line
|
||||
"
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("pending steer".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
chat.queued_user_messages
|
||||
.push_back(UserMessage::from("queued draft".to_string()).into());
|
||||
chat.refresh_pending_input_preview();
|
||||
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => assert_eq!(
|
||||
items,
|
||||
vec![UserInput::Text {
|
||||
text: "pending steer".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
),
|
||||
other => panic!("expected Op::UserTurn, got {other:?}"),
|
||||
}
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "replaced".into(),
|
||||
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::Replaced,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}),
|
||||
});
|
||||
|
||||
assert!(chat.pending_steers.is_empty());
|
||||
assert!(chat.queued_user_messages.is_empty());
|
||||
assert_eq!(chat.bottom_pane.composer_text(), "");
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => assert_eq!(
|
||||
items,
|
||||
vec![UserInput::Text {
|
||||
text: "queued draft".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
),
|
||||
other => panic!("expected queued draft Op::UserTurn, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ctrl_c_shutdown_works_with_caps_lock() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -1221,17 +1065,14 @@ async fn custom_prompt_submit_sends_review_op() {
|
||||
chat.handle_paste(" please audit dependencies ".to_string());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
// Expect AppEvent::CodexOp(AppCommand::Review { .. }) with trimmed prompt
|
||||
// Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt
|
||||
let evt = rx.try_recv().expect("expected one app event");
|
||||
match evt {
|
||||
AppEvent::CodexOp(AppCommand::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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1263,15 +1104,7 @@ async fn interrupt_exec_marks_failed_snapshot() {
|
||||
|
||||
// Simulate the task being aborted (as if ESC was pressed), which should
|
||||
// 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 {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_turn_interrupted(&mut chat, "turn-1");
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
@@ -1291,26 +1124,10 @@ async fn interrupted_turn_error_message_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
// Simulate an in-progress task so the widget is in a running state.
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
// 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 {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_turn_interrupted(&mut chat, "turn-1");
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
@@ -1389,24 +1206,8 @@ async fn interrupted_turn_after_goal_budget_limited_uses_budget_message_snapshot
|
||||
async fn direct_budget_limited_turn_uses_budget_message_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::BudgetLimited,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
handle_budget_limited_turn(&mut chat, "turn-1");
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let last = lines_to_single_string(cells.last().unwrap());
|
||||
@@ -1420,24 +1221,8 @@ async fn budget_limited_turn_restores_queued_input_without_submitting() {
|
||||
.push_back(UserMessage::from("follow-up after budget stop").into());
|
||||
chat.refresh_pending_input_preview();
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::BudgetLimited,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
handle_budget_limited_turn(&mut chat, "turn-1");
|
||||
|
||||
assert!(chat.queued_user_messages.is_empty());
|
||||
assert_eq!(
|
||||
@@ -1457,25 +1242,9 @@ async fn interrupted_turn_pending_steers_message_snapshot() {
|
||||
chat.pending_steers.push_back(pending_steer("steer 1"));
|
||||
chat.submit_pending_steers_after_interrupt = true;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_turn_interrupted(&mut chat, "turn-1");
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let info = cells
|
||||
@@ -1557,66 +1326,13 @@ async fn review_branch_picker_escape_navigates_back_then_dismisses() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn review_ended_keeps_unified_exec_processes() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5");
|
||||
begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6");
|
||||
assert_eq!(chat.unified_exec_processes.len(), 2);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::ReviewEnded,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}),
|
||||
});
|
||||
|
||||
assert_eq!(chat.unified_exec_processes.len(), 2);
|
||||
|
||||
chat.add_ps_output();
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let combined = cells
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
combined.contains("Background terminals"),
|
||||
"expected /ps to remain available after review-ended abort; got {combined:?}"
|
||||
);
|
||||
assert!(
|
||||
combined.contains("sleep 5") && combined.contains("sleep 6"),
|
||||
"expected /ps to list running unified exec processes; got {combined:?}"
|
||||
);
|
||||
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enter_submits_steer_while_review_is_running() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "review-1".into(),
|
||||
msg: EventMsg::EnteredReviewMode(ReviewRequest {
|
||||
target: ReviewTarget::UncommittedChanges,
|
||||
user_facing_hint: Some("current changes".to_string()),
|
||||
}),
|
||||
});
|
||||
handle_entered_review_mode(&mut chat, "current changes");
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
|
||||
chat.bottom_pane.set_composer_text(
|
||||
@@ -1649,37 +1365,21 @@ async fn enter_submits_steer_while_review_is_running() {
|
||||
async fn review_queues_user_messages_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "review-1".into(),
|
||||
msg: EventMsg::EnteredReviewMode(ReviewRequest {
|
||||
target: ReviewTarget::UncommittedChanges,
|
||||
user_facing_hint: Some("current changes".to_string()),
|
||||
}),
|
||||
});
|
||||
handle_entered_review_mode(&mut chat, "current changes");
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
|
||||
chat.submit_user_message(UserMessage::from(
|
||||
"Steer submitted while /review was running.".to_string(),
|
||||
));
|
||||
chat.handle_codex_event(Event {
|
||||
id: "steer-rejected".into(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: "cannot steer a review turn".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Review,
|
||||
}),
|
||||
handle_error(
|
||||
&mut chat,
|
||||
"cannot steer a review turn",
|
||||
Some(CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Review,
|
||||
}),
|
||||
});
|
||||
);
|
||||
|
||||
let width: u16 = 80;
|
||||
let height: u16 = 18;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn turn_complete_event(turn_id: &str, last_agent_message: Option<&str>) -> TurnCompleteEvent {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"turn_id": turn_id,
|
||||
"last_agent_message": last_agent_message,
|
||||
}))
|
||||
.expect("turn complete event should deserialize")
|
||||
fn complete_turn_with_message(chat: &mut ChatWidget, turn_id: &str, message: Option<&str>) {
|
||||
if let Some(message) = message {
|
||||
complete_assistant_message(
|
||||
chat,
|
||||
&format!("{turn_id}-message"),
|
||||
message,
|
||||
Some(MessagePhase::FinalAnswer),
|
||||
);
|
||||
}
|
||||
handle_turn_completed(chat, turn_id, /*duration_ms*/ None);
|
||||
}
|
||||
|
||||
fn submit_composer_text(chat: &mut ChatWidget, text: &str) {
|
||||
@@ -51,7 +55,7 @@ async fn slash_compact_eagerly_queues_follow_up_before_turn_start() {
|
||||
|
||||
assert!(chat.bottom_pane.is_task_running());
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::CodexOp(AppCommand::Compact)) => {}
|
||||
Ok(AppEvent::CodexOp(Op::Compact)) => {}
|
||||
other => panic!("expected compact op to be submitted, got {other:?}"),
|
||||
}
|
||||
|
||||
@@ -75,15 +79,7 @@ async fn slash_compact_eagerly_queues_follow_up_before_turn_start() {
|
||||
async fn queued_slash_compact_dispatches_after_active_turn() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
queue_composer_text_with_tab(&mut chat, "/compact");
|
||||
|
||||
@@ -94,16 +90,13 @@ async fn queued_slash_compact_dispatches_after_active_turn() {
|
||||
);
|
||||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-complete".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
|
||||
});
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
|
||||
|
||||
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|event| matches!(event, AppEvent::CodexOp(AppCommand::Compact))),
|
||||
.any(|event| matches!(event, AppEvent::CodexOp(Op::Compact))),
|
||||
"expected queued /compact to submit compact op; events: {events:?}"
|
||||
);
|
||||
}
|
||||
@@ -112,43 +105,26 @@ async fn queued_slash_compact_dispatches_after_active_turn() {
|
||||
async fn queued_slash_review_with_args_dispatches_after_active_turn() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
queue_composer_text_with_tab(&mut chat, "/review check regressions");
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-complete".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
|
||||
});
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
|
||||
|
||||
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:?}"),
|
||||
@@ -159,15 +135,7 @@ async fn queued_slash_review_with_args_dispatches_after_active_turn() {
|
||||
async fn queued_slash_review_with_args_restores_for_edit() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
queue_composer_text_with_tab(&mut chat, "/review check regressions");
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
|
||||
@@ -182,15 +150,7 @@ async fn queued_slash_review_with_args_restores_for_edit() {
|
||||
async fn queued_bang_shell_dispatches_after_active_turn() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
queue_composer_text_with_tab(&mut chat, "!echo hi");
|
||||
|
||||
@@ -201,10 +161,7 @@ async fn queued_bang_shell_dispatches_after_active_turn() {
|
||||
);
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-complete".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
|
||||
});
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
|
||||
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"),
|
||||
@@ -221,25 +178,14 @@ async fn queued_bang_shell_dispatches_after_active_turn() {
|
||||
async fn queued_empty_bang_shell_reports_help_when_dequeued_and_drains_next_input() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
queue_composer_text_with_tab(&mut chat, "!");
|
||||
queue_composer_text_with_tab(&mut chat, "hello after help");
|
||||
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-complete".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
|
||||
});
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let rendered = cells
|
||||
@@ -269,23 +215,12 @@ async fn queued_empty_bang_shell_reports_help_when_dequeued_and_drains_next_inpu
|
||||
async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
queue_composer_text_with_tab(&mut chat, "!echo hi");
|
||||
queue_composer_text_with_tab(&mut chat, "hello after shell");
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-complete".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
|
||||
});
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
|
||||
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"),
|
||||
@@ -321,23 +256,12 @@ async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() {
|
||||
async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_popup_text: &str) {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
queue_composer_text_with_tab(&mut chat, command);
|
||||
queue_composer_text_with_tab(&mut chat, "hello after menu");
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-complete".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
|
||||
});
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
|
||||
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
@@ -373,23 +297,12 @@ async fn queued_slash_menu_cancel_drains_next_input() {
|
||||
async fn queued_slash_menu_selection_drains_next_input() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
queue_composer_text_with_tab(&mut chat, "/permissions");
|
||||
queue_composer_text_with_tab(&mut chat, "hello after selection");
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-complete".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
|
||||
});
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert!(
|
||||
@@ -417,23 +330,12 @@ async fn queued_bare_rename_drains_next_input_after_name_update() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
queue_composer_text_with_tab(&mut chat, "/rename");
|
||||
queue_composer_text_with_tab(&mut chat, "hello after rename");
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-complete".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
|
||||
});
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
|
||||
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Name thread"));
|
||||
@@ -446,18 +348,20 @@ async fn queued_bare_rename_drains_next_input_after_name_update() {
|
||||
assert!(
|
||||
events.iter().any(|event| matches!(
|
||||
event,
|
||||
AppEvent::CodexOp(AppCommand::SetThreadName { name }) if name == "Queued rename"
|
||||
AppEvent::CodexOp(Op::SetThreadName { name }) if name == "Queued rename"
|
||||
)),
|
||||
"expected rename prompt to submit thread name; events: {events:?}"
|
||||
);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "rename".into(),
|
||||
msg: EventMsg::ThreadNameUpdated(codex_protocol::protocol::ThreadNameUpdatedEvent {
|
||||
thread_id,
|
||||
thread_name: Some("Queued rename".to_string()),
|
||||
}),
|
||||
});
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ThreadNameUpdated(
|
||||
codex_app_server_protocol::ThreadNameUpdatedNotification {
|
||||
thread_id: thread_id.to_string(),
|
||||
thread_name: Some("Queued rename".to_string()),
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => assert_eq!(
|
||||
@@ -477,30 +381,19 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
queue_composer_text_with_tab(&mut chat, "/rename Queued rename");
|
||||
queue_composer_text_with_tab(&mut chat, "first after rename");
|
||||
queue_composer_text_with_tab(&mut chat, "second after rename");
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-complete".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
|
||||
});
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
|
||||
|
||||
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
|
||||
assert!(
|
||||
events.iter().any(|event| matches!(
|
||||
event,
|
||||
AppEvent::CodexOp(AppCommand::SetThreadName { name }) if name == "Queued rename"
|
||||
AppEvent::CodexOp(Op::SetThreadName { name }) if name == "Queued rename"
|
||||
)),
|
||||
"expected queued /rename to submit thread name; events: {events:?}"
|
||||
);
|
||||
@@ -534,13 +427,15 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() {
|
||||
vec!["second after rename"]
|
||||
);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "rename".into(),
|
||||
msg: EventMsg::ThreadNameUpdated(codex_protocol::protocol::ThreadNameUpdatedEvent {
|
||||
thread_id,
|
||||
thread_name: Some("Queued rename".to_string()),
|
||||
}),
|
||||
});
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ThreadNameUpdated(
|
||||
codex_app_server_protocol::ThreadNameUpdatedNotification {
|
||||
thread_id: thread_id.to_string(),
|
||||
thread_name: Some("Queued rename".to_string()),
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
assert_eq!(
|
||||
@@ -548,19 +443,8 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() {
|
||||
vec!["second after rename"]
|
||||
);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-2-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-2".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-2-complete".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-2", Some("done"))),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-2");
|
||||
complete_turn_with_message(&mut chat, "turn-2", Some("done"));
|
||||
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => assert_eq!(
|
||||
@@ -579,24 +463,13 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() {
|
||||
async fn queued_unknown_slash_reports_error_when_dequeued() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
queue_composer_text_with_tab(&mut chat, "/does-not-exist");
|
||||
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-complete".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
|
||||
});
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let rendered = cells
|
||||
@@ -1137,7 +1010,7 @@ async fn slash_rename_prefills_existing_thread_name() {
|
||||
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::CodexOp(AppCommand::SetThreadName { name })) if name == "Current project title"
|
||||
Ok(AppEvent::CodexOp(Op::SetThreadName { name })) if name == "Current project title"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1259,16 +1132,7 @@ async fn slash_logout_requests_app_server_logout() {
|
||||
async fn slash_copy_state_tracks_turn_complete_final_reply() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: Some("Final reply **markdown**".to_string()),
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
}),
|
||||
});
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("Final reply **markdown**"));
|
||||
|
||||
assert_eq!(
|
||||
chat.last_agent_markdown_text(),
|
||||
@@ -1281,27 +1145,18 @@ async fn slash_copy_state_tracks_plan_item_completion() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let plan_text = "## Plan\n\n1. Build it\n2. Test it".to_string();
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "item-plan".into(),
|
||||
msg: EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||
thread_id: ThreadId::new(),
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
||||
thread_id: String::new(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
item: TurnItem::Plan(PlanItem {
|
||||
item: AppServerThreadItem::Plan {
|
||||
id: "plan-1".to_string(),
|
||||
text: plan_text.clone(),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
}),
|
||||
});
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
||||
|
||||
assert_eq!(chat.last_agent_markdown_text(), Some(plan_text.as_str()));
|
||||
assert_matches!(
|
||||
@@ -1438,16 +1293,7 @@ async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() {
|
||||
async fn slash_copy_state_is_preserved_during_running_task() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: Some("Previous completed reply".to_string()),
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
}),
|
||||
});
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("Previous completed reply"));
|
||||
chat.on_task_started();
|
||||
|
||||
assert_eq!(
|
||||
@@ -1456,50 +1302,11 @@ async fn slash_copy_state_is_preserved_during_running_task() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omits_text() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event_replay(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "Legacy final message".into(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
}),
|
||||
});
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
}),
|
||||
});
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
|
||||
assert_eq!(
|
||||
chat.last_agent_markdown_text(),
|
||||
Some("Legacy final message")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
complete_assistant_message(
|
||||
&mut chat,
|
||||
"msg-1",
|
||||
@@ -1507,16 +1314,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text(
|
||||
/*phase*/ None,
|
||||
);
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
}),
|
||||
});
|
||||
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
|
||||
assert_eq!(
|
||||
@@ -1533,18 +1331,10 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text(
|
||||
async fn agent_turn_complete_notification_does_not_reuse_stale_copy_source() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Previous reply"))),
|
||||
});
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("Previous reply"));
|
||||
chat.pending_notification = None;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-2".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event(
|
||||
"turn-2", /*last_agent_message*/ None,
|
||||
)),
|
||||
});
|
||||
handle_turn_completed(&mut chat, "turn-2", /*duration_ms*/ None);
|
||||
|
||||
assert_matches!(
|
||||
chat.pending_notification,
|
||||
@@ -1576,10 +1366,7 @@ async fn active_goal_without_follow_up_suppresses_agent_turn_complete_notificati
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Still working"))),
|
||||
});
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("Still working"));
|
||||
|
||||
assert_matches!(chat.pending_notification, None);
|
||||
}
|
||||
@@ -1588,21 +1375,10 @@ async fn active_goal_without_follow_up_suppresses_agent_turn_complete_notificati
|
||||
async fn queued_follow_up_suppresses_agent_turn_complete_notification() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
chat.queue_user_message("Continue".into());
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Still working"))),
|
||||
});
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("Still working"));
|
||||
|
||||
assert_matches!(chat.pending_notification, None);
|
||||
assert!(chat.queued_user_messages.is_empty());
|
||||
@@ -1613,21 +1389,10 @@ async fn queued_follow_up_suppresses_agent_turn_complete_notification() {
|
||||
async fn queued_menu_slash_keeps_agent_turn_complete_notification() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
queue_composer_text_with_tab(&mut chat, "/model");
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Done"))),
|
||||
});
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("Done"));
|
||||
|
||||
assert_matches!(
|
||||
chat.pending_notification,
|
||||
@@ -1641,40 +1406,20 @@ async fn queued_menu_slash_keeps_agent_turn_complete_notification() {
|
||||
async fn slash_copy_uses_latest_surviving_response_after_rollback() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event_replay(Event {
|
||||
id: "user-1".into(),
|
||||
msg: EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "foo".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event_replay(Event {
|
||||
id: "agent-1".into(),
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "foo response".to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event_replay(Event {
|
||||
id: "user-2".into(),
|
||||
msg: EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "bar".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event_replay(Event {
|
||||
id: "agent-2".into(),
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "bar response".to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
}),
|
||||
});
|
||||
replay_user_message_text(&mut chat, "user-1", "foo", ReplayKind::ThreadSnapshot);
|
||||
replay_agent_message(
|
||||
&mut chat,
|
||||
"agent-1",
|
||||
"foo response",
|
||||
ReplayKind::ThreadSnapshot,
|
||||
);
|
||||
replay_user_message_text(&mut chat, "user-2", "bar", ReplayKind::ThreadSnapshot);
|
||||
replay_agent_message(
|
||||
&mut chat,
|
||||
"agent-2",
|
||||
"bar response",
|
||||
ReplayKind::ThreadSnapshot,
|
||||
);
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
assert_eq!(chat.last_agent_markdown_text(), Some("bar response"));
|
||||
|
||||
@@ -1691,23 +1436,13 @@ async fn slash_copy_uses_latest_surviving_response_after_rollback() {
|
||||
async fn slash_copy_reports_when_rewind_exceeds_retained_copy_history() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event_replay(Event {
|
||||
id: "user-1".into(),
|
||||
msg: EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "foo".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event_replay(Event {
|
||||
id: "agent-1".into(),
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "foo response".to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
}),
|
||||
});
|
||||
replay_user_message_text(&mut chat, "user-1", "foo", ReplayKind::ThreadSnapshot);
|
||||
replay_agent_message(
|
||||
&mut chat,
|
||||
"agent-1",
|
||||
"foo response",
|
||||
ReplayKind::ThreadSnapshot,
|
||||
);
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
|
||||
chat.truncate_agent_copy_history_to_user_turn_count(/*user_turn_count*/ 0);
|
||||
@@ -1954,97 +1689,6 @@ async fn slash_rollout_handles_missing_path() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn undo_success_events_render_info_messages() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".to_string(),
|
||||
msg: EventMsg::UndoStarted(UndoStartedEvent {
|
||||
message: Some("Undo requested for the last turn...".to_string()),
|
||||
}),
|
||||
});
|
||||
assert!(
|
||||
chat.bottom_pane.status_indicator_visible(),
|
||||
"status indicator should be visible during undo"
|
||||
);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".to_string(),
|
||||
msg: EventMsg::UndoCompleted(UndoCompletedEvent {
|
||||
success: true,
|
||||
message: None,
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1, "expected final status only");
|
||||
assert!(
|
||||
!chat.bottom_pane.status_indicator_visible(),
|
||||
"status indicator should be hidden after successful undo"
|
||||
);
|
||||
|
||||
let completed = lines_to_single_string(&cells[0]);
|
||||
assert!(
|
||||
completed.contains("Undo completed successfully."),
|
||||
"expected default success message, got {completed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn undo_failure_events_render_error_message() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-2".to_string(),
|
||||
msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }),
|
||||
});
|
||||
assert!(
|
||||
chat.bottom_pane.status_indicator_visible(),
|
||||
"status indicator should be visible during undo"
|
||||
);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-2".to_string(),
|
||||
msg: EventMsg::UndoCompleted(UndoCompletedEvent {
|
||||
success: false,
|
||||
message: Some("Failed to restore workspace state.".to_string()),
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1, "expected final status only");
|
||||
assert!(
|
||||
!chat.bottom_pane.status_indicator_visible(),
|
||||
"status indicator should be hidden after failed undo"
|
||||
);
|
||||
|
||||
let completed = lines_to_single_string(&cells[0]);
|
||||
assert!(
|
||||
completed.contains("Failed to restore workspace state."),
|
||||
"expected failure message, got {completed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn undo_started_hides_interrupt_hint() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-hint".to_string(),
|
||||
msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }),
|
||||
});
|
||||
|
||||
let status = chat
|
||||
.bottom_pane
|
||||
.status_widget()
|
||||
.expect("status indicator should be active");
|
||||
assert!(
|
||||
!status.interrupt_hint_visible(),
|
||||
"undo should hide the interrupt hint because the operation cannot be cancelled"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fast_slash_command_updates_and_persists_local_service_tier() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
|
||||
@@ -2056,7 +1700,7 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() {
|
||||
assert!(
|
||||
events.iter().any(|event| matches!(
|
||||
event,
|
||||
AppEvent::CodexOp(AppCommand::OverrideTurnContext {
|
||||
AppEvent::CodexOp(Op::OverrideTurnContext {
|
||||
service_tier: Some(Some(ServiceTier::Fast)),
|
||||
..
|
||||
})
|
||||
@@ -2106,29 +1750,18 @@ async fn queued_fast_slash_applies_before_next_queued_message() {
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
queue_composer_text_with_tab(&mut chat, "/fast on");
|
||||
queue_composer_text_with_tab(&mut chat, "hello after fast");
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-complete".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
|
||||
});
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
|
||||
|
||||
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
|
||||
assert!(
|
||||
events.iter().any(|event| matches!(
|
||||
event,
|
||||
AppEvent::CodexOp(AppCommand::OverrideTurnContext {
|
||||
AppEvent::CodexOp(Op::OverrideTurnContext {
|
||||
service_tier: Some(Some(ServiceTier::Fast)),
|
||||
..
|
||||
})
|
||||
@@ -2167,7 +1800,7 @@ async fn user_turn_sends_standard_override_after_fast_is_turned_off() {
|
||||
assert!(
|
||||
events.iter().any(|event| matches!(
|
||||
event,
|
||||
AppEvent::CodexOp(AppCommand::OverrideTurnContext {
|
||||
AppEvent::CodexOp(Op::OverrideTurnContext {
|
||||
service_tier: Some(None),
|
||||
..
|
||||
})
|
||||
@@ -2199,28 +1832,18 @@ async fn user_turn_sends_standard_override_after_fast_is_turned_off() {
|
||||
async fn compact_queues_user_messages_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-start".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
chat.submit_user_message(UserMessage::from(
|
||||
"Steer submitted while /compact was running.".to_string(),
|
||||
));
|
||||
chat.handle_codex_event(Event {
|
||||
id: "steer-rejected".into(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: "cannot steer a compact turn".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Compact,
|
||||
}),
|
||||
handle_error(
|
||||
&mut chat,
|
||||
"cannot steer a compact turn",
|
||||
Some(CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Compact,
|
||||
}),
|
||||
});
|
||||
);
|
||||
|
||||
let width: u16 = 80;
|
||||
let height: u16 = 18;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -21,12 +21,8 @@ async fn terminal_title_shows_action_required_while_exec_approval_is_pending() {
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-action-required".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(request),
|
||||
});
|
||||
handle_exec_approval_request(&mut chat, "sub-action-required", request);
|
||||
|
||||
chat.pre_draw_tick();
|
||||
|
||||
@@ -67,12 +63,8 @@ async fn terminal_title_action_required_respects_spinner_setting() {
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-no-spinner".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(request),
|
||||
});
|
||||
handle_exec_approval_request(&mut chat, "sub-no-spinner", request);
|
||||
|
||||
chat.pre_draw_tick();
|
||||
|
||||
@@ -99,12 +91,8 @@ async fn terminal_title_action_required_blinks_when_animations_are_enabled() {
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-blink".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(request),
|
||||
});
|
||||
handle_exec_approval_request(&mut chat, "sub-blink", request);
|
||||
|
||||
chat.pre_draw_tick();
|
||||
|
||||
@@ -138,12 +126,8 @@ async fn terminal_title_activity_indicators_do_not_animate_when_animations_are_d
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-no-animations".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(request),
|
||||
});
|
||||
handle_exec_approval_request(&mut chat, "sub-no-animations", request);
|
||||
|
||||
chat.pre_draw_tick();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -20,13 +21,13 @@ use crate::exec_cell::spinner;
|
||||
use crate::exec_command::relativize_to_home;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::legacy_core::web_search_detail;
|
||||
use crate::live_wrap::take_prefix_by_width;
|
||||
use crate::markdown::append_markdown;
|
||||
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)]
|
||||
@@ -43,32 +44,33 @@ 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_app_server_protocol::ToolRequestUserInputAnswer;
|
||||
use codex_app_server_protocol::ToolRequestUserInputQuestion;
|
||||
use codex_app_server_protocol::WebSearchAction;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
#[cfg(test)]
|
||||
use codex_mcp::qualified_mcp_tool_name_prefix;
|
||||
use codex_otel::RuntimeMetricsSummary;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
#[cfg(test)]
|
||||
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;
|
||||
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;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_cli::format_env_display;
|
||||
@@ -860,13 +862,28 @@ fn exec_snippet(command: &[String]) -> String {
|
||||
truncate_exec_snippet(&full_cmd)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum ReviewDecision {
|
||||
Approved,
|
||||
ApprovedExecpolicyAmendment {
|
||||
proposed_execpolicy_amendment: ExecPolicyAmendment,
|
||||
},
|
||||
ApprovedForSession,
|
||||
NetworkPolicyAmendment {
|
||||
network_policy_amendment: NetworkPolicyAmendment,
|
||||
},
|
||||
Denied,
|
||||
TimedOut,
|
||||
Abort,
|
||||
}
|
||||
|
||||
pub fn new_approval_decision_cell(
|
||||
command: Vec<String>,
|
||||
decision: codex_protocol::protocol::ReviewDecision,
|
||||
decision: ReviewDecision,
|
||||
actor: ApprovalDecisionActor,
|
||||
) -> Box<dyn HistoryCell> {
|
||||
use codex_protocol::protocol::NetworkPolicyRuleAction;
|
||||
use codex_protocol::protocol::ReviewDecision::*;
|
||||
use ReviewDecision::*;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
|
||||
let (symbol, summary): (Span<'static>, Vec<Span<'static>>) = match decision {
|
||||
Approved => {
|
||||
@@ -1231,28 +1248,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 +1311,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 +1326,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 +1335,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>,
|
||||
@@ -1555,6 +1578,13 @@ pub(crate) struct McpToolCallCell {
|
||||
animations_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct McpInvocation {
|
||||
pub(crate) server: String,
|
||||
pub(crate) tool: String,
|
||||
pub(crate) arguments: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl McpToolCallCell {
|
||||
pub(crate) fn new(
|
||||
call_id: String,
|
||||
@@ -1746,6 +1776,42 @@ fn web_search_header(completed: bool) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn web_search_action_detail(action: &WebSearchAction) -> String {
|
||||
match action {
|
||||
WebSearchAction::Search { query, queries } => {
|
||||
query.clone().filter(|q| !q.is_empty()).unwrap_or_else(|| {
|
||||
let items = queries.as_ref();
|
||||
let first = items
|
||||
.and_then(|queries| queries.first())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if items.is_some_and(|queries| queries.len() > 1) && !first.is_empty() {
|
||||
format!("{first} ...")
|
||||
} else {
|
||||
first
|
||||
}
|
||||
})
|
||||
}
|
||||
WebSearchAction::OpenPage { url } => url.clone().unwrap_or_default(),
|
||||
WebSearchAction::FindInPage { url, pattern } => match (pattern, url) {
|
||||
(Some(pattern), Some(url)) => format!("'{pattern}' in {url}"),
|
||||
(Some(pattern), None) => format!("'{pattern}'"),
|
||||
(None, Some(url)) => url.clone(),
|
||||
(None, None) => String::new(),
|
||||
},
|
||||
WebSearchAction::Other => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn web_search_detail(action: Option<&WebSearchAction>, query: &str) -> String {
|
||||
let detail = action.map(web_search_action_detail).unwrap_or_default();
|
||||
if detail.is_empty() {
|
||||
query.to_string()
|
||||
} else {
|
||||
detail
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct WebSearchCell {
|
||||
call_id: String,
|
||||
@@ -2041,7 +2107,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 +2291,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 {
|
||||
@@ -2415,8 +2493,8 @@ pub(crate) fn new_mcp_inventory_loading(animations_enabled: bool) -> McpInventor
|
||||
/// Renders a completed (or interrupted) request_user_input exchange in history.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RequestUserInputResultCell {
|
||||
pub(crate) questions: Vec<RequestUserInputQuestion>,
|
||||
pub(crate) answers: HashMap<String, RequestUserInputAnswer>,
|
||||
pub(crate) questions: Vec<ToolRequestUserInputQuestion>,
|
||||
pub(crate) answers: HashMap<String, ToolRequestUserInputAnswer>,
|
||||
pub(crate) interrupted: bool,
|
||||
}
|
||||
|
||||
@@ -2540,7 +2618,7 @@ fn wrap_with_prefix(
|
||||
/// Split a request_user_input answer into option labels and an optional freeform note.
|
||||
/// Notes are encoded as "user_note: <text>" entries in the answers list.
|
||||
fn split_request_user_input_answer(
|
||||
answer: &RequestUserInputAnswer,
|
||||
answer: &ToolRequestUserInputAnswer,
|
||||
) -> (Vec<String>, Option<String>) {
|
||||
let mut options = Vec::new();
|
||||
let mut note = None;
|
||||
@@ -2992,18 +3070,17 @@ 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;
|
||||
use codex_otel::RuntimeMetricsSummary;
|
||||
use codex_protocol::ThreadId;
|
||||
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 +3089,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 +3260,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(),
|
||||
@@ -3196,10 +3274,10 @@ mod tests {
|
||||
permission_profile: PermissionProfile::read_only(),
|
||||
active_permission_profile: None,
|
||||
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()),
|
||||
}
|
||||
@@ -3297,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),
|
||||
@@ -3319,7 +3397,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),
|
||||
@@ -3336,7 +3414,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),
|
||||
@@ -3355,7 +3433,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),
|
||||
@@ -4216,10 +4294,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,
|
||||
@@ -4229,9 +4308,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()),
|
||||
|
||||
+83
-412
@@ -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,13 @@ 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;
|
||||
use std::sync::Arc;
|
||||
pub use token_usage::TokenUsage;
|
||||
use tracing::Level;
|
||||
use tracing::error;
|
||||
use tracing::warn;
|
||||
@@ -88,6 +83,7 @@ mod app_event;
|
||||
mod app_event_sender;
|
||||
mod app_server_approval_conversions;
|
||||
mod app_server_session;
|
||||
mod approval_events;
|
||||
mod ascii_animation;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod audio_device;
|
||||
@@ -117,6 +113,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 +154,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,6 +167,7 @@ mod terminal_palette;
|
||||
mod terminal_title;
|
||||
mod text_formatting;
|
||||
mod theme_picker;
|
||||
mod token_usage;
|
||||
mod tooltips;
|
||||
mod transcript_reflow;
|
||||
mod tui;
|
||||
@@ -189,7 +189,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 +231,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())
|
||||
}
|
||||
|
||||
@@ -482,7 +485,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 +704,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 +1082,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 +1159,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 +1204,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 +1265,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 +1326,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 +1371,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 +1476,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,19 +1649,12 @@ 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;
|
||||
@@ -2031,17 +1911,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<()> {
|
||||
@@ -2081,60 +1950,65 @@ mod tests {
|
||||
|
||||
#[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, "")?;
|
||||
Box::pin(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(),
|
||||
)
|
||||
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,
|
||||
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);
|
||||
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
})
|
||||
.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 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(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2204,118 +2078,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 +2111,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 +2126,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 +2175,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(())
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,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(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, /*message*/ 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, /*message*/ 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, /*message*/ 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, /*message*/ 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()
|
||||
|
||||
@@ -1018,7 +1018,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;
|
||||
@@ -1049,7 +1048,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::history_cell::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,
|
||||
},
|
||||
|
||||
@@ -3,9 +3,12 @@ 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;
|
||||
@@ -14,9 +17,6 @@ use codex_protocol::models::ActivePermissionProfile;
|
||||
use codex_protocol::models::ActivePermissionProfileModification;
|
||||
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;
|
||||
@@ -247,6 +247,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()),
|
||||
@@ -257,10 +259,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 {
|
||||
@@ -283,18 +282,13 @@ impl StatusHistoryCell {
|
||||
.find(|(k, _)| *k == "approval")
|
||||
.map(|(_, v)| v.clone())
|
||||
.unwrap_or_else(|| "<unknown>".to_string());
|
||||
let permission_profile = config.permissions.permission_profile();
|
||||
let active_permission_profile = config.permissions.active_permission_profile();
|
||||
let sandbox = status_permission_summary(&permission_profile, config.cwd.as_path());
|
||||
let approval = status_approval_label(
|
||||
config.permissions.approval_policy.value(),
|
||||
config.approvals_reviewer,
|
||||
&approval,
|
||||
);
|
||||
let approval = status_approval_label(approval_policy, config.approvals_reviewer, &approval);
|
||||
let permissions = status_permissions_label(
|
||||
active_permission_profile.as_ref(),
|
||||
&permission_profile,
|
||||
config.permissions.approval_policy.value(),
|
||||
approval_policy,
|
||||
&sandbox,
|
||||
&approval,
|
||||
);
|
||||
|
||||
@@ -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,29 +8,73 @@ 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::ApprovalsReviewer;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::models::ActivePermissionProfile;
|
||||
use codex_protocol::models::ActivePermissionProfileModification;
|
||||
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 codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
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())
|
||||
@@ -40,11 +84,8 @@ async fn test_config(temp_home: &TempDir) -> Config {
|
||||
config.approvals_reviewer = ApprovalsReviewer::User;
|
||||
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
|
||||
@@ -171,13 +212,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,
|
||||
@@ -224,16 +265,13 @@ async fn status_permissions_non_default_workspace_write_uses_workspace_label() {
|
||||
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");
|
||||
|
||||
@@ -250,7 +288,7 @@ async fn status_permissions_named_read_only_profile_shows_builtin_label() {
|
||||
config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(AskForApproval::OnRequest)
|
||||
.set(AskForApproval::OnRequest.to_core())
|
||||
.expect("set approval policy");
|
||||
config
|
||||
.permissions
|
||||
@@ -273,7 +311,7 @@ async fn status_permissions_read_only_profile_shows_additional_writable_roots()
|
||||
config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(AskForApproval::OnRequest)
|
||||
.set(AskForApproval::OnRequest.to_core())
|
||||
.expect("set approval policy");
|
||||
let extra_root = test_path_buf("/workspace/extra").abs();
|
||||
let file_system_policy = PermissionProfile::read_only()
|
||||
@@ -309,7 +347,7 @@ async fn status_permissions_named_workspace_profile_shows_builtin_label() {
|
||||
config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(AskForApproval::OnRequest)
|
||||
.set(AskForApproval::OnRequest.to_core())
|
||||
.expect("set approval policy");
|
||||
config
|
||||
.permissions
|
||||
@@ -333,7 +371,7 @@ async fn status_permissions_workspace_auto_review_shows_reviewer_label() {
|
||||
config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(AskForApproval::OnRequest)
|
||||
.set(AskForApproval::OnRequest.to_core())
|
||||
.expect("set approval policy");
|
||||
config
|
||||
.permissions
|
||||
@@ -356,7 +394,7 @@ async fn status_permissions_named_profile_shows_additional_writable_roots() {
|
||||
config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(AskForApproval::OnRequest)
|
||||
.set(AskForApproval::OnRequest.to_core())
|
||||
.expect("set approval policy");
|
||||
let extra_root = test_path_buf("/workspace/extra").abs();
|
||||
config
|
||||
@@ -391,7 +429,7 @@ async fn status_permissions_broadened_workspace_profile_shows_builtin_label() {
|
||||
config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(AskForApproval::OnRequest)
|
||||
.set(AskForApproval::OnRequest.to_core())
|
||||
.expect("set approval policy");
|
||||
config
|
||||
.permissions
|
||||
@@ -532,14 +570,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!(
|
||||
@@ -555,14 +596,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!(
|
||||
@@ -650,8 +694,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,
|
||||
@@ -956,8 +1000,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,
|
||||
@@ -1116,13 +1160,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,
|
||||
@@ -1183,13 +1227,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 {
|
||||
@@ -1369,13 +1413,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,
|
||||
@@ -1436,13 +1480,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 {
|
||||
|
||||
@@ -148,11 +148,6 @@ impl StatusIndicatorWidget {
|
||||
self.show_interrupt_hint = visible;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn interrupt_hint_visible(&self) -> bool {
|
||||
self.show_interrupt_hint
|
||||
}
|
||||
|
||||
pub(crate) fn pause_timer(&mut self) {
|
||||
self.pause_timer_at(Instant::now());
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
})
|
||||
}
|
||||
@@ -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