merge local main into code-branch-20251006-192649 before merge

This commit is contained in:
James Peter
2025-10-07 07:46:55 +10:00
2 changed files with 414 additions and 40 deletions
+224 -30
View File
@@ -2173,6 +2173,66 @@ impl ChatWidget<'_> {
}
}
#[inline]
fn overall_task_status_for(agents: &[AgentInfo]) -> &'static str {
if agents.is_empty() {
"preparing"
} else if agents
.iter()
.any(|a| matches!(a.status, AgentStatus::Running))
{
"running"
} else if agents
.iter()
.all(|a| matches!(a.status, AgentStatus::Completed))
{
"complete"
} else if agents
.iter()
.any(|a| matches!(a.status, AgentStatus::Failed))
{
"failed"
} else if agents
.iter()
.any(|a| matches!(a.status, AgentStatus::Cancelled))
{
"cancelled"
} else {
"planning"
}
}
/// Mark all tracked agents as having reached a terminal state when a turn finishes.
fn finalize_agent_activity(&mut self) {
if self.active_agents.is_empty()
&& self.agent_runtime.is_empty()
&& self.agents_terminal.entries.is_empty()
{
self.agents_ready_to_start = false;
return;
}
for agent in self.active_agents.iter_mut() {
if matches!(agent.status, AgentStatus::Pending | AgentStatus::Running) {
agent.status = AgentStatus::Completed;
}
}
for entry in self.agents_terminal.entries.values_mut() {
if matches!(entry.status, AgentStatus::Pending | AgentStatus::Running) {
entry.status = AgentStatus::Completed;
entry.push_log(
AgentLogKind::Status,
format!("Status → {}", agent_status_label(AgentStatus::Completed)),
);
}
}
self.agents_ready_to_start = false;
let status = Self::overall_task_status_for(&self.active_agents);
self.overall_task_status = status.to_string();
}
fn remove_background_completion_message(&mut self, call_id: &str) {
if let Some(idx) = self.history_cells.iter().rposition(|cell| {
@@ -8591,6 +8651,7 @@ impl ChatWidget<'_> {
// observed the corresponding TaskComplete event. Clear the active marker now so
// the status spinner can hide promptly when nothing else is running.
self.active_task_ids.remove(&id);
self.finalize_agent_activity();
self.maybe_hide_spinner();
// Important: do not advance Auto Drive here. The StreamController will emit
// AppEvent::InsertFinalAnswer, and the App thread will finalize the assistant
@@ -8838,8 +8899,8 @@ impl ChatWidget<'_> {
self.auto_handle_post_turn_review(cfg);
}
}
// Defensive: clear transient agents-preparing state
self.agents_ready_to_start = false;
// Defensive: mark any lingering agent state as complete so the spinner can quiesce
self.finalize_agent_activity();
// Convert any lingering running exec/tool cells to completed so the UI doesn't hang
self.finalize_all_running_due_to_answer();
// Mark any running web searches as completed
@@ -9946,39 +10007,20 @@ impl ChatWidget<'_> {
}
// Update overall task status based on agent states
self.overall_task_status = if self.active_agents.is_empty() {
"preparing".to_string()
} else if self
let status = Self::overall_task_status_for(&self.active_agents);
self.overall_task_status = status.to_string();
let agents_still_active = self
.active_agents
.iter()
.any(|a| matches!(a.status, AgentStatus::Running))
{
"running".to_string()
} else if self
.active_agents
.iter()
.all(|a| matches!(a.status, AgentStatus::Completed))
{
"complete".to_string()
} else if self
.active_agents
.iter()
.any(|a| matches!(a.status, AgentStatus::Failed))
{
"failed".to_string()
} else if self
.active_agents
.iter()
.any(|a| matches!(a.status, AgentStatus::Cancelled))
{
"cancelled".to_string()
} else {
"planning".to_string()
};
.any(|a| matches!(a.status, AgentStatus::Pending | AgentStatus::Running));
if agents_still_active {
self.bottom_pane.set_task_running(true);
}
// Reflect concise agent status in the input border
let count = self.active_agents.len();
let msg = match self.overall_task_status.as_str() {
let msg = match status {
"preparing" => format!("agents: preparing ({} ready)", count),
"running" => format!("agents: running ({})", count),
"complete" => format!("agents: complete ({} ok)", count),
@@ -18982,6 +19024,8 @@ mod tests {
TextTone,
};
use code_core::protocol::OrderMeta;
use code_core::protocol::{AgentMessageEvent, AgentStatusUpdateEvent, Event, EventMsg};
use code_core::protocol::AgentInfo as CoreAgentInfo;
use ratatui::backend::TestBackend;
use ratatui::text::Line;
use ratatui::Terminal;
@@ -19095,6 +19139,156 @@ mod tests {
);
}
#[test]
fn final_answer_clears_spinner_when_agent_never_reports_terminal_status() {
let mut harness = ChatWidgetHarness::new();
let chat = harness.chat();
reset_history(chat);
let turn_id = "turn-1".to_string();
chat.handle_code_event(Event {
id: turn_id.clone(),
event_seq: 0,
msg: EventMsg::TaskStarted,
order: None,
});
chat.handle_code_event(Event {
id: turn_id.clone(),
event_seq: 1,
msg: EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent {
agents: vec![CoreAgentInfo {
id: "agent-1".to_string(),
name: "Todo Agent".to_string(),
status: "running".to_string(),
batch_id: None,
model: None,
last_progress: None,
result: None,
error: None,
}],
context: None,
task: None,
}),
order: None,
});
assert!(
chat.bottom_pane.is_task_running(),
"spinner should remain active while the agent reports running"
);
chat.handle_code_event(Event {
id: turn_id.clone(),
event_seq: 2,
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "Completed todo items.".to_string(),
}),
order: None,
});
assert!(
!chat.bottom_pane.is_task_running(),
"spinner should clear once the final answer arrives even without a terminal status update"
);
assert_eq!(chat.overall_task_status, "complete".to_string());
assert!(
chat
.agent_runtime
.values()
.all(|rt| rt.completed_at.is_none()),
"runtime should remain incomplete until backend reports a terminal status"
);
assert!(
chat
.active_agents
.iter()
.all(|agent| !matches!(agent.status, AgentStatus::Pending | AgentStatus::Running)),
"agents should be forced into a terminal status after the answer completes"
);
}
#[test]
fn spinner_rearms_when_late_agent_update_reports_running() {
let mut harness = ChatWidgetHarness::new();
let chat = harness.chat();
reset_history(chat);
let turn_id = "turn-1".to_string();
chat.handle_code_event(Event {
id: turn_id.clone(),
event_seq: 0,
msg: EventMsg::TaskStarted,
order: None,
});
chat.handle_code_event(Event {
id: turn_id.clone(),
event_seq: 1,
msg: EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent {
agents: vec![CoreAgentInfo {
id: "agent-1".to_string(),
name: "Todo Agent".to_string(),
status: "running".to_string(),
batch_id: None,
model: None,
last_progress: None,
result: None,
error: None,
}],
context: None,
task: None,
}),
order: None,
});
assert!(chat.bottom_pane.is_task_running(), "spinner should be running initially");
chat.handle_code_event(Event {
id: turn_id.clone(),
event_seq: 2,
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "Completed todo items.".to_string(),
}),
order: None,
});
assert!(
!chat.bottom_pane.is_task_running(),
"final answer should clear the spinner when no terminal update arrives"
);
chat.handle_code_event(Event {
id: turn_id.clone(),
event_seq: 3,
msg: EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent {
agents: vec![CoreAgentInfo {
id: "agent-1".to_string(),
name: "Todo Agent".to_string(),
status: "running".to_string(),
batch_id: None,
model: None,
last_progress: None,
result: None,
error: None,
}],
context: None,
task: None,
}),
order: None,
});
assert!(
chat.bottom_pane.is_task_running(),
"late running update should re-enable the spinner"
);
}
#[test]
fn scrollback_spacer_preserves_top_cell_bottom_line() {
let mut harness = ChatWidgetHarness::new();
+190 -10
View File
@@ -10,6 +10,7 @@ use code_core::config::Config;
use code_core::config::ConfigOverrides;
use code_core::config::ConfigToml;
use code_core::config::find_code_home;
use code_core::config::load_config_as_toml;
use code_core::config::load_config_as_toml_with_cli_overrides;
use code_core::protocol::AskForApproval;
use code_core::protocol::SandboxPolicy;
@@ -290,6 +291,33 @@ pub async fn run_main(
.iter()
.any(|(path, _)| path.starts_with("tui.theme"));
let code_home = match find_code_home() {
Ok(code_home) => code_home,
#[allow(clippy::print_stderr)]
Err(err) => {
eprintln!("Error finding codex home: {err}");
std::process::exit(1);
}
};
let workspace_write_network_access_explicit = {
let cli_override = cli_kv_overrides
.iter()
.any(|(path, _)| path == "sandbox_workspace_write.network_access");
if cli_override {
true
} else {
match load_config_as_toml(&code_home) {
Ok(raw) => raw
.get("sandbox_workspace_write")
.and_then(|value| value.as_table())
.map_or(false, |table| table.contains_key("network_access")),
Err(_) => false,
}
}
};
let mut config = {
// Load configuration and support CLI overrides.
@@ -308,17 +336,9 @@ pub async fn run_main(
// we load config.toml here to determine project state.
#[allow(clippy::print_stderr)]
let (config_toml, theme_set_in_config_file) = {
let code_home = match find_code_home() {
Ok(code_home) => code_home,
Err(err) => {
eprintln!("Error finding codex home: {err}");
std::process::exit(1);
}
};
let theme_set_in_config_file = theme_configured_in_config_file(&code_home);
match load_config_as_toml_with_cli_overrides(&code_home, cli_kv_overrides) {
match load_config_as_toml_with_cli_overrides(&code_home, cli_kv_overrides.clone()) {
Ok(config_toml) => (config_toml, theme_set_in_config_file),
Err(err) => {
eprintln!("Error loading config.toml: {err}");
@@ -335,6 +355,7 @@ pub async fn run_main(
approval_policy,
sandbox_mode,
cli.config_profile.clone(),
workspace_write_network_access_explicit,
)?;
let log_dir = code_core::config::log_dir(&config)?;
@@ -697,6 +718,7 @@ fn determine_repo_trust_state(
approval_policy_overide: Option<AskForApproval>,
sandbox_mode_override: Option<SandboxMode>,
config_profile_override: Option<String>,
workspace_write_network_access_explicit: bool,
) -> std::io::Result<bool> {
let config_profile = config_toml.get_config_profile(config_profile_override)?;
@@ -729,6 +751,31 @@ fn determine_repo_trust_state(
// If the current cwd project is trusted and no explicit config has been set,
// default to fully trusted, non‑interactive execution to match expected behavior.
// This restores the previous semantics before the recent trust‑flow refactor.
if let Some(workspace_write) = config_toml.sandbox_workspace_write.as_ref() {
// Honor explicit sandbox WorkspaceWrite protections (like allow_git_writes = false)
// even when the project is marked as trusted.
if !workspace_write.allow_git_writes {
// Maintain the historical networking behaviour from DangerFullAccess: allow outbound
// network even when we pivot into WorkspaceWrite solely to protect `.git`.
let network_access = if workspace_write.network_access {
true
} else if workspace_write_network_access_explicit {
false
} else {
true
};
config.approval_policy = AskForApproval::Never;
config.sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: workspace_write.writable_roots.clone(),
network_access,
exclude_tmpdir_env_var: workspace_write.exclude_tmpdir_env_var,
exclude_slash_tmp: workspace_write.exclude_slash_tmp,
allow_git_writes: workspace_write.allow_git_writes,
};
return Ok(false);
}
}
config.approval_policy = AskForApproval::Never;
config.sandbox_policy = SandboxPolicy::DangerFullAccess;
Ok(false)
@@ -737,4 +784,137 @@ fn determine_repo_trust_state(
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
use code_core::config::ProjectConfig;
use code_core::config_types::SandboxWorkspaceWrite;
use code_core::protocol::AskForApproval;
use std::collections::HashMap;
use tempfile::TempDir;
fn make_trusted_config(
sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
) -> std::io::Result<(Config, ConfigToml)> {
let code_home = TempDir::new()?;
let workspace = TempDir::new()?;
let mut config_toml = ConfigToml::default();
config_toml.sandbox_workspace_write = sandbox_workspace_write;
let mut projects = HashMap::new();
projects.insert(
workspace.path().to_string_lossy().to_string(),
ProjectConfig {
trust_level: Some("trusted".to_string()),
approval_policy: None,
sandbox_mode: None,
always_allow_commands: None,
hooks: vec![],
commands: vec![],
},
);
config_toml.projects = Some(projects);
let overrides = ConfigOverrides {
cwd: Some(workspace.path().to_path_buf()),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
config_toml.clone(),
overrides,
code_home.path().to_path_buf(),
)?;
Ok((config, config_toml))
}
#[test]
fn trusted_workspace_honors_allow_git_writes_override() -> std::io::Result<()> {
let (mut config, config_toml) = make_trusted_config(Some(SandboxWorkspaceWrite {
allow_git_writes: false,
..Default::default()
}))?;
let show_trust = determine_repo_trust_state(
&mut config,
&config_toml,
None,
None,
None,
false,
)?;
assert!(!show_trust);
match &config.sandbox_policy {
SandboxPolicy::WorkspaceWrite {
allow_git_writes,
network_access,
..
} => {
assert!(!allow_git_writes);
assert!(*network_access, "trusted WorkspaceWrite should retain network access");
}
other => panic!("expected workspace-write sandbox, got {other:?}"),
}
assert!(matches!(config.approval_policy, AskForApproval::Never));
Ok(())
}
#[test]
fn trusted_workspace_default_stays_danger_full_access() -> std::io::Result<()> {
let (mut config, config_toml) = make_trusted_config(None)?;
let show_trust = determine_repo_trust_state(
&mut config,
&config_toml,
None,
None,
None,
false,
)?;
assert!(!show_trust);
assert!(matches!(config.sandbox_policy, SandboxPolicy::DangerFullAccess));
assert!(matches!(config.approval_policy, AskForApproval::Never));
Ok(())
}
#[test]
fn trusted_workspace_respects_explicit_network_disable() -> std::io::Result<()> {
let (mut config, config_toml) = make_trusted_config(Some(SandboxWorkspaceWrite {
allow_git_writes: false,
network_access: false,
..Default::default()
}))?;
let show_trust = determine_repo_trust_state(
&mut config,
&config_toml,
None,
None,
None,
true,
)?;
assert!(!show_trust);
match &config.sandbox_policy {
SandboxPolicy::WorkspaceWrite {
allow_git_writes,
network_access,
..
} => {
assert!(!allow_git_writes);
assert!(!network_access, "explicit opt-out should disable network access");
}
other => panic!("expected workspace-write sandbox, got {other:?}"),
}
Ok(())
}
}