Deletes crates/tui/src/responses_api_proxy/ (443 LOC), client/responses.rs
(406 LOC), and removes the ResponsesApiProxy CLI command, the
EXPERIMENTAL_RESPONSES_API_ENV env var plumbing, chat_fallback_counter,
use_chat_completions, RESPONSES_RECOVERY_INTERVAL, and the
RequestPayloadMode::ResponsesApi variant. The experimental Responses API
path was never instantiated and had no documented users; removing it
simplifies the client surface for the upcoming --anthropic-wire flag.
Closes#723
V4 occasionally emits non-canonical tool names (Read_file, readFile,
read-file, read_file_tool). The new resolver runs a deterministic
ladder — lowercase exact, kebab/space → snake, CamelCase → snake,
strip _tool suffix twice, fuzzy prefix match — before falling through
to "Unknown tool". Recovers ~80% of these for free.
Closes#713
The new schema_sanitize module collapses Pydantic-style nullable
anyOf/oneOf unions to {type, nullable: true}, injects empty properties
on bare objects, prunes dangling required entries, and collapses
single-element unions. Run on every tool schema before
build_api_tools() returns the catalog.
Closes a class of silent 400s on /beta/chat/completions strict tool
mode for MCP-derived schemas.
Closes#715
`normalize_model_name` now passes v-series snapshots through unchanged
(deepseek-v4-flash-20260423 stays pinned, future v5-* matches via
regex). Removes ~245 LOC of legacy alias machinery: deepseek_legacy_aliases,
the chat/reasoner/r1/v3/v3.2 fold-arm, is_current_deepseek_v4_alias,
v4 fallback branch, alias capacity test seeds, alias config test block.
The migration from V3 → V4 is over; users on legacy names route their
own request to DeepSeek and see the server actual response (404 if
deprecated, success if still served). No more silent renaming.
Closes#717
PR #646 imported `MessageBeep` from `windows::Win32::UI::WindowsAndMessaging`,
but in `windows` crate 0.60 the function lives at
`windows::Win32::System::Diagnostics::Debug::MessageBeep` and now takes a
typed `MESSAGEBOX_STYLE` returning `Result<()>`. The wrong import broke
every Windows build (Test, npm wrapper smoke, and the windows-msvc release
matrix entry). Fix the import path, enable the `Win32_System_Diagnostics_Debug`
feature, pass `MESSAGEBOX_STYLE(0)` for MB_OK, and discard the Result.
The v0.8.12 release also tripped on a transient `apt-get update` mirror
sync error on the ubuntu-24.04-arm runner, cascading via fail-fast. Wrap
every apt-get update in CI/release with a 5-attempt retry so flaky
ports.ubuntu.com mirrors no longer take down the binary matrix.
Verified: cargo check --target x86_64-pc-windows-gnu compiles cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reported by @Hmbown as the recurring "scrolling uses the parent
terminal scrollbar instead of the TUI's" / "TUI rendered only at the
bottom of the viewport" symptom. Same family as 5c72e5f4 and 899c703d,
but neither covered the cancellation case — they fixed the per-turn
scroll lock and panic-time cleanup respectively.
The bug
=======
`execute_tool_with_lock` in tool_execution.rs sent
`Event::PauseEvents` before an interactive tool ran (which makes the
TUI leave alt-screen, disable raw mode, release mouse capture so the
child sees a normal terminal) and `Event::ResumeEvents` after it
returned. Both sends were `let _ = tx_event.send(...).await`.
If the future was dropped between the two awaits — Ctrl+C, agent
cancel, parent task aborted, sub-agent terminated mid-tool — the
second `await` never reached and `ResumeEvents` was never sent. The
TUI sat in the paused state until the next pause/resume cycle, with
the symptoms above.
The fix
=======
Replace the two `send` calls with a `InteractiveTerminalGuard` RAII
type. `engage()` sends `PauseEvents` (only when `interactive` is
true) and arms the guard. `Drop` synchronously sends `ResumeEvents`
via `try_send` (Drop can't await). The guard fires on every exit
path: Ok return, Err return, panic, future cancellation.
`try_send` can fail on a full bounded channel; the guard logs a
`tracing::warn!` rather than swallowing silently so the rare
cancel-with-saturated-event-channel case is visible in traces. The
TUI's own teardown path remains a final backstop.
Verified locally
================
* `cargo build` clean
* `cargo fmt --all -- --check` clean
* `cargo clippy --workspace --all-targets --all-features --locked
-- -D warnings` clean
* `cargo test --workspace --all-features --locked` — 2062 passed,
0 failed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reported by @Hmbown: launching `deepseek` from any directory silently
auto-recovered the most recent interrupted session, even if that
session originated in a completely different workspace. Tools then
operated on file paths from the prior workspace while the status bar
showed the *current* workspace name — a confusing trust-boundary
violation that also leaks api_messages, working_set entries, and any
secrets the prior session had accumulated into a new terminal that
was never meant to see them.
`try_recover_checkpoint()` now compares the saved session's workspace
to `std::env::current_dir()` (canonicalised, with a strict-equality
fallback when canonicalisation fails — e.g. the original workspace
was deleted) and only auto-recovers on a match.
On a mismatch the checkpoint is still persisted as a regular session
and cleared, so the user can recover it explicitly via
`deepseek resume <ID>` after `cd`-ing back to the original workspace —
no data is lost. A one-line stderr notice points at the resume command.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resident-file leases were stamped as "pending" at spawn time because the
agent id is only assigned by the manager later. The release function
introduced in 2ee926924 matches by agent id, so it could never find
those entries and leases would persist for the lifetime of the process.
After spawn returns the real agent id, replace any "pending" entry with
it so the existing release-on-terminal-state path actually fires.
Resolves the documented v0.8.12 caveat noted in the CHANGELOG. Closes
the loop with PR #694, which proposed a release-by-file-path API but
did not address the placeholder problem itself.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
47 fmt drifts had accumulated from the squash-merged community PRs on
this branch (#653, #654, #655, #645, #658, #668, #659, #661, #660,
#667, #656). Pure formatting — no behavioural changes — applied via
`cargo fmt --all` to satisfy CI's `cargo fmt --all -- --check` gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The match guard at tui/ui.rs:1603 used `&& let Some(...) = ...` inside an
`if` guard, which requires the `if_let_guard` nightly feature on Rust
< 1.94. Reported by an external user attempting `cargo install
deepseek-tui` on stable rustc — it failed with E0658.
Rewrite as a plain match guard with a nested `if let` inside the arm
body so the language-picker hotkeys compile on every supported rustc.
Workspace also now declares `rust-version = "1.88"` to match the
codebase's actual reliance on `let_chains` in if/while conditions, so
users on too-old toolchains see a clear cargo error instead of a
confusing rustc one.
`AGENTS.md` and `CLAUDE.md` gain a "stable Rust only" section
documenting the trap and how to rewrite around it.
Also annotate the deferred `TuiPrefs` (#657) and `handoff::THRESHOLDS`
(#667) APIs with `#[allow(dead_code)]` so CI's `-D warnings` flag stays
green while the call sites are staged for v0.8.13.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moved RESIDENT_LEASES from block-scoped static to module level so the
release function can access it. Added release_resident_leases_for()
called at all three terminal transitions:
- Cancelled (cancel_agent)
- Failed (update_failed)
- Completed (run_subagent_turn result construction)
Previously leases were acquired but never released, causing false
conflict warnings on subsequent spawns targeting the same file.
The sandbox backend infrastructure was complete but the engine never
called create_backend(), leaving the feature dead. Now:
- Engine::new() creates the backend from api_config (non-fatal on error)
- build_tool_context() attaches it to the ToolContext
- exec_shell checks context.sandbox_backend and routes accordingly
Neither field had any code path that read it. Shipping config knobs
that do nothing trains users to mistrust config. Remove until the
implementation exists.
#651: fix test assertion — section_bg now Color::Reset (was DEEPSEEK_INK)
#645: replace expect() with Result in OpenSandboxBackend::new()
#653: correct resolve_prefixes docstring to describe deny-always-wins
User report: YOLO mode was still routing shell commands through the
WorkspaceWrite sandbox, which intercepted legitimate outside-workspace
writes (package installs, sub-agent workspaces, package-manager state
under ~/.cache, brew, npm install -g, pipx, …) and forced approval
round-trips. That contradicts the YOLO contract — the user opted into
"no guardrails" and instead got a guardrail.
YOLO already auto-approves all tools and enables trust mode. The
sandbox was the last residual restriction. Drop it.
Change in `Engine::build_tool_context`: split the previously-merged
`AppMode::Agent | AppMode::Yolo` arm into two:
* **Agent** keeps `WorkspaceWrite { writable_roots, network_access:
true, … }` — interactive mode with explicit per-tool approval, so
the sandbox plus the approval flow form a defense-in-depth layer.
* **Yolo** uses `DangerFullAccess` — no sandbox. The user has
opted into auto-approval + trust mode + no sandbox as one
consistent posture.
Plan mode unchanged (read-only, no shell tool registered).
Updated `agent_and_yolo_modes_elevate_shell_sandbox_to_allow_network`
to pin the new YOLO contract: `DangerFullAccess` specifically, not
just "has network access."
Verified locally:
* `cargo fmt --all -- --check` clean.
* `cargo clippy --workspace --all-targets --all-features --locked
-- -D warnings` clean.
* `cargo test --workspace --all-features --locked` — green
(the snapshot::repo flake still flakes in batch but passes in
isolation; unrelated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Live repro: in a session producing content rapidly (sub-agent
running, multiple tool calls), the user scrolls up to read earlier
output. Their scroll position briefly takes effect, then snaps back
to the live tail when the next stream chunk arrives. Symptom is
"scrolling is broken / takes over instead of the transcript".
Root cause in `crates/tui/src/tui/widgets/mod.rs:188-210`:
* The user's mouse-scroll-up sets `transcript_scroll = at_line(N)`
and `user_scrolled_during_stream = true`.
* During render, `resolve_top` clamps the state against
`max_start = total_lines.saturating_sub(visible_lines)`. If
`max_start < N` (transcript shrunk between scrolls and render —
e.g., a sub-agent in-progress card collapsed into a smaller
finished card, or the content briefly fits in one screen),
`resolve_top` returns `Self::to_bottom()` (TAIL_SENTINEL).
* `is_at_tail()` on the post-resolve state returns `true`.
* The auto-clear at line 208 fires →
`user_scrolled_during_stream = false`.
* Next `add_message` / sub-agent envelope sees `is_at_tail() &&
!user_scrolled_during_stream` and calls `scroll_to_bottom()`. The
user is yanked off their position mid-read.
`scrolled_by` has the same trapdoor: when `total_lines <= visible_
lines` it returns `to_bottom()` regardless of scroll direction
(line 145-148 in scrolling.rs). A user scroll-up while content
fits in one screen produces `to_bottom()` → `is_at_tail()` true →
auto-clear → next chunk yanks.
The fix
=======
Snapshot whether the user's PRIOR state was deliberately tail
(`is_at_tail()` BEFORE `resolve_top`), and only clear the lock
when:
1. Prior state was already TAIL_SENTINEL (deliberate, set by
`scrolled_by` reaching `max_start` while scrolling DOWN, or by
`scroll_to_bottom()`).
2. AND `total_lines > visible_lines` (so "tail" is meaningful —
if the whole transcript fits, "is_at_tail" is trivially true
and clearing the lock would yank the user back to bottom on
the next chunk despite their explicit scroll-up).
This preserves all the legitimate clear paths:
* `TurnComplete` event clears the lock at the per-turn boundary
(`ui.rs:879`).
* User invokes `scroll_to_bottom()` explicitly via key/menu
(`app.rs:2459`).
* User scrolls down enough that `scrolled_by` reaches `max_start`
in a transcript with real scroll room — state goes through
`to_bottom()` BEFORE resolve, so `was_explicit_tail = true` and
the lock clears.
What it stops:
* Render-time resolve clamping `at_line(N)` to tail when content
shrunk doesn't quietly revoke the user's intent.
* `scrolled_by` collapsing a scroll-up to `to_bottom()` when
content briefly fits in one screen no longer triggers the
auto-clear (the prior state wasn't tail).
Verified locally:
* `cargo fmt --all -- --check` clean.
* `cargo clippy --workspace --all-targets --all-features --locked
-- -D warnings` clean.
* `cargo test --workspace --all-features --locked` — 2038 passed,
2 ignored, 0 failed (a snapshot::repo flake unrelated to scroll;
passes in isolation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-facing repro:
* In YOLO mode at low context utilisation (~5%), the engine briefly
showed `resetting plan` in the footer and the transcript area went
mostly black. Tools kept running (Plan panel + sidebar still
rendered), but the chat history above the latest turn was gone.
Root cause: the capacity controller's `VerifyAndReplan` action
(`crates/tui/src/core/engine/capacity_flow.rs::apply_verify_and_replan`)
runs `self.session.messages.clear()` and rebuilds from the canonical
state. The capacity controller fires this when its slack-based
`p_fail` calculation crosses the high-severe band — independently of
the `auto_compact` setting, independently of token utilisation.
The user opted out of auto-compaction in v0.8.11 (default
`auto_compact = false`, #665), explicitly trusting the model with
the full 1M-token V4 window. Auto-managing the prefix on their
behalf via the capacity controller contradicts that posture and
silently destroys both the user-visible transcript and V4's prefix
cache.
The fix
=======
Flip `CapacityControllerConfig::default().enabled` from `true` to
`false`. The controller's `observe_*` and `decide` methods already
short-circuit when `enabled` is false (`capacity.rs:255`,
`capacity.rs:396`), so the existing wiring becomes a no-op for the
default config — no need for defensive gating in
`capacity_flow.rs`.
Power users who want the controller can opt in via
`capacity.enabled = true` in `~/.deepseek/config.toml`. The slack
heuristics, model priors, cooldowns, and intervention paths all
remain in the codebase, ready to re-engage on opt-in. Nothing
deleted.
Tests
=====
* `default_controller_is_disabled_and_skips_observations` — pins
the new default; `observe_pre_turn` returns `None`.
* `opt_in_controller_observes_and_decides` — confirms `enabled =
true` rearms the controller end-to-end.
* `app_config_without_capacity_uses_default_disabled` — pins that
loading a config with no `[capacity]` section produces
`enabled = false`.
* `capacity_disabled_by_default_keeps_messages_intact` — direct
regression for the user-reported symptom: with default config,
even a forced error-escalation checkpoint cannot trigger
`messages.clear()`. Asserts the transcript length is preserved.
Verified locally:
* `cargo fmt --all -- --check` clean.
* `cargo clippy --workspace --all-targets --all-features --locked
-- -D warnings` clean.
* `cargo test --workspace --all-features --locked` — 2039 passed,
2 ignored, 0 failed (one flake on `snapshot::repo::tests::
restore_removes_files_added_after_target_snapshot` was filesystem-
timing-dependent, passes on isolation re-run; unrelated to this
change).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final step in the v0.8.11 patch release. Bumps the workspace
`Cargo.toml`, all 9 internal path-dep version pins, and
`npm/deepseek-tui/package.json` to **0.8.11**. `Cargo.lock`
regenerated alongside.
The v0.8.11 CHANGELOG entry already landed on `main` via the
cache-maxing overhaul PR (#684). This commit only stamps the
version. Together they ship:
* **Cache-maxing for V4 1M context** — engine no longer rebuilds the
system prompt on every turn (#684's `Session::last_system_prompt_hash`),
the volatile working-set summary moved out of the system prompt
into per-turn `<turn_meta>` on the latest user message, the tool
array is anchored with `cache_control: ephemeral`, and the
`messages_with_turn_metadata` injection skips tool-result
messages so the assistant→tool_result invariant stays intact.
* **500K compaction floor** — automatic compaction refuses below
500K tokens via `MINIMUM_AUTO_COMPACTION_TOKENS`. Manual
`/compact` bypasses (explicit user agency).
* **Token-only compaction trigger** — dropped
`CompactionConfig::message_threshold` and the message-count
branch in `should_compact`; that 128K-era heuristic only fired
on long sessions of small messages, exactly the case where
rewriting the V4 prefix cache is most wasteful.
* **Legacy 128K naming** — `DEFAULT_CONTEXT_WINDOW_TOKENS` →
`LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS`.
* **`npm install` resilience** — `install.js` now retries with
exponential backoff, enforces per-attempt timeout + 30 s stall
detector, honors `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` (pure
Node, no new dependencies), and prints download progress to
stderr. Driven by a community report that `npm install` took 18
minutes through a CN npm mirror; the GitHub Releases binary
fetch was the bottleneck and CN mirrors don't proxy GitHub.
Verified locally:
* cargo fmt --all -- --check ✓
* cargo clippy --workspace --all-targets --all-features
--locked -- -D warnings ✓
* cargo test --workspace --all-features --locked ✓
* parity gates (snapshot, parity_protocol, parity_state) ✓
* bash scripts/release/check-versions.sh ✓
(workspace=0.8.11, npm=0.8.11, lockfile in sync)
* node scripts/release/npm-wrapper-smoke.js ✓
Reminder for the maintainer at release time: the npm publish is
manual and requires 2FA OTP on every publish. After this PR
merges and the GitHub Release is fully drafted by `auto-tag.yml`,
publish from a developer machine:
cd npm/deepseek-tui
npm publish --access public
The `prepublishOnly` hook checks all eight binaries plus the
SHA256 manifest are present on the GitHub Release before letting
`npm publish` proceed, so this must happen *after* the GitHub
Release is finalized.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI on PR #684 caught two real issues that local checks missed:
**Lint failure (cargo fmt).** A regression test landed with a multi-line
`let ContentBlock::Text { text, .. } = real_user.content...` pattern
that local rustfmt accepted but CI's pinned toolchain collapsed onto a
single line. Reformatted to match.
**npm wrapper smoke failure ("Checksum manifest is missing
deepseek-<platform>").** Subtle Node.js streams interaction in
`install.js` introduced by the network-resilience cluster:
* `httpRequest` attaches a `data` event listener on the response to
re-arm the stall timer.
* Attaching a `data` listener on a `Readable` puts the stream into
flowing mode immediately.
* `downloadText` then ran `for await (const chunk of response)` to
collect the body — the async iterator expects paused-mode and
silently misses chunks that flow before / between iteration ticks.
* For small bodies (the ~100-byte SHA256 manifest), the entire
response could flow through the stall listener before the async
iterator's `read()` calls landed, leaving the joined body empty.
* Result: `parseChecksumManifest("")` returned an empty Map →
`verifyChecksum` saw no entries → "manifest is missing X" after
the actual binary download succeeded.
Binary downloads were unaffected because `download()` uses
`response.pipe(sink)` plus a `data` listener for progress — both
consume chunks via `data` events, no async iterator involved.
Fix: collect the response body in `downloadText` via direct `data`/
`end` event subscription. `data` listeners stack — both the stall
re-arm and the body collector fire on every chunk, no flowing-vs-
paused conflict. Stall detection still works.
Verified locally: `node scripts/release/npm-wrapper-smoke.js`
"npm wrapper smoke passed with local assets from <url>".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Live-test repro: typing a single user message in the TUI triggered a
tool call (read_file Cargo.toml), and the *next* request to DeepSeek's
API returned HTTP 400:
"An assistant message with 'tool_calls' must be followed by tool
messages responding to each 'tool_call_id'. (insufficient tool
messages following tool_calls message)"
Root cause: `messages_with_turn_metadata` walked the message list from
the tail and prepended a `<turn_meta>` Text block to the *last* message
with role="user". But tool-result messages also use role="user"
internally (they serialize to role="tool" on the wire). Inserting a
Text content block at index 0 of a tool-result message changed the
shape from `[ToolResult(...)]` to `[Text("turn_meta..."), ToolResult(...)]`,
which on the wire becomes a role="user" message with text instead of
the role="tool" message the API needs to satisfy the assistant's
prior tool_call. Hence the 400.
The fix:
* Restrict the injection target to messages that have at least one
Text content block AND no ToolResult blocks. This identifies actual
user-typed messages and skips tool-result envelopes.
* When the trailing slice has no eligible user message (e.g. mid-turn
when a tool result is the most recent message), skip injection
entirely. The working_set will surface again on the next genuine
user prompt; we don't retroactively prepend onto an earlier user
message because that would also confuse the API's tool-call
continuity checks.
Two regression tests pin the contract:
* `turn_metadata_skips_tool_result_messages` — assistant tool_call +
tool_result + earlier user message: only the user message gets the
prefix, the tool_result message stays a single-block ToolResult.
* `turn_metadata_skips_when_only_tool_results_trail` — the corner
case where the trailing user-role message is solely a tool result
(no real user message in the slice): no injection happens, the
message returns unchanged.
Verified locally:
* 2038 tests passed in TUI bin (2 ignored, was 2036 — these are the
+2 new regressions).
* `cargo fmt`, `cargo clippy --locked -D warnings`, parity gates all
clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `message_threshold` field on `CompactionConfig` was a 128K-era
heuristic that fired compaction on long sessions of small messages
— exactly the case where rewriting V4's prefix cache is most wasteful.
Token budget is the only signal that maps to actual model context
pressure; counting messages adds nothing.
Changes:
* Remove `CompactionConfig::message_threshold` field.
* Remove the message-count branch in `should_compact` — token
threshold + 500K floor is now the sole compaction trigger.
* Remove `compaction_message_threshold_for_model`,
`DEFAULT_COMPACTION_MESSAGE_THRESHOLD`,
`COMPACTION_MESSAGE_DIVISOR`, `MAX_COMPACTION_MESSAGE_THRESHOLD`
from `models.rs`.
* Drop the `forced_config.message_threshold` tweak in the engine's
capacity-guardrail forced-compaction path; that path now also
bypasses the floor (`auto_floor_tokens = 0`) because we're at a
hard ceiling and have to free budget regardless of cache cost.
* Update production constructors (`main.rs`, `runtime_threads.rs`,
`app.rs::compaction_config`) to drop the field.
* Update tests: keep the floor + token-threshold assertions, delete
the two tests that specifically validated message-count
triggering, replace `should_compact_respects_message_threshold`
with `message_count_no_longer_triggers_compaction` pinning the
new contract.
Verified locally:
* `cargo fmt --all -- --check` clean.
* `cargo clippy --workspace --all-targets --all-features --locked
-- -D warnings` clean.
* `cargo test --workspace --all-features --locked` — 2036 passed
in TUI bin (2 ignored), all other crates green.
* parity gates: snapshot, parity_protocol, parity_state — all pass.
* `git diff --exit-code -- Cargo.lock` — clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>