From 5401eaae0869a0676099afcc99c003664d52f049 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Fri, 15 May 2026 18:08:58 -0500 Subject: [PATCH] chore(release): prepare v0.8.38 (#1698) --- CHANGELOG.md | 28 +++- Cargo.lock | 28 ++-- Cargo.toml | 2 +- crates/agent/Cargo.toml | 2 +- crates/app-server/Cargo.toml | 18 +-- crates/cli/Cargo.toml | 14 +- crates/config/Cargo.toml | 2 +- crates/core/Cargo.toml | 16 +-- crates/execpolicy/Cargo.toml | 2 +- crates/hooks/Cargo.toml | 2 +- crates/tools/Cargo.toml | 2 +- crates/tui/CHANGELOG.md | 28 +++- crates/tui/Cargo.toml | 4 +- crates/tui/src/tools/approval_cache.rs | 184 ++++++++++++++----------- crates/tui/src/tools/diagnostics.rs | 8 ++ crates/tui/src/tui/ui.rs | 15 +- crates/tui/src/tui/ui/tests.rs | 27 ++++ npm/deepseek-tui/package.json | 4 +- 18 files changed, 250 insertions(+), 136 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc2e9ceb..25e663bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.38] - 2026-05-15 + ### Changed - **Update guidance is clearer on the website.** The homepage and install page @@ -39,6 +41,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 default providers sync into the runtime config before the first request, and reselecting the active provider from the picker keeps the current model instead of falling back to the provider default (#1632). +- **OpenAI-compatible batch tool calls keep all start events.** Streaming + responses with multiple `tool_calls` in one assistant message now preserve + every tool-use block instead of pairing many tool results with only the last + tool start event (#1686). +- **Diagnostics tool schemas include an empty `required` list.** The built-in + `diagnostics` tool now sends `required: []` with its empty object schema so + DeepSeek no longer rejects it as a null required array (#1685). - **Windows wheel-as-arrow scrolling works with mouse capture enabled.** `composer_arrows_scroll` now defaults on for Windows terminals even when mouse capture is enabled, so wheel events that arrive as arrow keys scroll the @@ -51,6 +60,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 blocks now return a failed tool result instead of a success, so repeated blocked checklist/tool retries can trip the existing failure warning and halt path instead of spinning indefinitely (#1574). +- **Denied tool approvals are scoped to the exact call.** Denying one + write/shell approval now caches the canonical argument fingerprint instead of + a lossy tool/prefix key, so later calls to the same tool with different + arguments can still be reviewed and approved (#1617). ### Thanks @@ -59,13 +72,21 @@ terminal cleanup-guard idea harvested from #1630, and **imkingjh999 ([@imkingjh999](https://github.com/imkingjh999))** for the provider/model switching fixes harvested from #1642. Thanks to **Photo ([@eng2007](https://github.com/eng2007))** for the provider-aware `/model` -picker catalog work harvested from #1201. Thanks to +picker catalog work harvested from #1201. Thanks to **hexin +([@h3c-hexin](https://github.com/h3c-hexin))** for the OpenAI batch tool-call +streaming fix in #1686. Thanks to **chennest +([@chennest](https://github.com/chennest))** for the diagnostics schema report +in #1685. Thanks to **[@kunpeng-ai-lab](https://github.com/kunpeng-ai-lab)** for the Windows composer scroll fix harvested from #1578, and **WuMing ([@asdfg314284230](https://github.com/asdfg314284230))** for the Windows PowerShell flicker fix harvested from #1591. Thanks to **[@maker316](https://github.com/maker316)** for the LoopGuard/checklist loop -report in #1574. +report in #1574. Thanks to **lalala +([@lalala-233](https://github.com/lalala-233))** for the approval denial +regression report in #1617, and **Nightt +([@nightt5879](https://github.com/nightt5879))** for the exact-call approval +key work harvested from #1624. ## [0.8.37] - 2026-05-14 @@ -4264,7 +4285,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.37...HEAD +[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.38...HEAD +[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 [0.8.36]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.35...v0.8.36 [0.8.35]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.34...v0.8.35 diff --git a/Cargo.lock b/Cargo.lock index 8621c8a7..3ac2d21d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1160,7 +1160,7 @@ dependencies = [ [[package]] name = "deepseek-agent" -version = "0.8.37" +version = "0.8.38" dependencies = [ "deepseek-config", "serde", @@ -1168,7 +1168,7 @@ dependencies = [ [[package]] name = "deepseek-app-server" -version = "0.8.37" +version = "0.8.38" dependencies = [ "anyhow", "axum", @@ -1190,7 +1190,7 @@ dependencies = [ [[package]] name = "deepseek-config" -version = "0.8.37" +version = "0.8.38" dependencies = [ "anyhow", "deepseek-secrets", @@ -1202,7 +1202,7 @@ dependencies = [ [[package]] name = "deepseek-core" -version = "0.8.37" +version = "0.8.38" dependencies = [ "anyhow", "chrono", @@ -1220,7 +1220,7 @@ dependencies = [ [[package]] name = "deepseek-execpolicy" -version = "0.8.37" +version = "0.8.38" dependencies = [ "anyhow", "deepseek-protocol", @@ -1229,7 +1229,7 @@ dependencies = [ [[package]] name = "deepseek-hooks" -version = "0.8.37" +version = "0.8.38" dependencies = [ "anyhow", "async-trait", @@ -1243,7 +1243,7 @@ dependencies = [ [[package]] name = "deepseek-mcp" -version = "0.8.37" +version = "0.8.38" dependencies = [ "anyhow", "serde", @@ -1252,7 +1252,7 @@ dependencies = [ [[package]] name = "deepseek-protocol" -version = "0.8.37" +version = "0.8.38" dependencies = [ "serde", "serde_json", @@ -1260,7 +1260,7 @@ dependencies = [ [[package]] name = "deepseek-secrets" -version = "0.8.37" +version = "0.8.38" dependencies = [ "dirs", "keyring", @@ -1273,7 +1273,7 @@ dependencies = [ [[package]] name = "deepseek-state" -version = "0.8.37" +version = "0.8.38" dependencies = [ "anyhow", "chrono", @@ -1285,7 +1285,7 @@ dependencies = [ [[package]] name = "deepseek-tools" -version = "0.8.37" +version = "0.8.38" dependencies = [ "anyhow", "async-trait", @@ -1298,7 +1298,7 @@ dependencies = [ [[package]] name = "deepseek-tui" -version = "0.8.37" +version = "0.8.38" dependencies = [ "anyhow", "arboard", @@ -1361,7 +1361,7 @@ dependencies = [ [[package]] name = "deepseek-tui-cli" -version = "0.8.37" +version = "0.8.38" dependencies = [ "anyhow", "chrono", @@ -1386,7 +1386,7 @@ dependencies = [ [[package]] name = "deepseek-tui-core" -version = "0.8.37" +version = "0.8.38" [[package]] name = "deltae" diff --git a/Cargo.toml b/Cargo.toml index fd2e4aee..b790cc9d 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.37" +version = "0.8.38" 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 e118e5ea..6697dfea 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.37" } +deepseek-config = { path = "../config", version = "0.8.38" } serde.workspace = true diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index 8cef20c1..9ccc3ea6 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.37" } -deepseek-config = { path = "../config", version = "0.8.37" } -deepseek-core = { path = "../core", version = "0.8.37" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.37" } -deepseek-hooks = { path = "../hooks", version = "0.8.37" } -deepseek-mcp = { path = "../mcp", version = "0.8.37" } -deepseek-protocol = { path = "../protocol", version = "0.8.37" } -deepseek-state = { path = "../state", version = "0.8.37" } -deepseek-tools = { path = "../tools", version = "0.8.37" } +deepseek-agent = { path = "../agent", version = "0.8.38" } +deepseek-config = { path = "../config", version = "0.8.38" } +deepseek-core = { path = "../core", version = "0.8.38" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.38" } +deepseek-hooks = { path = "../hooks", version = "0.8.38" } +deepseek-mcp = { path = "../mcp", version = "0.8.38" } +deepseek-protocol = { path = "../protocol", version = "0.8.38" } +deepseek-state = { path = "../state", version = "0.8.38" } +deepseek-tools = { path = "../tools", version = "0.8.38" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index d9098ea7..9ed3cb49 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.37" } -deepseek-app-server = { path = "../app-server", version = "0.8.37" } -deepseek-config = { path = "../config", version = "0.8.37" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.37" } -deepseek-mcp = { path = "../mcp", version = "0.8.37" } -deepseek-secrets = { path = "../secrets", version = "0.8.37" } -deepseek-state = { path = "../state", version = "0.8.37" } +deepseek-agent = { path = "../agent", version = "0.8.38" } +deepseek-app-server = { path = "../app-server", version = "0.8.38" } +deepseek-config = { path = "../config", version = "0.8.38" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.38" } +deepseek-mcp = { path = "../mcp", version = "0.8.38" } +deepseek-secrets = { path = "../secrets", version = "0.8.38" } +deepseek-state = { path = "../state", version = "0.8.38" } chrono.workspace = true dirs.workspace = true serde.workspace = true diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 15fd19a9..b3c36950 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.37" } +deepseek-secrets = { path = "../secrets", version = "0.8.38" } dirs.workspace = true serde.workspace = true toml.workspace = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index bbd5240d..5fc3ddca 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.37" } -deepseek-config = { path = "../config", version = "0.8.37" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.37" } -deepseek-hooks = { path = "../hooks", version = "0.8.37" } -deepseek-mcp = { path = "../mcp", version = "0.8.37" } -deepseek-protocol = { path = "../protocol", version = "0.8.37" } -deepseek-state = { path = "../state", version = "0.8.37" } -deepseek-tools = { path = "../tools", version = "0.8.37" } +deepseek-agent = { path = "../agent", version = "0.8.38" } +deepseek-config = { path = "../config", version = "0.8.38" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.38" } +deepseek-hooks = { path = "../hooks", version = "0.8.38" } +deepseek-mcp = { path = "../mcp", version = "0.8.38" } +deepseek-protocol = { path = "../protocol", version = "0.8.38" } +deepseek-state = { path = "../state", version = "0.8.38" } +deepseek-tools = { path = "../tools", version = "0.8.38" } serde_json.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index 8726a31e..47f6490c 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.37" } +deepseek-protocol = { path = "../protocol", version = "0.8.38" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index ba7ab4fa..074b76d5 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.37" } +deepseek-protocol = { path = "../protocol", version = "0.8.38" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 5551bf98..b4811b9b 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.37" } +deepseek-protocol = { path = "../protocol", version = "0.8.38" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index dc2e9ceb..25e663bc 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.38] - 2026-05-15 + ### Changed - **Update guidance is clearer on the website.** The homepage and install page @@ -39,6 +41,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 default providers sync into the runtime config before the first request, and reselecting the active provider from the picker keeps the current model instead of falling back to the provider default (#1632). +- **OpenAI-compatible batch tool calls keep all start events.** Streaming + responses with multiple `tool_calls` in one assistant message now preserve + every tool-use block instead of pairing many tool results with only the last + tool start event (#1686). +- **Diagnostics tool schemas include an empty `required` list.** The built-in + `diagnostics` tool now sends `required: []` with its empty object schema so + DeepSeek no longer rejects it as a null required array (#1685). - **Windows wheel-as-arrow scrolling works with mouse capture enabled.** `composer_arrows_scroll` now defaults on for Windows terminals even when mouse capture is enabled, so wheel events that arrive as arrow keys scroll the @@ -51,6 +60,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 blocks now return a failed tool result instead of a success, so repeated blocked checklist/tool retries can trip the existing failure warning and halt path instead of spinning indefinitely (#1574). +- **Denied tool approvals are scoped to the exact call.** Denying one + write/shell approval now caches the canonical argument fingerprint instead of + a lossy tool/prefix key, so later calls to the same tool with different + arguments can still be reviewed and approved (#1617). ### Thanks @@ -59,13 +72,21 @@ terminal cleanup-guard idea harvested from #1630, and **imkingjh999 ([@imkingjh999](https://github.com/imkingjh999))** for the provider/model switching fixes harvested from #1642. Thanks to **Photo ([@eng2007](https://github.com/eng2007))** for the provider-aware `/model` -picker catalog work harvested from #1201. Thanks to +picker catalog work harvested from #1201. Thanks to **hexin +([@h3c-hexin](https://github.com/h3c-hexin))** for the OpenAI batch tool-call +streaming fix in #1686. Thanks to **chennest +([@chennest](https://github.com/chennest))** for the diagnostics schema report +in #1685. Thanks to **[@kunpeng-ai-lab](https://github.com/kunpeng-ai-lab)** for the Windows composer scroll fix harvested from #1578, and **WuMing ([@asdfg314284230](https://github.com/asdfg314284230))** for the Windows PowerShell flicker fix harvested from #1591. Thanks to **[@maker316](https://github.com/maker316)** for the LoopGuard/checklist loop -report in #1574. +report in #1574. Thanks to **lalala +([@lalala-233](https://github.com/lalala-233))** for the approval denial +regression report in #1617, and **Nightt +([@nightt5879](https://github.com/nightt5879))** for the exact-call approval +key work harvested from #1624. ## [0.8.37] - 2026-05-14 @@ -4264,7 +4285,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.37...HEAD +[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.38...HEAD +[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 [0.8.36]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.35...v0.8.36 [0.8.35]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.34...v0.8.35 diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 5579d6db..9784397a 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.37" } -deepseek-tools = { path = "../tools", version = "0.8.37" } +deepseek-secrets = { path = "../secrets", version = "0.8.38" } +deepseek-tools = { path = "../tools", version = "0.8.38" } 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/tools/approval_cache.rs b/crates/tui/src/tools/approval_cache.rs index c574d79a..db9a201a 100644 --- a/crates/tui/src/tools/approval_cache.rs +++ b/crates/tui/src/tools/approval_cache.rs @@ -10,8 +10,8 @@ //! //! | Tool | Key | //! |---------------|------------------------------------------| -//! | `apply_patch` | `patch:` | -//! | `exec_shell` | `shell:` | +//! | file writes | `file::` | +//! | shell tools | `shell::` | //! | `fetch_url` | `net:` | //! | everything else| `tool::` | //! @@ -21,9 +21,11 @@ //! calls with the same fingerprint still prompt). use std::collections::HashMap; +use std::fmt::Write as _; use std::time::Instant; -use crate::command_safety::classify_command; +use serde_json::Value; +use sha2::{Digest, Sha256}; /// The fingerprint of a tool call — stable enough to match repeated /// calls but specific enough to avoid privilege confusion. @@ -113,97 +115,32 @@ impl ApprovalCache { /// Build the approval‑cache key for a tool call. /// -/// The key incorporates the tool name and a lossy digest of the -/// arguments so that the cache can distinguish `exec_shell "ls"` -/// from `exec_shell "rm -rf /"` while still recognising repeated -/// invocations of the same harmless command. +/// The key incorporates the tool name and a canonical digest of the +/// arguments so that denying one call suppresses exact retries, not later +/// invocations of the same tool with different parameters. #[must_use] pub fn build_approval_key(tool_name: &str, input: &serde_json::Value) -> ApprovalKey { let fingerprint = match tool_name { - "apply_patch" => { - let paths_hash = hash_patch_paths(input); - format!("patch:{paths_hash}") + "apply_patch" | "write_file" | "edit_file" | "fim_edit" => { + format!("file:{tool_name}:{}", hash_json_value(input)) } "exec_shell" + | "task_shell_start" | "exec_shell_wait" | "exec_shell_interact" | "exec_wait" | "exec_interact" => { - let prefix = command_prefix(input); - format!("shell:{prefix}") + format!("shell:{tool_name}:{}", hash_json_value(input)) } "fetch_url" | "web.fetch" | "web_fetch" => { let host = parse_host(input); format!("net:{host}") } - _ => { - let input_hash = hash_json_input(input); - format!("tool:{tool_name}:{input_hash}") - } + _ => format!("tool:{tool_name}:{}", hash_json_value(input)), }; ApprovalKey(fingerprint) } -/// Return the canonical command prefix for the shell command in `input`. -/// -/// Uses [`classify_command`] from the arity dictionary so that -/// `auto_allow = ["git status"]` correctly matches `git status -s` and -/// `git status --porcelain` without also matching `git push`. -fn command_prefix(input: &serde_json::Value) -> String { - let cmd = input.get("command").and_then(|v| v.as_str()).unwrap_or(""); - let tokens: Vec<&str> = cmd.split_whitespace().collect(); - if tokens.is_empty() { - return "".to_string(); - } - classify_command(&tokens) -} - -/// Hash the sorted set of file paths referenced by a patch input. -fn hash_patch_paths(input: &serde_json::Value) -> String { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - - let mut paths: Vec<&str> = Vec::new(); - - if let Some(changes) = input.get("changes").and_then(|v| v.as_array()) { - for change in changes { - if let Some(path) = change.get("path").and_then(|v| v.as_str()) { - paths.push(path); - } - } - } else if let Some(patch_text) = input.get("patch").and_then(|v| v.as_str()) { - for line in patch_text.lines() { - if let Some(rest) = line.strip_prefix("+++ b/") { - paths.push(rest.trim()); - } - } - } - - paths.sort(); - paths.dedup(); - - if paths.is_empty() { - return "no_files".to_string(); - } - - let mut hasher = DefaultHasher::new(); - for path in &paths { - path.hash(&mut hasher); - } - format!("{:x}", hasher.finish()) -} - -fn hash_json_input(input: &serde_json::Value) -> String { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - - let mut hasher = DefaultHasher::new(); - serde_json::to_string(input) - .unwrap_or_default() - .hash(&mut hasher); - format!("{:x}", hasher.finish()) -} - /// Parse the host portion from a URL input. fn parse_host(input: &serde_json::Value) -> String { let url = input.get("url").and_then(|v| v.as_str()).unwrap_or(""); @@ -215,6 +152,64 @@ fn parse_host(input: &serde_json::Value) -> String { } } +fn hash_json_value(value: &Value) -> String { + let mut canonical = String::new(); + push_canonical_json(value, &mut canonical); + + let digest = Sha256::digest(canonical.as_bytes()); + let mut short = String::with_capacity(16); + for byte in &digest[..8] { + write!(&mut short, "{byte:02x}").expect("writing to String cannot fail"); + } + short +} + +fn push_canonical_json(value: &Value, out: &mut String) { + match value { + Value::Null => out.push_str("null"), + Value::Bool(value) => { + out.push_str("bool:"); + out.push_str(if *value { "true" } else { "false" }); + } + Value::Number(value) => { + out.push_str("number:"); + out.push_str(&value.to_string()); + } + Value::String(value) => { + out.push_str("string:"); + let encoded = serde_json::to_string(value).expect("serializing a string cannot fail"); + out.push_str(&encoded); + } + Value::Array(items) => { + out.push('['); + for (index, item) in items.iter().enumerate() { + if index > 0 { + out.push(','); + } + push_canonical_json(item, out); + } + out.push(']'); + } + Value::Object(map) => { + let mut entries = map.iter().collect::>(); + entries.sort_by_key(|(key, _)| *key); + + out.push('{'); + for (index, (key, value)) in entries.into_iter().enumerate() { + if index > 0 { + out.push(','); + } + let encoded_key = + serde_json::to_string(key).expect("serializing an object key cannot fail"); + out.push_str(&encoded_key); + out.push(':'); + push_canonical_json(value, out); + } + out.push('}'); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -258,10 +253,10 @@ mod tests { } #[test] - fn command_prefix_drops_flags() { + fn shell_keys_include_full_command_arguments() { let key_a = build_approval_key("exec_shell", &json!({"command": "cargo build"})); let key_b = build_approval_key("exec_shell", &json!({"command": "cargo build --release"})); - assert_eq!(key_a, key_b); + assert_ne!(key_a, key_b); } #[test] @@ -277,6 +272,19 @@ mod tests { assert_ne!(key_a, key_b); } + #[test] + fn patch_keys_differ_by_body_for_same_path() { + let key_a = build_approval_key( + "apply_patch", + &json!({"changes": [{"path": "a.rs", "content": "x"}]}), + ); + let key_b = build_approval_key( + "apply_patch", + &json!({"changes": [{"path": "a.rs", "content": "y"}]}), + ); + assert_ne!(key_a, key_b); + } + #[test] fn net_keys_differ_by_host() { let key_a = build_approval_key("fetch_url", &json!({"url": "https://example.com"})); @@ -299,4 +307,24 @@ mod tests { let key_b = build_approval_key("edit_file", &input); assert_eq!(key_a, key_b); } + + #[test] + fn input_hash_is_stable_across_object_key_order() { + let key_a = build_approval_key("write_file", &json!({"path": "a.txt", "content": "x"})); + let key_b = build_approval_key("write_file", &json!({"content": "x", "path": "a.txt"})); + assert_eq!(key_a, key_b); + } + + #[test] + fn canonical_json_omits_trailing_commas() { + let mut canonical = String::new(); + push_canonical_json(&json!({"b": [true, false], "a": {"x": 1}}), &mut canonical); + + assert_eq!( + canonical, + r#"{"a":{"x":number:1},"b":[bool:true,bool:false]}"# + ); + assert!(!canonical.contains(",]")); + assert!(!canonical.contains(",}")); + } } diff --git a/crates/tui/src/tools/diagnostics.rs b/crates/tui/src/tools/diagnostics.rs index 20da482f..6e266f44 100644 --- a/crates/tui/src/tools/diagnostics.rs +++ b/crates/tui/src/tools/diagnostics.rs @@ -58,6 +58,7 @@ impl ToolSpec for DiagnosticsTool { json!({ "type": "object", "properties": {}, + "required": [], "additionalProperties": false }) } @@ -217,6 +218,13 @@ mod tests { run(&["commit", "-q", "-m", "init"]); } + #[test] + fn diagnostics_schema_has_empty_required_array() { + let schema = DiagnosticsTool.input_schema(); + assert_eq!(schema["properties"], json!({})); + assert_eq!(schema["required"], json!([])); + } + #[tokio::test] async fn diagnostics_runs_best_effort_outside_git_repo() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 13f60e16..f2500da5 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -150,6 +150,15 @@ const PERIODIC_FULL_REPAINT_EVERY_N: u64 = 50; const TURN_META_PREFIX: &str = ""; const SESSION_TITLE_MAX_CHARS: usize = 32; +fn is_session_approved_for_tool(app: &App, tool_name: &str, approval_key: &str) -> bool { + app.approval_session_approved.contains(approval_key) + || app.approval_session_approved.contains(tool_name) +} + +fn is_session_denied_for_key(app: &App, approval_key: &str) -> bool { + app.approval_session_denied.contains(approval_key) +} + fn sidebar_width_for_chat_area(app: &App, chat_width: u16) -> Option { if app.sidebar_focus == SidebarFocus::Hidden || chat_width < SIDEBAR_VISIBLE_MIN_WIDTH { return None; @@ -1687,10 +1696,8 @@ async fn run_event_loop( approval_key, } => { let session_approved = - app.approval_session_approved.contains(&approval_key) - || app.approval_session_approved.contains(&tool_name); - let session_denied = app.approval_session_denied.contains(&approval_key) - || app.approval_session_denied.contains(&tool_name); + is_session_approved_for_tool(app, &tool_name, &approval_key); + let session_denied = is_session_denied_for_key(app, &approval_key); if session_denied { // The user already said no to this exact tool / // approval key in this session; auto-deny so the diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 55c67b9e..b6d00045 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1203,6 +1203,33 @@ fn create_test_app() -> App { app } +#[test] +fn session_denied_cache_matches_only_approval_key() { + let mut app = create_test_app(); + app.approval_session_denied.insert("edit_file".to_string()); + + assert!( + !is_session_denied_for_key(&app, "file:edit_file:fresh"), + "a legacy tool-name entry must not deny a later fresh call" + ); + + app.approval_session_denied + .insert("file:edit_file:retry".to_string()); + assert!(is_session_denied_for_key(&app, "file:edit_file:retry")); +} + +#[test] +fn session_approved_cache_keeps_tool_name_session_grants() { + let mut app = create_test_app(); + app.approval_session_approved + .insert("edit_file".to_string()); + + assert!( + is_session_approved_for_tool(&app, "edit_file", "file:edit_file:fresh"), + "approve-for-session should still cover future calls of the same tool" + ); +} + fn create_test_options() -> TuiOptions { TuiOptions { model: "deepseek-v4-pro".to_string(), diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index a2e689c4..f7fb6f2e 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -1,7 +1,7 @@ { "name": "deepseek-tui", - "version": "0.8.37", - "deepseekBinaryVersion": "0.8.37", + "version": "0.8.38", + "deepseekBinaryVersion": "0.8.38", "description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.", "author": "Hmbown", "license": "MIT",