feat(v0.8.45): land control-plane workstream slices (#2035)

Includes cancellable file_search/list_dir behavior, deterministic sub-agent whale-species nicknames, and an honest /balance command scaffold for the provider-billing work. Reviewed the overlapping file-search cancellation contribution in #2044 from @h3c-hexin; that PR's Windows failure was unrelated to the cancellation code, and the contributor context is preserved here.
This commit is contained in:
Hunter Bown
2026-05-24 21:18:54 -05:00
committed by GitHub
parent 4a9f7cbb2b
commit d22da53e2d
8 changed files with 498 additions and 35 deletions
+28
View File
@@ -0,0 +1,28 @@
//! Balance: query the active provider's account balance or credit status.
//!
//! Provider-specific network dispatch is still pending. Until that lands, keep
//! this command explicit about being a scaffold so users do not mistake it for
//! a live balance lookup.
use crate::config::ApiProvider;
use crate::tui::app::App;
use super::CommandResult;
/// Query provider account balance / credits.
pub fn balance(app: &mut App) -> CommandResult {
let provider = app.api_provider;
match provider {
ApiProvider::Deepseek
| ApiProvider::DeepseekCN
| ApiProvider::Openrouter
| ApiProvider::Novita => CommandResult::message(format!(
"Balance check for {} is planned, but provider balance network dispatch is not wired in this build yet.",
provider.display_name()
)),
_ => CommandResult::message(format!(
"Balance check is not supported for {} yet. Check the provider dashboard for account balance details.",
provider.display_name()
)),
}
}
+52 -1
View File
@@ -5,6 +5,7 @@
mod anchor;
mod attachment;
mod balance;
mod change;
mod config;
mod core;
@@ -518,6 +519,13 @@ pub const COMMANDS: &[CommandInfo] = &[
usage: "/cost",
description_id: MessageId::CmdCostDescription,
},
// Balance query (#2019)
CommandInfo {
name: "balance",
aliases: &[],
usage: "/balance",
description_id: MessageId::CmdBalanceDescription,
},
// Profile switching (#390)
CommandInfo {
name: "profile",
@@ -603,6 +611,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
"translate" | "translation" | "transale" => core::translate(app),
"tokens" => debug::tokens(app),
"cost" => debug::cost(app),
"balance" => balance::balance(app),
"cache" => debug::cache(app, arg),
// ChangeLog command
@@ -1063,7 +1072,7 @@ fn suggest_command_names(input: &str, limit: usize) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::config::{ApiProvider, Config};
use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs};
use crate::tools::todo::TodoStatus;
use crate::tui::app::{App, AppAction, TuiOptions};
@@ -1485,6 +1494,48 @@ mod tests {
}
}
#[test]
fn balance_command_has_own_help_text() {
let info = get_command_info("balance").expect("balance command should be registered");
assert_eq!(info.description_id, MessageId::CmdBalanceDescription);
assert!(
info.description_for(Locale::En)
.contains("provider account balance")
);
}
#[test]
fn balance_command_reports_scaffold_without_claiming_dispatch() {
let mut app = create_test_app();
app.api_provider = ApiProvider::Deepseek;
let result = execute("/balance", &mut app);
let msg = result
.message
.expect("balance scaffold should explain current state");
assert!(!result.is_error);
assert!(msg.contains("DeepSeek"));
assert!(msg.contains("not wired"));
assert!(!msg.contains("sent"));
}
#[test]
fn balance_command_reports_unsupported_provider_clearly() {
let mut app = create_test_app();
app.api_provider = ApiProvider::Ollama;
let result = execute("/balance", &mut app);
let msg = result
.message
.expect("unsupported providers should return a clear message");
assert!(!result.is_error);
assert!(msg.contains("Ollama"));
assert!(msg.contains("not supported"));
assert!(msg.contains("dashboard"));
}
#[test]
fn unknown_command_suggests_nearest_match() {
let mut app = create_test_app();
+7
View File
@@ -252,6 +252,7 @@ pub enum MessageId {
CmdChangeTranslationQueued,
CmdChangeTranslationUnavailable,
CmdChangePreviousVersion,
CmdBalanceDescription,
CmdClearDescription,
CmdCompactDescription,
CmdConfigDescription,
@@ -486,6 +487,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::HelpFooterClose,
MessageId::CmdAnchorDescription,
MessageId::CmdAttachDescription,
MessageId::CmdBalanceDescription,
MessageId::CmdCacheDescription,
MessageId::CmdClearDescription,
MessageId::CmdCompactDescription,
@@ -915,6 +917,7 @@ fn english(id: MessageId) -> &'static str {
MessageId::CmdChangePreviousVersion => {
"Previous version: {version} — run `/change {version}` to view it"
}
MessageId::CmdBalanceDescription => "Check the active provider account balance",
MessageId::CmdClearDescription => "Clear conversation history",
MessageId::CmdCompactDescription => {
"Trigger context compaction to free up space (legacy; v0.6.6 prefers cycle restart)"
@@ -1294,6 +1297,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
MessageId::CmdChangePreviousVersion => {
"前のバージョン: {version} — `/change {version}` で表示"
}
MessageId::CmdBalanceDescription => "アクティブなプロバイダーのアカウント残高を確認",
MessageId::CmdClearDescription => "会話履歴をクリア",
MessageId::CmdCompactDescription => {
"コンテキスト圧縮で容量を確保(旧式:v0.6.6 以降はサイクル再起動を推奨)"
@@ -1648,6 +1652,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
MessageId::CmdChangePreviousVersion => {
"上一个版本: {version} —— 输入 `/change {version}` 查看"
}
MessageId::CmdBalanceDescription => "查看当前提供商账户余额",
MessageId::CmdClearDescription => "清除对话历史",
MessageId::CmdCompactDescription => {
"触发上下文压缩以释放空间(旧版命令;v0.6.6 起建议改用循环重启)"
@@ -1962,6 +1967,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
MessageId::CmdChangePreviousVersion => {
"Versão anterior: {version} — execute `/change {version}` para visualizar"
}
MessageId::CmdBalanceDescription => "Verificar o saldo da conta do provedor ativo",
MessageId::CmdClearDescription => "Limpar o histórico da conversa",
MessageId::CmdCompactDescription => {
"Compactar o contexto para liberar espaço (legado; a v0.6.6 prefere o reinício de ciclo)"
@@ -2348,6 +2354,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> {
MessageId::CmdChangePreviousVersion => {
"Versión anterior: {version} — ejecuta `/change {version}` para verla"
}
MessageId::CmdBalanceDescription => "Consultar el saldo de la cuenta del proveedor activo",
MessageId::CmdClearDescription => "Limpiar el historial de la conversación",
MessageId::CmdCompactDescription => {
"Compactar el contexto para liberar espacio (heredado; v0.6.6 prefiere reinicio de ciclo)"
+136 -20
View File
@@ -11,8 +11,10 @@ use super::spec::{
use async_trait::async_trait;
use serde_json::{Value, json};
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
use tokio_util::sync::CancellationToken;
// === ReadFileTool ===
@@ -761,6 +763,8 @@ fn punctuation_normalized_matches(contents: &str, search: &str) -> Vec<(usize, u
/// Tool for listing directory contents.
pub struct ListDirTool;
const LIST_DIR_TIMEOUT: Duration = Duration::from_secs(30);
#[async_trait]
impl ToolSpec for ListDirTool {
fn name(&self) -> &'static str {
@@ -796,30 +800,107 @@ impl ToolSpec for ListDirTool {
let path_str = optional_str(&input, "path").unwrap_or(".");
let dir_path = context.resolve_path(path_str)?;
let mut entries = Vec::new();
for entry in fs::read_dir(&dir_path).map_err(|e| {
ToolError::execution_failed(format!(
"Failed to read directory {}: {}",
dir_path.display(),
e
))
})? {
let entry = entry.map_err(|e| ToolError::execution_failed(e.to_string()))?;
let file_type = entry
.file_type()
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
entries.push(json!({
"name": entry.file_name().to_string_lossy().to_string(),
"is_dir": file_type.is_dir(),
}));
}
let entries =
list_dir_entries_async(dir_path, context.cancel_token.clone(), LIST_DIR_TIMEOUT)
.await?;
ToolResult::json(&entries).map_err(|e| ToolError::execution_failed(e.to_string()))
}
}
async fn list_dir_entries_async(
dir_path: PathBuf,
cancel_token: Option<CancellationToken>,
timeout: Duration,
) -> Result<Vec<Value>, ToolError> {
let worker_cancel_token = cancel_token.clone();
run_blocking_list_dir(timeout, cancel_token, move || {
list_dir_entries(&dir_path, worker_cancel_token.as_ref())
})
.await
}
async fn run_blocking_list_dir<F>(
timeout: Duration,
cancel_token: Option<CancellationToken>,
list_dir: F,
) -> Result<Vec<Value>, ToolError>
where
F: FnOnce() -> Result<Vec<Value>, ToolError> + Send + 'static,
{
if cancel_token
.as_ref()
.is_some_and(CancellationToken::is_cancelled)
{
return Err(list_dir_cancelled());
}
let task = tokio::task::spawn_blocking(list_dir);
let result = match cancel_token {
Some(token) => {
tokio::select! {
biased;
() = token.cancelled() => return Err(list_dir_cancelled()),
result = tokio::time::timeout(timeout, task) => result,
}
}
None => tokio::time::timeout(timeout, task).await,
};
let joined = result.map_err(|_| list_dir_timeout(timeout))?;
joined.map_err(|err| {
ToolError::execution_failed(format!("list_dir worker failed before completion: {err}"))
})?
}
fn list_dir_entries(
dir_path: &Path,
cancel_token: Option<&CancellationToken>,
) -> Result<Vec<Value>, ToolError> {
check_list_dir_cancelled(cancel_token)?;
let mut entries = Vec::new();
for entry in fs::read_dir(dir_path).map_err(|e| {
ToolError::execution_failed(format!(
"Failed to read directory {}: {}",
dir_path.display(),
e
))
})? {
check_list_dir_cancelled(cancel_token)?;
let entry = entry.map_err(|e| ToolError::execution_failed(e.to_string()))?;
let file_type = entry
.file_type()
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
entries.push(json!({
"name": entry.file_name().to_string_lossy().to_string(),
"is_dir": file_type.is_dir(),
}));
}
Ok(entries)
}
fn check_list_dir_cancelled(cancel_token: Option<&CancellationToken>) -> Result<(), ToolError> {
if cancel_token.is_some_and(CancellationToken::is_cancelled) {
return Err(list_dir_cancelled());
}
Ok(())
}
fn list_dir_cancelled() -> ToolError {
ToolError::execution_failed("list_dir cancelled before completion")
}
fn list_dir_timeout(timeout: Duration) -> ToolError {
ToolError::Timeout {
seconds: timeout.as_secs().max(1),
}
}
// === Unit Tests ===
#[cfg(test)]
@@ -1647,6 +1728,41 @@ mod tests {
assert!(result.content.contains("nested.txt"));
}
#[tokio::test]
async fn test_list_dir_respects_cancel_token() {
let tmp = tempdir().expect("tempdir");
fs::write(tmp.path().join("file.txt"), "").expect("write");
let cancel_token = CancellationToken::new();
cancel_token.cancel();
let ctx = ToolContext::new(tmp.path().to_path_buf()).with_cancel_token(cancel_token);
let tool = ListDirTool;
let err = tool
.execute(json!({}), &ctx)
.await
.expect_err("cancelled list_dir should return an error");
assert!(
format!("{err:?}").contains("cancelled"),
"unexpected error: {err:?}"
);
}
#[tokio::test]
async fn test_list_dir_blocking_wrapper_reports_timeout() {
let err = run_blocking_list_dir(Duration::from_millis(1), None, || {
std::thread::sleep(Duration::from_millis(50));
Ok(Vec::new())
})
.await
.expect_err("slow list_dir worker should time out");
assert!(
matches!(err, ToolError::Timeout { seconds: 1 }),
"unexpected error: {err:?}"
);
}
#[test]
fn test_read_file_tool_properties() {
let tool = ReadFileTool;
+131 -2
View File
@@ -1,12 +1,14 @@
//! File search tool with fuzzy matching and scoring.
use std::cmp::Ordering;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::time::Duration;
use async_trait::async_trait;
use ignore::WalkBuilder;
use serde::Serialize;
use serde_json::{Value, json};
use tokio_util::sync::CancellationToken;
use crate::tools::search::matches_glob;
@@ -15,6 +17,8 @@ use super::spec::{
optional_str, optional_u64, required_str,
};
const FILE_SEARCH_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Serialize)]
struct FileSearchMatch {
path: String,
@@ -87,11 +91,88 @@ impl ToolSpec for FileSearchTool {
let extensions = parse_extensions(&input);
let exclude_patterns = parse_exclude_patterns(&input);
let matches = search_files(query, &base_path, extensions, exclude_patterns, limit)?;
let matches = search_files_async(
query.to_string(),
base_path,
extensions,
exclude_patterns,
limit,
context.cancel_token.clone(),
FILE_SEARCH_TIMEOUT,
)
.await?;
ToolResult::json(&matches).map_err(|e| ToolError::execution_failed(e.to_string()))
}
}
async fn search_files_async(
query: String,
base_path: PathBuf,
extensions: Vec<String>,
exclude_patterns: Vec<String>,
limit: usize,
cancel_token: Option<CancellationToken>,
timeout: Duration,
) -> Result<Vec<FileSearchMatch>, ToolError> {
let worker_cancel_token = cancel_token.clone();
run_blocking_file_search(timeout, cancel_token, move || {
search_files(
&query,
&base_path,
extensions,
exclude_patterns,
limit,
worker_cancel_token.as_ref(),
)
})
.await
}
async fn run_blocking_file_search<F>(
timeout: Duration,
cancel_token: Option<CancellationToken>,
search: F,
) -> Result<Vec<FileSearchMatch>, ToolError>
where
F: FnOnce() -> Result<Vec<FileSearchMatch>, ToolError> + Send + 'static,
{
if cancel_token
.as_ref()
.is_some_and(CancellationToken::is_cancelled)
{
return Err(file_search_cancelled());
}
let task = tokio::task::spawn_blocking(search);
let result = match cancel_token {
Some(token) => {
tokio::select! {
biased;
() = token.cancelled() => return Err(file_search_cancelled()),
result = tokio::time::timeout(timeout, task) => result,
}
}
None => tokio::time::timeout(timeout, task).await,
};
let joined = result.map_err(|_| file_search_timeout(timeout))?;
joined.map_err(|err| {
ToolError::execution_failed(format!(
"file_search worker failed before completion: {err}"
))
})?
}
fn file_search_cancelled() -> ToolError {
ToolError::execution_failed("file_search cancelled before completion")
}
fn file_search_timeout(timeout: Duration) -> ToolError {
ToolError::Timeout {
seconds: timeout.as_secs().max(1),
}
}
fn parse_extensions(input: &Value) -> Vec<String> {
let mut out = Vec::new();
if let Some(values) = input.get("extensions").and_then(|v| v.as_array()) {
@@ -147,7 +228,10 @@ fn search_files(
extensions: Vec<String>,
exclude_patterns: Vec<String>,
limit: usize,
cancel_token: Option<&CancellationToken>,
) -> Result<Vec<FileSearchMatch>, ToolError> {
check_cancelled(cancel_token)?;
if !base_path.exists() {
return Err(ToolError::invalid_input(format!(
"Base path does not exist: {}",
@@ -163,6 +247,8 @@ fn search_files(
let walker = builder.build();
for entry in walker {
check_cancelled(cancel_token)?;
let entry = match entry {
Ok(entry) => entry,
Err(_) => continue,
@@ -206,6 +292,13 @@ fn search_files(
Ok(results)
}
fn check_cancelled(cancel_token: Option<&CancellationToken>) -> Result<(), ToolError> {
if cancel_token.is_some_and(CancellationToken::is_cancelled) {
return Err(file_search_cancelled());
}
Ok(())
}
fn should_exclude(rel_path: &str, exclude_patterns: &[String]) -> bool {
exclude_patterns
.iter()
@@ -408,6 +501,42 @@ mod tests {
assert!(!result.content.contains("target/needle.txt"));
}
#[tokio::test]
async fn test_file_search_respects_cancel_token() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
std::fs::write(root.join("needle.txt"), "yes\n").expect("write");
let cancel_token = CancellationToken::new();
cancel_token.cancel();
let ctx = ToolContext::new(root.to_path_buf()).with_cancel_token(cancel_token);
let tool = FileSearchTool;
let err = tool
.execute(json!({"query": "needle"}), &ctx)
.await
.expect_err("cancelled file_search should return an error");
assert!(
format!("{err:?}").contains("cancelled"),
"unexpected error: {err:?}"
);
}
#[tokio::test]
async fn test_file_search_blocking_wrapper_reports_timeout() {
let err = run_blocking_file_search(Duration::from_millis(1), None, || {
std::thread::sleep(Duration::from_millis(50));
Ok(Vec::new())
})
.await
.expect_err("slow file_search worker should time out");
assert!(
matches!(err, ToolError::Timeout { seconds: 1 }),
"unexpected error: {err:?}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn test_file_search_does_not_follow_symlinked_files() {
+121 -7
View File
@@ -86,10 +86,17 @@ const SUBAGENT_RESTART_REASON: &str = "Interrupted by process restart";
const VALID_SUBAGENT_TYPES: &str = "general, explore, plan, review, implementer, verifier, tool_agent, custom, \
worker, explorer, awaiter, default, implement, builder, verify, validator, tester, tool-agent, executor, fin";
/// Whale species names rotated through `whale_nickname_for_index` to label
/// sub-agents in the UI. English and Simplified-Chinese names are interleaved
/// so any newly spawned agent has a roughly even chance of either — the goal
/// is friendly variety, not a strict locale match.
/// Whale species used as friendly names for sub-agents in the UI. The full
/// Cetacea infraorder — baleen whales (Mysticeti), toothed whales
/// (Odontoceti), plus select dolphin species (family Delphinidae) that
/// don't conflate with existing agent type labels. Porpoises (Phocoenidae)
/// are excluded because their name doesn't carry well as a friendly label.
///
/// English and Simplified-Chinese names are interleaved so any newly spawned
/// agent has a roughly even chance of either — the goal is friendly variety,
/// not a strict locale match.
///
/// Taxonomy source: Society for Marine Mammalogy (2025).
pub const WHALE_NICKNAMES: &[&str] = &[
"Blue",
"蓝鲸",
@@ -107,6 +114,14 @@ pub const WHALE_NICKNAMES: &[&str] = &[
"小须鲸",
"Antarctic Minke",
"南极小须鲸",
"Pygmy Right",
"小露脊鲸",
"Omura's",
"大村鲸",
"Eden's",
"艾氏鲸",
"Rice's",
"赖斯鲸",
"Gray",
"灰鲸",
"Bowhead",
@@ -139,8 +154,99 @@ pub const WHALE_NICKNAMES: &[&str] = &[
"贝氏喙鲸",
"Blainville's Beaked",
"柏氏喙鲸",
"Ginkgo-toothed Beaked",
"银杏齿喙鲸",
"Strap-toothed",
"带齿喙鲸",
"Stejneger's Beaked",
"斯氏喙鲸",
"Dwarf Sperm",
"小抹香鲸",
"Pygmy Sperm",
"侏儒抹香鲸",
"Rough-toothed",
"糙齿海豚",
"Atlantic Spotted",
"大西洋斑海豚",
"Pantropical Spotted",
"热带斑海豚",
"Spinner",
"长吻飞旋海豚",
"Clymene",
"短吻飞旋海豚",
"Striped",
"条纹海豚",
"Common Bottlenose",
"宽吻海豚",
"Indo-Pacific Bottlenose",
"印太瓶鼻海豚",
"Risso's",
"灰海豚",
"Commerson's",
"花斑海豚",
"Chilean",
"智利海豚",
"Heaviside's",
"海氏矮海豚",
"Hector's",
"赫氏矮海豚",
"Amazon River",
"亚马逊河豚",
"Ganges River",
"恒河豚",
"Indus River",
"印度河豚",
"La Plata",
"拉普拉塔河豚",
"Franciscana",
"拉河豚",
];
/// Return a deterministic whale name for a given agent ID using a hash of
/// the ID string. The same ID always gets the same name — stable across
/// session restarts for persisted agents.
#[must_use]
pub fn whale_name_for_id(id: &str) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
id.hash(&mut hasher);
let idx = (hasher.finish() as usize) % WHALE_NICKNAMES.len();
WHALE_NICKNAMES[idx].to_string()
}
/// Assign a unique whale name for an agent ID, avoiding collisions with
/// names already in `active_names`. If the deterministic name is taken,
/// appends a numeric suffix (e.g. "Orca (2)").
#[must_use]
pub fn assign_unique_whale_name(
id: &str,
active_names: &std::collections::HashSet<String>,
) -> String {
let base = whale_name_for_id(id);
if !active_names.contains(&base) {
return base;
}
// Deterministic suffix from the same hash to keep it stable
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
id.hash(&mut hasher);
let suffix_seed = hasher.finish();
for i in 2.. {
let candidate = format!("{base} ({i})");
if !active_names.contains(&candidate) {
return candidate;
}
// Vary the probe using the seed
let probe = (suffix_seed.wrapping_add(i as u64)) % 100;
let candidate2 = format!("{base} ({probe})");
if !active_names.contains(&candidate2) {
return candidate2;
}
}
// Fallback (should never reach here)
format!("{base} ({})", id.get(..4).unwrap_or("?"))
}
/// Removal version for deprecated tool aliases.
const DEPRECATION_REMOVAL_VERSION: &str = "0.8.0";
@@ -886,9 +992,11 @@ pub struct SubAgent {
}
impl SubAgent {
/// Create a new sub-agent.
/// Create a new sub-agent. The `id` is generated by the caller so that
/// deterministic whale-naming can hash the ID before construction.
#[allow(clippy::too_many_arguments)]
fn new(
id: String,
agent_type: SubAgentType,
prompt: String,
assignment: SubAgentAssignment,
@@ -898,7 +1006,6 @@ impl SubAgent {
input_tx: mpsc::UnboundedSender<SubAgentInput>,
session_boot_id: String,
) -> Self {
let id = format!("agent_{}", &Uuid::new_v4().to_string()[..8]);
let session_name = id.clone();
Self {
@@ -1199,12 +1306,19 @@ impl SubAgentManager {
runtime.model = model.to_string();
}
let effective_model = runtime.model.clone();
let agent_id = format!("agent_{}", &Uuid::new_v4().to_string()[..8]);
let active_names: std::collections::HashSet<String> = self
.agents
.values()
.filter_map(|a| a.nickname.clone())
.collect();
let nickname = options
.nickname
.or_else(|| Some(whale_nickname_for_index(self.agents.len())));
.or_else(|| Some(assign_unique_whale_name(&agent_id, &active_names)));
let tools = build_allowed_tools(&agent_type, allowed_tools, runtime.allow_shell)?;
let (input_tx, input_rx) = mpsc::unbounded_channel();
let mut agent = SubAgent::new(
agent_id.clone(),
agent_type.clone(),
prompt.clone(),
assignment.clone(),
+10
View File
@@ -803,6 +803,7 @@ async fn test_wait_for_result_reports_timeout_when_still_running() {
let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 2)));
let (input_tx, _input_rx) = mpsc::unbounded_channel();
let agent = SubAgent::new(
"test_agent_1".to_string(),
SubAgentType::Explore,
"prompt".to_string(),
make_assignment(),
@@ -834,6 +835,7 @@ async fn agent_eval_on_completed_session_returns_full_projection_not_running_err
let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 1)));
let (input_tx, _input_rx) = mpsc::unbounded_channel();
let mut agent = SubAgent::new(
"test_agent_2".to_string(),
SubAgentType::Explore,
"analyze 14 issues".to_string(),
make_assignment(),
@@ -887,6 +889,7 @@ async fn test_running_count_counts_only_agents_with_live_task_handles() {
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
let (input_tx, _input_rx) = mpsc::unbounded_channel();
let mut agent = SubAgent::new(
"test_agent_3".to_string(),
SubAgentType::Explore,
"prompt".to_string(),
make_assignment(),
@@ -918,6 +921,7 @@ fn test_running_count_ignores_running_status_without_task_handle() {
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
let (input_tx, _input_rx) = mpsc::unbounded_channel();
let mut agent = SubAgent::new(
"test_agent_4".to_string(),
SubAgentType::Explore,
"prompt".to_string(),
make_assignment(),
@@ -938,6 +942,7 @@ async fn test_running_count_ignores_finished_task_handles() {
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
let (input_tx, _input_rx) = mpsc::unbounded_channel();
let mut agent = SubAgent::new(
"test_agent_5".to_string(),
SubAgentType::Explore,
"prompt".to_string(),
make_assignment(),
@@ -966,6 +971,7 @@ fn test_assign_updates_running_agent_and_sends_message() {
let mut manager = SubAgentManager::new(PathBuf::from("."), 2);
let (input_tx, mut input_rx) = mpsc::unbounded_channel();
let agent = SubAgent::new(
"test_agent_6".to_string(),
SubAgentType::General,
"work".to_string(),
make_assignment(),
@@ -1003,6 +1009,7 @@ fn test_assign_rejects_message_for_non_running_agent() {
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
let (input_tx, _input_rx) = mpsc::unbounded_channel();
let mut agent = SubAgent::new(
"test_agent_7".to_string(),
SubAgentType::Explore,
"prompt".to_string(),
make_assignment(),
@@ -1027,6 +1034,7 @@ fn test_assign_updates_non_running_metadata_without_message() {
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
let (input_tx, _input_rx) = mpsc::unbounded_channel();
let mut agent = SubAgent::new(
"test_agent_8".to_string(),
SubAgentType::Plan,
"prompt".to_string(),
make_assignment(),
@@ -1062,6 +1070,7 @@ fn test_persist_and_reload_marks_running_agent_as_interrupted() {
let mut manager = SubAgentManager::new(workspace.clone(), 2).with_state_path(state_path);
let (input_tx, _input_rx) = mpsc::unbounded_channel();
let running = SubAgent::new(
"test_agent_9_running".to_string(),
SubAgentType::General,
"work".to_string(),
make_assignment(),
@@ -1760,6 +1769,7 @@ fn insert_prior_session_agent(
) {
let (input_tx, _input_rx) = mpsc::unbounded_channel();
let mut agent = SubAgent::new(
id.to_string(),
SubAgentType::General,
"old prompt".to_string(),
make_assignment(),
+13 -5
View File
@@ -1548,6 +1548,7 @@ pub(crate) fn subagent_view_agents(
SubAgentStatus::Running,
progress,
Some("live"),
None, // live rows compute nickname from agent manager on render
));
}
}
@@ -1565,6 +1566,7 @@ pub(crate) fn subagent_view_agents(
lifecycle_to_subagent_status(card.status),
card.summary.as_deref().unwrap_or(card.agent_type.as_str()),
Some("transcript"),
None, // transcript-derived rows get nickname from manager on render
));
}
HistoryCell::SubAgent(SubAgentCell::Fanout(card)) => {
@@ -1581,6 +1583,7 @@ pub(crate) fn subagent_view_agents(
lifecycle_to_subagent_status(worker.status),
&objective,
Some(card.kind.as_str()),
None, // fanout worker rows get nickname from manager on render
));
}
}
@@ -1607,6 +1610,7 @@ fn live_subagent_result(
status: SubAgentStatus,
objective: &str,
role: Option<&str>,
nickname: Option<String>,
) -> SubAgentResult {
SubAgentResult {
name: agent_id.to_string(),
@@ -1619,7 +1623,7 @@ fn live_subagent_result(
role: role.map(str::to_string),
},
model: String::new(),
nickname: None,
nickname,
status,
result: None,
steps_taken: 0,
@@ -1861,15 +1865,19 @@ fn append_subagent_group(
for agent in agents {
let id = truncate_view_text(&agent.agent_id, 11);
let display_name = agent
.nickname
.as_deref()
.map(|nick| format!("{nick:<12}"))
.unwrap_or_else(|| format!("{id:<12}"));
let kind = format_agent_type(&agent.agent_type);
let (status, status_style, status_detail) = format_agent_status(&agent.status);
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{id:<12}"),
Style::default().fg(palette::TEXT_PRIMARY),
),
Span::styled(display_name, Style::default().fg(palette::TEXT_PRIMARY)),
Span::raw(" "),
Span::styled(format!("{id:<11}"), Style::default().fg(palette::TEXT_DIM)),
Span::styled(
format!("{kind:<9}"),
Style::default().fg(palette::TEXT_MUTED),