chore(release): prepare v0.8.40

This commit is contained in:
Hunter Bown
2026-05-18 23:29:20 +08:00
parent 4144fc6a2b
commit 912da38cca
23 changed files with 470 additions and 82 deletions
+58 -1
View File
@@ -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
Generated
+14 -14
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+9 -9
View File
@@ -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
+7 -7
View File
@@ -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
+1 -1
View File
@@ -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
+8 -8
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+58 -1
View File
@@ -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
+2 -2
View File
@@ -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"
+2 -6
View File
@@ -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"]
+75 -2
View File
@@ -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::<Vec<_>>();
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![
+3 -1
View File
@@ -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,
+1 -5
View File
@@ -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.
+1 -4
View File
@@ -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 {
+5 -1
View File
@@ -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))]
{
+99 -13
View File
@@ -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!("<deepseek:subagent.done>{payload}</deepseek:subagent.done>")
}
#[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,
})
}
+32
View File
@@ -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);
+88
View File
@@ -1096,6 +1096,7 @@ fn editorial_tool_rows(rows: Vec<SidebarToolRow>, limit: usize) -> Vec<SidebarTo
let mut candidates: Vec<Candidate> = 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<String> = Vec::new();
for (order, mut row) in rows.into_iter().enumerate() {
@@ -1118,6 +1119,22 @@ fn editorial_tool_rows(rows: Vec<SidebarToolRow>, limit: usize) -> Vec<SidebarTo
continue;
}
if is_shell_wait_poll_row(&row) {
let key = shell_wait_poll_key(&row);
if let Some((_, grouped, count, _)) = shell_wait_groups
.iter_mut()
.find(|(_, _, _, existing_key)| existing_key == &key)
{
*count += 1;
if !row.summary.trim().is_empty() {
grouped.summary = row.summary;
}
} else {
shell_wait_groups.push((order, row, 1, key));
}
continue;
}
if is_low_value_tool(&row.name) && row.status == ToolStatus::Success {
if let Some((_, grouped, count)) = low_value_groups
.iter_mut()
@@ -1165,6 +1182,20 @@ fn editorial_tool_rows(rows: Vec<SidebarToolRow>, limit: usize) -> Vec<SidebarTo
});
}
for (order, mut row, count, key) in shell_wait_groups {
if count > 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::<Vec<_>>().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();
+2 -2
View File
@@ -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",