diff --git a/CHANGELOG.md b/CHANGELOG.md index be88ef4b..41c35f38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,62 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.40] - 2026-05-18 + +### Fixed + +- **WSL2 and headless Linux startup no longer blocks on clipboard init.** The + TUI now defers clipboard initialization so machines without an X server can + reach the first frame instead of hanging on a blank screen (#1773, #1772). +- **Windows alt-screen output stays clean when `RUST_LOG` is set.** Runtime + tracing is routed away from the interactive buffer so logs no longer leak + into the TUI display (#1774, #1776). +- **OpenAI-compatible custom model names are preserved.** Non-DeepSeek + providers now pass explicit model names through instead of rewriting them to + a DeepSeek default (#1714, #1740). +- **DeepSeek reasoning replay works through OpenAI-compatible endpoints.** + DeepSeek models selected under the generic `openai` provider now replay + prior `reasoning_content` consistently and classify streamed reasoning the + same way the replay path does (#1694, #1739, #1743). +- **Thinking-only turns no longer disappear.** If a clean turn ends with + thinking but no final answer text, the UI now surfaces a clear status instead + of silently ending the turn (#1727, #1742). +- **Windows `cmd /C` preserves quoted shell arguments.** Commands such as + `git commit -m "feat: complete sub-pages"` now round-trip through the Windows + shell wrapper without losing the quoted message (#1691, #1744). +- **Home/End are line-local inside multiline composer drafts.** The keys now + jump to the current input line boundary before falling back to transcript + navigation (#1748, #1749). +- **Ctrl+C restores the canceled prompt reliably.** Canceling a streaming turn + puts the submitted prompt back in the composer and suppresses late stream + events from drawing stale output (#1757, #1764). +- **Compaction recovers from cache-aligned summary context overflow.** When a + cache-preserving summary request itself exceeds the provider context window, + compaction retries with the bounded formatted summary path instead of failing + with a 400 "compression command failed" style error. +- **Terminal sub-agent sessions expose full transcript handles.** Completed + and canceled child agents now store the full child message transcript behind + `transcript_handle`, so the parent can inspect details with `handle_read` + instead of relying only on a lossy summary (#1738). +- **Repeated shell wait rows collapse in the Tasks sidebar.** Multiple live + `task_shell_wait` polls for the same background job now render as one row + with an explicit collapsed-wait count, reducing the stuck-task appearance + tracked for v0.8.40 (#1737). + +### Thanks + +Thanks to **jayzhu ([@zlh124](https://github.com/zlh124))** for the WSL2 +startup report and clipboard-init fix in #1772/#1773. Thanks to **Paulo Aboim +Pinto ([@aboimpinto](https://github.com/aboimpinto))** for the Windows +alt-screen logging report and fix in #1774/#1776, and for the Home/End +composer work in #1748/#1749. Thanks to **Zhongyue Lin +([@LeoLin990405](https://github.com/LeoLin990405))** for the provider model +passthrough, reasoning replay, thinking-only turn, and Windows quoting fixes +in #1740, #1743, #1742, and #1744. Thanks to **Nightt +([@nightt5879](https://github.com/nightt5879))** for the Ctrl+C prompt restore +fix in #1764. Thanks to **Bevis** and the community reports that surfaced the +compaction failure mode addressed in this release. + ## [0.8.39] - 2026-05-17 ### Fixed @@ -4364,7 +4420,8 @@ Welcome — and thank you. - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.39...HEAD +[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.40...HEAD +[0.8.40]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.39...v0.8.40 [0.8.39]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.38...v0.8.39 [0.8.38]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.37...v0.8.38 [0.8.37]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.36...v0.8.37 diff --git a/Cargo.lock b/Cargo.lock index f8fd5cf1..d82c2943 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1160,7 +1160,7 @@ dependencies = [ [[package]] name = "deepseek-agent" -version = "0.8.39" +version = "0.8.40" dependencies = [ "deepseek-config", "serde", @@ -1168,7 +1168,7 @@ dependencies = [ [[package]] name = "deepseek-app-server" -version = "0.8.39" +version = "0.8.40" dependencies = [ "anyhow", "axum", @@ -1190,7 +1190,7 @@ dependencies = [ [[package]] name = "deepseek-config" -version = "0.8.39" +version = "0.8.40" dependencies = [ "anyhow", "deepseek-secrets", @@ -1202,7 +1202,7 @@ dependencies = [ [[package]] name = "deepseek-core" -version = "0.8.39" +version = "0.8.40" dependencies = [ "anyhow", "chrono", @@ -1220,7 +1220,7 @@ dependencies = [ [[package]] name = "deepseek-execpolicy" -version = "0.8.39" +version = "0.8.40" dependencies = [ "anyhow", "deepseek-protocol", @@ -1229,7 +1229,7 @@ dependencies = [ [[package]] name = "deepseek-hooks" -version = "0.8.39" +version = "0.8.40" dependencies = [ "anyhow", "async-trait", @@ -1243,7 +1243,7 @@ dependencies = [ [[package]] name = "deepseek-mcp" -version = "0.8.39" +version = "0.8.40" dependencies = [ "anyhow", "serde", @@ -1252,7 +1252,7 @@ dependencies = [ [[package]] name = "deepseek-protocol" -version = "0.8.39" +version = "0.8.40" dependencies = [ "serde", "serde_json", @@ -1260,7 +1260,7 @@ dependencies = [ [[package]] name = "deepseek-secrets" -version = "0.8.39" +version = "0.8.40" dependencies = [ "dirs", "keyring", @@ -1273,7 +1273,7 @@ dependencies = [ [[package]] name = "deepseek-state" -version = "0.8.39" +version = "0.8.40" dependencies = [ "anyhow", "chrono", @@ -1285,7 +1285,7 @@ dependencies = [ [[package]] name = "deepseek-tools" -version = "0.8.39" +version = "0.8.40" dependencies = [ "anyhow", "async-trait", @@ -1298,7 +1298,7 @@ dependencies = [ [[package]] name = "deepseek-tui" -version = "0.8.39" +version = "0.8.40" dependencies = [ "anyhow", "arboard", @@ -1361,7 +1361,7 @@ dependencies = [ [[package]] name = "deepseek-tui-cli" -version = "0.8.39" +version = "0.8.40" dependencies = [ "anyhow", "chrono", @@ -1386,7 +1386,7 @@ dependencies = [ [[package]] name = "deepseek-tui-core" -version = "0.8.39" +version = "0.8.40" [[package]] name = "deltae" diff --git a/Cargo.toml b/Cargo.toml index b6a89361..7b085757 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" [workspace.package] -version = "0.8.39" +version = "0.8.40" edition = "2024" # Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the # codebase relies on extensively. Cargo enforces this so users on older diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index d4073e9c..d3354bdb 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.8.39" } +deepseek-config = { path = "../config", version = "0.8.40" } serde.workspace = true diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index 3232b90a..b2c3333a 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.8.39" } -deepseek-config = { path = "../config", version = "0.8.39" } -deepseek-core = { path = "../core", version = "0.8.39" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.39" } -deepseek-hooks = { path = "../hooks", version = "0.8.39" } -deepseek-mcp = { path = "../mcp", version = "0.8.39" } -deepseek-protocol = { path = "../protocol", version = "0.8.39" } -deepseek-state = { path = "../state", version = "0.8.39" } -deepseek-tools = { path = "../tools", version = "0.8.39" } +deepseek-agent = { path = "../agent", version = "0.8.40" } +deepseek-config = { path = "../config", version = "0.8.40" } +deepseek-core = { path = "../core", version = "0.8.40" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.40" } +deepseek-hooks = { path = "../hooks", version = "0.8.40" } +deepseek-mcp = { path = "../mcp", version = "0.8.40" } +deepseek-protocol = { path = "../protocol", version = "0.8.40" } +deepseek-state = { path = "../state", version = "0.8.40" } +deepseek-tools = { path = "../tools", version = "0.8.40" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 1b1d7a37..7fe0dceb 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.8.39" } -deepseek-app-server = { path = "../app-server", version = "0.8.39" } -deepseek-config = { path = "../config", version = "0.8.39" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.39" } -deepseek-mcp = { path = "../mcp", version = "0.8.39" } -deepseek-secrets = { path = "../secrets", version = "0.8.39" } -deepseek-state = { path = "../state", version = "0.8.39" } +deepseek-agent = { path = "../agent", version = "0.8.40" } +deepseek-app-server = { path = "../app-server", version = "0.8.40" } +deepseek-config = { path = "../config", version = "0.8.40" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.40" } +deepseek-mcp = { path = "../mcp", version = "0.8.40" } +deepseek-secrets = { path = "../secrets", version = "0.8.40" } +deepseek-state = { path = "../state", version = "0.8.40" } chrono.workspace = true dirs.workspace = true serde.workspace = true diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 9da35b31..4803e125 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.8.39" } +deepseek-secrets = { path = "../secrets", version = "0.8.40" } dirs.workspace = true serde.workspace = true toml.workspace = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 7f93d0ee..271d506c 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,13 +9,13 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -deepseek-agent = { path = "../agent", version = "0.8.39" } -deepseek-config = { path = "../config", version = "0.8.39" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.39" } -deepseek-hooks = { path = "../hooks", version = "0.8.39" } -deepseek-mcp = { path = "../mcp", version = "0.8.39" } -deepseek-protocol = { path = "../protocol", version = "0.8.39" } -deepseek-state = { path = "../state", version = "0.8.39" } -deepseek-tools = { path = "../tools", version = "0.8.39" } +deepseek-agent = { path = "../agent", version = "0.8.40" } +deepseek-config = { path = "../config", version = "0.8.40" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.40" } +deepseek-hooks = { path = "../hooks", version = "0.8.40" } +deepseek-mcp = { path = "../mcp", version = "0.8.40" } +deepseek-protocol = { path = "../protocol", version = "0.8.40" } +deepseek-state = { path = "../state", version = "0.8.40" } +deepseek-tools = { path = "../tools", version = "0.8.40" } serde_json.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index 660115d0..c4fcf478 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.8.39" } +deepseek-protocol = { path = "../protocol", version = "0.8.40" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index 8f91c970..8c870af9 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.8.39" } +deepseek-protocol = { path = "../protocol", version = "0.8.40" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 5508e08a..dd8893d1 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.8.39" } +deepseek-protocol = { path = "../protocol", version = "0.8.40" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index be88ef4b..41c35f38 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -5,6 +5,62 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.40] - 2026-05-18 + +### Fixed + +- **WSL2 and headless Linux startup no longer blocks on clipboard init.** The + TUI now defers clipboard initialization so machines without an X server can + reach the first frame instead of hanging on a blank screen (#1773, #1772). +- **Windows alt-screen output stays clean when `RUST_LOG` is set.** Runtime + tracing is routed away from the interactive buffer so logs no longer leak + into the TUI display (#1774, #1776). +- **OpenAI-compatible custom model names are preserved.** Non-DeepSeek + providers now pass explicit model names through instead of rewriting them to + a DeepSeek default (#1714, #1740). +- **DeepSeek reasoning replay works through OpenAI-compatible endpoints.** + DeepSeek models selected under the generic `openai` provider now replay + prior `reasoning_content` consistently and classify streamed reasoning the + same way the replay path does (#1694, #1739, #1743). +- **Thinking-only turns no longer disappear.** If a clean turn ends with + thinking but no final answer text, the UI now surfaces a clear status instead + of silently ending the turn (#1727, #1742). +- **Windows `cmd /C` preserves quoted shell arguments.** Commands such as + `git commit -m "feat: complete sub-pages"` now round-trip through the Windows + shell wrapper without losing the quoted message (#1691, #1744). +- **Home/End are line-local inside multiline composer drafts.** The keys now + jump to the current input line boundary before falling back to transcript + navigation (#1748, #1749). +- **Ctrl+C restores the canceled prompt reliably.** Canceling a streaming turn + puts the submitted prompt back in the composer and suppresses late stream + events from drawing stale output (#1757, #1764). +- **Compaction recovers from cache-aligned summary context overflow.** When a + cache-preserving summary request itself exceeds the provider context window, + compaction retries with the bounded formatted summary path instead of failing + with a 400 "compression command failed" style error. +- **Terminal sub-agent sessions expose full transcript handles.** Completed + and canceled child agents now store the full child message transcript behind + `transcript_handle`, so the parent can inspect details with `handle_read` + instead of relying only on a lossy summary (#1738). +- **Repeated shell wait rows collapse in the Tasks sidebar.** Multiple live + `task_shell_wait` polls for the same background job now render as one row + with an explicit collapsed-wait count, reducing the stuck-task appearance + tracked for v0.8.40 (#1737). + +### Thanks + +Thanks to **jayzhu ([@zlh124](https://github.com/zlh124))** for the WSL2 +startup report and clipboard-init fix in #1772/#1773. Thanks to **Paulo Aboim +Pinto ([@aboimpinto](https://github.com/aboimpinto))** for the Windows +alt-screen logging report and fix in #1774/#1776, and for the Home/End +composer work in #1748/#1749. Thanks to **Zhongyue Lin +([@LeoLin990405](https://github.com/LeoLin990405))** for the provider model +passthrough, reasoning replay, thinking-only turn, and Windows quoting fixes +in #1740, #1743, #1742, and #1744. Thanks to **Nightt +([@nightt5879](https://github.com/nightt5879))** for the Ctrl+C prompt restore +fix in #1764. Thanks to **Bevis** and the community reports that surfaced the +compaction failure mode addressed in this release. + ## [0.8.39] - 2026-05-17 ### Fixed @@ -4364,7 +4420,8 @@ Welcome — and thank you. - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.39...HEAD +[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.40...HEAD +[0.8.40]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.39...v0.8.40 [0.8.39]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.38...v0.8.39 [0.8.38]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.37...v0.8.38 [0.8.37]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.36...v0.8.37 diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index da469181..200a0209 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -21,8 +21,8 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.100" arboard = "3.4" -deepseek-secrets = { path = "../secrets", version = "0.8.39" } -deepseek-tools = { path = "../tools", version = "0.8.39" } +deepseek-secrets = { path = "../secrets", version = "0.8.40" } +deepseek-tools = { path = "../tools", version = "0.8.40" } schemaui = { version = "0.12.0", default-features = false, optional = true } async-stream = "0.3.6" async-trait = "0.1" diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index bdcb7667..1009d848 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -2716,12 +2716,8 @@ mod tests { ] }); - let result = sanitize_thinking_mode_messages( - &mut body, - "gpt-4o", - Some("max"), - ApiProvider::Openai, - ); + let result = + sanitize_thinking_mode_messages(&mut body, "gpt-4o", Some("max"), ApiProvider::Openai); assert!(result.is_none()); let assistant = body["messages"] diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index 0493ed31..e54b4138 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -1155,7 +1155,20 @@ async fn create_summary( build_formatted_summary_request(model, messages, limits) }; - let response = client.create_message(request).await?; + let mut telemetry_cache_aligned = used_cache_aligned; + let response = match client.create_message(request).await { + Ok(response) => response, + Err(err) if used_cache_aligned && is_context_window_error(&err) => { + logging::warn(format!( + "Cache-aligned compaction summary exceeded the model context window ({err}); \ + retrying with bounded formatted summary input" + )); + telemetry_cache_aligned = false; + let fallback_request = build_formatted_summary_request(model, messages, limits); + client.create_message(fallback_request).await? + } + Err(err) => return Err(err), + }; // Compaction summary calls are billed by DeepSeek; route the // tokens through the side-channel so the dashboard total // matches the website (#526). @@ -1168,7 +1181,7 @@ async fn create_summary( // `RUST_LOG=compaction=debug` (the module-path form // `deepseek_tui::compaction=debug` does NOT match — `EnvFilter` // matches the explicit target string when one is set). - log_summary_cache_telemetry(used_cache_aligned, &response.usage); + log_summary_cache_telemetry(telemetry_cache_aligned, &response.usage); // Extract text from response let summary = response @@ -1184,6 +1197,22 @@ async fn create_summary( Ok(summary) } +fn is_context_window_error(e: &anyhow::Error) -> bool { + let text = e.to_string(); + if crate::error_taxonomy::classify_error_message(&text) + != crate::error_taxonomy::ErrorCategory::InvalidInput + { + return false; + } + + let lower = text.to_lowercase(); + lower.contains("context") + || lower.contains("token") + || lower.contains("prompt is too long") + || lower.contains("requested") + || lower.contains("maximum") +} + /// Cache-hit percentage for a compaction summary call. /// /// Denominator is `input_tokens` (the total prompt size), not @@ -1806,6 +1835,50 @@ mod tests { assert!((summary_cache_hit_percent(50, 0) - 0.0).abs() < f64::EPSILON); } + #[test] + fn context_window_errors_are_detected_for_summary_fallback() { + for msg in [ + "HTTP 400 Bad Request: maximum context length is 1000000 tokens", + "invalid_request_error: prompt is too long for the current model", + "You requested 1000001 tokens but the maximum is 1000000", + "request exceeds context window", + ] { + assert!( + is_context_window_error(&anyhow::anyhow!(msg)), + "expected context-window detection for `{msg}`", + ); + } + + assert!(!is_context_window_error(&anyhow::anyhow!( + "Invalid request: missing required field" + ))); + assert!(!is_context_window_error(&anyhow::anyhow!( + "503 Service Unavailable" + ))); + } + + #[test] + fn formatted_summary_request_bounds_large_input() { + let messages = (0..90) + .map(|idx| { + msg( + "user", + &format!("turn {idx}: {}", "中文上下文 ".repeat(1_000)), + ) + }) + .collect::>(); + let limits = summary_input_limits_for_model("deepseek-v4-pro"); + + let request = build_formatted_summary_request("deepseek-v4-pro", &messages, limits); + + assert_eq!(request.messages.len(), 1); + let ContentBlock::Text { text, .. } = &request.messages[0].content[0] else { + panic!("expected summary text request"); + }; + assert!(text.contains("characters omitted before summary")); + assert!(text.chars().count() <= limits.input_max_chars + 2_000); + } + #[test] fn cache_aligned_summary_request_preserves_message_prefix() { let messages = vec![ diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 17b56992..ecc94792 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -2392,7 +2392,9 @@ fn apply_env_overrides(config: &mut Config) { .providers .get_or_insert_with(ProvidersConfig::default); let entry = match provider { - ApiProvider::Deepseek | ApiProvider::DeepseekCN => unreachable!("DeepSeek providers are handled in the if branch above (issue #1714)"), + ApiProvider::Deepseek | ApiProvider::DeepseekCN => unreachable!( + "DeepSeek providers are handled in the if branch above (issue #1714)" + ), ApiProvider::NvidiaNim => &mut providers.nvidia_nim, ApiProvider::Openai => &mut providers.openai, ApiProvider::Atlascloud => &mut providers.atlascloud, diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index c7cd0523..1cfe8f09 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -2045,11 +2045,7 @@ fn should_emit_thinking_only_status( steers_pending: bool, holding_for_subagents: bool, ) -> bool { - tool_uses_empty - && turn_error_is_none - && !cancelled - && !steers_pending - && !holding_for_subagents + tool_uses_empty && turn_error_is_none && !cancelled && !steers_pending && !holding_for_subagents } /// Resolve an `"auto"` reasoning-effort tier to a concrete value. diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 7932d0c0..a86dbbfc 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -200,10 +200,7 @@ fn push_shell_args(cmd: &mut Command, program: &str, args: &[String]) { .and_then(|s| s.to_str()) .map(|s| s.eq_ignore_ascii_case("cmd")) .unwrap_or(false); - if is_cmd - && args.len() == 2 - && args[0].eq_ignore_ascii_case("/C") - { + if is_cmd && args.len() == 2 && args[0].eq_ignore_ascii_case("/C") { cmd.raw_arg(&args[0]); cmd.raw_arg(&args[1]); } else { diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 775fb491..a9bbf2a0 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -819,7 +819,11 @@ fn test_list_jobs_cleans_up_completed_old_processes() { #[test] fn issue_1691_quoted_commit_message_round_trips() { let cmd = r#"git commit -m "feat: complete sub-pages""#; - let spec = CommandSpec::shell(cmd, std::path::PathBuf::from("/tmp"), Duration::from_secs(5)); + let spec = CommandSpec::shell( + cmd, + std::path::PathBuf::from("/tmp"), + Duration::from_secs(5), + ); #[cfg(not(windows))] { diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index e4ffe006..408dd571 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -1624,6 +1624,7 @@ async fn subagent_session_projection( timed_out: bool, context: &ToolContext, ) -> SubAgentSessionProjection { + let transcript_session_id = format!("agent:{}", snapshot.agent_id); let transcript_payload = json!({ "kind": "subagent_session_snapshot", "agent_id": snapshot.agent_id.clone(), @@ -1639,11 +1640,22 @@ async fn subagent_session_projection( }); let transcript_handle = { let mut store = context.runtime.handle_store.lock().await; - store.insert_json( - format!("agent:{}", snapshot.agent_id), - "transcript", - transcript_payload, - ) + let full_transcript_lookup = VarHandle { + kind: "var_handle".to_string(), + session_id: transcript_session_id.clone(), + name: "full_transcript".to_string(), + type_name: String::new(), + length: 0, + repr_preview: String::new(), + sha256: String::new(), + }; + if snapshot.status != SubAgentStatus::Running + && let Some(record) = store.get(&full_transcript_lookup) + { + record.handle.clone() + } else { + store.insert_json(transcript_session_id, "transcript", transcript_payload) + } }; SubAgentSessionProjection { @@ -2160,7 +2172,7 @@ impl ToolSpec for AgentEvalTool { } fn description(&self) -> &'static str { - "Fetch or wait on a child sub-agent session. Optionally deliver a message/items to a running session, then return the latest session projection. With block=true (default), waits for the session to reach a terminal boundary; block=false is a non-blocking status fetch." + "Fetch or wait on a child sub-agent session. Optionally deliver a message/items to a running session, then return the latest session projection. With block=true (default), waits for the session to reach a terminal boundary; block=false is a non-blocking status fetch. Terminal projections expose a handle_read-compatible transcript_handle for the full child transcript." } fn input_schema(&self) -> Value { @@ -3292,6 +3304,36 @@ fn subagent_failed_sentinel(agent_id: &str, _err: &str) -> String { format!("{payload}") } +#[allow(clippy::too_many_arguments)] +async fn insert_subagent_full_transcript_handle( + runtime: &SubAgentRuntime, + agent_id: &str, + agent_type: &SubAgentType, + assignment: &SubAgentAssignment, + status: &SubAgentStatus, + result: Option<&String>, + messages: &[Message], + steps_taken: u32, + duration_ms: u64, + fork_context: bool, +) -> VarHandle { + let payload = json!({ + "kind": "subagent_full_transcript", + "agent_id": agent_id, + "agent_type": agent_type.as_str(), + "status": subagent_status_name(status), + "context_mode": if fork_context { "forked" } else { "fresh" }, + "fork_context": fork_context, + "result": result, + "steps_taken": steps_taken, + "duration_ms": duration_ms, + "assignment": assignment, + "messages": messages, + }); + let mut store = runtime.context.runtime.handle_store.lock().await; + store.insert_json(format!("agent:{agent_id}"), "full_transcript", payload) +} + #[allow(clippy::too_many_arguments, clippy::too_many_lines)] async fn run_subagent( runtime: &SubAgentRuntime, @@ -3362,6 +3404,21 @@ async fn run_subagent( agent_id: agent_id.clone(), }); } + let status = SubAgentStatus::Cancelled; + let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); + insert_subagent_full_transcript_handle( + runtime, + &agent_id, + &agent_type, + &assignment, + &status, + None, + &messages, + steps, + duration_ms, + fork_context_enabled, + ) + .await; return Ok(SubAgentResult { name: agent_id.clone(), agent_id: agent_id.clone(), @@ -3376,10 +3433,10 @@ async fn run_subagent( assignment: assignment.clone(), model: runtime.model.clone(), nickname: None, - status: SubAgentStatus::Cancelled, + status, result: None, steps_taken: steps, - duration_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX), + duration_ms, from_prior_session: false, }); } @@ -3443,6 +3500,21 @@ async fn run_subagent( agent_id: agent_id.clone(), }); } + let status = SubAgentStatus::Cancelled; + let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); + insert_subagent_full_transcript_handle( + runtime, + &agent_id, + &agent_type, + &assignment, + &status, + None, + &messages, + steps, + duration_ms, + fork_context_enabled, + ) + .await; return Ok(SubAgentResult { name: agent_id.clone(), agent_id: agent_id.clone(), @@ -3452,11 +3524,10 @@ async fn run_subagent( assignment: assignment.clone(), model: runtime.model.clone(), nickname: None, - status: SubAgentStatus::Cancelled, + status, result: None, steps_taken: steps, - duration_ms: u64::try_from(started_at.elapsed().as_millis()) - .unwrap_or(u64::MAX), + duration_ms, from_prior_session: false, }); } @@ -3582,6 +3653,21 @@ async fn run_subagent( } release_resident_leases_for(&agent_id); + let status = SubAgentStatus::Completed; + let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); + insert_subagent_full_transcript_handle( + runtime, + &agent_id, + &agent_type, + &assignment, + &status, + final_result.as_ref(), + &messages, + steps, + duration_ms, + fork_context_enabled, + ) + .await; Ok(SubAgentResult { name: agent_id.clone(), @@ -3597,10 +3683,10 @@ async fn run_subagent( assignment, model: runtime.model.clone(), nickname: None, - status: SubAgentStatus::Completed, + status, result: final_result, steps_taken: steps, - duration_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX), + duration_ms, from_prior_session: false, }) } diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 3b0f097e..7e228f41 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -358,6 +358,38 @@ async fn session_projection_exposes_forked_prefix_cache_contract() { assert_eq!(projection.transcript_handle.name, "transcript"); } +#[tokio::test] +async fn terminal_session_projection_prefers_full_transcript_handle() { + let mut snapshot = make_snapshot(SubAgentStatus::Completed); + snapshot.result = Some("done".to_string()); + + let ctx = ToolContext::new("."); + let full_handle = { + let mut store = ctx.runtime.handle_store.lock().await; + store.insert_json( + "agent:agent_test", + "full_transcript", + json!({ + "kind": "subagent_full_transcript", + "agent_id": "agent_test", + "messages": [ + { + "role": "assistant", + "content": [ + { "type": "text", "text": "complete child output" } + ] + } + ] + }), + ) + }; + + let projection = subagent_session_projection(snapshot, false, &ctx).await; + + assert_eq!(projection.transcript_handle, full_handle); + assert_eq!(projection.transcript_handle.name, "full_transcript"); +} + #[test] fn test_delegate_defaults_to_fork_context() { let input = with_default_fork_context(json!({ "prompt": "review current work" }), true); diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 1340a48e..4a102150 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -1096,6 +1096,7 @@ fn editorial_tool_rows(rows: Vec, limit: usize) -> Vec = Vec::new(); let mut low_value_groups: Vec<(usize, SidebarToolRow, usize)> = Vec::new(); let mut ci_poll_groups: Vec<(usize, SidebarToolRow, usize)> = Vec::new(); + let mut shell_wait_groups: Vec<(usize, SidebarToolRow, usize, String)> = Vec::new(); let mut seen_success: Vec = Vec::new(); for (order, mut row) in rows.into_iter().enumerate() { @@ -1118,6 +1119,22 @@ fn editorial_tool_rows(rows: Vec, limit: usize) -> Vec, limit: usize) -> Vec 1 { + row.summary = compact_join([ + format!("{key} \u{00B7} {count} waits collapsed"), + row.summary.clone(), + ]); + } + candidates.push(Candidate { + rank: tool_row_rank(&row), + order, + row, + }); + } + for (order, mut row, count) in low_value_groups { if count > 1 { row.name = format!("{} x{count}", row.name); @@ -1199,6 +1230,27 @@ fn is_ci_poll_row(row: &SidebarToolRow) -> bool { row.name.starts_with("gh pr checks") || row.name.starts_with("gh run watch") } +fn is_shell_wait_poll_row(row: &SidebarToolRow) -> bool { + row.status == ToolStatus::Running && row.name == "wait shell job" +} + +fn shell_wait_poll_key(row: &SidebarToolRow) -> String { + const MARKER: &str = "task_id:"; + if let Some((_, rest)) = row.summary.split_once(MARKER) { + let task_id = rest + .trim_start() + .split(|ch: char| ch.is_whitespace() || ch == ',' || ch == '\u{00B7}') + .next() + .unwrap_or_default() + .trim(); + if !task_id.is_empty() { + return task_id.to_string(); + } + } + + normalize_activity_text(&row.summary) +} + fn normalize_activity_text(text: &str) -> String { text.split_whitespace().collect::>().join(" ") } @@ -2359,6 +2411,42 @@ mod tests { ); } + #[test] + fn tasks_panel_collapses_repeated_shell_waits_for_same_job() { + let mut app = create_test_app(); + let mut active = ActiveCell::new(); + for id in ["shell-wait-1", "shell-wait-2"] { + active.push_tool( + id, + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "task_shell_wait".to_string(), + status: ToolStatus::Running, + input_summary: Some("task_id: shell_33a08c3c".to_string()), + output: None, + prompts: None, + spillover_path: None, + output_summary: Some("Background task running (no new output).".to_string()), + is_diff: false, + })), + ); + } + app.active_cell = Some(active); + + let text = lines_to_text(&task_panel_lines(&app, 100, 8)); + + assert_eq!( + text.iter() + .filter(|line| line.contains("[~] wait shell job")) + .count(), + 1, + "duplicate waits for the same shell job should collapse: {text:?}" + ); + assert!( + text.iter().any(|line| line.contains("2 waits collapsed")), + "collapsed row should explain why only one wait is visible: {text:?}" + ); + } + #[test] fn navigator_empty_state_says_no_agents() { let summary = SidebarSubagentSummary::default(); diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index e24432ff..22f48bfc 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -1,7 +1,7 @@ { "name": "deepseek-tui", - "version": "0.8.39", - "deepseekBinaryVersion": "0.8.39", + "version": "0.8.40", + "deepseekBinaryVersion": "0.8.40", "description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.", "author": "Hmbown", "license": "MIT",