diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c68454d..5c8898c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,10 +20,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`exec_shell_interact`** poll loop now observes the turn cancel token so stalled interactive sessions don't block turn cancellation. (#248) - **Transcript running-tool hint** — executing shell cells now show "Ctrl+B opens shell controls" while running. (#248) - **Keybinding registry** now includes `Ctrl+B` (opens shell controls) next to `Ctrl+C` (cancel/exits). (#248) +- **Deferred swarm card creation** — `agent_swarm` no longer pre-seeds an all-pending FanoutCard from `ToolCallStarted`; the card is created only when the first `SwarmProgress` event carries real worker state. Until then the sidebar uses the declared task count as a pending dispatch placeholder. (#236, #238) +- **Swarm wording normalized** — fanout-family fallback labels now render as `swarm`, matching the canonical `agent_swarm` / `rlm` model and avoiding mixed `fanout` / `swarm` terminology in the transcript. (#236, #238) - **OPERATIONS_RUNBOOK** and **TOOL_SURFACE** updated with new shell control paths and `exec_shell_cancel` documentation. +### Fixed +- **Nonblocking swarm state drift** — the sidebar no longer falls back to `0` or a contradictory seeded placeholder before the first progress event arrives, which removes the visible `pending` vs `running/done` mismatch during early `agent_swarm` dispatch. (#236, #238) +- **Unicode-safe search globbing** — search wildcard matching now iterates on UTF-8 char boundaries instead of raw byte offsets, preventing panics on filenames like `dialogue_line__冰糖.mp3`. (#249) + ### Tests - 7 new integration tests: foreground-to-background detach, wait-cancel-leaves-process, single-task cancel, bulk cancel (kill-all), foreground-cancel-kills, ShellControlView default/select states +- Expanded swarm/sidebar regression coverage for deferred card creation and pending-count fallback before first `SwarmProgress`. (#236, #238) +- Added a Unicode filename regression test for wildcard search matching. (#249) ## [0.7.7] - 2026-04-30 diff --git a/Cargo.lock b/Cargo.lock index 2be857d4..55fac355 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1011,7 +1011,7 @@ dependencies = [ [[package]] name = "deepseek-agent" -version = "0.7.7" +version = "0.7.8" dependencies = [ "deepseek-config", "serde", @@ -1019,7 +1019,7 @@ dependencies = [ [[package]] name = "deepseek-app-server" -version = "0.7.7" +version = "0.7.8" dependencies = [ "anyhow", "axum", @@ -1042,7 +1042,7 @@ dependencies = [ [[package]] name = "deepseek-config" -version = "0.7.7" +version = "0.7.8" dependencies = [ "anyhow", "deepseek-secrets", @@ -1055,7 +1055,7 @@ dependencies = [ [[package]] name = "deepseek-core" -version = "0.7.7" +version = "0.7.8" dependencies = [ "anyhow", "chrono", @@ -1074,7 +1074,7 @@ dependencies = [ [[package]] name = "deepseek-execpolicy" -version = "0.7.7" +version = "0.7.8" dependencies = [ "anyhow", "deepseek-protocol", @@ -1083,7 +1083,7 @@ dependencies = [ [[package]] name = "deepseek-hooks" -version = "0.7.7" +version = "0.7.8" dependencies = [ "anyhow", "async-trait", @@ -1097,7 +1097,7 @@ dependencies = [ [[package]] name = "deepseek-mcp" -version = "0.7.7" +version = "0.7.8" dependencies = [ "anyhow", "deepseek-protocol", @@ -1107,7 +1107,7 @@ dependencies = [ [[package]] name = "deepseek-protocol" -version = "0.7.7" +version = "0.7.8" dependencies = [ "serde", "serde_json", @@ -1115,7 +1115,7 @@ dependencies = [ [[package]] name = "deepseek-secrets" -version = "0.7.7" +version = "0.7.8" dependencies = [ "dirs", "keyring", @@ -1128,7 +1128,7 @@ dependencies = [ [[package]] name = "deepseek-state" -version = "0.7.7" +version = "0.7.8" dependencies = [ "anyhow", "chrono", @@ -1140,7 +1140,7 @@ dependencies = [ [[package]] name = "deepseek-tools" -version = "0.7.7" +version = "0.7.8" dependencies = [ "anyhow", "async-trait", @@ -1153,7 +1153,7 @@ dependencies = [ [[package]] name = "deepseek-tui" -version = "0.7.7" +version = "0.7.8" dependencies = [ "anyhow", "arboard", @@ -1213,7 +1213,7 @@ dependencies = [ [[package]] name = "deepseek-tui-cli" -version = "0.7.7" +version = "0.7.8" dependencies = [ "anyhow", "chrono", @@ -1236,7 +1236,7 @@ dependencies = [ [[package]] name = "deepseek-tui-core" -version = "0.7.7" +version = "0.7.8" [[package]] name = "deranged" diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index dd88dccd..68ed79bf 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -7,5 +7,5 @@ repository.workspace = true description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture" [dependencies] -deepseek-config = { path = "../config", version = "0.7.7" } +deepseek-config = { path = "../config", version = "0.7.8" } serde.workspace = true diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index 2efd02bf..67d37ae9 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect anyhow.workspace = true axum.workspace = true clap.workspace = true -deepseek-agent = { path = "../agent", version = "0.7.7" } -deepseek-config = { path = "../config", version = "0.7.7" } -deepseek-core = { path = "../core", version = "0.7.7" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.7.7" } -deepseek-hooks = { path = "../hooks", version = "0.7.7" } -deepseek-mcp = { path = "../mcp", version = "0.7.7" } -deepseek-protocol = { path = "../protocol", version = "0.7.7" } -deepseek-state = { path = "../state", version = "0.7.7" } -deepseek-tools = { path = "../tools", version = "0.7.7" } +deepseek-agent = { path = "../agent", version = "0.7.8" } +deepseek-config = { path = "../config", version = "0.7.8" } +deepseek-core = { path = "../core", version = "0.7.8" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.7.8" } +deepseek-hooks = { path = "../hooks", version = "0.7.8" } +deepseek-mcp = { path = "../mcp", version = "0.7.8" } +deepseek-protocol = { path = "../protocol", version = "0.7.8" } +deepseek-state = { path = "../state", version = "0.7.8" } +deepseek-tools = { path = "../tools", version = "0.7.8" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 00d5d817..62a9b253 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -14,13 +14,13 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -deepseek-agent = { path = "../agent", version = "0.7.7" } -deepseek-app-server = { path = "../app-server", version = "0.7.7" } -deepseek-config = { path = "../config", version = "0.7.7" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.7.7" } -deepseek-mcp = { path = "../mcp", version = "0.7.7" } -deepseek-secrets = { path = "../secrets", version = "0.7.7" } -deepseek-state = { path = "../state", version = "0.7.7" } +deepseek-agent = { path = "../agent", version = "0.7.8" } +deepseek-app-server = { path = "../app-server", version = "0.7.8" } +deepseek-config = { path = "../config", version = "0.7.8" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.7.8" } +deepseek-mcp = { path = "../mcp", version = "0.7.8" } +deepseek-secrets = { path = "../secrets", version = "0.7.8" } +deepseek-state = { path = "../state", version = "0.7.8" } chrono.workspace = true dirs.workspace = true serde.workspace = true diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 1d783fda..a4071fb0 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -8,7 +8,7 @@ description = "Config schema and precedence model for DeepSeek workspace archite [dependencies] anyhow.workspace = true -deepseek-secrets = { path = "../secrets", version = "0.7.7" } +deepseek-secrets = { path = "../secrets", version = "0.7.8" } dirs.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index bf0b18ff..a5a613ff 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,14 +9,14 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -deepseek-agent = { path = "../agent", version = "0.7.7" } -deepseek-config = { path = "../config", version = "0.7.7" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.7.7" } -deepseek-hooks = { path = "../hooks", version = "0.7.7" } -deepseek-mcp = { path = "../mcp", version = "0.7.7" } -deepseek-protocol = { path = "../protocol", version = "0.7.7" } -deepseek-state = { path = "../state", version = "0.7.7" } -deepseek-tools = { path = "../tools", version = "0.7.7" } +deepseek-agent = { path = "../agent", version = "0.7.8" } +deepseek-config = { path = "../config", version = "0.7.8" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.7.8" } +deepseek-hooks = { path = "../hooks", version = "0.7.8" } +deepseek-mcp = { path = "../mcp", version = "0.7.8" } +deepseek-protocol = { path = "../protocol", version = "0.7.8" } +deepseek-state = { path = "../state", version = "0.7.8" } +deepseek-tools = { path = "../tools", version = "0.7.8" } serde_json.workspace = true tokio.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index 585162c8..d0a1e3ef 100644 --- a/crates/execpolicy/Cargo.toml +++ b/crates/execpolicy/Cargo.toml @@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace [dependencies] anyhow.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.7.7" } +deepseek-protocol = { path = "../protocol", version = "0.7.8" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index 84e29ccd..507ee9bf 100644 --- a/crates/hooks/Cargo.toml +++ b/crates/hooks/Cargo.toml @@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc anyhow.workspace = true async-trait.workspace = true chrono.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.7.7" } +deepseek-protocol = { path = "../protocol", version = "0.7.8" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index c4024c8c..ddb641e5 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -8,6 +8,6 @@ description = "MCP server lifecycle and tool proxy compatibility for DeepSeek wo [dependencies] anyhow.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.7.7" } +deepseek-protocol = { path = "../protocol", version = "0.7.8" } serde.workspace = true serde_json.workspace = true diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index fec201f4..68befd90 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral [dependencies] anyhow.workspace = true async-trait.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.7.7" } +deepseek-protocol = { path = "../protocol", version = "0.7.8" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 69abbfe9..88944250 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -13,8 +13,8 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.100" arboard = "3.4" -deepseek-secrets = { path = "../secrets", version = "0.7.7" } -deepseek-tools = { path = "../tools", version = "0.7.7" } +deepseek-secrets = { path = "../secrets", version = "0.7.8" } +deepseek-tools = { path = "../tools", version = "0.7.8" } async-stream = "0.3.6" async-trait = "0.1" bytes = "1.11.0" diff --git a/crates/tui/src/tools/search.rs b/crates/tui/src/tools/search.rs index db0ab479..9d6169da 100644 --- a/crates/tui/src/tools/search.rs +++ b/crates/tui/src/tools/search.rs @@ -356,13 +356,19 @@ fn matches_simple_glob(text: &str, pattern: &str) -> bool { return true; } - // Try matching at each position + // Try matching at each position (use char-indices to stay on + // UTF-8 boundaries — byte-index slicing panics on multi-byte + // characters like 冰糖, see #249). let remaining: String = text_chars.collect(); - for i in 0..=remaining.len() { + for (i, _) in remaining.char_indices() { if matches_simple_glob(&remaining[i..], &next_pattern) { return true; } } + // Also try the empty suffix at end of string + if matches_simple_glob("", &next_pattern) { + return true; + } return false; } '?' => { @@ -423,6 +429,21 @@ mod tests { assert!(!matches_glob("lib/main.rs", "src/*.rs")); } + /// Regression for #249: byte-index slicing panics on multi-byte + /// characters inside filenames like `dialogue_line__冰糖.mp3`. + #[test] + fn test_matches_glob_unicode_filename() { + let filename = "dialogue_line__冰糖.mp3"; + // The filename should match *.mp3 without panicking. + assert!(matches_glob(filename, "*.mp3")); + // Asterisk matching against multi-byte characters must succeed. + assert!(matches_glob(filename, "dialogue_line__*")); + // Literal multi-byte characters inside the pattern must match. + assert!(matches_glob(filename, "*冰糖*")); + // Non-matching pattern must not panic either. + assert!(!matches_glob(filename, "nonexistent*")); + } + #[tokio::test] async fn test_grep_files_basic() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 998953d1..efc49efc 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -497,6 +497,11 @@ pub struct App { /// spawned by the same `agent_swarm` / `rlm` invocation route into /// this card; reset when a fresh fanout-family tool call starts. pub last_fanout_card_index: Option, + /// Number of tasks declared by a pending `agent_swarm` invocation that + /// hasn't yet received its first SwarmProgress event. Used by the + /// sidebar to show "dispatching N" before the FanoutCard exists (#236/#238). + /// Cleared once sync_fanout_card_from_swarm_outcome creates the card. + pub pending_swarm_task_count: Option, /// Canonical swarm/job snapshots by swarm id. Transcript cards, sidebar /// counts, and footer status read from this model instead of recomputing /// worker totals independently. @@ -942,6 +947,7 @@ impl App { agent_progress: HashMap::new(), subagent_card_index: HashMap::new(), last_fanout_card_index: None, + pending_swarm_task_count: None, swarm_jobs: HashMap::new(), last_swarm_id: None, swarm_card_index: HashMap::new(), diff --git a/crates/tui/src/tui/subagent_routing.rs b/crates/tui/src/tui/subagent_routing.rs index 1aaf6422..fabc0c7b 100644 --- a/crates/tui/src/tui/subagent_routing.rs +++ b/crates/tui/src/tui/subagent_routing.rs @@ -27,22 +27,32 @@ pub(super) fn running_agent_count(app: &App) -> usize { } pub(super) fn active_fanout_counts(app: &App) -> Option<(usize, usize)> { + // Canonical source: the in-progress SwarmOutcome from swarm_jobs. if let Some(swarm_id) = app.last_swarm_id.as_ref() && let Some(outcome) = app.swarm_jobs.get(swarm_id) { return Some((outcome.counts.running, outcome.counts.total)); } - let idx = app.last_fanout_card_index?; - let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get(idx) else { - return None; - }; - let running = card - .workers - .iter() - .filter(|slot| matches!(slot.status, AgentLifecycle::Running)) - .count(); - Some((running, card.worker_count())) + // Card exists — read running count from the canonical slot states. + if let Some(idx) = app.last_fanout_card_index + && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get(idx) + { + let running = card + .workers + .iter() + .filter(|slot| matches!(slot.status, AgentLifecycle::Running)) + .count(); + return Some((running, card.worker_count())); + } + + // No card yet — swarm was just dispatched but no SwarmProgress has + // arrived. Show the declared task count so the sidebar doesn't read zero. + if let Some(total) = app.pending_swarm_task_count { + return Some((0, total)); + } + + None } pub(super) fn seed_fanout_card_from_tool_call( @@ -61,32 +71,15 @@ pub(super) fn seed_fanout_card_from_tool_call( return false; } - let ids = tasks - .iter() - .enumerate() - .map(|(idx, task)| { - let task_id = task - .get("id") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .unwrap_or_else(|| idx.to_string()); - format!("task:{task_id}") - }) - .collect::>(); - - let history_threshold_before_push = app.history.len(); - let active_in_flight = app.active_cell.is_some(); - let card = FanoutCard::new(name.to_string()).with_workers(ids); - app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card))); - shift_active_virtual_indices_after_history_insert( - app, - active_in_flight, - history_threshold_before_push, - ); - app.last_fanout_card_index = Some(app.history.len().saturating_sub(1)); - app.mark_history_updated(); + // Codex pattern: don't pre-seed a FanoutCard with all-Pending workers. + // The card gets created by sync_fanout_card_from_swarm_outcome when the + // first SwarmProgress carries real worker states. This eliminates the + // "0 done · 0 running · 0 failed · N pending" vs sidebar "N running" + // contradiction (#236/#238). + // + // Store the pending dispatch info so the transcript tool card (running + // state) serves as the visual placeholder until workers start. + app.pending_swarm_task_count = Some(tasks.len()); true } @@ -220,6 +213,8 @@ pub(super) fn sync_fanout_card_from_swarm_outcome(app: &mut App, outcome: &Swarm }; app.swarm_card_index.insert(outcome.swarm_id.clone(), idx); + app.pending_swarm_task_count = None; + let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get_mut(idx) else { return false; }; @@ -284,26 +279,6 @@ fn swarm_task_status_to_lifecycle(status: &SwarmTaskStatus) -> AgentLifecycle { } } -fn shift_active_virtual_indices_after_history_insert( - app: &mut App, - active_in_flight: bool, - threshold: usize, -) { - if !active_in_flight { - return; - } - for idx in app.tool_cells.values_mut() { - if *idx >= threshold { - *idx = idx.wrapping_add(1); - } - } - for (cell_idx, _) in app.exploring_entries.values_mut() { - if *cell_idx >= threshold { - *cell_idx = cell_idx.wrapping_add(1); - } - } -} - pub(super) fn reconcile_subagent_activity_state(app: &mut App) { let running_agents: Vec<(String, String)> = app .subagent_cache @@ -426,7 +401,7 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox card.claim_pending_worker(&agent_id, AgentLifecycle::Running); app.subagent_card_index.insert(agent_id, idx); } else { - let mut card = FanoutCard::new(dispatch_kind.unwrap_or("fanout").to_string()); + let mut card = FanoutCard::new(dispatch_kind.unwrap_or("swarm").to_string()); card.upsert_worker(&agent_id, AgentLifecycle::Running); app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card))); let idx = app.history.len().saturating_sub(1); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 4bfc3af9..6e21c1b1 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -2433,11 +2433,18 @@ fn agent_swarm_seeded_fanout_card_uses_declared_task_count() { }), )); - let HistoryCell::SubAgent(SubAgentCell::Fanout(card)) = &app.history[0] else { - panic!("expected seeded fanout card"); - }; - assert_eq!(card.worker_count(), 3); - assert_eq!(active_fanout_counts(&app), Some((0, 3))); + // Card is deferred until first SwarmProgress (#236/#238). + // Before that, only the pending task count is stored. + assert_eq!(app.pending_swarm_task_count, Some(3)); + assert!( + app.history.is_empty(), + "no card pre-seeded before SwarmProgress" + ); + assert_eq!( + active_fanout_counts(&app), + Some((0, 3)), + "sidebar reads pending count" + ); } #[test] @@ -2462,10 +2469,11 @@ fn seeded_fanout_card_preserves_existing_active_tool_indices() { }), )); + // No card created → no history insertion → tool_cells indices unchanged. assert_eq!( app.tool_cells.get("search-1").copied(), - Some(1), - "active tool virtual index should shift after history insertion" + Some(0), + "active tool virtual index unchanged when card is deferred" ); let result = crate::tools::spec::ToolResult::success("done"); diff --git a/crates/tui/src/tui/widgets/agent_card.rs b/crates/tui/src/tui/widgets/agent_card.rs index aa1a21bd..9cb60d83 100644 --- a/crates/tui/src/tui/widgets/agent_card.rs +++ b/crates/tui/src/tui/widgets/agent_card.rs @@ -219,6 +219,7 @@ impl FanoutCard { } /// Pre-seed worker slots when the fanout size is known up front. + #[allow(dead_code)] pub fn with_workers(mut self, ids: I) -> Self where I: IntoIterator, diff --git a/crates/tui/src/tui/widgets/tool_card.rs b/crates/tui/src/tui/widgets/tool_card.rs index 3ae2e094..3891de80 100644 --- a/crates/tui/src/tui/widgets/tool_card.rs +++ b/crates/tui/src/tui/widgets/tool_card.rs @@ -37,7 +37,7 @@ pub enum ToolFamily { Find, /// Single sub-agent dispatch. `◐ delegate`. Delegate, - /// Multi-agent fanout (swarm, csv). `⋮⋮ fanout`. + /// Multi-agent swarm (agent_swarm, csv, rlm). `⋮⋮ swarm`. Fanout, /// Reasoning / chain-of-thought. `… think`. Reasoning has its own /// render path (`render_thinking` in `history.rs`); the family is @@ -153,7 +153,7 @@ pub fn family_label(family: ToolFamily) -> &'static str { ToolFamily::Run => "run", ToolFamily::Find => "find", ToolFamily::Delegate => "delegate", - ToolFamily::Fanout => "fanout", + ToolFamily::Fanout => "swarm", ToolFamily::Think => "think", ToolFamily::Generic => "tool", }