mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-17 15:34:54 +00:00
merge local main into code-branch-20251006-192649 before merge
This commit is contained in:
+224
-30
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user