chore(release): merge v0.8.24 (#1283)
Bugfix + community PR release. See CHANGELOG.md for full notes.
This commit is contained in:
@@ -5,6 +5,95 @@ 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.24] - 2026-05-09
|
||||
|
||||
A bugfix + refactor release picking up the backlog after the v0.8.23 security
|
||||
release. Big thanks to **wplll** (cache-aware prompt + `/cache inspect`),
|
||||
**Liu-Vince** (MCP pagination diagnosis), **@Giggitycountless** (snapshot cap
|
||||
proposal), and to issue reporters **@SamhandsomeLee**,
|
||||
**@barjatiyasaurabh**, **@tyculw**, **@hongyuatcufe**, and **@ljlbit** for
|
||||
the bugs fixed below.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mouse-wheel scroll survives focus toggles** — on macOS, switching away
|
||||
(Cmd+Tab, opening the screenshot tool, etc.) and back can drop the
|
||||
terminal's mouse-tracking mode, leaving wheel scroll dead until restart.
|
||||
The TUI now re-arms `EnableMouseCapture` on `FocusGained` alongside the
|
||||
existing keyboard-mode recapture, so wheel events keep flowing after a
|
||||
focus round-trip.
|
||||
- **Workspace-local slash commands are now loaded (#1259)** — user command
|
||||
files placed in `<workspace>/.deepseek/commands/`,
|
||||
`<workspace>/.claude/commands/`, and `<workspace>/.cursor/commands/` are
|
||||
now discovered alongside the existing global `~/.deepseek/commands/`.
|
||||
Workspace-local commands shadow global by name, matching the precedence
|
||||
model already used for skills. Reported by **@SamhandsomeLee**.
|
||||
- **`@`-mention completion finds AI-tool dot-directories** — files inside
|
||||
`.deepseek/`, `.cursor/`, `.claude/`, and `.agents/` are now discoverable
|
||||
in `@`-mention Tab-completion even when those directories are excluded by
|
||||
`.gitignore`. The fix also applies to the Ctrl+P file picker and fuzzy
|
||||
file resolution.
|
||||
- **MCP paginated discovery (#1250, #1256)** — tools, resources, resource
|
||||
templates, and prompts from MCP servers that paginate their responses
|
||||
(e.g., gbrain at 5 items per page) are now fully discovered by following
|
||||
the MCP spec's `nextCursor` across all pages. Reported by
|
||||
**@hongyuatcufe**; thanks to **Liu-Vince** for the diagnosis and PR
|
||||
#1256 with the same fix shape.
|
||||
- **Snapshot storage has a disk-space cap (#1112)** — the snapshot side repo
|
||||
now enforces a 500 MB hard limit. When the limit is exceeded at snapshot
|
||||
time, the oldest snapshots are pruned aggressively to stay under a 400 MB
|
||||
target. Guards against the reported 1.2 TB snapshot blowup during
|
||||
high-churn sessions. Reported by **@tyculw**; thanks to
|
||||
**@Giggitycountless** for the PR #1131 proposal that informed the
|
||||
hard-cap approach.
|
||||
- **`/clear` now resets the Todos sidebar (#1258)** — previously `/clear`
|
||||
only reset the Plan panel; the Todos checklist persisted across clears
|
||||
until app restart. The fix ensures `clear_todos()` clears the
|
||||
`SharedTodoList` inner state. Reported by **@barjatiyasaurabh**.
|
||||
|
||||
### Added
|
||||
|
||||
- **Cache-aware prompt diagnostics + payload optimization (#1196)** — adds
|
||||
a `PromptBuilder` that classifies the system prompt into `static` /
|
||||
`history` / `dynamic` layers for cache-prefix stability, plus:
|
||||
- `/cache inspect` — shows SHA-256 hashes per layer, base static prefix
|
||||
hash vs full request prefix hash, static-prefix stability across
|
||||
turns, and first-divergence tracking. Does not print prompt text.
|
||||
- `/cache warmup` — prefetches the stable prefix to seed the DeepSeek
|
||||
context cache.
|
||||
- **Project Context Pack injected into the stable prefix by default**
|
||||
— a structured workspace summary (directory listing up to 4 levels /
|
||||
400 entries, README excerpt up to 4 KB, config + key source file
|
||||
lists). Adds **~1–10 KB to every prompt depending on repo size**, in
|
||||
exchange for a much more cacheable prefix. **Default ON**; disable
|
||||
with `[context] project_pack = false` in `~/.deepseek/config.toml`
|
||||
if you'd rather keep prompts minimal.
|
||||
- Wire-payload optimization: large tool outputs are budgeted, repeated
|
||||
identical tool outputs and `<turn_meta>` blocks are deduplicated
|
||||
with stable refs (wire-only — local session messages stay intact).
|
||||
- Footer cache-hit % chip from `prompt_cache_hit_tokens` /
|
||||
`prompt_cache_miss_tokens` in the API response.
|
||||
|
||||
Thanks **wplll** for the design and implementation.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Language directive strengthened against project-context bias (#1118)**
|
||||
— the system prompt now explicitly instructs the model that project
|
||||
context (AGENTS.md, auto-generated instructions, file trees) is NOT a
|
||||
language signal. Chinese filenames in a repo no longer bias the model
|
||||
toward Chinese replies when the user writes in English. Reported by
|
||||
**@ljlbit**.
|
||||
|
||||
### Known issues
|
||||
|
||||
- **Windows flicker/shake regression (#1260, #1251)** — v0.8.22 and v0.8.23
|
||||
exhibit content flickering on Windows 10 (v0.8.20 works correctly). The
|
||||
issue is likely caused by the viewport-reset escape sequence
|
||||
(`\x1b[r\x1b[?6l\x1b[H\x1b[2J\x1b[3J`) added in v0.8.22 to fix viewport
|
||||
drift. On Windows conhost, this sequence may trigger a full screen clear
|
||||
on every repaint. A platform guard or less aggressive sequence is needed.
|
||||
|
||||
## [0.8.23] - 2026-05-08
|
||||
|
||||
A security-focused follow-up to v0.8.22. The bulk of the diff is hardening of
|
||||
|
||||
Generated
+14
-14
@@ -1151,7 +1151,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-agent"
|
||||
version = "0.8.23"
|
||||
version = "0.8.24"
|
||||
dependencies = [
|
||||
"deepseek-config",
|
||||
"serde",
|
||||
@@ -1159,7 +1159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-app-server"
|
||||
version = "0.8.23"
|
||||
version = "0.8.24"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -1181,7 +1181,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-config"
|
||||
version = "0.8.23"
|
||||
version = "0.8.24"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-secrets",
|
||||
@@ -1193,7 +1193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-core"
|
||||
version = "0.8.23"
|
||||
version = "0.8.24"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1211,7 +1211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-execpolicy"
|
||||
version = "0.8.23"
|
||||
version = "0.8.24"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-protocol",
|
||||
@@ -1220,7 +1220,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-hooks"
|
||||
version = "0.8.23"
|
||||
version = "0.8.24"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1234,7 +1234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-mcp"
|
||||
version = "0.8.23"
|
||||
version = "0.8.24"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -1243,7 +1243,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-protocol"
|
||||
version = "0.8.23"
|
||||
version = "0.8.24"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1251,7 +1251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-secrets"
|
||||
version = "0.8.23"
|
||||
version = "0.8.24"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"keyring",
|
||||
@@ -1264,7 +1264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-state"
|
||||
version = "0.8.23"
|
||||
version = "0.8.24"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1276,7 +1276,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tools"
|
||||
version = "0.8.23"
|
||||
version = "0.8.24"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1289,7 +1289,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui"
|
||||
version = "0.8.23"
|
||||
version = "0.8.24"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -1350,7 +1350,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui-cli"
|
||||
version = "0.8.23"
|
||||
version = "0.8.24"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1374,7 +1374,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui-core"
|
||||
version = "0.8.23"
|
||||
version = "0.8.24"
|
||||
|
||||
[[package]]
|
||||
name = "deltae"
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.23"
|
||||
version = "0.8.24"
|
||||
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
|
||||
|
||||
@@ -225,31 +225,44 @@ deepseek --provider ollama --model deepseek-coder:1.3b
|
||||
|
||||
---
|
||||
|
||||
## What's New In v0.8.23
|
||||
## What's New In v0.8.24
|
||||
|
||||
A security-focused follow-up to v0.8.22: sanitized child-process environments,
|
||||
tighter tool-safety classifications, and fixes for MCP, secrets, and the
|
||||
runtime API. [Full changelog](CHANGELOG.md).
|
||||
A community-focused bugfix release picking up the backlog after the v0.8.23
|
||||
security release. [Full changelog](CHANGELOG.md).
|
||||
|
||||
- **Child-process environment scrubbed** — shells, MCP servers, hooks, and other
|
||||
spawned subprocesses now start from an explicit env allowlist instead of
|
||||
inheriting every parent variable. No more accidental `*_API_KEY` or
|
||||
`GITHUB_TOKEN` leakage through subprocesses.
|
||||
- **macOS Keychain prompts gone** — the file-backed secret store is now the
|
||||
default; the OS keyring is opt-in via `DEEPSEEK_SECRET_BACKEND=system|keyring`.
|
||||
- **MCP servers stay working** — MCP stdio launches now inherit the env vars
|
||||
that `npx`, `uvx`, `python -m`, and proxy-bound corporate setups need, while
|
||||
still scrubbing secrets.
|
||||
- **MCP spawn errors are visible** — instead of an opaque wrapper message, you
|
||||
now see the real OS error ("No such file or directory") when an MCP server
|
||||
can't start.
|
||||
- **Live thinking is compact by default** — the streaming thinking panel
|
||||
collapses by default; expand via the details toggle.
|
||||
- **Runtime API requires auth by default** — `deepseek serve --http` no longer
|
||||
accepts unauthenticated requests.
|
||||
- **Plus**: hardened `run_tests` approval, symlink-traversal guards, Plan-mode
|
||||
tool-surface tightening, path-sanitization fixes, and a new
|
||||
`docs/RELEASE_CHECKLIST.md`.
|
||||
- **Cache-aware prompt diagnostics + payload optimization** (#1196, thanks
|
||||
**wplll**) — new `/cache inspect` and `/cache warmup` commands, layered
|
||||
prompt classification (static / history / dynamic) with per-layer SHA-256
|
||||
hashes, wire-payload dedup for repeated tool outputs, and a footer cache-hit
|
||||
% chip from the DeepSeek API response. A new **Project Context Pack** is
|
||||
injected into the stable prefix by default to improve cache hit rates;
|
||||
disable with `[context] project_pack = false` if you'd rather keep prompts
|
||||
minimal.
|
||||
- **Workspace-local slash commands** (#1259) — drop a `.deepseek/commands/foo.md`
|
||||
in any project and `/foo` works there. Also scans `.cursor/commands/` and
|
||||
`.claude/commands/`. Project-local shadows global by name.
|
||||
- **`@`-mention completion finds AI-tool dot-directories** — files inside
|
||||
`.deepseek/`, `.cursor/`, `.claude/`, and `.agents/` are now discoverable
|
||||
via `@` completion even when those dirs are in `.gitignore`.
|
||||
- **MCP paginated discovery** (#1250, thanks **Liu-Vince**) — MCP servers that
|
||||
paginate `tools/list` (e.g., gbrain at 5 per page) now have all their tools
|
||||
discovered via `nextCursor`.
|
||||
- **Snapshot disk cap** (#1112) — the snapshot side repo enforces a 500 MB
|
||||
hard limit, pruning oldest first when it's hit. Guards against the reported
|
||||
1.2 TB blowup. Thanks **@Giggitycountless** for the PR #1131 proposal.
|
||||
- **`/clear` resets the Todos sidebar** (#1258) — was only clearing the Plan
|
||||
panel before.
|
||||
- **Mouse-wheel survives focus toggles** — re-arms `EnableMouseCapture` on
|
||||
`FocusGained` so wheel scroll keeps working after Cmd+Tab or screenshot
|
||||
workflows.
|
||||
- **i18n: prompts in English get English replies** (#1118) — Chinese
|
||||
filenames in a project tree no longer bias the model toward Chinese
|
||||
responses.
|
||||
- **Plus**: language-directive strengthening, MCP error-message clarity
|
||||
improvements (PR #1196), and assorted polish.
|
||||
|
||||
⚠️ **Known issue:** v0.8.22+ have a Windows 10 conhost flicker regression
|
||||
(#1260) tracked for v0.8.25. v0.8.20 works correctly if you're affected.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+28
-16
@@ -192,24 +192,36 @@ deepseek --provider ollama --model deepseek-coder:1.3b
|
||||
|
||||
---
|
||||
|
||||
## v0.8.23 新功能
|
||||
## v0.8.24 新功能
|
||||
|
||||
面向安全的 v0.8.22 跟进版本:子进程环境清理、工具安全分类收紧,以及 MCP、
|
||||
密钥存储和运行时 API 的修复。[完整更新日志](CHANGELOG.md)。
|
||||
承接 v0.8.23 安全发布之后的社区 bug 修复版本。[完整更新日志](CHANGELOG.md)。
|
||||
|
||||
- **子进程环境已清理** — shell、MCP 服务器、hooks 等子进程现在从显式环境变量
|
||||
白名单启动,不再继承所有父进程变量。`*_API_KEY`、`GITHUB_TOKEN` 等敏感信息
|
||||
不会通过子进程泄露。
|
||||
- **macOS 钥匙串弹窗已消除** — 文件存储现在是默认的密钥后端;系统钥匙串需通过
|
||||
`DEEPSEEK_SECRET_BACKEND=system|keyring` 主动选择加入。
|
||||
- **MCP 服务器保持正常运行** — MCP stdio 启动时保留 `npx`、`uvx`、`python -m`
|
||||
和企业代理等所需的环境变量,同时继续清理密钥。
|
||||
- **MCP 启动错误现在可见** — 不再显示模糊的包装错误信息,而是直接展示真实的
|
||||
操作系统错误(如 "No such file or directory")。
|
||||
- **实时思考默认折叠** — 流式思考面板默认折叠,可通过详情切换展开。
|
||||
- **运行时 API 默认要求认证** — `deepseek serve --http` 不再接受未认证请求。
|
||||
- **此外**:加固 `run_tests` 审批策略、符号链接遍历防护、Plan 模式工具集收紧、
|
||||
路径清理修复,以及新增 `docs/RELEASE_CHECKLIST.md`。
|
||||
- **缓存感知的 prompt 诊断和载荷优化** (#1196,感谢 **wplll**) — 新增
|
||||
`/cache inspect` 和 `/cache warmup` 命令,对系统 prompt 进行分层(static /
|
||||
history / dynamic)并展示每层的 SHA-256 哈希;线材有效载荷去重重复工具输出;
|
||||
页脚展示来自 DeepSeek API 响应的缓存命中率。新增**项目上下文包**默认注入到
|
||||
稳定前缀以提高缓存命中率;如需保持 prompt 简洁,可在配置中设置
|
||||
`[context] project_pack = false` 关闭。
|
||||
- **工作区本地的斜杠命令** (#1259) — 在任意项目中放置
|
||||
`.deepseek/commands/foo.md`,`/foo` 即可在该项目中可用。同时扫描
|
||||
`.cursor/commands/` 和 `.claude/commands/`。项目本地按名称覆盖全局。
|
||||
- **`@` 提示补全可发现 AI 工具点目录** — 即使
|
||||
`.deepseek/`、`.cursor/`、`.claude/`、`.agents/` 在 `.gitignore` 中,
|
||||
这些目录下的文件也能通过 `@` 补全发现。
|
||||
- **MCP 分页发现** (#1250,感谢 **Liu-Vince**) — 对 `tools/list` 进行分页的
|
||||
MCP 服务器(如 gbrain 每页 5 个)现在通过 `nextCursor` 完整发现所有工具。
|
||||
- **快照磁盘容量上限** (#1112) — 快照副本仓库现在强制 500 MB 上限,
|
||||
超出时按时间从旧到新清理。可防止报告中 1.2 TB 快照失控。感谢
|
||||
**@Giggitycountless** 的 PR #1131 提案。
|
||||
- **`/clear` 现在重置 Todos 侧边栏** (#1258) — 以前只清空 Plan 面板。
|
||||
- **鼠标滚轮在焦点切换后仍可用** — 在 `FocusGained` 时重新启用
|
||||
`EnableMouseCapture`,使 Cmd+Tab 或截屏后滚轮滚动仍正常工作。
|
||||
- **i18n:英文提问得到英文回复** (#1118) — 项目中的中文文件名不再使模型偏向
|
||||
中文回复。
|
||||
- **此外**:语言指令加强、MCP 错误信息更清晰(来自 PR #1196),及若干打磨。
|
||||
|
||||
⚠️ **已知问题**:v0.8.22+ 在 Windows 10 conhost 上存在闪烁回归(#1260),
|
||||
跟踪到 v0.8.25 修复。如受影响,v0.8.20 工作正常。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.23" }
|
||||
deepseek-config = { path = "../config", version = "0.8.24" }
|
||||
serde.workspace = true
|
||||
|
||||
@@ -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.23" }
|
||||
deepseek-config = { path = "../config", version = "0.8.23" }
|
||||
deepseek-core = { path = "../core", version = "0.8.23" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.23" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.8.23" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.8.23" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.23" }
|
||||
deepseek-state = { path = "../state", version = "0.8.23" }
|
||||
deepseek-tools = { path = "../tools", version = "0.8.23" }
|
||||
deepseek-agent = { path = "../agent", version = "0.8.24" }
|
||||
deepseek-config = { path = "../config", version = "0.8.24" }
|
||||
deepseek-core = { path = "../core", version = "0.8.24" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.24" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.8.24" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.8.24" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.24" }
|
||||
deepseek-state = { path = "../state", version = "0.8.24" }
|
||||
deepseek-tools = { path = "../tools", version = "0.8.24" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -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.23" }
|
||||
deepseek-app-server = { path = "../app-server", version = "0.8.23" }
|
||||
deepseek-config = { path = "../config", version = "0.8.23" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.23" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.8.23" }
|
||||
deepseek-secrets = { path = "../secrets", version = "0.8.23" }
|
||||
deepseek-state = { path = "../state", version = "0.8.23" }
|
||||
deepseek-agent = { path = "../agent", version = "0.8.24" }
|
||||
deepseek-app-server = { path = "../app-server", version = "0.8.24" }
|
||||
deepseek-config = { path = "../config", version = "0.8.24" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.24" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.8.24" }
|
||||
deepseek-secrets = { path = "../secrets", version = "0.8.24" }
|
||||
deepseek-state = { path = "../state", version = "0.8.24" }
|
||||
chrono.workspace = true
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -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.23" }
|
||||
deepseek-secrets = { path = "../secrets", version = "0.8.24" }
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
@@ -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.23" }
|
||||
deepseek-config = { path = "../config", version = "0.8.23" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.23" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.8.23" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.8.23" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.23" }
|
||||
deepseek-state = { path = "../state", version = "0.8.23" }
|
||||
deepseek-tools = { path = "../tools", version = "0.8.23" }
|
||||
deepseek-agent = { path = "../agent", version = "0.8.24" }
|
||||
deepseek-config = { path = "../config", version = "0.8.24" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.24" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.8.24" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.8.24" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.24" }
|
||||
deepseek-state = { path = "../state", version = "0.8.24" }
|
||||
deepseek-tools = { path = "../tools", version = "0.8.24" }
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
@@ -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.23" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.24" }
|
||||
serde.workspace = true
|
||||
|
||||
@@ -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.23" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.24" }
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -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.23" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.24" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -21,8 +21,8 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
arboard = "3.4"
|
||||
deepseek-secrets = { path = "../secrets", version = "0.8.23" }
|
||||
deepseek-tools = { path = "../tools", version = "0.8.23" }
|
||||
deepseek-secrets = { path = "../secrets", version = "0.8.24" }
|
||||
deepseek-tools = { path = "../tools", version = "0.8.24" }
|
||||
schemaui = { version = "0.12.0", default-features = false, optional = true }
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1"
|
||||
|
||||
@@ -891,6 +891,9 @@ pub(super) fn parse_usage(usage: Option<&Value>) -> Usage {
|
||||
})
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let total_tokens = usage
|
||||
.and_then(|u| u.get("total_tokens"))
|
||||
.and_then(Value::as_u64);
|
||||
let reasoning_tokens_raw = usage
|
||||
.and_then(|u| u.get("completion_tokens_details"))
|
||||
.and_then(|details| details.get("reasoning_tokens"))
|
||||
@@ -899,6 +902,10 @@ pub(super) fn parse_usage(usage: Option<&Value>) -> Usage {
|
||||
&& let Some(reasoning_tokens) = reasoning_tokens_raw
|
||||
{
|
||||
output_tokens = reasoning_tokens;
|
||||
} else if output_tokens == 0
|
||||
&& let Some(total_tokens) = total_tokens
|
||||
{
|
||||
output_tokens = total_tokens.saturating_sub(input_tokens);
|
||||
}
|
||||
let cached_tokens = usage
|
||||
.and_then(|u| u.get("prompt_tokens_details"))
|
||||
@@ -979,6 +986,16 @@ impl DeepSeekClient {
|
||||
|
||||
mod chat;
|
||||
|
||||
pub(crate) use chat::PromptInspection;
|
||||
|
||||
pub(crate) fn inspect_prompt_for_request(request: &MessageRequest) -> PromptInspection {
|
||||
chat::inspect_prompt_for_request(request)
|
||||
}
|
||||
|
||||
pub(crate) fn build_cache_warmup_request(request: &MessageRequest) -> MessageRequest {
|
||||
chat::build_cache_warmup_request(request)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1370,6 +1387,267 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_builder_keeps_system_first_and_current_user_input_last() {
|
||||
let request = MessageRequest {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
messages: vec![
|
||||
Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Previous answer".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentBlock::Text {
|
||||
text: "<turn_meta>\nCurrent local date: 2026-05-08\n</turn_meta>"
|
||||
.to_string(),
|
||||
cache_control: None,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "Current user question".to_string(),
|
||||
cache_control: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
max_tokens: 1024,
|
||||
system: Some(SystemPrompt::Text(
|
||||
"Stable mode, project rules, and tool policy".to_string(),
|
||||
)),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
reasoning_effort: Some("max".to_string()),
|
||||
stream: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
};
|
||||
|
||||
let out = build_chat_messages_for_request(&request);
|
||||
|
||||
assert_eq!(out[0].get("role").and_then(Value::as_str), Some("system"));
|
||||
assert_eq!(
|
||||
out[0].get("content").and_then(Value::as_str),
|
||||
Some("Stable mode, project rules, and tool policy")
|
||||
);
|
||||
let last = out.last().expect("latest user message");
|
||||
assert_eq!(last.get("role").and_then(Value::as_str), Some("user"));
|
||||
assert!(
|
||||
last.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|content| content.ends_with("Current user question")),
|
||||
"current-turn user input must be at the tail of the wire prompt: {last:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_inspect_reports_stable_layers_and_dynamic_user_task() {
|
||||
let request = MessageRequest {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
messages: vec![
|
||||
Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Prior answer".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Current task".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
},
|
||||
],
|
||||
max_tokens: 1024,
|
||||
system: Some(SystemPrompt::Text(
|
||||
"Base policy\n\n<project_instructions source=\"AGENTS.md\">\nRules\n</project_instructions>\n\n## Project Context Pack\n\n<project_context_pack>\n{}\n</project_context_pack>\n\n## Environment\n\n- lang: en"
|
||||
.to_string(),
|
||||
)),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
reasoning_effort: Some("max".to_string()),
|
||||
stream: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
};
|
||||
|
||||
let inspection = inspect_prompt_for_request(&request);
|
||||
|
||||
assert_eq!(inspection.base_static_prefix_hash.len(), 64);
|
||||
assert_eq!(inspection.full_request_prefix_hash.len(), 64);
|
||||
assert!(inspection.layers.iter().any(|layer| {
|
||||
layer.name == "Global system prefix"
|
||||
&& layer.stability.label() == "static"
|
||||
&& layer.char_len == "Base policy".chars().count()
|
||||
&& layer.sha256.len() == 64
|
||||
}));
|
||||
assert!(inspection.layers.iter().any(|layer| {
|
||||
layer.name == "Project context" && layer.stability.label() == "static"
|
||||
}));
|
||||
assert!(inspection.layers.iter().any(|layer| {
|
||||
layer.name == "Project context pack" && layer.stability.label() == "static"
|
||||
}));
|
||||
assert!(inspection.layers.iter().any(|layer| {
|
||||
layer.name == "Message #1 assistant" && layer.stability.label() == "history"
|
||||
}));
|
||||
assert!(
|
||||
inspection.layers.last().is_some_and(
|
||||
|layer| layer.name == "User task" && layer.stability.label() == "dynamic"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_inspect_keeps_static_base_hash_across_different_user_tasks() {
|
||||
fn request_with_user_task(task: &str) -> MessageRequest {
|
||||
MessageRequest {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
messages: vec![
|
||||
Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Prior answer".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: task.to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
},
|
||||
],
|
||||
max_tokens: 1024,
|
||||
system: Some(SystemPrompt::Text(
|
||||
"Base policy\n\n## Environment\n\n- shell: powershell\n\n## Skills\n\n- rust\n\n## Context Management\n\nKeep concise\n\n## Compact\n\nTemplate"
|
||||
.to_string(),
|
||||
)),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
reasoning_effort: Some("max".to_string()),
|
||||
stream: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
}
|
||||
}
|
||||
|
||||
let first = inspect_prompt_for_request(&request_with_user_task("First task"));
|
||||
let second = inspect_prompt_for_request(&request_with_user_task("Second task"));
|
||||
let mut changed_history_request = request_with_user_task("Second task");
|
||||
changed_history_request.messages[0] = Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Different prior answer".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
};
|
||||
let changed_history = inspect_prompt_for_request(&changed_history_request);
|
||||
|
||||
assert_eq!(
|
||||
first.base_static_prefix_hash,
|
||||
second.base_static_prefix_hash
|
||||
);
|
||||
assert_eq!(
|
||||
first.full_request_prefix_hash, second.full_request_prefix_hash,
|
||||
"full request prefix excludes the final dynamic user task"
|
||||
);
|
||||
assert_ne!(
|
||||
second.full_request_prefix_hash, changed_history.full_request_prefix_hash,
|
||||
"full request prefix can change when session history changes"
|
||||
);
|
||||
assert!(
|
||||
second.layers.last().is_some_and(
|
||||
|layer| layer.name == "User task" && layer.stability.label() == "dynamic"
|
||||
),
|
||||
"current user task must remain the final layer"
|
||||
);
|
||||
assert!(second.layers.iter().any(|layer| {
|
||||
layer.name == "Message #1 assistant" && layer.stability.label() == "history"
|
||||
}));
|
||||
assert!(!second.layers.iter().any(
|
||||
|layer| layer.name.starts_with("Message #") && layer.stability.label() == "static"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_warmup_request_reuses_stable_prefix_and_fixed_user_tail() {
|
||||
let request = MessageRequest {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
messages: vec![
|
||||
Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Stable prior answer".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Dynamic latest user task".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
},
|
||||
],
|
||||
max_tokens: 1024,
|
||||
system: Some(SystemPrompt::Text(
|
||||
"Base policy\n\n<project_instructions source=\"AGENTS.md\">\nStable project rules\n</project_instructions>\n\n## Previous Session Handoff\n\nDynamic handoff"
|
||||
.to_string(),
|
||||
)),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
reasoning_effort: Some("max".to_string()),
|
||||
stream: Some(true),
|
||||
temperature: Some(0.7),
|
||||
top_p: None,
|
||||
};
|
||||
|
||||
let warmup = build_cache_warmup_request(&request);
|
||||
|
||||
assert_eq!(warmup.max_tokens, 8);
|
||||
assert_eq!(warmup.temperature, Some(0.0));
|
||||
assert_eq!(warmup.reasoning_effort.as_deref(), Some("max"));
|
||||
assert_eq!(warmup.messages.len(), 2);
|
||||
assert_eq!(warmup.messages[0].role, "assistant");
|
||||
assert_eq!(warmup.messages[1].role, "user");
|
||||
assert_eq!(
|
||||
warmup.messages[1].content,
|
||||
vec![ContentBlock::Text {
|
||||
text: "请只回复 OK".to_string(),
|
||||
cache_control: None,
|
||||
}]
|
||||
);
|
||||
|
||||
let wire = build_chat_messages_for_request(&warmup);
|
||||
let system = wire
|
||||
.first()
|
||||
.and_then(|value| value.get("content"))
|
||||
.and_then(Value::as_str)
|
||||
.expect("warmup system prompt");
|
||||
assert!(system.contains("Stable project rules"));
|
||||
assert!(!system.contains("Dynamic handoff"));
|
||||
assert!(
|
||||
!wire
|
||||
.iter()
|
||||
.any(|value| value.to_string().contains("Dynamic latest user task")),
|
||||
"warmup must not include the dynamic latest user task"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_uses_deepseek_top_level_thinking_parameter() {
|
||||
let mut body = json!({});
|
||||
@@ -2042,6 +2320,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_usage_derives_completion_tokens_from_total_tokens_when_needed() {
|
||||
let usage = parse_usage(Some(&json!({
|
||||
"prompt_tokens": 100,
|
||||
"total_tokens": 125,
|
||||
"prompt_cache_hit_tokens": 70,
|
||||
"prompt_cache_miss_tokens": 30
|
||||
})));
|
||||
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
assert_eq!(usage.output_tokens, 25);
|
||||
assert_eq!(usage.prompt_cache_hit_tokens, Some(70));
|
||||
assert_eq!(usage.prompt_cache_miss_tokens, Some(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_usage_reads_v4_prompt_tokens_details_cached_tokens() {
|
||||
let usage = parse_usage(Some(&json!({
|
||||
|
||||
+925
-26
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,10 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use super::CommandResult;
|
||||
use crate::client::{PromptInspection, inspect_prompt_for_request};
|
||||
use crate::compaction::estimate_input_tokens_conservative;
|
||||
use crate::localization::{Locale, MessageId, tr};
|
||||
use crate::models::{ContentBlock, SystemPrompt, context_window_for_model};
|
||||
use crate::models::{ContentBlock, MessageRequest, SystemPrompt, context_window_for_model};
|
||||
use crate::tui::app::{App, AppAction, TurnCacheRecord};
|
||||
use crate::tui::history::HistoryCell;
|
||||
|
||||
@@ -136,9 +137,15 @@ pub fn context(_app: &mut App) -> CommandResult {
|
||||
/// `arg` is parsed as a count override (default 10, capped at the ring size).
|
||||
/// Renders a fixed-width table the user can paste into a bug report.
|
||||
pub fn cache(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
let want = arg
|
||||
.and_then(|s| s.trim().parse::<usize>().ok())
|
||||
.unwrap_or(10);
|
||||
let arg = arg.map(str::trim).filter(|s| !s.is_empty());
|
||||
if matches!(arg, Some("inspect")) {
|
||||
return CommandResult::message(format_cache_inspect(app));
|
||||
}
|
||||
if matches!(arg, Some("warmup")) {
|
||||
return CommandResult::action(AppAction::CacheWarmup);
|
||||
}
|
||||
|
||||
let want = arg.and_then(|s| s.parse::<usize>().ok()).unwrap_or(10);
|
||||
let cap = app.session.turn_cache_history.len();
|
||||
let count = want
|
||||
.min(cap)
|
||||
@@ -151,6 +158,150 @@ pub fn cache(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
CommandResult::message(format_cache_history(app, count, app.ui_locale))
|
||||
}
|
||||
|
||||
fn format_cache_inspect(app: &mut App) -> String {
|
||||
let reasoning_effort = if app.reasoning_effort == crate::tui::app::ReasoningEffort::Auto {
|
||||
app.last_effective_reasoning_effort
|
||||
.and_then(crate::tui::app::ReasoningEffort::api_value)
|
||||
.map(str::to_string)
|
||||
} else {
|
||||
app.reasoning_effort.api_value().map(str::to_string)
|
||||
};
|
||||
let request = MessageRequest {
|
||||
model: app.model.clone(),
|
||||
messages: app.api_messages.clone(),
|
||||
max_tokens: 0,
|
||||
system: app.system_prompt.clone(),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
reasoning_effort,
|
||||
stream: Some(true),
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
};
|
||||
let inspection = inspect_prompt_for_request(&request);
|
||||
let previous = app.session.last_cache_inspection.as_ref();
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str("Cache Inspect\n");
|
||||
out.push_str("Full prompt text is not printed. Hashes are SHA-256 of each rendered layer.\n");
|
||||
out.push_str(&format!(
|
||||
"Base static prefix hash: {}\n",
|
||||
inspection.base_static_prefix_hash
|
||||
));
|
||||
out.push_str(&format!(
|
||||
"Full request prefix hash: {}\n",
|
||||
inspection.full_request_prefix_hash
|
||||
));
|
||||
out.push_str(&format_static_prefix_status(previous, &inspection));
|
||||
out.push_str(&format_first_divergence(previous, &inspection));
|
||||
out.push('\n');
|
||||
|
||||
for layer in &inspection.layers {
|
||||
let mut line = format!(
|
||||
"{}: {}, chars={}, hash={}\n",
|
||||
layer.name,
|
||||
layer.stability.label(),
|
||||
layer.char_len,
|
||||
layer.sha256
|
||||
);
|
||||
if let Some(tool_result) = &layer.tool_result {
|
||||
let trimmed = line.trim_end_matches('\n').to_string();
|
||||
line = format!(
|
||||
"{trimmed}, original_chars={}, sent_chars={}, truncated={}, deduplicated={}\n",
|
||||
tool_result.original_chars,
|
||||
tool_result.sent_chars,
|
||||
tool_result.truncated,
|
||||
tool_result.deduplicated
|
||||
);
|
||||
}
|
||||
if let Some(turn_meta) = &layer.turn_meta {
|
||||
let trimmed = line.trim_end_matches('\n').to_string();
|
||||
line = format!(
|
||||
"{trimmed}, turn_meta_original_chars={}, turn_meta_sent_chars={}, turn_meta_deduplicated={}, turn_meta_sha256={}\n",
|
||||
turn_meta.original_chars,
|
||||
turn_meta.sent_chars,
|
||||
turn_meta.deduplicated,
|
||||
turn_meta.sha256
|
||||
);
|
||||
}
|
||||
out.push_str(&line);
|
||||
}
|
||||
app.session.last_cache_inspection = Some(inspection);
|
||||
out
|
||||
}
|
||||
|
||||
fn format_static_prefix_status(
|
||||
previous: Option<&PromptInspection>,
|
||||
current: &PromptInspection,
|
||||
) -> String {
|
||||
let Some(previous) = previous else {
|
||||
return "Static base prefix stability: no previous request\n".to_string();
|
||||
};
|
||||
if previous.base_static_prefix_hash == current.base_static_prefix_hash {
|
||||
return "Static base prefix stability: OK\n".to_string();
|
||||
}
|
||||
|
||||
let changed = changed_static_layers(previous, current);
|
||||
if changed.is_empty() {
|
||||
"Static base prefix stability: WARNING (base hash changed)\n".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"Static base prefix stability: WARNING changed layers: {}\n",
|
||||
changed.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_first_divergence(
|
||||
previous: Option<&PromptInspection>,
|
||||
current: &PromptInspection,
|
||||
) -> String {
|
||||
let Some(previous) = previous else {
|
||||
return "First divergence from previous request: unavailable\n".to_string();
|
||||
};
|
||||
let max_len = previous.layers.len().max(current.layers.len());
|
||||
for index in 0..max_len {
|
||||
match (previous.layers.get(index), current.layers.get(index)) {
|
||||
(Some(prev), Some(curr)) if prev.name == curr.name && prev.sha256 == curr.sha256 => {}
|
||||
(Some(prev), Some(curr)) if prev.name == curr.name => {
|
||||
return format!("First divergence from previous request: {}\n", curr.name);
|
||||
}
|
||||
(Some(_), Some(curr)) => {
|
||||
return format!("First divergence from previous request: {}\n", curr.name);
|
||||
}
|
||||
(None, Some(curr)) => {
|
||||
return format!("First divergence from previous request: {}\n", curr.name);
|
||||
}
|
||||
(Some(prev), None) => {
|
||||
return format!(
|
||||
"First divergence from previous request: {} removed\n",
|
||||
prev.name
|
||||
);
|
||||
}
|
||||
(None, None) => break,
|
||||
}
|
||||
}
|
||||
"First divergence from previous request: none\n".to_string()
|
||||
}
|
||||
|
||||
fn changed_static_layers(previous: &PromptInspection, current: &PromptInspection) -> Vec<String> {
|
||||
current
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|layer| layer.stability.label() == "static")
|
||||
.filter(|layer| {
|
||||
previous
|
||||
.layers
|
||||
.iter()
|
||||
.find(|previous_layer| previous_layer.name == layer.name)
|
||||
.is_none_or(|previous_layer| previous_layer.sha256 != layer.sha256)
|
||||
})
|
||||
.map(|layer| layer.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn format_cache_history(app: &App, count: usize, locale: Locale) -> String {
|
||||
let total = app.session.turn_cache_history.len();
|
||||
let start = total.saturating_sub(count);
|
||||
@@ -416,6 +567,171 @@ mod tests {
|
||||
assert!(msg.contains("no turns recorded yet"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_inspect_reports_hashes_without_prompt_text() {
|
||||
let mut app = create_test_app();
|
||||
app.system_prompt = Some(SystemPrompt::Text(
|
||||
"Base policy\n\n<project_instructions source=\"AGENTS.md\">\nSECRET_PROJECT_RULE\n</project_instructions>"
|
||||
.to_string(),
|
||||
));
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "SECRET_USER_TASK".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let result = cache(&mut app, Some("inspect"));
|
||||
let msg = result.message.expect("inspect output");
|
||||
|
||||
assert!(msg.contains("Cache Inspect"));
|
||||
assert!(msg.contains("Base static prefix hash:"));
|
||||
assert!(msg.contains("Full request prefix hash:"));
|
||||
assert!(msg.contains("Static base prefix stability: no previous request"));
|
||||
assert!(msg.contains("First divergence from previous request: unavailable"));
|
||||
assert!(msg.contains("Global system prefix: static"));
|
||||
assert!(msg.contains("Project context: static"));
|
||||
assert!(msg.contains("User task: dynamic"));
|
||||
assert!(!msg.contains("SECRET_PROJECT_RULE"));
|
||||
assert!(!msg.contains("SECRET_USER_TASK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_inspect_reports_divergence_from_previous_request() {
|
||||
let mut app = create_test_app();
|
||||
app.system_prompt = Some(SystemPrompt::Text(
|
||||
"Base policy\n\n## Environment\n\n- shell: powershell".to_string(),
|
||||
));
|
||||
app.api_messages.push(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![crate::models::ContentBlock::Text {
|
||||
text: "Prior answer".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![crate::models::ContentBlock::Text {
|
||||
text: "First task".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let first = cache(&mut app, Some("inspect"))
|
||||
.message
|
||||
.expect("first inspect output");
|
||||
assert!(first.contains("Static base prefix stability: no previous request"));
|
||||
|
||||
if let Some(last) = app.api_messages.last_mut()
|
||||
&& let Some(crate::models::ContentBlock::Text { text, .. }) = last.content.first_mut()
|
||||
{
|
||||
*text = "Second task".to_string();
|
||||
}
|
||||
|
||||
let second = cache(&mut app, Some("inspect"))
|
||||
.message
|
||||
.expect("second inspect output");
|
||||
assert!(second.contains("Static base prefix stability: OK"));
|
||||
assert!(second.contains("First divergence from previous request: User task"));
|
||||
assert!(second.contains("Message #1 assistant: history"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_inspect_displays_tool_result_budget_metadata() {
|
||||
let mut app = create_test_app();
|
||||
let long_output = format!("{}{}", "A".repeat(7_000), "Z".repeat(7_000));
|
||||
app.api_messages.push(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "shell_command".to_string(),
|
||||
input: serde_json::json!({"command": "cargo test"}),
|
||||
caller: None,
|
||||
}],
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
content: long_output.clone(),
|
||||
is_error: None,
|
||||
content_blocks: None,
|
||||
}],
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
id: "tool-2".to_string(),
|
||||
name: "shell_command".to_string(),
|
||||
input: serde_json::json!({"command": "cargo test"}),
|
||||
caller: None,
|
||||
}],
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tool-2".to_string(),
|
||||
content: long_output,
|
||||
is_error: None,
|
||||
content_blocks: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let result = cache(&mut app, Some("inspect"));
|
||||
let msg = result.message.expect("inspect output");
|
||||
|
||||
assert!(msg.contains("original_chars=14000"), "got: {msg}");
|
||||
assert!(msg.contains("truncated=true"), "got: {msg}");
|
||||
assert!(msg.contains("deduplicated=false"), "got: {msg}");
|
||||
assert!(msg.contains("deduplicated=true"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_inspect_displays_turn_meta_dedup_metadata() {
|
||||
let mut app = create_test_app();
|
||||
let turn_meta = format!(
|
||||
"<turn_meta>\nCurrent local date: 2026-05-09\n{}\n</turn_meta>",
|
||||
"Working set: src/lib.rs\n".repeat(20)
|
||||
);
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentBlock::Text {
|
||||
text: turn_meta.clone(),
|
||||
cache_control: None,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "first task".to_string(),
|
||||
cache_control: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentBlock::Text {
|
||||
text: turn_meta,
|
||||
cache_control: None,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "second task".to_string(),
|
||||
cache_control: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let result = cache(&mut app, Some("inspect"));
|
||||
let msg = result.message.expect("inspect output");
|
||||
|
||||
assert!(msg.contains("turn_meta_original_chars="), "got: {msg}");
|
||||
assert!(msg.contains("turn_meta_sent_chars="), "got: {msg}");
|
||||
assert!(msg.contains("turn_meta_deduplicated=false"), "got: {msg}");
|
||||
assert!(msg.contains("turn_meta_deduplicated=true"), "got: {msg}");
|
||||
assert!(msg.contains("turn_meta_sha256="), "got: {msg}");
|
||||
assert!(!msg.contains("Working set: src/lib.rs"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_command_renders_recorded_turns_with_ratio() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
@@ -488,7 +488,7 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
CommandInfo {
|
||||
name: "cache",
|
||||
aliases: &[],
|
||||
usage: "/cache [count]",
|
||||
usage: "/cache [count|inspect|warmup]",
|
||||
description_id: MessageId::CmdCacheDescription,
|
||||
},
|
||||
];
|
||||
@@ -719,7 +719,13 @@ pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> {
|
||||
|
||||
/// Get all command names matching a prefix, including both built-in
|
||||
/// static commands and user-defined commands, formatted as `/name`.
|
||||
pub fn all_command_names_matching(prefix: &str) -> Vec<String> {
|
||||
///
|
||||
/// `workspace` is used to also scan workspace-local command directories;
|
||||
/// pass `None` when no workspace context is available.
|
||||
pub fn all_command_names_matching(
|
||||
prefix: &str,
|
||||
workspace: Option<&std::path::Path>,
|
||||
) -> Vec<String> {
|
||||
let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase();
|
||||
let mut result: Vec<String> = COMMANDS
|
||||
.iter()
|
||||
@@ -730,7 +736,7 @@ pub fn all_command_names_matching(prefix: &str) -> Vec<String> {
|
||||
.collect();
|
||||
|
||||
// Add user-defined commands
|
||||
result.extend(user_commands::user_commands_matching(&prefix));
|
||||
result.extend(user_commands::user_commands_matching(&prefix, workspace));
|
||||
|
||||
result.sort();
|
||||
result.dedup();
|
||||
@@ -921,6 +927,25 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_inspect_dispatches_through_cache_command() {
|
||||
let mut app = create_test_app();
|
||||
let result = execute("/cache inspect", &mut app);
|
||||
let msg = result.message.expect("cache inspect should return text");
|
||||
assert!(msg.contains("Cache Inspect"));
|
||||
assert!(msg.contains("Base static prefix hash:"));
|
||||
assert!(msg.contains("Full request prefix hash:"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_warmup_dispatches_action() {
|
||||
let mut app = create_test_app();
|
||||
let result = execute("/cache warmup", &mut app);
|
||||
assert!(result.message.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::CacheWarmup)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_config_opens_config_view_action() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
@@ -1,36 +1,54 @@
|
||||
//! User-defined slash commands from `~/.deepseek/commands/<name>.md`.
|
||||
//! User-defined slash commands from `~/.deepseek/commands/<name>.md` and
|
||||
//! workspace-local `<workspace>/.deepseek/commands/<name>.md`.
|
||||
//!
|
||||
//! Users drop `.md` files into `~/.deepseek/commands/` and the filename
|
||||
//! Users drop `.md` files into a commands directory and the filename
|
||||
//! (without `.md` extension) becomes a slash command. When invoked via
|
||||
//! `/name`, the file contents are sent as a user message.
|
||||
//!
|
||||
//! ## Precedence
|
||||
//!
|
||||
//! Workspace-local directories shadow user-global by name:
|
||||
//!
|
||||
//! 1. `<workspace>/.deepseek/commands/` (project-local, highest)
|
||||
//! 2. `<workspace>/.claude/commands/` (Claude Code interop)
|
||||
//! 3. `<workspace>/.cursor/commands/` (Cursor interop)
|
||||
//! 4. `~/.deepseek/commands/` (user-global, lowest)
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::tui::app::{App, AppAction};
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
/// Path to the user commands directory: `~/.deepseek/commands/`.
|
||||
fn commands_dir() -> PathBuf {
|
||||
/// Path to the global user commands directory: `~/.deepseek/commands/`.
|
||||
fn global_commands_dir() -> PathBuf {
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
|
||||
home.join(".deepseek").join("commands")
|
||||
}
|
||||
|
||||
/// Scan `~/.deepseek/commands/` for `.md` files and return `(name, content)` pairs.
|
||||
///
|
||||
/// The name is the filename without the `.md` extension, normalized to
|
||||
/// lowercase. Files that fail to read are silently skipped. The directory
|
||||
/// is re-scanned on every call so newly-added commands show up immediately
|
||||
/// without requiring a restart.
|
||||
pub fn load_user_commands() -> Vec<(String, String)> {
|
||||
let dir = commands_dir();
|
||||
/// Return all candidate commands directories in precedence order.
|
||||
fn commands_dirs(workspace: Option<&Path>) -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(ws) = workspace {
|
||||
dirs.push(ws.join(".deepseek").join("commands"));
|
||||
dirs.push(ws.join(".claude").join("commands"));
|
||||
dirs.push(ws.join(".cursor").join("commands"));
|
||||
}
|
||||
dirs.push(global_commands_dir());
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Scan a single commands directory for `.md` files and return
|
||||
/// `(name, content)` pairs. Errors are silently skipped.
|
||||
fn load_commands_from_dir(dir: &Path) -> Vec<(String, String)> {
|
||||
let mut commands: Vec<(String, String)> = Vec::new();
|
||||
|
||||
if !dir.exists() {
|
||||
if !dir.is_dir() {
|
||||
return commands;
|
||||
}
|
||||
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return commands,
|
||||
};
|
||||
@@ -51,6 +69,27 @@ pub fn load_user_commands() -> Vec<(String, String)> {
|
||||
commands.push((stem, content));
|
||||
}
|
||||
|
||||
commands
|
||||
}
|
||||
|
||||
/// Scan every candidate commands directory and return merged
|
||||
/// `(name, content)` pairs. Workspace-local directories shadow
|
||||
/// user-global by name — the first occurrence of a name wins.
|
||||
///
|
||||
/// Pass `None` for the workspace to scan only the global directory
|
||||
/// (backward-compatible with callers that don't have workspace context).
|
||||
pub fn load_user_commands(workspace: Option<&Path>) -> Vec<(String, String)> {
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut commands: Vec<(String, String)> = Vec::new();
|
||||
|
||||
for dir in commands_dirs(workspace) {
|
||||
for (name, content) in load_commands_from_dir(&dir) {
|
||||
if seen.insert(name.clone()) {
|
||||
commands.push((name, content));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by name for deterministic ordering.
|
||||
commands.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
commands
|
||||
@@ -72,13 +111,13 @@ fn apply_template(template: &str, args: &str) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
pub fn try_dispatch_user_command(_app: &mut App, input: &str) -> Option<CommandResult> {
|
||||
pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option<CommandResult> {
|
||||
let parts: Vec<&str> = input.trim().splitn(2, ' ').collect();
|
||||
let command = parts[0].to_lowercase();
|
||||
let command = command.strip_prefix('/').unwrap_or(&command);
|
||||
let args = parts.get(1).copied().unwrap_or("").trim();
|
||||
|
||||
let user_commands = load_user_commands();
|
||||
let user_commands = load_user_commands(Some(&app.workspace));
|
||||
|
||||
for (name, content) in &user_commands {
|
||||
if name == command {
|
||||
@@ -94,9 +133,12 @@ pub fn try_dispatch_user_command(_app: &mut App, input: &str) -> Option<CommandR
|
||||
///
|
||||
/// The prefix should be the command name portion only (after `/`).
|
||||
/// Returns entries formatted as `/name`.
|
||||
pub fn user_commands_matching(prefix: &str) -> Vec<String> {
|
||||
///
|
||||
/// `workspace` is used to also scan workspace-local command directories;
|
||||
/// pass `None` when no workspace context is available.
|
||||
pub fn user_commands_matching(prefix: &str, workspace: Option<&Path>) -> Vec<String> {
|
||||
let prefix = prefix.to_lowercase();
|
||||
load_user_commands()
|
||||
load_user_commands(workspace)
|
||||
.into_iter()
|
||||
.filter(|(name, _)| name.starts_with(&prefix))
|
||||
.map(|(name, _)| format!("/{}", name))
|
||||
@@ -106,10 +148,11 @@ pub fn user_commands_matching(prefix: &str) -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_commands_dir_contains_deepseek_commands() {
|
||||
let dir = commands_dir();
|
||||
fn test_global_commands_dir_contains_deepseek_commands() {
|
||||
let dir = global_commands_dir();
|
||||
let parts: Vec<_> = dir
|
||||
.components()
|
||||
.filter_map(|component| component.as_os_str().to_str())
|
||||
@@ -124,13 +167,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_user_commands_when_dir_absent() {
|
||||
// Use a temp dir that definitely doesn't have a commands dir.
|
||||
let _tmp = std::env::temp_dir().join("deepseek-test-nonexistent");
|
||||
// Temporarily override the home for this test by checking the
|
||||
// function with a non-existent directory path.
|
||||
let cmds = load_user_commands();
|
||||
// Should not panic; returns empty vec when dir doesn't exist.
|
||||
fn test_load_user_commands_when_no_dir_exists() {
|
||||
let cmds = load_user_commands(None);
|
||||
// Should not panic; returns empty vec when no directories exist.
|
||||
assert!(cmds.is_empty() || !cmds.is_empty());
|
||||
}
|
||||
|
||||
@@ -166,8 +205,162 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_commands_matching_with_prefix() {
|
||||
let matches = user_commands_matching("zzzznotfound");
|
||||
fn test_user_commands_matching_with_prefix_no_workspace() {
|
||||
let matches = user_commands_matching("zzzznotfound", None);
|
||||
assert!(matches.is_empty());
|
||||
}
|
||||
|
||||
// ── Workspace-local commands tests ─────────────────────────────────
|
||||
|
||||
fn write_command(dir: &Path, name: &str, body: &str) {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.md")), body).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_user_commands_scans_workspace_local_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path();
|
||||
let cmds_dir = ws.join(".deepseek").join("commands");
|
||||
write_command(&cmds_dir, "hello", "echo hi");
|
||||
|
||||
let cmds = load_user_commands(Some(ws));
|
||||
let names: Vec<&str> = cmds.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert!(
|
||||
names.contains(&"hello"),
|
||||
"expected 'hello' in workspace-local commands: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_user_commands_scans_claude_and_cursor_dirs() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path();
|
||||
write_command(
|
||||
&ws.join(".claude").join("commands"),
|
||||
"claude-cmd",
|
||||
"claude body",
|
||||
);
|
||||
write_command(
|
||||
&ws.join(".cursor").join("commands"),
|
||||
"cursor-cmd",
|
||||
"cursor body",
|
||||
);
|
||||
|
||||
let cmds = load_user_commands(Some(ws));
|
||||
let names: Vec<&str> = cmds.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert!(
|
||||
names.contains(&"claude-cmd"),
|
||||
"expected 'claude-cmd': {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"cursor-cmd"),
|
||||
"expected 'cursor-cmd': {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_local_shadows_global_by_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path();
|
||||
|
||||
// Workspace-local version
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
"shared",
|
||||
"workspace version",
|
||||
);
|
||||
// Global version — simulate by putting it in a "global" temp dir.
|
||||
// Since we can't easily override `dirs::home_dir()`, we test the
|
||||
// first-match-wins semantics by putting the same name in both
|
||||
// workspace-scanned dirs. The first dir in precedence order wins.
|
||||
write_command(
|
||||
&ws.join(".claude").join("commands"),
|
||||
"shared",
|
||||
"claude version",
|
||||
);
|
||||
|
||||
let cmds = load_user_commands(Some(ws));
|
||||
let shared = cmds
|
||||
.iter()
|
||||
.find(|(n, _)| n == "shared")
|
||||
.expect("shared present");
|
||||
assert_eq!(
|
||||
shared.1, "workspace version",
|
||||
"workspace-local (.deepseek) must shadow later dirs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_user_commands_without_workspace_falls_back_to_global_only() {
|
||||
// When no workspace is passed, only the global ~/.deepseek/commands/
|
||||
// is scanned. On test machines this dir often doesn't exist, so we
|
||||
// just verify we don't panic.
|
||||
let cmds = load_user_commands(None);
|
||||
// This should not panic; can be empty or have user's real commands.
|
||||
let _ = cmds;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_dispatch_uses_workspace_local_command() {
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::TuiOptions;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
"hello",
|
||||
"Hello, $ARGUMENTS!",
|
||||
);
|
||||
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: ws.clone(),
|
||||
config_path: None,
|
||||
config_profile: None,
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
use_bracketed_paste: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
initial_input: None,
|
||||
};
|
||||
let mut app = App::new(options, &Config::default());
|
||||
let result = try_dispatch_user_command(&mut app, "/hello world");
|
||||
assert!(result.is_some());
|
||||
let cmd_result = result.unwrap();
|
||||
match cmd_result.action {
|
||||
Some(AppAction::SendMessage(msg)) => {
|
||||
assert!(msg.contains("Hello, world!"), "got: {msg}");
|
||||
}
|
||||
other => panic!("expected SendMessage action, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_commands_matching_with_workspace() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path();
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
"project-cmd",
|
||||
"body",
|
||||
);
|
||||
|
||||
let matches = user_commands_matching("project", Some(ws));
|
||||
assert!(
|
||||
matches.contains(&"/project-cmd".to_string()),
|
||||
"got: {matches:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,6 +690,10 @@ pub struct ContextConfig {
|
||||
/// v0.7.5 audits V4 prefix-cache behavior.
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
/// Include a deterministic project context pack in the stable prompt
|
||||
/// prefix. Default: true; set `[context] project_pack = false` to disable.
|
||||
#[serde(default)]
|
||||
pub project_pack: Option<bool>,
|
||||
/// Verbatim window: last N turns never summarized. Default: 16.
|
||||
#[serde(default)]
|
||||
pub verbatim_window_turns: Option<usize>,
|
||||
@@ -1499,6 +1503,11 @@ impl Config {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn project_context_pack_enabled(&self) -> bool {
|
||||
self.context.project_pack.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Return whether shell execution is allowed.
|
||||
#[must_use]
|
||||
pub fn allow_shell(&self) -> bool {
|
||||
@@ -2444,6 +2453,10 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
|
||||
lsp: override_cfg.lsp.or(base.lsp),
|
||||
context: ContextConfig {
|
||||
enabled: override_cfg.context.enabled.or(base.context.enabled),
|
||||
project_pack: override_cfg
|
||||
.context
|
||||
.project_pack
|
||||
.or(base.context.project_pack),
|
||||
verbatim_window_turns: override_cfg
|
||||
.context
|
||||
.verbatim_window_turns
|
||||
@@ -4104,6 +4117,15 @@ api_key = "old-openrouter-key"
|
||||
assert_eq!(merged.context.enabled, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_context_pack_defaults_on_and_can_be_disabled() {
|
||||
let mut config = Config::default();
|
||||
assert!(config.project_context_pack_enabled());
|
||||
|
||||
config.context.project_pack = Some(false);
|
||||
assert!(!config.project_context_pack_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_future_deepseek_model_id() -> Result<()> {
|
||||
let config = Config {
|
||||
|
||||
@@ -95,6 +95,7 @@ pub struct EngineConfig {
|
||||
/// `instructions = [...]` config (or the per-project override).
|
||||
/// Resolved via `expand_path` so `~` works.
|
||||
pub instructions: Vec<PathBuf>,
|
||||
pub project_context_pack_enabled: bool,
|
||||
/// Maximum number of assistant steps before stopping.
|
||||
pub max_steps: u32,
|
||||
/// Maximum number of concurrently active subagents.
|
||||
@@ -166,6 +167,7 @@ impl Default for EngineConfig {
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
skills_dir: crate::skills::default_skills_dir(),
|
||||
instructions: Vec::new(),
|
||||
project_context_pack_enabled: true,
|
||||
max_steps: 100,
|
||||
max_subagents: DEFAULT_MAX_SUBAGENTS,
|
||||
features: Features::with_defaults(),
|
||||
@@ -442,6 +444,7 @@ impl Engine {
|
||||
prompts::PromptSessionContext {
|
||||
user_memory_block: user_memory_block.as_deref(),
|
||||
goal_objective: config.goal_objective.as_deref(),
|
||||
project_context_pack_enabled: config.project_context_pack_enabled,
|
||||
locale_tag: &config.locale_tag,
|
||||
},
|
||||
session.approval_mode,
|
||||
@@ -1862,6 +1865,7 @@ impl Engine {
|
||||
prompts::PromptSessionContext {
|
||||
user_memory_block: user_memory_block.as_deref(),
|
||||
goal_objective: self.config.goal_objective.as_deref(),
|
||||
project_context_pack_enabled: self.config.project_context_pack_enabled,
|
||||
locale_tag: &self.config.locale_tag,
|
||||
},
|
||||
self.session.approval_mode,
|
||||
|
||||
@@ -829,6 +829,33 @@ fn turn_metadata_includes_current_local_date_without_working_set() {
|
||||
assert!(text.contains(&format!("Current local date: {today}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_text_message_keeps_current_turn_input_after_turn_metadata() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let config = EngineConfig {
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
let (engine, _handle) = Engine::new(config, &Config::default());
|
||||
|
||||
let user_msg =
|
||||
engine.user_text_message_with_turn_metadata("explain the cache metrics".to_string());
|
||||
|
||||
let last_text = user_msg
|
||||
.content
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|block| {
|
||||
if let ContentBlock::Text { text, .. } = block {
|
||||
Some(text.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.expect("user text block");
|
||||
assert_eq!(last_text, "explain the cache metrics");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_with_turn_metadata_preserves_stored_messages_for_prefix_cache() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -4186,6 +4186,7 @@ async fn run_exec_agent(
|
||||
mcp_config_path: config.mcp_config_path(),
|
||||
skills_dir: config.skills_dir(),
|
||||
instructions: config.instructions_paths(),
|
||||
project_context_pack_enabled: config.project_context_pack_enabled(),
|
||||
max_steps: 100,
|
||||
max_subagents,
|
||||
features: config.features(),
|
||||
|
||||
+120
-54
@@ -774,94 +774,160 @@ impl McpConnection {
|
||||
|
||||
/// Discover available tools from the MCP server
|
||||
async fn discover_tools(&mut self) -> Result<()> {
|
||||
let list_id = self.next_id();
|
||||
self.send(serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": list_id,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}))
|
||||
.await?;
|
||||
let mut cursor: Option<String> = None;
|
||||
loop {
|
||||
let list_id = self.next_id();
|
||||
let params = match &cursor {
|
||||
Some(c) => serde_json::json!({ "cursor": c }),
|
||||
None => serde_json::json!({}),
|
||||
};
|
||||
self.send(serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": list_id,
|
||||
"method": "tools/list",
|
||||
"params": params
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let response = self.recv(list_id).await?;
|
||||
let response = self.recv(list_id).await?;
|
||||
let Some(result) = response.get("result") else {
|
||||
break;
|
||||
};
|
||||
|
||||
if let Some(result) = response.get("result")
|
||||
&& let Some(tools) = result.get("tools")
|
||||
{
|
||||
self.tools = serde_json::from_value(tools.clone()).unwrap_or_default();
|
||||
if let Some(tools) = result.get("tools") {
|
||||
let page: Vec<McpTool> = serde_json::from_value(tools.clone()).unwrap_or_default();
|
||||
self.tools.extend(page);
|
||||
}
|
||||
|
||||
cursor = result
|
||||
.get("nextCursor")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_owned);
|
||||
if cursor.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discover available resources from the MCP server
|
||||
async fn discover_resources(&mut self) -> Result<()> {
|
||||
let list_id = self.next_id();
|
||||
self.send(serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": list_id,
|
||||
"method": "resources/list",
|
||||
"params": {}
|
||||
}))
|
||||
.await?;
|
||||
let mut cursor: Option<String> = None;
|
||||
loop {
|
||||
let list_id = self.next_id();
|
||||
let params = match &cursor {
|
||||
Some(c) => serde_json::json!({ "cursor": c }),
|
||||
None => serde_json::json!({}),
|
||||
};
|
||||
self.send(serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": list_id,
|
||||
"method": "resources/list",
|
||||
"params": params
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let response = self.recv(list_id).await?;
|
||||
let response = self.recv(list_id).await?;
|
||||
let Some(result) = response.get("result") else {
|
||||
break;
|
||||
};
|
||||
|
||||
if let Some(result) = response.get("result")
|
||||
&& let Some(resources) = result.get("resources")
|
||||
{
|
||||
self.resources = serde_json::from_value(resources.clone()).unwrap_or_default();
|
||||
if let Some(resources) = result.get("resources") {
|
||||
let page: Vec<McpResource> =
|
||||
serde_json::from_value(resources.clone()).unwrap_or_default();
|
||||
self.resources.extend(page);
|
||||
}
|
||||
|
||||
cursor = result
|
||||
.get("nextCursor")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_owned);
|
||||
if cursor.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discover available resource templates from the MCP server
|
||||
async fn discover_resource_templates(&mut self) -> Result<()> {
|
||||
let list_id = self.next_id();
|
||||
self.send(serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": list_id,
|
||||
"method": "resources/templates/list",
|
||||
"params": {}
|
||||
}))
|
||||
.await?;
|
||||
let mut cursor: Option<String> = None;
|
||||
loop {
|
||||
let list_id = self.next_id();
|
||||
let params = match &cursor {
|
||||
Some(c) => serde_json::json!({ "cursor": c }),
|
||||
None => serde_json::json!({}),
|
||||
};
|
||||
self.send(serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": list_id,
|
||||
"method": "resources/templates/list",
|
||||
"params": params
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let response = self.recv(list_id).await?;
|
||||
let response = self.recv(list_id).await?;
|
||||
let Some(result) = response.get("result") else {
|
||||
break;
|
||||
};
|
||||
|
||||
if let Some(result) = response.get("result") {
|
||||
let templates = result
|
||||
.get("resourceTemplates")
|
||||
.or_else(|| result.get("templates"))
|
||||
.or_else(|| result.get("resource_templates"));
|
||||
if let Some(templates) = templates {
|
||||
self.resource_templates =
|
||||
let page: Vec<McpResourceTemplate> =
|
||||
serde_json::from_value(templates.clone()).unwrap_or_default();
|
||||
self.resource_templates.extend(page);
|
||||
}
|
||||
|
||||
cursor = result
|
||||
.get("nextCursor")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_owned);
|
||||
if cursor.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discover available prompts from the MCP server
|
||||
async fn discover_prompts(&mut self) -> Result<()> {
|
||||
let list_id = self.next_id();
|
||||
self.send(serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": list_id,
|
||||
"method": "prompts/list",
|
||||
"params": {}
|
||||
}))
|
||||
.await?;
|
||||
let mut cursor: Option<String> = None;
|
||||
loop {
|
||||
let list_id = self.next_id();
|
||||
let params = match &cursor {
|
||||
Some(c) => serde_json::json!({ "cursor": c }),
|
||||
None => serde_json::json!({}),
|
||||
};
|
||||
self.send(serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": list_id,
|
||||
"method": "prompts/list",
|
||||
"params": params
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let response = self.recv(list_id).await?;
|
||||
let response = self.recv(list_id).await?;
|
||||
let Some(result) = response.get("result") else {
|
||||
break;
|
||||
};
|
||||
|
||||
if let Some(result) = response.get("result")
|
||||
&& let Some(prompts) = result.get("prompts")
|
||||
{
|
||||
self.prompts = serde_json::from_value(prompts.clone()).unwrap_or_default();
|
||||
if let Some(prompts) = result.get("prompts") {
|
||||
let page: Vec<McpPrompt> =
|
||||
serde_json::from_value(prompts.clone()).unwrap_or_default();
|
||||
self.prompts.extend(page);
|
||||
}
|
||||
|
||||
cursor = result
|
||||
.get("nextCursor")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_owned);
|
||||
if cursor.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@
|
||||
//! The loaded content is injected into the system prompt to give the agent
|
||||
//! context about the project's conventions, structure, and requirements.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Names of project context files to look for, in priority order.
|
||||
@@ -26,6 +28,25 @@ const PROJECT_CONTEXT_FILES: &[&str] = &[
|
||||
|
||||
/// Maximum size for project context files (to prevent loading huge files)
|
||||
const MAX_CONTEXT_SIZE: usize = 100 * 1024; // 100KB
|
||||
const PACK_README_MAX_CHARS: usize = 4_000;
|
||||
const PACK_MAX_ENTRIES: usize = 400;
|
||||
const PACK_MAX_SOURCE_FILES: usize = 80;
|
||||
const PACK_MAX_CONFIG_FILES: usize = 80;
|
||||
const PACK_MAX_DEPTH: usize = 4;
|
||||
const PACK_IGNORED_DIRS: &[&str] = &[
|
||||
".git",
|
||||
"node_modules",
|
||||
".venv",
|
||||
"venv",
|
||||
"__pycache__",
|
||||
"dist",
|
||||
"build",
|
||||
"target",
|
||||
".idea",
|
||||
".vscode",
|
||||
".pytest_cache",
|
||||
".DS_Store",
|
||||
];
|
||||
|
||||
// === Errors ===
|
||||
|
||||
@@ -99,6 +120,203 @@ impl ProjectContext {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ProjectContextPack {
|
||||
project_name: String,
|
||||
directory_structure: Vec<String>,
|
||||
readme: Option<ReadmePack>,
|
||||
config_files: Vec<String>,
|
||||
key_source_files: Vec<String>,
|
||||
counts: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ReadmePack {
|
||||
path: String,
|
||||
excerpt: String,
|
||||
}
|
||||
|
||||
/// Generate a deterministic, cache-friendly project context pack.
|
||||
///
|
||||
/// The pack intentionally uses only stable workspace facts: relative paths,
|
||||
/// sorted entries, bounded README text, and sorted JSON object fields. It does
|
||||
/// not include timestamps, random ids, absolute temp paths, or live git state.
|
||||
pub fn generate_project_context_pack(workspace: &Path) -> Option<String> {
|
||||
let mut entries = Vec::new();
|
||||
collect_pack_entries(workspace, workspace, 0, &mut entries);
|
||||
entries.sort();
|
||||
entries.truncate(PACK_MAX_ENTRIES);
|
||||
|
||||
let mut config_files = entries
|
||||
.iter()
|
||||
.filter(|path| is_config_file(path))
|
||||
.take(PACK_MAX_CONFIG_FILES)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
config_files.sort();
|
||||
|
||||
let mut key_source_files = entries
|
||||
.iter()
|
||||
.filter(|path| is_source_file(path))
|
||||
.take(PACK_MAX_SOURCE_FILES)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
key_source_files.sort();
|
||||
|
||||
let readme = read_readme_excerpt(workspace, &entries);
|
||||
let mut counts = BTreeMap::new();
|
||||
counts.insert("config_files".to_string(), config_files.len());
|
||||
counts.insert("directory_entries".to_string(), entries.len());
|
||||
counts.insert("key_source_files".to_string(), key_source_files.len());
|
||||
|
||||
let pack = ProjectContextPack {
|
||||
project_name: workspace
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("workspace")
|
||||
.to_string(),
|
||||
directory_structure: entries,
|
||||
readme,
|
||||
config_files,
|
||||
key_source_files,
|
||||
counts,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&pack).ok()?;
|
||||
Some(format!(
|
||||
"## Project Context Pack\n\n<project_context_pack>\n{json}\n</project_context_pack>"
|
||||
))
|
||||
}
|
||||
|
||||
fn collect_pack_entries(root: &Path, dir: &Path, depth: usize, out: &mut Vec<String>) {
|
||||
if depth > PACK_MAX_DEPTH || out.len() >= PACK_MAX_ENTRIES {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(read_dir) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
let mut children = read_dir.filter_map(Result::ok).collect::<Vec<_>>();
|
||||
children.sort_by_key(|entry| entry.path());
|
||||
|
||||
for entry in children {
|
||||
if out.len() >= PACK_MAX_ENTRIES {
|
||||
break;
|
||||
}
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if file_type.is_dir() && PACK_IGNORED_DIRS.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(relative) = relative_slash_path(root, &path) {
|
||||
if file_type.is_dir() {
|
||||
out.push(format!("{relative}/"));
|
||||
collect_pack_entries(root, &path, depth + 1, out);
|
||||
} else if file_type.is_file() {
|
||||
out.push(relative);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relative_slash_path(root: &Path, path: &Path) -> Option<String> {
|
||||
let relative = path.strip_prefix(root).ok()?;
|
||||
let mut parts = Vec::new();
|
||||
for component in relative.components() {
|
||||
parts.push(component.as_os_str().to_string_lossy().to_string());
|
||||
}
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("/"))
|
||||
}
|
||||
}
|
||||
|
||||
fn read_readme_excerpt(workspace: &Path, entries: &[String]) -> Option<ReadmePack> {
|
||||
let path = entries
|
||||
.iter()
|
||||
.find(|path| {
|
||||
let lower = path.to_ascii_lowercase();
|
||||
lower == "readme.md" || lower == "readme.txt" || lower == "readme"
|
||||
})?
|
||||
.clone();
|
||||
let raw = fs::read_to_string(workspace.join(&path)).ok()?;
|
||||
let excerpt = truncate_chars(raw.trim(), PACK_README_MAX_CHARS);
|
||||
if excerpt.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ReadmePack { path, excerpt })
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_chars(value: &str, max_chars: usize) -> String {
|
||||
if value.chars().count() <= max_chars {
|
||||
return value.to_string();
|
||||
}
|
||||
value.chars().take(max_chars).collect::<String>()
|
||||
}
|
||||
|
||||
fn is_config_file(path: &str) -> bool {
|
||||
let lower = path.to_ascii_lowercase();
|
||||
let name = lower.rsplit('/').next().unwrap_or(lower.as_str());
|
||||
matches!(
|
||||
name,
|
||||
"cargo.toml"
|
||||
| "package.json"
|
||||
| "tsconfig.json"
|
||||
| "pyproject.toml"
|
||||
| "requirements.txt"
|
||||
| "go.mod"
|
||||
| "config.toml"
|
||||
| "deepseek.toml"
|
||||
| "dockerfile"
|
||||
| "compose.yaml"
|
||||
| "compose.yml"
|
||||
| "docker-compose.yaml"
|
||||
| "docker-compose.yml"
|
||||
| "makefile"
|
||||
) || lower.ends_with(".config.js")
|
||||
|| lower.ends_with(".config.ts")
|
||||
|| lower.ends_with(".toml")
|
||||
|| lower.ends_with(".yaml")
|
||||
|| lower.ends_with(".yml")
|
||||
}
|
||||
|
||||
fn is_source_file(path: &str) -> bool {
|
||||
let lower = path.to_ascii_lowercase();
|
||||
matches!(
|
||||
lower.rsplit('.').next(),
|
||||
Some(
|
||||
"rs" | "py"
|
||||
| "js"
|
||||
| "jsx"
|
||||
| "ts"
|
||||
| "tsx"
|
||||
| "go"
|
||||
| "java"
|
||||
| "kt"
|
||||
| "c"
|
||||
| "cc"
|
||||
| "cpp"
|
||||
| "h"
|
||||
| "hpp"
|
||||
| "cs"
|
||||
| "rb"
|
||||
| "php"
|
||||
| "swift"
|
||||
| "sql"
|
||||
| "sh"
|
||||
| "bash"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// Load project context from the workspace directory.
|
||||
///
|
||||
/// This searches for known project context files and loads the first one found.
|
||||
@@ -528,4 +746,36 @@ mod tests {
|
||||
.contains("Organization instructions")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_context_pack_is_stable_and_sorted() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("README.md"), "# Demo\n\nReadme body").expect("write");
|
||||
fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"demo\"").expect("write");
|
||||
fs::create_dir_all(tmp.path().join("src")).expect("mkdir src");
|
||||
fs::write(tmp.path().join("src").join("z.rs"), "mod z;").expect("write z");
|
||||
fs::write(tmp.path().join("src").join("a.rs"), "mod a;").expect("write a");
|
||||
fs::create_dir_all(tmp.path().join("node_modules").join("pkg")).expect("mkdir ignored");
|
||||
fs::write(
|
||||
tmp.path().join("node_modules").join("pkg").join("index.js"),
|
||||
"ignored",
|
||||
)
|
||||
.expect("write ignored");
|
||||
|
||||
let first = generate_project_context_pack(tmp.path()).expect("pack");
|
||||
let second = generate_project_context_pack(tmp.path()).expect("pack again");
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert!(first.contains("\"project_name\""));
|
||||
assert!(first.contains("\"directory_structure\""));
|
||||
assert!(first.contains("\"README.md\""));
|
||||
assert!(first.contains("\"Cargo.toml\""));
|
||||
assert!(first.contains("\"src/a.rs\""));
|
||||
assert!(first.contains("\"src/z.rs\""));
|
||||
assert!(!first.contains("node_modules"));
|
||||
assert!(
|
||||
first.find("\"src/a.rs\"").expect("a before z")
|
||||
< first.find("\"src/z.rs\"").expect("z")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use std::path::{Path, PathBuf};
|
||||
pub struct PromptSessionContext<'a> {
|
||||
pub user_memory_block: Option<&'a str>,
|
||||
pub goal_objective: Option<&'a str>,
|
||||
pub project_context_pack_enabled: bool,
|
||||
/// Resolved BCP-47 locale tag for the `## Environment` block in
|
||||
/// the system prompt (e.g. `"en"`, `"zh-Hans"`, `"ja"`). The
|
||||
/// caller is responsible for resolving this from `Settings`; no
|
||||
@@ -333,6 +334,7 @@ pub fn system_prompt_for_mode_with_context_and_skills(
|
||||
PromptSessionContext {
|
||||
user_memory_block,
|
||||
goal_objective: None,
|
||||
project_context_pack_enabled: true,
|
||||
locale_tag: "en",
|
||||
},
|
||||
)
|
||||
@@ -383,6 +385,12 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval(
|
||||
mode_prompt
|
||||
};
|
||||
|
||||
if session_context.project_context_pack_enabled
|
||||
&& let Some(pack) = crate::project_context::generate_project_context_pack(workspace)
|
||||
{
|
||||
full_prompt = format!("{full_prompt}\n\n{pack}");
|
||||
}
|
||||
|
||||
// 2.25. Environment block — locale, platform, shell, pwd. All
|
||||
// four inputs are session-stable (workspace path is fixed for
|
||||
// the run; locale is loaded once by the caller; platform/shell
|
||||
@@ -545,6 +553,7 @@ mod tests {
|
||||
PromptSessionContext {
|
||||
user_memory_block: None,
|
||||
goal_objective: None,
|
||||
project_context_pack_enabled: true,
|
||||
locale_tag: "ja",
|
||||
},
|
||||
) {
|
||||
@@ -556,6 +565,59 @@ mod tests {
|
||||
assert!(prompt.contains("- deepseek_version:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_context_pack_can_be_disabled() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(tmp.path().join("README.md"), "# Pack test").expect("write readme");
|
||||
let prompt = match system_prompt_for_mode_with_context_skills_and_session(
|
||||
AppMode::Agent,
|
||||
tmp.path(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
PromptSessionContext {
|
||||
user_memory_block: None,
|
||||
goal_objective: None,
|
||||
project_context_pack_enabled: false,
|
||||
locale_tag: "en",
|
||||
},
|
||||
) {
|
||||
SystemPrompt::Text(text) => text,
|
||||
SystemPrompt::Blocks(_) => panic!("expected text system prompt"),
|
||||
};
|
||||
assert!(!prompt.contains("<project_context_pack>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_context_pack_is_before_dynamic_tail() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(tmp.path().join("README.md"), "# Pack test").expect("write readme");
|
||||
std::fs::create_dir_all(tmp.path().join(".deepseek")).expect("mkdir");
|
||||
std::fs::write(tmp.path().join(".deepseek").join("handoff.md"), "handoff")
|
||||
.expect("handoff");
|
||||
let prompt = match system_prompt_for_mode_with_context_skills_and_session(
|
||||
AppMode::Agent,
|
||||
tmp.path(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
PromptSessionContext {
|
||||
user_memory_block: None,
|
||||
goal_objective: None,
|
||||
project_context_pack_enabled: true,
|
||||
locale_tag: "en",
|
||||
},
|
||||
) {
|
||||
SystemPrompt::Text(text) => text,
|
||||
SystemPrompt::Blocks(_) => panic!("expected text system prompt"),
|
||||
};
|
||||
assert!(prompt.contains("<project_context_pack>"));
|
||||
assert!(
|
||||
prompt.find("<project_context_pack>").expect("pack")
|
||||
< prompt.find("## Previous Session Handoff").expect("handoff")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handoff_artifact_is_prepended_to_system_prompt_when_present() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
@@ -716,6 +778,7 @@ mod tests {
|
||||
PromptSessionContext {
|
||||
user_memory_block: None,
|
||||
goal_objective: Some("Fix transcript corruption"),
|
||||
project_context_pack_enabled: true,
|
||||
locale_tag: "en",
|
||||
},
|
||||
) {
|
||||
@@ -743,6 +806,7 @@ mod tests {
|
||||
PromptSessionContext {
|
||||
user_memory_block: None,
|
||||
goal_objective: Some(" "),
|
||||
project_context_pack_enabled: true,
|
||||
locale_tag: "en",
|
||||
},
|
||||
) {
|
||||
|
||||
@@ -6,6 +6,8 @@ Choose the natural language for each turn from the latest user message first —
|
||||
|
||||
Code, file paths, identifiers, tool names, environment variables, command-line flags, URLs, and log lines stay in their original form — translating `read_file` to `读取文件` would break tool calls. Only natural-language prose mirrors the user.
|
||||
|
||||
**Project context is NOT a language signal.** Project instructions (AGENTS.md, CLAUDE.md, auto-generated instructions.md), file listings, directory trees, skill descriptions, and other artifacts placed in the system prompt describe what you're working on — not what language to respond in. Chinese filenames in a project tree, for example, do not mean the user wants Chinese replies. The user's message text alone determines the response language.
|
||||
|
||||
## Runtime Identity
|
||||
|
||||
If the user asks what DeepSeek TUI version you are running, use the `deepseek_version` field in the `## Environment` section as the runtime version. Workspace files such as `Cargo.toml` describe the checkout you are inspecting; they may be stale, dirty, or intentionally different from the installed runtime. If those disagree, report both instead of replacing the runtime version with the workspace version.
|
||||
|
||||
@@ -1923,6 +1923,7 @@ impl RuntimeThreadManager {
|
||||
mcp_config_path: self.config.mcp_config_path(),
|
||||
skills_dir: self.config.skills_dir(),
|
||||
instructions: self.config.instructions_paths(),
|
||||
project_context_pack_enabled: self.config.project_context_pack_enabled(),
|
||||
max_steps: 100,
|
||||
max_subagents: self.config.max_subagents().clamp(1, MAX_SUBAGENTS),
|
||||
features: self.config.features(),
|
||||
|
||||
@@ -50,6 +50,15 @@ pub struct SnapshotRepo {
|
||||
|
||||
const STALE_TMP_PACK_AGE: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
/// Maximum total snapshot storage in megabytes before pruning kicks in at
|
||||
/// snapshot time. Keeps the side repo from blowing up the user's disk during
|
||||
/// long-running or high-churn sessions (#1112).
|
||||
const MAX_SNAPSHOT_SIZE_MB: u64 = 500;
|
||||
|
||||
/// Grace margin below `MAX_SNAPSHOT_SIZE_MB` used as the prune target
|
||||
/// so the repo doesn't hit the limit again one snapshot later.
|
||||
const PRUNE_TARGET_MB: u64 = 400;
|
||||
|
||||
const BUILTIN_EXCLUDES: &str = "\
|
||||
# DeepSeek TUI built-in snapshot exclusions
|
||||
node_modules/
|
||||
@@ -203,8 +212,53 @@ impl SnapshotRepo {
|
||||
/// `git add -A` honours the user's workspace ignore rules while staging
|
||||
/// into the side repo's index.
|
||||
///
|
||||
/// Before committing, checks whether the snapshot directory exceeds
|
||||
/// [`MAX_SNAPSHOT_SIZE_MB`] and prunes the oldest snapshots if it does.
|
||||
///
|
||||
/// Returns the snapshot's commit SHA.
|
||||
pub fn snapshot(&self, label: &str) -> io::Result<SnapshotId> {
|
||||
// Guard against disk blowup (#1112): if the snapshot directory has
|
||||
// grown beyond the limit, prune aggressively before adding more.
|
||||
if let Ok(current_mb) = dir_size_mb(&self.git_dir)
|
||||
&& current_mb > MAX_SNAPSHOT_SIZE_MB
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "snapshot",
|
||||
current_mb,
|
||||
limit_mb = MAX_SNAPSHOT_SIZE_MB,
|
||||
"snapshot storage approaching limit — pruning aggressively"
|
||||
);
|
||||
// Walk backward from a 1-second retention to zero until
|
||||
// we're under the target, or until there's nothing left.
|
||||
let mut age = Duration::from_secs(1);
|
||||
for _ in 0..10 {
|
||||
let _ = self.prune_older_than(age);
|
||||
if let Ok(new_size) = dir_size_mb(&self.git_dir)
|
||||
&& new_size <= PRUNE_TARGET_MB
|
||||
{
|
||||
tracing::info!(
|
||||
target: "snapshot",
|
||||
new_size_mb = new_size,
|
||||
"pruned snapshot storage back under limit"
|
||||
);
|
||||
break;
|
||||
}
|
||||
age = age.saturating_sub(Duration::from_millis(100));
|
||||
}
|
||||
// Fallback: if even 0-second pruning didn't help (shouldn't
|
||||
// happen but belt-and-suspenders), nuke the refs so the next
|
||||
// snapshot starts a fresh history.
|
||||
if let Ok(final_size) = dir_size_mb(&self.git_dir)
|
||||
&& final_size > MAX_SNAPSHOT_SIZE_MB
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "snapshot",
|
||||
"snapshot storage still over limit after pruning; wiping history"
|
||||
);
|
||||
let _ = self.prune_older_than(Duration::ZERO);
|
||||
let _ = self.prune_unreachable_objects();
|
||||
}
|
||||
}
|
||||
// Stage every tracked + untracked path the workspace exposes.
|
||||
// `--all` here means `add` + `update` + `remove` — the same set
|
||||
// `git status` would show.
|
||||
@@ -517,6 +571,32 @@ fn write_builtin_excludes(git_dir: &Path) -> io::Result<()> {
|
||||
std::fs::write(info_dir.join("exclude"), BUILTIN_EXCLUDES)
|
||||
}
|
||||
|
||||
/// Recursively compute the total size of a directory in megabytes.
|
||||
fn dir_size_mb(root: &Path) -> io::Result<u64> {
|
||||
fn walk(dir: &Path, total: &mut u64) -> io::Result<()> {
|
||||
if !dir.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let ft = entry.file_type()?;
|
||||
if ft.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if ft.is_dir() {
|
||||
walk(&path, total)?;
|
||||
} else if ft.is_file() {
|
||||
*total = total.saturating_add(entry.metadata().map(|m| m.len()).unwrap_or(0));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut total: u64 = 0;
|
||||
walk(root, &mut total)?;
|
||||
Ok(total / (1024 * 1024))
|
||||
}
|
||||
|
||||
fn cleanup_stale_pack_temps(git_dir: &Path, stale_age: Duration) -> io::Result<usize> {
|
||||
let pack_dir = git_dir.join("objects").join("pack");
|
||||
if !pack_dir.exists() {
|
||||
@@ -1011,4 +1091,39 @@ mod tests {
|
||||
assert!(!is_home_directory(&workspace_canonical, Some(home)));
|
||||
assert!(!is_home_directory(&home_canonical, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_size_mb_measures_directory_bytes() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let dir = tmp.path().join("sizedir");
|
||||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||
// 3 bytes per file — well under 1 MB.
|
||||
std::fs::write(dir.join("a.txt"), b"abc").unwrap();
|
||||
std::fs::write(dir.join("sub/b.txt"), b"xyz").unwrap();
|
||||
|
||||
let size = dir_size_mb(&dir).expect("dir_size_mb");
|
||||
assert_eq!(size, 0, "6 bytes should be 0 MB");
|
||||
|
||||
// Write 2 MB of data.
|
||||
let big = dir.join("big.bin");
|
||||
std::fs::write(&big, vec![0u8; 2 * 1024 * 1024]).unwrap();
|
||||
let size = dir_size_mb(&dir).expect("dir_size_mb after big write");
|
||||
assert_eq!(size, 2, "expected 2 MB after writing 2 MB file");
|
||||
}
|
||||
|
||||
/// Regression: snapshot size cap (#1112). When the snapshot dir grows,
|
||||
/// `snapshot()` must prune old snapshots to stay under the limit.
|
||||
/// This test uses the real size constants, which are 500/400 MB —
|
||||
/// we can't easily blow up a temp dir to 500 MB in a unit test.
|
||||
/// Instead we verify the guard logic doesn't panic or error on a
|
||||
/// small repo (well under the cap), and that `snapshot()` still works.
|
||||
#[test]
|
||||
fn snapshot_succeeds_when_under_size_cap() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let (repo, _home) = make_repo(tmp.path());
|
||||
// The side repo is tiny — well under 500 MB. Snapshot should work.
|
||||
std::fs::write(repo.work_tree().join("f.txt"), b"hello").unwrap();
|
||||
let id = repo.snapshot("pre-turn:1").expect("snapshot under cap");
|
||||
assert_eq!(id.as_str().len(), 40);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use ratatui::layout::Rect;
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::client::PromptInspection;
|
||||
use crate::compaction::CompactionConfig;
|
||||
use crate::config::{
|
||||
ApiProvider, Config, DEFAULT_TEXT_MODEL, SavedCredential, has_api_key, save_api_key,
|
||||
@@ -626,6 +627,7 @@ pub struct SessionState {
|
||||
pub total_tokens: u32,
|
||||
pub total_conversation_tokens: u32,
|
||||
pub turn_cache_history: VecDeque<TurnCacheRecord>,
|
||||
pub last_cache_inspection: Option<PromptInspection>,
|
||||
}
|
||||
|
||||
impl Default for SessionState {
|
||||
@@ -646,6 +648,7 @@ impl Default for SessionState {
|
||||
total_tokens: 0,
|
||||
total_conversation_tokens: 0,
|
||||
turn_cache_history: VecDeque::new(),
|
||||
last_cache_inspection: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3692,11 +3695,20 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn clear_todos(&mut self) -> bool {
|
||||
// Clear the todo list (the sidebar checklist). Uses try_lock so the
|
||||
// UI thread doesn't block if the engine briefly holds the mutex
|
||||
// during tool execution; the caller can retry or show a busy message.
|
||||
let todos_cleared = if let Ok(mut todos) = self.todos.try_lock() {
|
||||
todos.clear();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Also clear the plan state — /clear means a full reset.
|
||||
if let Ok(mut plan) = self.plan_state.try_lock() {
|
||||
*plan = crate::tools::plan::PlanState::default();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
todos_cleared
|
||||
}
|
||||
|
||||
pub fn update_model_compaction_budget(&mut self) {
|
||||
@@ -3809,6 +3821,7 @@ pub enum AppAction {
|
||||
},
|
||||
ListSubAgents,
|
||||
FetchModels,
|
||||
CacheWarmup,
|
||||
/// Switch the active LLM backend (DeepSeek vs NVIDIA NIM) without
|
||||
/// restarting the process. The runtime rebuilds its API client from
|
||||
/// the updated config. `model` overrides the post-switch model
|
||||
|
||||
@@ -434,6 +434,44 @@ fn collect_candidates(root: &Path) -> Vec<String> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Whitelist AI-tool dot-directories so they're discoverable even when
|
||||
// gitignored. Walk each one separately with gitignore disabled.
|
||||
for dir in [".deepseek", ".cursor", ".claude", ".agents"] {
|
||||
let dot_dir = root.join(dir);
|
||||
if !dot_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let mut dot_builder = WalkBuilder::new(&dot_dir);
|
||||
dot_builder
|
||||
.hidden(true)
|
||||
.follow_links(false)
|
||||
.git_ignore(false)
|
||||
.ignore(false)
|
||||
.max_depth(Some(WALK_DEPTH.saturating_sub(1)));
|
||||
for entry in dot_builder.build().flatten() {
|
||||
// Exclude machine-generated bulk (e.g. .deepseek/snapshots/).
|
||||
if entry.path().starts_with(root.join(".deepseek/snapshots")) {
|
||||
continue;
|
||||
}
|
||||
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
let rel = path.strip_prefix(root).unwrap_or(path);
|
||||
if rel.as_os_str().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let display = path_to_workspace_string(rel);
|
||||
if !display.is_empty() {
|
||||
out.push(display);
|
||||
}
|
||||
if out.len() >= MAX_CANDIDATES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
@@ -20,7 +20,13 @@ pub fn visible_slash_menu_entries(app: &App, limit: usize) -> Vec<SlashMenuEntry
|
||||
if app.slash_menu_hidden {
|
||||
return Vec::new();
|
||||
}
|
||||
slash_completion_hints(&app.input, limit, &app.cached_skills, app.ui_locale)
|
||||
slash_completion_hints(
|
||||
&app.input,
|
||||
limit,
|
||||
&app.cached_skills,
|
||||
app.ui_locale,
|
||||
Some(&app.workspace),
|
||||
)
|
||||
}
|
||||
|
||||
/// Apply the currently-selected slash menu entry to the composer input.
|
||||
@@ -63,10 +69,16 @@ pub fn try_autocomplete_slash_command(app: &mut App) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
let candidates = slash_completion_hints(&app.input, 128, &app.cached_skills, app.ui_locale)
|
||||
.into_iter()
|
||||
.map(|entry| entry.name)
|
||||
.collect::<Vec<_>>();
|
||||
let candidates = slash_completion_hints(
|
||||
&app.input,
|
||||
128,
|
||||
&app.cached_skills,
|
||||
app.ui_locale,
|
||||
Some(&app.workspace),
|
||||
)
|
||||
.into_iter()
|
||||
.map(|entry| entry.name)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if candidates.is_empty() {
|
||||
return false;
|
||||
|
||||
@@ -30,7 +30,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
use crate::audit::log_sensitive_event;
|
||||
use crate::automation_manager::{AutomationManager, AutomationSchedulerConfig, spawn_scheduler};
|
||||
use crate::client::DeepSeekClient;
|
||||
use crate::client::{DeepSeekClient, build_cache_warmup_request};
|
||||
use crate::commands;
|
||||
use crate::compaction::estimate_input_tokens_conservative;
|
||||
use crate::config::{ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL};
|
||||
@@ -40,7 +40,10 @@ use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
|
||||
use crate::core::events::Event as EngineEvent;
|
||||
use crate::core::ops::Op;
|
||||
use crate::hooks::HookEvent;
|
||||
use crate::models::{ContentBlock, Message, SystemPrompt, context_window_for_model};
|
||||
use crate::llm_client::LlmClient;
|
||||
use crate::models::{
|
||||
ContentBlock, Message, MessageRequest, SystemPrompt, Usage, context_window_for_model,
|
||||
};
|
||||
use crate::palette;
|
||||
use crate::prompts;
|
||||
use crate::session_manager::{
|
||||
@@ -510,6 +513,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|
||||
mcp_config_path: config.mcp_config_path(),
|
||||
skills_dir: app.skills_dir.clone(),
|
||||
instructions: config.instructions_paths(),
|
||||
project_context_pack_enabled: config.project_context_pack_enabled(),
|
||||
// Effectively unlimited. V4 has a 1M context window and the user
|
||||
// wants the model running until it's actually done. The previous cap
|
||||
// of 100 hit the ceiling on long multi-step plans (wide refactors,
|
||||
@@ -1592,8 +1596,14 @@ async fn run_event_loop(
|
||||
// terminal's keyboard mode, which breaks IME compositor state.
|
||||
// Acknowledging FocusGained and re-pushing the flags restores
|
||||
// the IME so CJK input methods work after a focus toggle.
|
||||
// The same reset can drop the terminal's mouse-tracking mode,
|
||||
// leaving wheel scroll dead until restart — re-arm mouse
|
||||
// capture on focus-gain so wheel events keep flowing.
|
||||
if terminal_event_needs_viewport_recapture(&evt) {
|
||||
push_keyboard_enhancement_flags(terminal.backend_mut());
|
||||
if app.use_mouse_capture {
|
||||
let _ = execute!(terminal.backend_mut(), EnableMouseCapture);
|
||||
}
|
||||
force_terminal_repaint = true;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
@@ -2981,6 +2991,50 @@ async fn fetch_available_models(config: &Config) -> Result<Vec<String>> {
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
async fn run_cache_warmup(app: &App, config: &Config) -> Result<Usage> {
|
||||
let client = DeepSeekClient::new(config)?;
|
||||
let reasoning_effort = if app.reasoning_effort == ReasoningEffort::Auto {
|
||||
app.last_effective_reasoning_effort
|
||||
.and_then(ReasoningEffort::api_value)
|
||||
.map(str::to_string)
|
||||
} else {
|
||||
app.reasoning_effort.api_value().map(str::to_string)
|
||||
};
|
||||
let request = MessageRequest {
|
||||
model: app.model.clone(),
|
||||
messages: app.api_messages.clone(),
|
||||
max_tokens: 1024,
|
||||
system: app.system_prompt.clone(),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
reasoning_effort,
|
||||
stream: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
};
|
||||
let warmup = build_cache_warmup_request(&request);
|
||||
let response =
|
||||
tokio::time::timeout(Duration::from_secs(45), client.create_message(warmup)).await??;
|
||||
Ok(response.usage)
|
||||
}
|
||||
|
||||
fn format_cache_warmup_result(usage: &Usage) -> String {
|
||||
let cache = match (
|
||||
usage.prompt_cache_hit_tokens,
|
||||
usage.prompt_cache_miss_tokens,
|
||||
) {
|
||||
(Some(hit), Some(miss)) => format!("Cache warmup complete: hit {hit} | miss {miss}"),
|
||||
(Some(hit), None) => format!("Cache warmup complete: hit {hit} | miss unavailable"),
|
||||
(None, Some(miss)) => format!("Cache warmup complete: hit unavailable | miss {miss}"),
|
||||
(None, None) => "Cache warmup complete: cache telemetry unavailable".to_string(),
|
||||
};
|
||||
format!(
|
||||
"{cache}\nNote: the first warmup is usually a miss. Later requests that reuse the same stable prefix may hit the provider cache; a hit is not guaranteed."
|
||||
)
|
||||
}
|
||||
|
||||
fn format_available_models_message(current_model: &str, models: &[String]) -> String {
|
||||
let mut lines = vec![format!("Available models ({})", models.len())];
|
||||
for model in models {
|
||||
@@ -3739,6 +3793,7 @@ async fn dispatch_user_message(
|
||||
prompts::PromptSessionContext {
|
||||
user_memory_block: None,
|
||||
goal_objective: app.goal.goal_objective.as_deref(),
|
||||
project_context_pack_enabled: config.project_context_pack_enabled(),
|
||||
locale_tag: app.ui_locale.tag(),
|
||||
},
|
||||
),
|
||||
@@ -4556,6 +4611,24 @@ async fn apply_command_result(
|
||||
}
|
||||
}
|
||||
}
|
||||
AppAction::CacheWarmup => {
|
||||
app.status_message = Some("Warming DeepSeek cache...".to_string());
|
||||
match run_cache_warmup(app, config).await {
|
||||
Ok(usage) => {
|
||||
let message = format_cache_warmup_result(&usage);
|
||||
app.add_message(HistoryCell::System {
|
||||
content: message.clone(),
|
||||
});
|
||||
app.status_message = Some("Cache warmup complete".to_string());
|
||||
}
|
||||
Err(error) => {
|
||||
app.add_message(HistoryCell::System {
|
||||
content: format!("Cache warmup failed: {error}"),
|
||||
});
|
||||
app.status_message = Some("Cache warmup failed".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
AppAction::SwitchProvider { provider, model } => {
|
||||
switch_provider(app, engine_handle, config, provider, model).await;
|
||||
}
|
||||
@@ -7002,9 +7075,15 @@ fn footer_coherence_spans(app: &App) -> Vec<Span<'static>> {
|
||||
}
|
||||
|
||||
fn footer_cache_spans(app: &App) -> Vec<Span<'static>> {
|
||||
let Some(hit_tokens) = app.session.last_prompt_cache_hit_tokens else {
|
||||
if app.session.last_prompt_tokens.is_none() && app.session.last_completion_tokens.is_none() {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(hit_tokens) = app.session.last_prompt_cache_hit_tokens else {
|
||||
return vec![Span::styled(
|
||||
"Cache: unavailable",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)];
|
||||
};
|
||||
let miss_tokens = app
|
||||
.session
|
||||
.last_prompt_cache_miss_tokens
|
||||
@@ -7015,11 +7094,11 @@ fn footer_cache_spans(app: &App) -> Vec<Span<'static>> {
|
||||
.saturating_sub(hit_tokens)
|
||||
});
|
||||
let total = hit_tokens.saturating_add(miss_tokens);
|
||||
if total == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let percent = (f64::from(hit_tokens) / f64::from(total) * 100.0).clamp(0.0, 100.0);
|
||||
let percent = if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(f64::from(hit_tokens) / f64::from(total) * 100.0).clamp(0.0, 100.0)
|
||||
};
|
||||
// Threshold-based coloring for cache hit rate (#396):
|
||||
// >80%: green (good cache utilization)
|
||||
// 40-80%: yellow/warning
|
||||
@@ -7032,7 +7111,10 @@ fn footer_cache_spans(app: &App) -> Vec<Span<'static>> {
|
||||
palette::STATUS_ERROR
|
||||
};
|
||||
vec![Span::styled(
|
||||
format!("cache hit {:.0}%", percent),
|
||||
format!(
|
||||
"Cache: {:.1}% hit | hit {hit_tokens} | miss {miss_tokens}",
|
||||
percent
|
||||
),
|
||||
Style::default().fg(color),
|
||||
)]
|
||||
}
|
||||
|
||||
@@ -1483,11 +1483,22 @@ fn footer_auxiliary_spans_show_cache_when_compact() {
|
||||
app.session.last_prompt_cache_miss_tokens = Some(12_000);
|
||||
app.session.session_cost = 12.34;
|
||||
|
||||
let compact = spans_text(&footer_auxiliary_spans(&app, 14));
|
||||
assert!(compact.contains("cache"));
|
||||
let compact = spans_text(&footer_auxiliary_spans(&app, 48));
|
||||
assert!(compact.contains("Cache: 75.0% hit"));
|
||||
assert!(!compact.contains('$'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {
|
||||
let mut app = create_test_app();
|
||||
app.session.last_prompt_tokens = Some(48_000);
|
||||
app.session.last_completion_tokens = Some(2_000);
|
||||
|
||||
let roomy = spans_text(&footer_auxiliary_spans(&app, 72));
|
||||
|
||||
assert!(roomy.contains("Cache: unavailable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {
|
||||
let mut app = create_test_app();
|
||||
@@ -1496,8 +1507,8 @@ fn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {
|
||||
app.session.last_prompt_cache_miss_tokens = Some(12_000);
|
||||
app.session.session_cost = 12.34;
|
||||
|
||||
let roomy = spans_text(&footer_auxiliary_spans(&app, 32));
|
||||
assert!(roomy.contains("cache hit 75%"));
|
||||
let roomy = spans_text(&footer_auxiliary_spans(&app, 72));
|
||||
assert!(roomy.contains("Cache: 75.0% hit | hit 36000 | miss 12000"));
|
||||
assert!(roomy.contains("$12.34"));
|
||||
assert!(
|
||||
!roomy.contains("ctx"),
|
||||
|
||||
@@ -1953,6 +1953,7 @@ pub(crate) fn slash_completion_hints(
|
||||
limit: usize,
|
||||
cached_skills: &[(String, String)],
|
||||
locale: crate::localization::Locale,
|
||||
workspace: Option<&std::path::Path>,
|
||||
) -> Vec<SlashMenuEntry> {
|
||||
if !input.starts_with('/') {
|
||||
return Vec::new();
|
||||
@@ -1970,7 +1971,7 @@ pub(crate) fn slash_completion_hints(
|
||||
// built-in ones from the static registry and use a generic label for
|
||||
// user-defined commands.
|
||||
if completing_skill_arg.is_none() {
|
||||
for name in commands::all_command_names_matching(prefix) {
|
||||
for name in commands::all_command_names_matching(prefix, workspace) {
|
||||
let command_key = name.trim_start_matches('/');
|
||||
let description = if let Some(info) = commands::get_command_info(command_key) {
|
||||
info.description_for(locale).to_string()
|
||||
@@ -2359,14 +2360,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn slash_completion_hints_include_links_and_config() {
|
||||
let hints = slash_completion_hints("/", 128, &[], Locale::En);
|
||||
let hints = slash_completion_hints("/", 128, &[], Locale::En, None);
|
||||
assert!(hints.iter().any(|hint| hint.name == "/config"));
|
||||
assert!(hints.iter().any(|hint| hint.name == "/links"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_completion_hints_exclude_set_and_deepseek_commands() {
|
||||
let hints = slash_completion_hints("/", 128, &[], Locale::En);
|
||||
let hints = slash_completion_hints("/", 128, &[], Locale::En, None);
|
||||
assert!(!hints.iter().any(|hint| hint.name == "/set"));
|
||||
assert!(!hints.iter().any(|hint| hint.name == "/deepseek"));
|
||||
}
|
||||
@@ -2377,7 +2378,7 @@ mod tests {
|
||||
("search-files".to_string(), "Search files".to_string()),
|
||||
("my-review".to_string(), "Review code".to_string()),
|
||||
];
|
||||
let hints = slash_completion_hints("/", 128, &cached_skills, Locale::En);
|
||||
let hints = slash_completion_hints("/", 128, &cached_skills, Locale::En, None);
|
||||
assert!(
|
||||
hints
|
||||
.iter()
|
||||
@@ -2396,7 +2397,7 @@ mod tests {
|
||||
("search-files".to_string(), "Search files".to_string()),
|
||||
("my-review".to_string(), "Review code".to_string()),
|
||||
];
|
||||
let hints = slash_completion_hints("/se", 128, &cached_skills, Locale::En);
|
||||
let hints = slash_completion_hints("/se", 128, &cached_skills, Locale::En, None);
|
||||
assert!(
|
||||
hints
|
||||
.iter()
|
||||
@@ -2411,7 +2412,7 @@ mod tests {
|
||||
("search-files".to_string(), "Search files".to_string()),
|
||||
("my-review".to_string(), "Review code".to_string()),
|
||||
];
|
||||
let hints = slash_completion_hints("/skill my", 128, &cached_skills, Locale::En);
|
||||
let hints = slash_completion_hints("/skill my", 128, &cached_skills, Locale::En, None);
|
||||
assert_eq!(hints.len(), 1);
|
||||
assert_eq!(hints[0].name, "/skill my-review");
|
||||
assert!(hints[0].is_skill);
|
||||
|
||||
+261
-11
@@ -93,11 +93,7 @@ impl Workspace {
|
||||
|
||||
fn build_file_index(&self) -> HashMap<String, Vec<PathBuf>> {
|
||||
let mut index: HashMap<String, Vec<PathBuf>> = HashMap::new();
|
||||
let mut builder = WalkBuilder::new(&self.root);
|
||||
builder.hidden(true).follow_links(false).max_depth(Some(6));
|
||||
// Honor `.deepseekignore` in addition to the defaults the `ignore` crate
|
||||
// already respects (`.gitignore`, `.git/info/exclude`, `.ignore`).
|
||||
let _ = builder.add_custom_ignore_filename(".deepseekignore");
|
||||
let builder = discovery_walk_builder(&self.root, Some(6));
|
||||
|
||||
for entry in builder.build().flatten() {
|
||||
if entry
|
||||
@@ -111,6 +107,37 @@ impl Workspace {
|
||||
.push(entry.path().to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
// Also index AI-tool dot-directories with gitignore disabled.
|
||||
for dir_name in DISCOVERY_ALWAYS_DIRS {
|
||||
let dot_dir = self.root.join(dir_name);
|
||||
if !dot_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let mut dot_builder = WalkBuilder::new(&dot_dir);
|
||||
dot_builder
|
||||
.hidden(true)
|
||||
.follow_links(false)
|
||||
.git_ignore(false)
|
||||
.ignore(false)
|
||||
.max_depth(Some(5));
|
||||
for entry in dot_builder.build().flatten() {
|
||||
// Exclude machine-generated bulk (e.g. .deepseek/snapshots/).
|
||||
if path_is_excluded_from_discovery(&self.root, entry.path()) {
|
||||
continue;
|
||||
}
|
||||
if entry
|
||||
.file_type()
|
||||
.is_some_and(|ft| ft.is_file() || ft.is_dir())
|
||||
{
|
||||
let name = entry.file_name().to_string_lossy().to_lowercase();
|
||||
index
|
||||
.entry(name)
|
||||
.or_default()
|
||||
.push(entry.path().to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
@@ -180,6 +207,111 @@ impl Workspace {
|
||||
/// monorepos.
|
||||
const COMPLETIONS_WALK_DEPTH: usize = 6;
|
||||
|
||||
/// Directories that must remain discoverable for `@`-mention completion and
|
||||
/// fuzzy file resolution even when excluded by `.gitignore`. AI-tool
|
||||
/// convention directories (`.deepseek/`, `.cursor/`, `.claude/`, `.agents/`)
|
||||
/// are routinely gitignored, but users need to `@`-mention files inside them.
|
||||
const DISCOVERY_ALWAYS_DIRS: &[&str] = &[".deepseek", ".cursor", ".claude", ".agents"];
|
||||
|
||||
/// Subdirectories under `DISCOVERY_ALWAYS_DIRS` that must NOT be indexed
|
||||
/// even when the parent dir is walked with gitignore disabled. These are
|
||||
/// large, machine-generated, or sensitive paths that would blow up the
|
||||
/// walker (e.g. `.deepseek/snapshots/` — the snapshot side repo that
|
||||
/// #1112 caps at 500 MB; indexing it would trigger the same OOM/hang
|
||||
/// the cap was built to prevent).
|
||||
const DISCOVERY_EXCLUDED_SUBDIRS: &[&str] = &[".deepseek/snapshots"];
|
||||
|
||||
/// Check whether a path resolved against `walk_root` falls inside any
|
||||
/// `DISCOVERY_EXCLUDED_SUBDIRS` entry. Used to keep the snapshot side
|
||||
/// repo (`.deepseek/snapshots/`) out of the completion/index walk.
|
||||
fn path_is_excluded_from_discovery(walk_root: &Path, path: &Path) -> bool {
|
||||
for excluded in DISCOVERY_EXCLUDED_SUBDIRS {
|
||||
if path.starts_with(walk_root.join(excluded)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Configure a `WalkBuilder` for workspace discovery: hidden files, no
|
||||
/// symlink following, depth-limited, custom `.deepseekignore` honored,
|
||||
/// and gitignore overrides for AI-tool dot-directories so `@`-completion
|
||||
/// finds them even when they're gitignored.
|
||||
fn discovery_walk_builder(root: &Path, max_depth: Option<usize>) -> WalkBuilder {
|
||||
let mut builder = WalkBuilder::new(root);
|
||||
builder.hidden(true).follow_links(false);
|
||||
if let Some(depth) = max_depth {
|
||||
builder.max_depth(Some(depth));
|
||||
}
|
||||
let _ = builder.add_custom_ignore_filename(".deepseekignore");
|
||||
builder
|
||||
}
|
||||
|
||||
/// Walk the AI-tool dot-directories (`.deepseek/`, `.cursor/`, `.claude/`,
|
||||
/// `.agents/`) with gitignore disabled so their contents are discoverable
|
||||
/// even when the project's `.gitignore` / `.ignore` excludes them.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn walk_always_discoverable_dirs(
|
||||
walk_root: &Path,
|
||||
display_root: &Path,
|
||||
needle: &str,
|
||||
limit: usize,
|
||||
prefix_hits: &mut Vec<String>,
|
||||
substring_hits: &mut Vec<String>,
|
||||
seen: &mut HashSet<PathBuf>,
|
||||
max_depth: Option<usize>,
|
||||
) {
|
||||
for dir_name in DISCOVERY_ALWAYS_DIRS {
|
||||
let dot_dir = walk_root.join(dir_name);
|
||||
if !dot_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let mut builder = WalkBuilder::new(&dot_dir);
|
||||
builder
|
||||
.hidden(true)
|
||||
.follow_links(false)
|
||||
.git_ignore(false)
|
||||
.ignore(false);
|
||||
if let Some(depth) = max_depth {
|
||||
builder.max_depth(Some(depth.saturating_sub(1)));
|
||||
}
|
||||
for entry in builder.build().flatten() {
|
||||
if prefix_hits.len() + substring_hits.len() >= limit {
|
||||
break;
|
||||
}
|
||||
let path = entry.path();
|
||||
// Exclude machine-generated bulk (e.g. .deepseek/snapshots/)
|
||||
// even though gitignore is disabled for this walk.
|
||||
if path_is_excluded_from_discovery(walk_root, path) {
|
||||
continue;
|
||||
}
|
||||
let Ok(rel) = path.strip_prefix(display_root) else {
|
||||
continue;
|
||||
};
|
||||
let rel_str = rel.to_string_lossy().replace('\\', "/");
|
||||
if rel_str.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let abs = path.to_path_buf();
|
||||
if !seen.insert(abs) {
|
||||
continue;
|
||||
}
|
||||
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
|
||||
let candidate = if is_dir {
|
||||
format!("{rel_str}/")
|
||||
} else {
|
||||
rel_str.clone()
|
||||
};
|
||||
let lower = candidate.to_lowercase();
|
||||
if needle.is_empty() || lower.starts_with(needle) {
|
||||
prefix_hits.push(candidate);
|
||||
} else if lower.contains(needle) {
|
||||
substring_hits.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn walk_for_completions(
|
||||
walk_root: &Path,
|
||||
@@ -190,12 +322,7 @@ fn walk_for_completions(
|
||||
substring_hits: &mut Vec<String>,
|
||||
seen: &mut HashSet<PathBuf>,
|
||||
) {
|
||||
let mut builder = WalkBuilder::new(walk_root);
|
||||
builder
|
||||
.hidden(true)
|
||||
.follow_links(false)
|
||||
.max_depth(Some(COMPLETIONS_WALK_DEPTH));
|
||||
let _ = builder.add_custom_ignore_filename(".deepseekignore");
|
||||
let builder = discovery_walk_builder(walk_root, Some(COMPLETIONS_WALK_DEPTH));
|
||||
|
||||
for entry in builder.build().flatten() {
|
||||
if prefix_hits.len() + substring_hits.len() >= limit {
|
||||
@@ -228,6 +355,19 @@ fn walk_for_completions(
|
||||
substring_hits.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
// Also walk the AI-tool dot-directories with gitignore disabled so
|
||||
// `.deepseek/`, `.cursor/`, etc. are always discoverable.
|
||||
walk_always_discoverable_dirs(
|
||||
walk_root,
|
||||
display_root,
|
||||
needle,
|
||||
limit,
|
||||
prefix_hits,
|
||||
substring_hits,
|
||||
seen,
|
||||
Some(COMPLETIONS_WALK_DEPTH),
|
||||
);
|
||||
}
|
||||
|
||||
impl Clone for Workspace {
|
||||
@@ -1195,4 +1335,114 @@ mod tests {
|
||||
// Index was populated exactly once (subsequent lookups reuse it).
|
||||
assert!(ws.file_index.get().is_some());
|
||||
}
|
||||
|
||||
/// Regression: `@`-mention completion must discover files inside
|
||||
/// `.deepseek/`, `.cursor/`, `.claude/`, `.agents/` even when
|
||||
/// those directories are excluded by `.gitignore` (or `.ignore`).
|
||||
/// The `discovery_walk_builder` override un-ignores them.
|
||||
#[test]
|
||||
fn completions_discovers_files_inside_gitignored_dot_dirs() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
// `.ignore` works even outside a git repo; use it to simulate
|
||||
// a project that gitignores its AI-tool dot-directories.
|
||||
std::fs::write(
|
||||
root.join(".ignore"),
|
||||
".deepseek/\n.cursor/\n.claude/\n.agents/\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create files inside each dot-dir.
|
||||
std::fs::create_dir_all(root.join(".deepseek/commands")).unwrap();
|
||||
std::fs::write(root.join(".deepseek/commands/build.md"), "build cmd").unwrap();
|
||||
std::fs::create_dir_all(root.join(".cursor/commands")).unwrap();
|
||||
std::fs::write(root.join(".cursor/commands/run.md"), "run cmd").unwrap();
|
||||
std::fs::create_dir_all(root.join(".claude/commands")).unwrap();
|
||||
std::fs::write(root.join(".claude/commands/test.md"), "test cmd").unwrap();
|
||||
std::fs::create_dir_all(root.join(".agents/skills/example")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".agents/skills/example/SKILL.md"),
|
||||
"name: example\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let ws = Workspace::with_cwd(root.to_path_buf(), None);
|
||||
|
||||
// Completions should find entries inside the dot-dirs.
|
||||
{
|
||||
let entries = ws.completions("build", 16);
|
||||
assert!(
|
||||
entries.iter().any(|e| e.contains("build.md")),
|
||||
"expected build.md in completions although .deepseek/ is ignored; got: {entries:?}"
|
||||
);
|
||||
}
|
||||
{
|
||||
let entries = ws.completions("run", 16);
|
||||
assert!(
|
||||
entries.iter().any(|e| e.contains("run.md")),
|
||||
"expected run.md from .cursor/; got: {entries:?}"
|
||||
);
|
||||
}
|
||||
{
|
||||
let entries = ws.completions("test", 16);
|
||||
assert!(
|
||||
entries.iter().any(|e| e.contains("test.md")),
|
||||
"expected test.md from .claude/; got: {entries:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Fuzzy resolution should also work.
|
||||
let f = ws.resolve("build.md").unwrap();
|
||||
assert!(f.ends_with("build.md"));
|
||||
let f2 = ws.resolve("SKILL.md").unwrap();
|
||||
assert!(f2.ends_with("SKILL.md"));
|
||||
}
|
||||
|
||||
/// Regression: the dot-dir walk must NOT index `.deepseek/snapshots/`,
|
||||
/// which is the snapshot side repo that can grow to hundreds of GB.
|
||||
/// Indexing it would re-create the same OOM/hang that #1112 was built
|
||||
/// to prevent.
|
||||
#[test]
|
||||
fn dot_dir_walk_excludes_snapshot_side_repo() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
// Create a snapshot-like directory tree.
|
||||
std::fs::create_dir_all(root.join(".deepseek/snapshots/deadbeef/deadbeef/.git/objects"))
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join(".deepseek/snapshots/deadbeef/deadbeef/.git/objects/snapshot.pack"),
|
||||
b"fake pack data",
|
||||
)
|
||||
.unwrap();
|
||||
// Also create a legitimate file in .deepseek/ that should be found.
|
||||
std::fs::create_dir_all(root.join(".deepseek/commands")).unwrap();
|
||||
std::fs::write(root.join(".deepseek/commands/build.md"), "build cmd").unwrap();
|
||||
|
||||
let ws = Workspace::with_cwd(root.to_path_buf(), None);
|
||||
|
||||
// Searching for "build" must find build.md.
|
||||
let entries = ws.completions("build", 16);
|
||||
assert!(
|
||||
entries.iter().any(|e| e.contains("build.md")),
|
||||
"build.md must still be found; got: {entries:?}"
|
||||
);
|
||||
// Searching for "snapshot" must NOT return snapshot files.
|
||||
let snap_entries = ws.completions("snapshot", 16);
|
||||
assert!(
|
||||
!snap_entries.iter().any(|e| e.contains("snapshot")),
|
||||
"snapshot files must NOT appear in completions; got: {snap_entries:?}"
|
||||
);
|
||||
|
||||
// Fuzzy index must also exclude snapshots.
|
||||
let f = ws.resolve("build.md").unwrap();
|
||||
assert!(f.ends_with("build.md"));
|
||||
// snapshot.pack should NOT resolve.
|
||||
let result = ws.resolve("snapshot.pack");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"snapshot.pack must not resolve via fuzzy index"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "deepseek-tui",
|
||||
"version": "0.8.23",
|
||||
"deepseekBinaryVersion": "0.8.23",
|
||||
"version": "0.8.24",
|
||||
"deepseekBinaryVersion": "0.8.24",
|
||||
"description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.",
|
||||
"author": "Hmbown",
|
||||
"license": "MIT",
|
||||
|
||||
Reference in New Issue
Block a user