mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
refactor: move source files into workspace crates
- Move src/* into crates/tui/src/ to create a proper workspace structure - Add .claude/ and .trimtab/ directories for Trimtab closed-loop workflow - Add DEPENDENCY_GRAPH.md and update documentation - Update Cargo.toml files to reflect new crate dependencies - Update CI workflows and npm package scripts - All tests pass, release build works
This commit is contained in:
1 parent
cc0ac49822
commit
7b91169017
171 files changed
+4326
-981
No files matched your search
@@ -0,0 +1,35 @@
|
||||
# /init-trimtab
|
||||
|
||||
Bootstrap or retune the Trimtab closed-loop workflow for this repository.
|
||||
|
||||
**Canonical protocol:** `.trimtab/init-trimtab-protocol.md`
|
||||
|
||||
## What this command does
|
||||
|
||||
1. Reads the canonical protocol from `.trimtab/init-trimtab-protocol.md`
|
||||
2. Inspects the repo's current state (docs, CI, task surfaces, dependency graph)
|
||||
3. Decides whether to bootstrap, upgrade, or retune
|
||||
4. Aligns all workflow surfaces to the protocol
|
||||
|
||||
## Invocation
|
||||
|
||||
Run `/init-trimtab` in Claude Code to initialize or retune the workflow.
|
||||
|
||||
## Surfaces managed
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `.trimtab/init-trimtab-protocol.md` | Canonical shared protocol |
|
||||
| `.claude/commands/init-trimtab.md` | This file (Claude Code entrypoint) |
|
||||
| `.codex/skills/init-trimtab/SKILL.md` | Codex skill registration |
|
||||
| `DEPENDENCY_GRAPH.md` | Crate + task dependency graph |
|
||||
| `AI_HANDOFF.md` | Operative task queue and context for next agent |
|
||||
| `CLAUDE.md` | Build/dev instructions (read-only — do not overwrite) |
|
||||
| `AGENTS.md` | Project instructions for AI assistants |
|
||||
|
||||
## Rules
|
||||
|
||||
- Do not overwrite CLAUDE.md — it is the source of truth for build commands
|
||||
- Do not flatten existing strong instructions into boilerplate
|
||||
- The no-self-verdict rule is non-negotiable
|
||||
- Keep Claude and Codex entrypoints thin; logic lives in the shared protocol
|
||||
@@ -53,6 +53,51 @@ jobs:
|
||||
- name: Build
|
||||
run: cargo build --release
|
||||
|
||||
npm-wrapper-smoke:
|
||||
name: npm wrapper smoke
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-13]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build wrapper binaries
|
||||
run: cargo build --release --locked -p deepseek-tui-cli -p deepseek-tui
|
||||
- name: Prepare local release assets
|
||||
run: node scripts/release/prepare-local-release-assets.js "$RUNNER_TEMP/release-assets"
|
||||
- name: Start local release server
|
||||
run: |
|
||||
python3 -m http.server 8123 --directory "$RUNNER_TEMP/release-assets" >/tmp/deepseek-release-server.log 2>&1 &
|
||||
sleep 1
|
||||
- name: Pack npm wrapper
|
||||
working-directory: npm/deepseek-tui
|
||||
env:
|
||||
DEEPSEEK_TUI_FORCE_DOWNLOAD: "1"
|
||||
DEEPSEEK_TUI_RELEASE_BASE_URL: http://127.0.0.1:8123/
|
||||
run: npm pack
|
||||
- name: Install packed wrapper
|
||||
env:
|
||||
DEEPSEEK_TUI_FORCE_DOWNLOAD: "1"
|
||||
DEEPSEEK_TUI_RELEASE_BASE_URL: http://127.0.0.1:8123/
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/npm-smoke"
|
||||
cd "$RUNNER_TEMP/npm-smoke"
|
||||
npm init -y
|
||||
npm install "$GITHUB_WORKSPACE/npm/deepseek-tui"/deepseek-tui-*.tgz
|
||||
- name: Smoke wrapper entrypoints
|
||||
env:
|
||||
DEEPSEEK_TUI_FORCE_DOWNLOAD: "1"
|
||||
DEEPSEEK_TUI_RELEASE_BASE_URL: http://127.0.0.1:8123/
|
||||
run: |
|
||||
cd "$RUNNER_TEMP/npm-smoke"
|
||||
npx --no-install deepseek --help
|
||||
npx --no-install deepseek-tui --help
|
||||
|
||||
# Check documentation builds without warnings
|
||||
docs:
|
||||
name: Documentation
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish to crates.io
|
||||
name: Publish workspace crates to crates.io
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -16,15 +16,12 @@ jobs:
|
||||
|
||||
- name: Verify version matches tag
|
||||
if: github.event_name == 'release'
|
||||
run: |
|
||||
TAG_VERSION=${GITHUB_REF#refs/tags/v}
|
||||
CARGO_VERSION=$(cargo metadata --format-version 1 --no-deps | jq -r '.packages[0].version')
|
||||
if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then
|
||||
echo "Tag version ($TAG_VERSION) does not match Cargo.toml version ($CARGO_VERSION)"
|
||||
exit 1
|
||||
fi
|
||||
run: ./scripts/release/verify-workspace-version.sh "${GITHUB_REF#refs/tags/v}"
|
||||
|
||||
- name: Publish to crates.io
|
||||
run: cargo publish
|
||||
- name: Preflight workspace crate publishes in workspace order
|
||||
run: ./scripts/release/publish-crates.sh dry-run
|
||||
|
||||
- name: Publish crates.io packages in workspace order
|
||||
run: ./scripts/release/publish-crates.sh publish
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
@@ -99,6 +99,18 @@ jobs:
|
||||
path: artifacts
|
||||
- name: List artifacts
|
||||
run: find artifacts -type f
|
||||
- name: Generate checksum manifest
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p artifacts/checksums
|
||||
manifest="artifacts/checksums/deepseek-artifacts-sha256.txt"
|
||||
: > "${manifest}"
|
||||
while IFS= read -r -d '' file; do
|
||||
hash="$(sha256sum "${file}" | awk '{print $1}')"
|
||||
base="$(basename "${file}")"
|
||||
printf '%s %s\n' "${hash}" "${base}" >> "${manifest}"
|
||||
done < <(find artifacts -type f ! -path 'artifacts/checksums/*' -print0 | sort -z)
|
||||
cat "${manifest}"
|
||||
- uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: artifacts/*/*
|
||||
|
||||
@@ -46,6 +46,7 @@ tmp/
|
||||
|
||||
# Local dev scripts and temp files
|
||||
*.sh
|
||||
!scripts/**
|
||||
test.txt
|
||||
TODO*.md
|
||||
todo*.md
|
||||
@@ -64,6 +65,7 @@ docs/rlm-paper.txt
|
||||
# Local runtime state
|
||||
.deepseek/
|
||||
session_*.json
|
||||
*.db
|
||||
|
||||
# Companion app (tracked separately)
|
||||
apps/
|
||||
@@ -0,0 +1,151 @@
|
||||
# Trimtab Protocol: deepseek-tui
|
||||
|
||||
> Canonical workflow protocol for closed-loop, self-verifying agentic development.
|
||||
> Claude Code and Codex entrypoints both delegate here.
|
||||
|
||||
## Topology
|
||||
|
||||
```
|
||||
Claude Code (Opus) — Orchestrator
|
||||
/ | \
|
||||
Sub-Opus agents Codex MCP Direct work
|
||||
(UI, research, (infra, backend, (small edits,
|
||||
review, plan) Rust systems) conversation)
|
||||
|
|
||||
Fresh Codex context
|
||||
(Closure verifier / Coach)
|
||||
```
|
||||
|
||||
- **Orchestrator / Player:** Claude Code (Opus)
|
||||
- **Workers:** Claude sub-agents (Agent tool) + Codex MCP sessions
|
||||
- **Closure Verifier / Coach:** Fresh Codex context (never the same context that wrote the code)
|
||||
|
||||
## No-Self-Verdict Rule
|
||||
|
||||
The agent that wrote or modified code MUST NOT be the one to declare it passes.
|
||||
Every batch — including review-only or zero-edit batches — goes to an independent
|
||||
verifier (fresh Codex context or separate sub-agent) before anyone says PASS.
|
||||
|
||||
## Task Surface
|
||||
|
||||
This repo uses **Linear** as the canonical issue tracker.
|
||||
|
||||
- **Project:** https://linear.app/shannon-labs/project/deepseek-tui-6213bbbeaa26
|
||||
- **Team:** Shannon Labs (SHA)
|
||||
|
||||
Operative task sources (priority order):
|
||||
1. **Linear issues** — canonical for all tracked work (SHA-2794 through SHA-2803)
|
||||
2. **DEPENDENCY_GRAPH.md** — local mirror of the task graph with ready queue
|
||||
3. **AI_HANDOFF.md** — implementation notes and architecture context
|
||||
4. **todo.md** — high-level goals
|
||||
|
||||
When starting a session:
|
||||
1. List Linear issues for the project (filter by state: not Done/Canceled)
|
||||
2. Identify the highest-priority unblocked issue
|
||||
3. Read the issue body directly — it is the task packet
|
||||
4. Execute using the waterfall rule
|
||||
|
||||
The issue body contains: Goal, Files, Pass/Fail Criteria, Boundary, Dependencies.
|
||||
Do not invent a second prompt when the issue already has the packet.
|
||||
|
||||
## Waterfall Rule
|
||||
|
||||
After a verified issue closes, the player continues to the next unblocked issue
|
||||
unless the operator explicitly reprioritizes. Do not stop and ask "what next?"
|
||||
when the answer is visible in the task graph.
|
||||
|
||||
## Work Packet Structure
|
||||
|
||||
Each task should be expressed as a packet with:
|
||||
|
||||
```
|
||||
GOAL: One sentence describing the desired end state
|
||||
FILES: List of files expected to change
|
||||
VERIFY: Concrete acceptance criteria (tests pass, clippy clean, visual check)
|
||||
BOUNDARY: What is out of scope for this packet
|
||||
```
|
||||
|
||||
## Build / Test / Lint Commands
|
||||
|
||||
```bash
|
||||
cargo build # Debug build
|
||||
cargo build --release # Release build
|
||||
cargo test --workspace --all-features # Full test suite
|
||||
cargo fmt --all -- --check # Format check
|
||||
cargo clippy --workspace --all-targets --all-features # Full lint
|
||||
cargo doc --workspace --no-deps # Build docs
|
||||
```
|
||||
|
||||
CI runs: fmt check, clippy, tests (Ubuntu/macOS/Windows), build.
|
||||
|
||||
## Delegation Guidelines
|
||||
|
||||
### Use Codex MCP for:
|
||||
- Rust systems work, infrastructure, CI/CD, backend, shell scripting
|
||||
- Second opinions on architecture or tradeoffs
|
||||
- Debugging infra/backend issues
|
||||
- Config: `approval-policy: "never"`, `sandbox: "workspace-write"`, `cwd: "/Volumes/VIXinSSD/deepseek-tui"`
|
||||
- For advice-only: `sandbox: "read-only"`
|
||||
|
||||
### Use Claude sub-agents (Agent tool) for:
|
||||
- TUI/UI work, research, codebase exploration
|
||||
- Code review and quality analysis
|
||||
- Planning and architecture docs
|
||||
|
||||
### Do directly:
|
||||
- Small file edits, quick answers, reading/searching known files
|
||||
|
||||
### Shared workspace warning:
|
||||
When dispatching into a directory where other agents may be working, include:
|
||||
"Note: You are in a shared workspace. Other agents may be reading or editing files
|
||||
concurrently. Focus only on your assigned task."
|
||||
|
||||
## Session Protocol
|
||||
|
||||
### Session Start
|
||||
1. Read CLAUDE.md, AI_HANDOFF.md, DEPENDENCY_GRAPH.md
|
||||
2. Check `git status` and recent commits
|
||||
3. Identify the current task from the task surface
|
||||
4. Announce what you're working on
|
||||
|
||||
### Session Close
|
||||
1. `git status` — review all changes
|
||||
2. `git add` specific files (never `git add -A` blindly)
|
||||
3. `git commit` with conventional commit message
|
||||
4. Verify: `cargo test --workspace --all-features`
|
||||
5. Verify: `cargo clippy --workspace --all-targets --all-features`
|
||||
6. Update AI_HANDOFF.md if task state changed
|
||||
7. Push only if operator approves
|
||||
|
||||
### Commit Convention
|
||||
Use conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`
|
||||
|
||||
## Crate Architecture Reference
|
||||
|
||||
```
|
||||
crates/
|
||||
cli/ deepseek-cli -> `deepseek` binary
|
||||
tui/ deepseek-tui -> `deepseek-tui` binary
|
||||
app-server/ deepseek-app-server HTTP/SSE + JSON-RPC server
|
||||
core/ deepseek-core Agent loop, session, turns
|
||||
protocol/ deepseek-protocol Request/response types
|
||||
config/ deepseek-config Config loading, profiles
|
||||
state/ deepseek-state SQLite persistence
|
||||
tools/ deepseek-tools Tool registry + specs
|
||||
mcp/ deepseek-mcp MCP server integration
|
||||
hooks/ deepseek-hooks Lifecycle hooks
|
||||
execpolicy/ deepseek-execpolicy Approval policy engine
|
||||
agent/ deepseek-agent Model/provider registry
|
||||
tui-core/ deepseek-tui-core TUI state machine scaffold
|
||||
```
|
||||
|
||||
See DEPENDENCY_GRAPH.md for the full dependency graph.
|
||||
|
||||
## Key Architectural Decisions
|
||||
|
||||
- Edition 2024, Rust 1.85+
|
||||
- Workspace version 0.3.30 (all crates share version)
|
||||
- TUI binary still references monolith source (src/) — migration incremental
|
||||
- DeepSeek API: Responses API preferred, chat completions fallback
|
||||
- Sandbox: macOS Seatbelt, Linux Landlock
|
||||
- Modes: Normal, Plan, Agent, YOLO, RLM, Duo (each gates different tools)
|
||||
@@ -44,11 +44,24 @@ For complex, multi-step tasks, you should delegate work:
|
||||
- **Delegation**: If a task is large, break it down into sub-tasks and use `agent_swarm` or `agent_spawn`.
|
||||
- **Testing**: Rigorously verify changes using `cargo test` and `cargo check`.
|
||||
|
||||
## Trimtab Workflow
|
||||
|
||||
This repo uses the Trimtab closed-loop protocol for self-verifying agentic development.
|
||||
|
||||
- **Protocol:** `.trimtab/init-trimtab-protocol.md` (canonical — read this first)
|
||||
- **Task graph:** `DEPENDENCY_GRAPH.md` (crate deps + task deps with ready queue)
|
||||
- **Task queue:** `AI_HANDOFF.md` (7 open issues with priorities)
|
||||
- **Goals:** `todo.md` (high-level objectives)
|
||||
- **Claude entrypoint:** `.claude/commands/init-trimtab.md`
|
||||
- **Codex skill:** `.codex/skills/init-trimtab/SKILL.md`
|
||||
|
||||
**No-self-verdict rule:** The agent that wrote code must not be the one to declare it passes. Always use an independent verifier (fresh Codex context or separate sub-agent).
|
||||
|
||||
## Important Notes
|
||||
|
||||
<!-- Add project-specific notes here -->
|
||||
|
||||
- **Finance tool currently unavailable**: The finance tool relies on Stooq which frequently returns no data. As a workaround, use `web.run` to fetch financial data from web sources.
|
||||
|
||||
- **Token/cost tracking inaccuracies**: Token counting and cost estimation may be inflated due to thinking token accounting bugs. Use `/compact` to manage context, and treat cost estimates as approximate.
|
||||
- **Web.run tool name**: Note that the tool is named `web.run` (single dot), not `web..run`. Some earlier versions of the CLI may have had this typo.
|
||||
|
||||
|
||||
+56
-9
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.31] - 2026-03-08
|
||||
|
||||
### Added
|
||||
- Replaced the finance tool backend with Yahoo Finance v8 + CoinGecko fallback for reliable real-time market data (stocks, ETFs, indices, forex, crypto).
|
||||
- Added compaction UX: status strip shows animated COMPACTING indicator during context summarization, footer reflects compaction state, and CompactionCompleted events now include message count statistics.
|
||||
- Added send flash: brief tinted background highlight on the last user message after sending.
|
||||
- Added braille typing indicator with smooth 10-frame animation cycle.
|
||||
|
||||
### Changed
|
||||
- Redesigned the footer status strip with mode/model/token/cost layout, quadrant separators, and a context-usage bar.
|
||||
- Added Unicode prefix indicators (▸ You, ◆ Answer, ● System) to chat history cells for visual distinction.
|
||||
- Improved thinking token delineation with labeled delimiters in transcript rendering.
|
||||
|
||||
### Fixed
|
||||
- Fixed Plan mode ESC key dismissing the prompt without clearing `plan_prompt_pending`, which prevented the prompt from reappearing on subsequent plan completions.
|
||||
|
||||
## [0.3.30] - 2026-03-06
|
||||
|
||||
### Added
|
||||
- Added a release-ready local npm smoke path that builds binaries, serves release assets locally, packs the wrapper, installs the tarball, and checks both entrypoints before publish.
|
||||
- Added an opt-in full-matrix local release-asset fixture so `npm run release:check` can be exercised before GitHub release assets exist.
|
||||
|
||||
### Changed
|
||||
- Bumped the Rust workspace crates and npm wrapper to `0.3.30`.
|
||||
- Pointed the npm wrapper's default `deepseekBinaryVersion` at `0.3.30` for the next coordinated Rust + npm release.
|
||||
- Updated the crates dry-run helper to work from a dirty workspace and to preflight dependent workspace crates without requiring unpublished versions to already exist on crates.io.
|
||||
|
||||
## [0.3.29] - 2026-03-03
|
||||
|
||||
### Added
|
||||
- Added npm publish-time release asset verification for the `deepseek-tui` package to fail fast when expected GitHub binaries are missing.
|
||||
- Added checksum manifests to GitHub release assets and checksum verification in the npm installer.
|
||||
- Added `npm pack` install-and-smoke CI coverage for the `deepseek-tui` wrapper package.
|
||||
- Added an end-to-end release runbook covering crates.io, GitHub Releases, and npm publication.
|
||||
|
||||
### Changed
|
||||
- Updated npm package documentation for clearer install modes, environment overrides, and release integrity behavior.
|
||||
- Improved installer support-matrix error messaging for unsupported platform/architecture combinations.
|
||||
- Decoupled npm package version from default binary artifact version via `deepseekBinaryVersion`, enabling packaging-only npm releases.
|
||||
- Moved the `deepseek-tui` binary target inside `crates/tui` so `cargo publish --dry-run -p deepseek-tui` works from the workspace package layout.
|
||||
- Replaced the root-level crates publish workflow with an ordered workspace publish flow.
|
||||
- Reworked first-run onboarding and README copy around primary workflows instead of shortcut memorization.
|
||||
- Relaxed onboarding API-key format heuristics so unusual keys warn instead of blocking setup.
|
||||
|
||||
## [0.3.28] - 2026-03-02
|
||||
|
||||
### Added
|
||||
@@ -352,7 +396,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.1.0] - 2026-01-12
|
||||
|
||||
### Added
|
||||
- Initial alpha release of DeepSeek CLI
|
||||
- Initial alpha release of DeepSeek TUI
|
||||
- Interactive TUI chat interface
|
||||
- DeepSeek API integration (OpenAI-compatible Responses API)
|
||||
- Tool execution (shell, file ops)
|
||||
@@ -363,7 +407,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Hooks system and config profiles
|
||||
- Example skills and launch assets
|
||||
|
||||
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.28...HEAD
|
||||
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.31...HEAD
|
||||
[0.3.31]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.30...v0.3.31
|
||||
[0.3.30]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.29...v0.3.30
|
||||
[0.3.29]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.28...v0.3.29
|
||||
[0.3.28]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.27...v0.3.28
|
||||
[0.3.23]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.22...v0.3.23
|
||||
[0.3.22]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.21...v0.3.22
|
||||
@@ -389,10 +436,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
[0.2.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.0...v0.2.2
|
||||
[0.2.0]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.2.0
|
||||
[0.0.2]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.0.2
|
||||
[0.0.1]: https://github.com/Hmbown/DeepSeek-CLI/releases/tag/v0.0.1
|
||||
[0.1.9]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.8...v0.1.9
|
||||
[0.1.8]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.7...v0.1.8
|
||||
[0.1.7]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.6...v0.1.7
|
||||
[0.1.6]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.5...v0.1.6
|
||||
[0.1.5]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.0...v0.1.5
|
||||
[0.1.0]: https://github.com/Hmbown/DeepSeek-CLI/releases/tag/v0.1.0
|
||||
[0.0.1]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.0.1
|
||||
[0.1.9]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.8...v0.1.9
|
||||
[0.1.8]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.7...v0.1.8
|
||||
[0.1.7]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.6...v0.1.7
|
||||
[0.1.6]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.5...v0.1.6
|
||||
[0.1.5]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.0...v0.1.5
|
||||
[0.1.0]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.1.0
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
# Contributing to DeepSeek CLI
|
||||
# Contributing to DeepSeek TUI
|
||||
|
||||
Thank you for your interest in contributing to DeepSeek CLI! This document provides guidelines and instructions for contributing.
|
||||
Thank you for your interest in contributing to DeepSeek TUI! This document provides guidelines and instructions for contributing.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -14,8 +14,8 @@ Thank you for your interest in contributing to DeepSeek CLI! This document provi
|
||||
|
||||
1. Fork and clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/DeepSeek-CLI.git
|
||||
cd DeepSeek-CLI
|
||||
git clone https://github.com/YOUR_USERNAME/DeepSeek-TUI.git
|
||||
cd DeepSeek-TUI
|
||||
```
|
||||
|
||||
2. Build the project:
|
||||
@@ -121,7 +121,7 @@ When reporting issues, please include:
|
||||
|
||||
- Operating system and version
|
||||
- Rust version (`rustc --version`)
|
||||
- DeepSeek CLI version (`deepseek --version`)
|
||||
- DeepSeek TUI version (`deepseek --version`)
|
||||
- Steps to reproduce the issue
|
||||
- Expected vs actual behavior
|
||||
- Relevant error messages or logs
|
||||
@@ -132,7 +132,7 @@ Be respectful and inclusive. We welcome contributors of all backgrounds and expe
|
||||
|
||||
## License
|
||||
|
||||
By contributing to DeepSeek CLI, you agree that your contributions will be licensed under the MIT License.
|
||||
By contributing to DeepSeek TUI, you agree that your contributions will be licensed under the MIT License.
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
Generated
+13
-13
@@ -806,7 +806,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-agent"
|
||||
version = "0.3.28"
|
||||
version = "0.3.31"
|
||||
dependencies = [
|
||||
"deepseek-config",
|
||||
"serde",
|
||||
@@ -814,7 +814,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-app-server"
|
||||
version = "0.3.28"
|
||||
version = "0.3.31"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -837,7 +837,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-config"
|
||||
version = "0.3.28"
|
||||
version = "0.3.31"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs",
|
||||
@@ -848,7 +848,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-core"
|
||||
version = "0.3.28"
|
||||
version = "0.3.31"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -867,7 +867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-execpolicy"
|
||||
version = "0.3.28"
|
||||
version = "0.3.31"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-protocol",
|
||||
@@ -876,7 +876,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-hooks"
|
||||
version = "0.3.28"
|
||||
version = "0.3.31"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -890,7 +890,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-mcp"
|
||||
version = "0.3.28"
|
||||
version = "0.3.31"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-protocol",
|
||||
@@ -900,7 +900,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-protocol"
|
||||
version = "0.3.28"
|
||||
version = "0.3.31"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -908,7 +908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-state"
|
||||
version = "0.3.28"
|
||||
version = "0.3.31"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -920,7 +920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tools"
|
||||
version = "0.3.28"
|
||||
version = "0.3.31"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -933,7 +933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui"
|
||||
version = "0.3.28"
|
||||
version = "0.3.31"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -987,7 +987,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui-cli"
|
||||
version = "0.3.28"
|
||||
version = "0.3.31"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1005,7 +1005,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui-core"
|
||||
version = "0.3.28"
|
||||
version = "0.3.31"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.28"
|
||||
version = "0.3.31"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/Hmbown/DeepSeek-TUI"
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Dependency Graph
|
||||
|
||||
## Crate Dependencies (from Cargo.toml)
|
||||
|
||||
```
|
||||
deepseek-tui (binary: `deepseek-tui`)
|
||||
(no workspace deps — uses monolith src/ directly)
|
||||
|
||||
deepseek-tui-cli (binary: `deepseek`)
|
||||
<- deepseek-agent
|
||||
<- deepseek-app-server
|
||||
<- deepseek-config
|
||||
<- deepseek-execpolicy
|
||||
<- deepseek-mcp
|
||||
<- deepseek-state
|
||||
|
||||
deepseek-app-server
|
||||
<- deepseek-agent
|
||||
<- deepseek-config
|
||||
<- deepseek-core
|
||||
<- deepseek-execpolicy
|
||||
<- deepseek-hooks
|
||||
<- deepseek-mcp
|
||||
<- deepseek-protocol
|
||||
<- deepseek-state
|
||||
<- deepseek-tools
|
||||
|
||||
deepseek-core (agent loop)
|
||||
<- deepseek-agent
|
||||
<- deepseek-config
|
||||
<- deepseek-execpolicy
|
||||
<- deepseek-hooks
|
||||
<- deepseek-mcp
|
||||
<- deepseek-protocol
|
||||
<- deepseek-state
|
||||
<- deepseek-tools
|
||||
|
||||
deepseek-tools <- deepseek-protocol
|
||||
deepseek-mcp <- deepseek-protocol
|
||||
deepseek-hooks <- deepseek-protocol
|
||||
deepseek-execpolicy <- deepseek-protocol
|
||||
deepseek-agent <- deepseek-config
|
||||
|
||||
deepseek-config (leaf — no internal deps)
|
||||
deepseek-protocol (leaf — no internal deps)
|
||||
deepseek-state (leaf — no internal deps)
|
||||
deepseek-tui-core (leaf — no internal deps)
|
||||
```
|
||||
|
||||
Note: `deepseek-tui` has zero workspace deps because it still compiles the
|
||||
monolith source tree (`src/main.rs`). The crate split is structural — actual
|
||||
source migration into individual crates is incremental.
|
||||
|
||||
## Build Order (bottom-up)
|
||||
|
||||
```
|
||||
Layer 0 (leaves): deepseek-protocol, deepseek-config, deepseek-state, deepseek-tui-core
|
||||
Layer 1: deepseek-tools, deepseek-mcp, deepseek-hooks, deepseek-execpolicy
|
||||
Layer 2: deepseek-agent
|
||||
Layer 3: deepseek-core
|
||||
Layer 4: deepseek-app-server, deepseek-tui
|
||||
Layer 5: deepseek-tui-cli
|
||||
```
|
||||
|
||||
## Task Dependencies (Linear: shannon-labs/deepseek-tui)
|
||||
|
||||
Canonical source: https://linear.app/shannon-labs/project/deepseek-tui-6213bbbeaa26
|
||||
|
||||
```
|
||||
[High] SHA-2794 UI Footer Redesign (Kimi CLI Style)
|
||||
-> no blockers ← READY
|
||||
-> files: crates/tui/src/tui/ui.rs, crates/tui/src/palette.rs
|
||||
|
||||
[High] SHA-2795 Thinking vs Normal Chat Delineation
|
||||
-> no blockers ← READY
|
||||
-> files: crates/tui/src/tui/ui.rs, history.rs, streaming.rs
|
||||
|
||||
[High] SHA-2798 Finance Tool Replacement
|
||||
-> no blockers ← READY
|
||||
-> files: crates/tui/src/tools/
|
||||
|
||||
[Med] SHA-2796 Intelligent Compaction UX
|
||||
-> no blockers ← READY
|
||||
-> files: crates/tui/src/compaction.rs, core/engine.rs
|
||||
|
||||
[Med] SHA-2797 Escape Key After Plan Mode
|
||||
-> no blockers (investigation) ← READY
|
||||
-> files: crates/tui/src/tui/ui.rs, app.rs
|
||||
|
||||
[Med] SHA-2799 "Alive and Animated" Feel
|
||||
-> blocked by SHA-2794, SHA-2795
|
||||
-> files: crates/tui/src/tui/ (various)
|
||||
|
||||
[Med] SHA-2801 Docs and Workflow Update
|
||||
-> blocked by SHA-2798
|
||||
-> files: AGENTS.md, README.md, CHANGELOG.md
|
||||
|
||||
[Med] SHA-2802 Release Prep
|
||||
-> blocked by SHA-2794, SHA-2795, SHA-2798
|
||||
-> files: Cargo.toml, CHANGELOG.md, npm/
|
||||
|
||||
[Low] SHA-2800 Header Redesign
|
||||
-> blocked by SHA-2794
|
||||
-> files: crates/tui/src/tui/widgets/header.rs
|
||||
|
||||
[Low] SHA-2803 Context Window Visualization
|
||||
-> blocked by SHA-2800
|
||||
-> files: crates/tui/src/tui/ui.rs
|
||||
```
|
||||
|
||||
## Ready Queue (unblocked, by priority)
|
||||
|
||||
1. **SHA-2794** UI Footer Redesign (High)
|
||||
2. **SHA-2795** Thinking vs Chat Delineation (High)
|
||||
3. **SHA-2798** Finance Tool Replacement (High)
|
||||
4. **SHA-2796** Intelligent Compaction UX (Medium)
|
||||
5. **SHA-2797** Escape Key After Plan Mode (Medium)
|
||||
@@ -35,7 +35,8 @@ deepseek-tui
|
||||
|
||||
```bash
|
||||
# From crates.io (requires Rust 1.85+)
|
||||
cargo install deepseek-tui --locked
|
||||
cargo install deepseek-tui --locked # TUI
|
||||
cargo install deepseek-tui-cli --locked # deepseek CLI facade
|
||||
|
||||
# From source
|
||||
git clone https://github.com/Hmbown/DeepSeek-TUI.git
|
||||
@@ -43,20 +44,41 @@ cd DeepSeek-TUI
|
||||
cargo install --path crates/tui --locked
|
||||
```
|
||||
|
||||
The canonical crates.io packages for this repository are `deepseek-tui` and
|
||||
`deepseek-tui-cli`. The unrelated `deepseek-cli` crate is not part of this
|
||||
project.
|
||||
|
||||
</details>
|
||||
|
||||
## What it does
|
||||
|
||||
An agent loop with file editing, shell execution, web search, git operations, task tracking, and [MCP](https://modelcontextprotocol.io) server integration. Context-aware memory compaction keeps long sessions on track.
|
||||
An agent loop with file editing, shell execution, `web.run` browsing, git operations, task tracking, and [MCP](https://modelcontextprotocol.io) server integration. Context-aware memory compaction keeps long sessions on track. `crates/tui` remains the live shipped runtime while the workspace extraction continues.
|
||||
|
||||
Three modes (**Tab** to switch):
|
||||
Four modes (**Tab** / **Shift+Tab** to cycle):
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| **Normal** | Chat-first mode for questions, explanation, and low-friction steering |
|
||||
| **Plan** | Design-first — proposes before acting |
|
||||
| **Agent** | Multi-step autonomous tool use |
|
||||
| **YOLO** | Full auto-approve, no guardrails |
|
||||
|
||||
## First Run Workflow
|
||||
|
||||
1. Paste your API key in onboarding.
|
||||
2. Choose a mode for the task in front of you:
|
||||
`Normal` to ask questions, `Plan` to review a plan first, `Agent` to let the model use tools, `YOLO` only inside a trusted workspace.
|
||||
3. Watch the status area while work is running:
|
||||
approvals, queued work, and active sub-agents stay there while the turn is live.
|
||||
4. Recover work with `Ctrl+R` or `/sessions` if you need to resume an interrupted thread.
|
||||
|
||||
## Everyday Workflows
|
||||
|
||||
- Use `Ctrl+K` for the command palette when you want to switch modes, open config, resume sessions, or inspect a tool quickly.
|
||||
- Use `/queue` to review or edit queued prompts before sending them.
|
||||
- Use `/subagents` to inspect background agent state when autonomous work fans out.
|
||||
- Use `/config` to adjust approval mode, theme, sidebar focus, and other runtime preferences.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
@@ -68,7 +90,7 @@ deepseek models # list available models
|
||||
deepseek serve --http # HTTP/SSE API server
|
||||
```
|
||||
|
||||
**F1** opens help. **Esc** cancels a running request. **Ctrl+K** opens command palette.
|
||||
Controls: `F1` help, `Esc` walks the cancel stack, `Ctrl+K` command palette.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -80,7 +102,7 @@ Full reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
|
||||
|
||||
## Docs
|
||||
|
||||
[docs/](docs/) — architecture, modes, MCP integration, runtime API.
|
||||
[docs/](docs/) — architecture, modes, MCP integration, runtime API, and release runbooks. The live runtime still ships from `crates/tui`; the newer workspace crates are incremental extraction targets.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════════════════╗
|
||||
# ║ DeepSeek CLI Configuration ║
|
||||
# ║ DeepSeek TUI Configuration ║
|
||||
# ║ ║
|
||||
# ║ Unofficial CLI for DeepSeek Platform - Not affiliated with DeepSeek Inc. ║
|
||||
# ╚══════════════════════════════════════════════════════════════════════════════╝
|
||||
@@ -58,7 +58,7 @@ alternate_screen = "auto" # auto | always | never
|
||||
[features]
|
||||
shell_tool = true
|
||||
subagents = true
|
||||
web_search = true # enables web.run and web_search
|
||||
web_search = true # enables canonical web.run plus the compatibility web_search alias
|
||||
apply_patch = true
|
||||
mcp = true
|
||||
exec_policy = true
|
||||
@@ -125,7 +125,7 @@ allow_shell = true
|
||||
#
|
||||
# [[hooks.hooks]]
|
||||
# event = "session_start"
|
||||
# command = "echo 'DeepSeek CLI session started'"
|
||||
# command = "echo 'DeepSeek TUI session started'"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Requirements (admin constraints) example file
|
||||
|
||||
@@ -7,5 +7,5 @@ repository.workspace = true
|
||||
description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
deepseek-config = { path = "../config", version = "0.3.28" }
|
||||
deepseek-config = { path = "../config", version = "0.3.31" }
|
||||
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.3.28" }
|
||||
deepseek-config = { path = "../config", version = "0.3.28" }
|
||||
deepseek-core = { path = "../core", version = "0.3.28" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.3.28" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.3.28" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.3.28" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.3.28" }
|
||||
deepseek-state = { path = "../state", version = "0.3.28" }
|
||||
deepseek-tools = { path = "../tools", version = "0.3.28" }
|
||||
deepseek-agent = { path = "../agent", version = "0.3.31" }
|
||||
deepseek-config = { path = "../config", version = "0.3.31" }
|
||||
deepseek-core = { path = "../core", version = "0.3.31" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.3.31" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.3.31" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.3.31" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.3.31" }
|
||||
deepseek-state = { path = "../state", version = "0.3.31" }
|
||||
deepseek-tools = { path = "../tools", version = "0.3.31" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -14,12 +14,12 @@ path = "src/main.rs"
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
clap_complete.workspace = true
|
||||
deepseek-agent = { path = "../agent", version = "0.3.28" }
|
||||
deepseek-app-server = { path = "../app-server", version = "0.3.28" }
|
||||
deepseek-config = { path = "../config", version = "0.3.28" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.3.28" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.3.28" }
|
||||
deepseek-state = { path = "../state", version = "0.3.28" }
|
||||
deepseek-agent = { path = "../agent", version = "0.3.31" }
|
||||
deepseek-app-server = { path = "../app-server", version = "0.3.31" }
|
||||
deepseek-config = { path = "../config", version = "0.3.31" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.3.31" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.3.31" }
|
||||
deepseek-state = { path = "../state", version = "0.3.31" }
|
||||
chrono.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
@@ -9,14 +9,14 @@ description = "Core runtime boundaries for DeepSeek workspace architecture"
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
deepseek-agent = { path = "../agent", version = "0.3.28" }
|
||||
deepseek-config = { path = "../config", version = "0.3.28" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.3.28" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.3.28" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.3.28" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.3.28" }
|
||||
deepseek-state = { path = "../state", version = "0.3.28" }
|
||||
deepseek-tools = { path = "../tools", version = "0.3.28" }
|
||||
deepseek-agent = { path = "../agent", version = "0.3.31" }
|
||||
deepseek-config = { path = "../config", version = "0.3.31" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.3.31" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.3.31" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.3.31" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.3.31" }
|
||||
deepseek-state = { path = "../state", version = "0.3.31" }
|
||||
deepseek-tools = { path = "../tools", version = "0.3.31" }
|
||||
serde_json.workspace = true
|
||||
tokio.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.3.28" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.3.31" }
|
||||
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.3.28" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.3.31" }
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -8,6 +8,6 @@ description = "MCP server lifecycle and tool proxy compatibility for DeepSeek wo
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
deepseek-protocol = { path = "../protocol", version = "0.3.28" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.3.31" }
|
||||
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.3.28" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.3.31" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -8,7 +8,7 @@ description = "Terminal UI for DeepSeek"
|
||||
|
||||
[[bin]]
|
||||
name = "deepseek-tui"
|
||||
path = "../../src/main.rs"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -91,12 +91,7 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
|
||||
app.mark_history_updated();
|
||||
}
|
||||
"default_mode" | "mode" => {
|
||||
let mode = match settings.default_mode.as_str() {
|
||||
"agent" | "normal" => AppMode::Agent,
|
||||
"plan" => AppMode::Plan,
|
||||
"yolo" => AppMode::Yolo,
|
||||
_ => AppMode::Agent,
|
||||
};
|
||||
let mode = AppMode::from_setting(&settings.default_mode);
|
||||
app.set_mode(mode);
|
||||
}
|
||||
"max_history" | "history" => {
|
||||
@@ -465,7 +460,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-cli-logout-test-{}-{}",
|
||||
"deepseek-tui-logout-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
@@ -132,7 +132,7 @@ pub fn home_dashboard(app: &mut App) -> CommandResult {
|
||||
let mut stats = String::new();
|
||||
|
||||
// Basic info
|
||||
let _ = writeln!(stats, "DeepSeek CLI Home Dashboard");
|
||||
let _ = writeln!(stats, "DeepSeek TUI Home Dashboard");
|
||||
let _ = writeln!(stats, "============================================");
|
||||
|
||||
// Model & mode
|
||||
@@ -418,7 +418,7 @@ mod tests {
|
||||
let result = home_dashboard(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("DeepSeek CLI Home Dashboard"));
|
||||
assert!(msg.contains("DeepSeek TUI Home Dashboard"));
|
||||
assert!(msg.contains("Model:"));
|
||||
assert!(msg.contains("Mode:"));
|
||||
assert!(msg.contains("Workspace:"));
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,4 @@
|
||||
//! Configuration loading and defaults for deepseek-cli.
|
||||
//! Configuration loading and defaults for DeepSeek TUI.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write;
|
||||
@@ -915,7 +915,7 @@ pub fn save_api_key(api_key: &str) -> Result<PathBuf> {
|
||||
} else {
|
||||
// Create new minimal config
|
||||
format!(
|
||||
r#"# DeepSeek CLI Configuration
|
||||
r#"# DeepSeek TUI Configuration
|
||||
# Get your API key from https://platform.deepseek.com
|
||||
# Or set DEEPSEEK_API_KEY environment variable
|
||||
|
||||
@@ -1068,7 +1068,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-cli-test-{}-{}",
|
||||
"deepseek-tui-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
@@ -1092,7 +1092,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-cli-tilde-test-{}-{}",
|
||||
"deepseek-tui-tilde-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
@@ -1121,7 +1121,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-cli-load-tilde-test-{}-{}",
|
||||
"deepseek-tui-load-tilde-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
@@ -1150,7 +1150,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-cli-load-fallback-test-{}-{}",
|
||||
"deepseek-tui-load-fallback-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
@@ -1209,7 +1209,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-cli-api-key-test-{}-{}",
|
||||
"deepseek-tui-api-key-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
File renamed without changes.
File renamed without changes.
@@ -982,10 +982,6 @@ fn tool_result_is_noisy(tool_name: &str) -> bool {
|
||||
| "exec_shell_interact"
|
||||
| "multi_tool_use.parallel"
|
||||
| "web_search"
|
||||
| "weather"
|
||||
| "finance"
|
||||
| "sports"
|
||||
| "time"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1214,9 +1210,17 @@ impl Engine {
|
||||
model,
|
||||
allow_shell,
|
||||
trust_mode,
|
||||
auto_approve,
|
||||
} => {
|
||||
self.handle_send_message(content, mode, model, allow_shell, trust_mode)
|
||||
.await;
|
||||
self.handle_send_message(
|
||||
content,
|
||||
mode,
|
||||
model,
|
||||
allow_shell,
|
||||
trust_mode,
|
||||
auto_approve,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Op::CancelRequest => {
|
||||
self.cancel_token.cancel();
|
||||
@@ -1252,7 +1256,7 @@ impl Engine {
|
||||
client,
|
||||
self.session.model.clone(),
|
||||
// Sub-agents don't inherit YOLO mode - use Agent mode defaults
|
||||
self.build_tool_context(AppMode::Agent),
|
||||
self.build_tool_context(AppMode::Agent, self.session.auto_approve),
|
||||
self.session.allow_shell,
|
||||
Some(self.tx_event.clone()),
|
||||
);
|
||||
@@ -1370,6 +1374,7 @@ impl Engine {
|
||||
model: String,
|
||||
allow_shell: bool,
|
||||
trust_mode: bool,
|
||||
auto_approve: bool,
|
||||
) {
|
||||
// Reset cancel token for fresh turn (in case previous was cancelled)
|
||||
self.reset_cancel_token();
|
||||
@@ -1432,6 +1437,7 @@ impl Engine {
|
||||
self.config.allow_shell = allow_shell;
|
||||
self.session.trust_mode = trust_mode;
|
||||
self.config.trust_mode = trust_mode;
|
||||
self.session.auto_approve = auto_approve;
|
||||
|
||||
// Update system prompt to match current mode and include persisted compaction context.
|
||||
self.refresh_system_prompt(mode);
|
||||
@@ -1440,7 +1446,7 @@ impl Engine {
|
||||
let todo_list = self.config.todos.clone();
|
||||
let plan_state = self.config.plan_state.clone();
|
||||
|
||||
let tool_context = self.build_tool_context(mode);
|
||||
let tool_context = self.build_tool_context(mode, auto_approve);
|
||||
let mut builder = if mode == AppMode::Plan {
|
||||
ToolRegistryBuilder::new()
|
||||
.with_read_only_file_tools()
|
||||
@@ -1557,6 +1563,11 @@ impl Engine {
|
||||
|
||||
async fn handle_manual_compaction(&mut self) {
|
||||
let id = format!("compact_{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
let zero_usage = Usage {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
server_tool_use: None,
|
||||
};
|
||||
let Some(client) = self.deepseek_client.clone() else {
|
||||
let message = "Manual compaction unavailable: API client not configured".to_string();
|
||||
let _ = self
|
||||
@@ -1567,7 +1578,18 @@ impl Engine {
|
||||
message: message.clone(),
|
||||
})
|
||||
.await;
|
||||
let _ = self.tx_event.send(Event::error(message, false)).await;
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::error(message.clone(), false))
|
||||
.await;
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::TurnComplete {
|
||||
usage: zero_usage,
|
||||
status: TurnOutcomeStatus::Failed,
|
||||
error: Some(message),
|
||||
})
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1586,6 +1608,9 @@ impl Engine {
|
||||
.working_set
|
||||
.pinned_message_indices(&self.session.messages, &self.session.workspace);
|
||||
let compaction_paths = self.session.working_set.top_paths(24);
|
||||
let messages_before = self.session.messages.len();
|
||||
let mut turn_status = TurnOutcomeStatus::Completed;
|
||||
let mut turn_error = None;
|
||||
|
||||
match compact_messages_safe(
|
||||
&client,
|
||||
@@ -1599,15 +1624,19 @@ impl Engine {
|
||||
{
|
||||
Ok(result) => {
|
||||
if !result.messages.is_empty() || self.session.messages.is_empty() {
|
||||
let messages_after = result.messages.len();
|
||||
self.session.messages = result.messages;
|
||||
self.merge_compaction_summary(result.summary_prompt);
|
||||
let removed = messages_before.saturating_sub(messages_after);
|
||||
let message = if result.retries_used > 0 {
|
||||
format!(
|
||||
"Manual context compaction completed (after {} retries)",
|
||||
"Compaction complete: {messages_before} → {messages_after} messages ({removed} removed, {} retries)",
|
||||
result.retries_used
|
||||
)
|
||||
} else {
|
||||
"Manual context compaction completed".to_string()
|
||||
format!(
|
||||
"Compaction complete: {messages_before} → {messages_after} messages ({removed} removed)"
|
||||
)
|
||||
};
|
||||
let _ = self
|
||||
.tx_event
|
||||
@@ -1615,10 +1644,12 @@ impl Engine {
|
||||
id,
|
||||
auto: false,
|
||||
message,
|
||||
messages_before: Some(messages_before),
|
||||
messages_after: Some(messages_after),
|
||||
})
|
||||
.await;
|
||||
} else {
|
||||
let message = "Manual context compaction skipped: empty result".to_string();
|
||||
let message = "Compaction skipped: produced empty result".to_string();
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionFailed {
|
||||
@@ -1627,6 +1658,8 @@ impl Engine {
|
||||
message: message.clone(),
|
||||
})
|
||||
.await;
|
||||
turn_status = TurnOutcomeStatus::Failed;
|
||||
turn_error = Some(message);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -1639,9 +1672,20 @@ impl Engine {
|
||||
message: message.clone(),
|
||||
})
|
||||
.await;
|
||||
let _ = self.tx_event.send(Event::status(message)).await;
|
||||
let _ = self.tx_event.send(Event::status(message.clone())).await;
|
||||
turn_status = TurnOutcomeStatus::Failed;
|
||||
turn_error = Some(message);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::TurnComplete {
|
||||
usage: zero_usage,
|
||||
status: turn_status,
|
||||
error: turn_error,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
fn estimated_input_tokens(&self) -> usize {
|
||||
@@ -1737,15 +1781,15 @@ impl Engine {
|
||||
&& (after_tokens < before_tokens || after_count < before_count || trimmed > 0);
|
||||
|
||||
if recovered {
|
||||
let removed = before_count.saturating_sub(after_count);
|
||||
let mut details = format!(
|
||||
"Emergency context compaction complete: ~{} -> ~{} tokens",
|
||||
before_tokens, after_tokens
|
||||
"Emergency compaction complete: {before_count} → {after_count} messages ({removed} removed), ~{before_tokens} → ~{after_tokens} tokens"
|
||||
);
|
||||
if retries_used > 0 {
|
||||
details.push_str(&format!(" ({} retries)", retries_used));
|
||||
}
|
||||
if trimmed > 0 {
|
||||
details.push_str(&format!(", trimmed {trimmed} oldest messages"));
|
||||
details.push_str(&format!(", trimmed {trimmed} oldest"));
|
||||
}
|
||||
let _ = self
|
||||
.tx_event
|
||||
@@ -1753,6 +1797,8 @@ impl Engine {
|
||||
id,
|
||||
auto: true,
|
||||
message: details.clone(),
|
||||
messages_before: Some(before_count),
|
||||
messages_after: Some(after_count),
|
||||
})
|
||||
.await;
|
||||
let _ = self.tx_event.send(Event::status(details)).await;
|
||||
@@ -1776,14 +1822,15 @@ impl Engine {
|
||||
false
|
||||
}
|
||||
|
||||
fn build_tool_context(&self, mode: AppMode) -> ToolContext {
|
||||
fn build_tool_context(&self, mode: AppMode, auto_approve: bool) -> ToolContext {
|
||||
let ctx = ToolContext::with_auto_approve(
|
||||
self.session.workspace.clone(),
|
||||
self.session.trust_mode,
|
||||
self.session.notes_path.clone(),
|
||||
self.session.mcp_config_path.clone(),
|
||||
mode == AppMode::Yolo,
|
||||
mode == AppMode::Yolo || auto_approve,
|
||||
)
|
||||
.with_state_namespace(self.session.id.clone())
|
||||
.with_features(self.config.features.clone())
|
||||
.with_shell_manager(self.shell_manager.clone());
|
||||
|
||||
@@ -2167,6 +2214,7 @@ impl Engine {
|
||||
.tx_event
|
||||
.send(Event::status("Auto-compacting context...".to_string()))
|
||||
.await;
|
||||
let auto_messages_before = self.session.messages.len();
|
||||
match compact_messages_safe(
|
||||
&client,
|
||||
&self.session.messages,
|
||||
@@ -2180,15 +2228,19 @@ impl Engine {
|
||||
Ok(result) => {
|
||||
// Only update if we got valid messages (never corrupt state)
|
||||
if !result.messages.is_empty() || self.session.messages.is_empty() {
|
||||
let auto_messages_after = result.messages.len();
|
||||
self.session.messages = result.messages;
|
||||
self.merge_compaction_summary(result.summary_prompt);
|
||||
let removed = auto_messages_before.saturating_sub(auto_messages_after);
|
||||
let status = if result.retries_used > 0 {
|
||||
format!(
|
||||
"Auto-compaction complete (after {} retries)",
|
||||
"Auto-compaction complete: {auto_messages_before} → {auto_messages_after} messages ({removed} removed, {} retries)",
|
||||
result.retries_used
|
||||
)
|
||||
} else {
|
||||
"Auto-compaction complete".to_string()
|
||||
format!(
|
||||
"Auto-compaction complete: {auto_messages_before} → {auto_messages_after} messages ({removed} removed)"
|
||||
)
|
||||
};
|
||||
let _ = self
|
||||
.tx_event
|
||||
@@ -2196,6 +2248,8 @@ impl Engine {
|
||||
id: compaction_id.clone(),
|
||||
auto: true,
|
||||
message: status.clone(),
|
||||
messages_before: Some(auto_messages_before),
|
||||
messages_after: Some(auto_messages_after),
|
||||
})
|
||||
.await;
|
||||
let _ = self.tx_event.send(Event::status(status)).await;
|
||||
@@ -4165,16 +4219,45 @@ pub fn spawn_engine(config: EngineConfig, api_config: &Config) -> EngineHandle {
|
||||
pub(crate) struct MockEngineHandle {
|
||||
pub handle: EngineHandle,
|
||||
pub rx_op: mpsc::Receiver<Op>,
|
||||
rx_approval: mpsc::Receiver<ApprovalDecision>,
|
||||
pub rx_steer: mpsc::Receiver<String>,
|
||||
pub tx_event: mpsc::Sender<Event>,
|
||||
pub cancel_token: CancellationToken,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum MockApprovalEvent {
|
||||
Approved {
|
||||
id: String,
|
||||
},
|
||||
Denied {
|
||||
id: String,
|
||||
},
|
||||
RetryWithPolicy {
|
||||
id: String,
|
||||
policy: crate::sandbox::SandboxPolicy,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl MockEngineHandle {
|
||||
pub(crate) async fn recv_approval_event(&mut self) -> Option<MockApprovalEvent> {
|
||||
match self.rx_approval.recv().await? {
|
||||
ApprovalDecision::Approved { id } => Some(MockApprovalEvent::Approved { id }),
|
||||
ApprovalDecision::Denied { id } => Some(MockApprovalEvent::Denied { id }),
|
||||
ApprovalDecision::RetryWithPolicy { id, policy } => {
|
||||
Some(MockApprovalEvent::RetryWithPolicy { id, policy })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn mock_engine_handle() -> MockEngineHandle {
|
||||
let (tx_op, rx_op) = mpsc::channel(32);
|
||||
let (tx_event, rx_event) = mpsc::channel(256);
|
||||
let (tx_approval, _rx_approval) = mpsc::channel(64);
|
||||
let (tx_approval, rx_approval) = mpsc::channel(64);
|
||||
let (tx_user_input, _rx_user_input) = mpsc::channel(32);
|
||||
let (tx_steer, rx_steer) = mpsc::channel(64);
|
||||
let cancel_token = CancellationToken::new();
|
||||
@@ -4191,6 +4274,7 @@ pub(crate) fn mock_engine_handle() -> MockEngineHandle {
|
||||
MockEngineHandle {
|
||||
handle,
|
||||
rx_op,
|
||||
rx_approval,
|
||||
rx_steer,
|
||||
tx_event,
|
||||
cancel_token,
|
||||
@@ -117,6 +117,19 @@ fn non_yolo_mode_retains_default_defer_policy() {
|
||||
assert!(!should_default_defer_tool("read_file", AppMode::Agent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_mode_can_build_auto_approved_tool_context() {
|
||||
let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
|
||||
|
||||
assert!(
|
||||
!engine
|
||||
.build_tool_context(AppMode::Agent, false)
|
||||
.auto_approve
|
||||
);
|
||||
assert!(engine.build_tool_context(AppMode::Agent, true).auto_approve);
|
||||
assert!(engine.build_tool_context(AppMode::Yolo, false).auto_approve);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_context_length_errors_from_provider_payloads() {
|
||||
let msg = r#"SSE stream request failed: HTTP 400 Bad Request: {"error":{"message":"This model's maximum context length is 131072 tokens. However, you requested 153056 tokens (148960 in the messages, 4096 in the completion).","type":"invalid_request_error"}}"#;
|
||||
@@ -213,7 +226,7 @@ async fn post_tool_replay_invoked_when_high_non_severe_risk() {
|
||||
|
||||
let registry = ToolRegistryBuilder::new()
|
||||
.with_read_only_file_tools()
|
||||
.build(engine.build_tool_context(AppMode::Agent));
|
||||
.build(engine.build_tool_context(AppMode::Agent, false));
|
||||
|
||||
let restarted = engine
|
||||
.run_capacity_post_tool_checkpoint(
|
||||
@@ -102,6 +102,12 @@ pub enum Event {
|
||||
id: String,
|
||||
auto: bool,
|
||||
message: String,
|
||||
/// Number of messages before compaction.
|
||||
#[allow(dead_code)]
|
||||
messages_before: Option<usize>,
|
||||
/// Number of messages after compaction.
|
||||
#[allow(dead_code)]
|
||||
messages_after: Option<usize>,
|
||||
},
|
||||
|
||||
/// Context compaction failed.
|
||||
File renamed without changes.
@@ -18,6 +18,7 @@ pub enum Op {
|
||||
model: String,
|
||||
allow_shell: bool,
|
||||
trust_mode: bool,
|
||||
auto_approve: bool,
|
||||
},
|
||||
|
||||
/// Cancel the current request
|
||||
@@ -73,6 +74,7 @@ impl Op {
|
||||
model: impl Into<String>,
|
||||
allow_shell: bool,
|
||||
trust_mode: bool,
|
||||
auto_approve: bool,
|
||||
) -> Self {
|
||||
Op::SendMessage {
|
||||
content: content.into(),
|
||||
@@ -80,6 +82,7 @@ impl Op {
|
||||
model: model.into(),
|
||||
allow_shell,
|
||||
trust_mode,
|
||||
auto_approve,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,9 @@ pub struct Session {
|
||||
/// Whether to trust paths outside workspace
|
||||
pub trust_mode: bool,
|
||||
|
||||
/// Whether the current session should auto-approve tool safety checks.
|
||||
pub auto_approve: bool,
|
||||
|
||||
/// Notes file path
|
||||
pub notes_path: PathBuf,
|
||||
|
||||
@@ -92,6 +95,7 @@ impl Session {
|
||||
total_usage: SessionUsage::default(),
|
||||
allow_shell,
|
||||
trust_mode,
|
||||
auto_approve: false,
|
||||
notes_path,
|
||||
mcp_config_path,
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,7 +1,7 @@
|
||||
// TODO(integrate): Wire feature flags into engine/tool registration — tracked as future work
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Feature flags and metadata for deepseek-cli.
|
||||
//! Feature flags and metadata for DeepSeek TUI.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -383,6 +383,22 @@ enum McpCommand {
|
||||
},
|
||||
/// Validate MCP config and required servers
|
||||
Validate,
|
||||
/// Register this DeepSeek binary as a local MCP stdio server.
|
||||
///
|
||||
/// This adds a config entry that runs `deepseek serve --mcp` (stdio protocol).
|
||||
/// For the HTTP/SSE runtime API, use `deepseek serve --http` directly instead.
|
||||
#[command(
|
||||
name = "add-self",
|
||||
long_about = "Register this DeepSeek binary as a local MCP stdio server.\n\nAdds a config entry to ~/.deepseek/mcp.json that launches `deepseek serve --mcp`\nvia the stdio transport. Other DeepSeek sessions (or any MCP client) can then\ndiscover and call tools exposed by this server.\n\nUse `deepseek serve --http` instead if you need the HTTP/SSE runtime API."
|
||||
)]
|
||||
AddSelf {
|
||||
/// Server name in mcp.json (default: "deepseek")
|
||||
#[arg(long, default_value = "deepseek")]
|
||||
name: String,
|
||||
/// Workspace directory for the MCP server
|
||||
#[arg(long)]
|
||||
workspace: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone)]
|
||||
@@ -861,7 +877,7 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
"DeepSeek CLI Doctor"
|
||||
"DeepSeek TUI Doctor"
|
||||
.truecolor(blue_r, blue_g, blue_b)
|
||||
.bold()
|
||||
);
|
||||
@@ -870,7 +886,7 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
|
||||
// Version info
|
||||
println!("{}", "Version Information:".bold());
|
||||
println!(" deepseek-cli: {}", env!("CARGO_PKG_VERSION"));
|
||||
println!(" deepseek-tui: {}", env!("CARGO_PKG_VERSION"));
|
||||
println!(" rust: {}", rustc_version());
|
||||
println!();
|
||||
|
||||
@@ -1006,8 +1022,35 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
"·".dimmed(),
|
||||
cfg.servers.len()
|
||||
);
|
||||
for name in cfg.servers.keys() {
|
||||
println!(" - {name}");
|
||||
for (name, server) in &cfg.servers {
|
||||
let status = doctor_check_mcp_server(server);
|
||||
let icon = match status {
|
||||
McpServerDoctorStatus::Ok(ref detail) => {
|
||||
format!(
|
||||
" {} {name}: {}",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
detail
|
||||
)
|
||||
}
|
||||
McpServerDoctorStatus::Warning(ref detail) => {
|
||||
format!(
|
||||
" {} {name}: {}",
|
||||
"!".truecolor(sky_r, sky_g, sky_b),
|
||||
detail
|
||||
)
|
||||
}
|
||||
McpServerDoctorStatus::Error(ref detail) => {
|
||||
format!(
|
||||
" {} {name}: {}",
|
||||
"✗".truecolor(red_r, red_g, red_b),
|
||||
detail
|
||||
)
|
||||
}
|
||||
};
|
||||
println!("{icon}");
|
||||
if !server.enabled {
|
||||
println!(" (disabled)");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -1784,6 +1827,56 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
|
||||
}
|
||||
bail!("one or more MCP servers failed validation");
|
||||
}
|
||||
McpCommand::AddSelf { name, workspace } => {
|
||||
let exe_path = std::env::current_exe()
|
||||
.map_err(|e| anyhow!("Cannot resolve current binary path: {e}"))?;
|
||||
let exe_str = exe_path.to_string_lossy().to_string();
|
||||
|
||||
let mut args = vec!["serve".to_string(), "--mcp".to_string()];
|
||||
if let Some(ref ws) = workspace {
|
||||
args.push("--workspace".to_string());
|
||||
args.push(ws.clone());
|
||||
}
|
||||
|
||||
let mut cfg = load_mcp_config(&config_path)?;
|
||||
if cfg.servers.contains_key(&name) {
|
||||
bail!(
|
||||
"MCP server '{name}' already exists in {}. Use `deepseek mcp remove {name}` first, or choose a different --name.",
|
||||
config_path.display()
|
||||
);
|
||||
}
|
||||
cfg.servers.insert(
|
||||
name.clone(),
|
||||
McpServerConfig {
|
||||
command: Some(exe_str.clone()),
|
||||
args,
|
||||
env: std::collections::HashMap::new(),
|
||||
url: None,
|
||||
connect_timeout: None,
|
||||
execute_timeout: None,
|
||||
read_timeout: None,
|
||||
disabled: false,
|
||||
enabled: true,
|
||||
required: false,
|
||||
enabled_tools: Vec::new(),
|
||||
disabled_tools: Vec::new(),
|
||||
},
|
||||
);
|
||||
save_mcp_config(&config_path, &cfg)?;
|
||||
println!(
|
||||
"Registered DeepSeek as MCP server '{name}' in {}",
|
||||
config_path.display()
|
||||
);
|
||||
println!(" command: {exe_str}");
|
||||
println!(
|
||||
" args: serve --mcp{}",
|
||||
workspace.map_or(String::new(), |ws| format!(" --workspace {ws}"))
|
||||
);
|
||||
println!();
|
||||
println!("Tip: Use `deepseek mcp validate` to test the connection.");
|
||||
println!(" Use `deepseek serve --http` for the HTTP/SSE runtime API instead.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1798,6 +1891,66 @@ fn load_mcp_config(path: &Path) -> Result<McpConfig> {
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Diagnostic status for an MCP server entry.
|
||||
#[derive(Debug)]
|
||||
enum McpServerDoctorStatus {
|
||||
Ok(String),
|
||||
Warning(String),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Check an MCP server config entry for common issues.
|
||||
fn doctor_check_mcp_server(server: &McpServerConfig) -> McpServerDoctorStatus {
|
||||
// No command or URL — incomplete entry.
|
||||
if server.command.is_none() && server.url.is_none() {
|
||||
return McpServerDoctorStatus::Error("no command or url configured".to_string());
|
||||
}
|
||||
|
||||
// URL-based server — just report the URL.
|
||||
if let Some(ref url) = server.url {
|
||||
return McpServerDoctorStatus::Ok(format!("HTTP/SSE server at {url}"));
|
||||
}
|
||||
|
||||
// Command-based: validate command path exists.
|
||||
let cmd = server.command.as_deref().unwrap_or("");
|
||||
if cmd.is_empty() {
|
||||
return McpServerDoctorStatus::Error("empty command".to_string());
|
||||
}
|
||||
|
||||
let cmd_path = Path::new(cmd);
|
||||
let is_absolute = cmd_path.is_absolute();
|
||||
|
||||
if is_absolute && !cmd_path.exists() {
|
||||
return McpServerDoctorStatus::Error(format!("command not found: {cmd}"));
|
||||
}
|
||||
|
||||
// Detect self-hosted DeepSeek server entries.
|
||||
let is_self_hosted = server
|
||||
.args
|
||||
.windows(2)
|
||||
.any(|w| w[0] == "serve" && w[1] == "--mcp");
|
||||
|
||||
let args_str = server.args.join(" ");
|
||||
if is_self_hosted {
|
||||
if is_absolute {
|
||||
McpServerDoctorStatus::Ok(format!("self-hosted MCP server ({cmd} {args_str})"))
|
||||
} else {
|
||||
McpServerDoctorStatus::Warning(format!(
|
||||
"self-hosted MCP server uses relative command \"{cmd}\" — consider using an absolute path"
|
||||
))
|
||||
}
|
||||
} else {
|
||||
McpServerDoctorStatus::Ok(format!(
|
||||
"stdio server ({cmd}{})",
|
||||
if args_str.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {args_str}")
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn save_mcp_config(path: &Path, cfg: &McpConfig) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
@@ -2140,6 +2293,7 @@ async fn run_exec_agent(
|
||||
model,
|
||||
auto_approve || config.allow_shell(),
|
||||
trust_mode,
|
||||
auto_approve,
|
||||
))
|
||||
.await?;
|
||||
|
||||
@@ -2299,3 +2453,90 @@ async fn run_exec_agent(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod doctor_mcp_tests {
|
||||
use super::*;
|
||||
|
||||
fn make_server(command: Option<&str>, args: &[&str], url: Option<&str>) -> McpServerConfig {
|
||||
McpServerConfig {
|
||||
command: command.map(String::from),
|
||||
args: args.iter().map(|s| s.to_string()).collect(),
|
||||
env: std::collections::HashMap::new(),
|
||||
url: url.map(String::from),
|
||||
connect_timeout: None,
|
||||
execute_timeout: None,
|
||||
read_timeout: None,
|
||||
disabled: false,
|
||||
enabled: true,
|
||||
required: false,
|
||||
enabled_tools: Vec::new(),
|
||||
disabled_tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_command_or_url_is_error() {
|
||||
let server = make_server(None, &[], None);
|
||||
assert!(matches!(
|
||||
doctor_check_mcp_server(&server),
|
||||
McpServerDoctorStatus::Error(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_server_is_ok() {
|
||||
let server = make_server(None, &[], Some("http://localhost:3000/mcp"));
|
||||
match doctor_check_mcp_server(&server) {
|
||||
McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("HTTP/SSE")),
|
||||
other => panic!("Expected Ok, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_server_is_ok() {
|
||||
let server = make_server(Some("node"), &["server.js"], None);
|
||||
match doctor_check_mcp_server(&server) {
|
||||
McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
|
||||
other => panic!("Expected Ok, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_self_hosted_absolute_is_ok() {
|
||||
let server = make_server(Some("/usr/local/bin/deepseek"), &["serve", "--mcp"], None);
|
||||
match doctor_check_mcp_server(&server) {
|
||||
McpServerDoctorStatus::Ok(detail) | McpServerDoctorStatus::Error(detail) => {
|
||||
// On systems where the path doesn't exist, this will be Error.
|
||||
// On systems where it does, it'll be Ok. Either is valid for the test.
|
||||
assert!(
|
||||
detail.contains("self-hosted") || detail.contains("not found"),
|
||||
"unexpected detail: {detail}"
|
||||
);
|
||||
}
|
||||
McpServerDoctorStatus::Warning(detail) => {
|
||||
panic!("Absolute path should not warn: {detail}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_self_hosted_relative_is_warning() {
|
||||
let server = make_server(Some("deepseek"), &["serve", "--mcp"], None);
|
||||
match doctor_check_mcp_server(&server) {
|
||||
McpServerDoctorStatus::Warning(detail) => {
|
||||
assert!(detail.contains("relative"));
|
||||
}
|
||||
other => panic!("Expected Warning for relative path, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_command_is_error() {
|
||||
let server = make_server(Some(""), &[], None);
|
||||
assert!(matches!(
|
||||
doctor_check_mcp_server(&server),
|
||||
McpServerDoctorStatus::Error(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -498,7 +498,7 @@ impl McpConnection {
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"clientInfo": {
|
||||
"name": "deepseek-cli",
|
||||
"name": "deepseek-tui",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
},
|
||||
"capabilities": {
|
||||
@@ -1478,7 +1478,7 @@ pub fn call_tool(
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"clientInfo": { "name": "deepseek-cli", "version": env!("CARGO_PKG_VERSION") },
|
||||
"clientInfo": { "name": "deepseek-tui", "version": env!("CARGO_PKG_VERSION") },
|
||||
"capabilities": {}
|
||||
}
|
||||
});
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -518,7 +518,7 @@ fn handle_command_official(
|
||||
}
|
||||
|
||||
fn print_banner(mode: &str) {
|
||||
println!("{}", ds_blue("DeepSeek CLI").bold());
|
||||
println!("{}", ds_blue("DeepSeek TUI").bold());
|
||||
println!("Mode: {mode}");
|
||||
println!("Type /help for commands. Use /exit to quit.\n");
|
||||
}
|
||||
File renamed without changes.
@@ -51,6 +51,7 @@ pub fn calculate_turn_cost(model: &str, input_tokens: u32, output_tokens: u32) -
|
||||
|
||||
/// Format a USD cost for compact display.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub fn format_cost(cost: f64) -> String {
|
||||
if cost < 0.0001 {
|
||||
"<$0.0001".to_string()
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Project context loading for deepseek-cli.
|
||||
//! Project context loading for DeepSeek TUI.
|
||||
//!
|
||||
//! This module handles loading project-specific context files that provide
|
||||
//! instructions and context to the AI agent. These include:
|
||||
@@ -210,7 +210,7 @@ pub fn create_default_agents_md(workspace: &Path) -> std::io::Result<PathBuf> {
|
||||
|
||||
let default_content = r#"# Project Agent Instructions
|
||||
|
||||
This file provides guidance to AI agents (DeepSeek CLI, Claude Code, etc.) when working with code in this repository.
|
||||
This file provides guidance to AI agents (DeepSeek TUI, Claude Code, etc.) when working with code in this repository.
|
||||
|
||||
## File Location
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -1,6 +1,6 @@
|
||||
You are DeepSeek CLI, an agentic coding assistant with full tool access.
|
||||
You are DeepSeek TUI, an agentic coding assistant with full tool access.
|
||||
|
||||
IMPORTANT: You are ALREADY running inside the DeepSeek CLI TUI. You have direct access to all tools below - do NOT try to run or launch the CLI binary. Your tools execute directly in the current session.
|
||||
IMPORTANT: You are ALREADY running inside the DeepSeek TUI. You have direct access to all tools below - do NOT try to run or launch the CLI binary. Your tools execute directly in the current session.
|
||||
|
||||
When given a task:
|
||||
1. Understand the goal, constraints, and acceptance criteria first.
|
||||
@@ -46,11 +46,11 @@ FILE OPERATIONS:
|
||||
- web_search: Quick web search (fallback when citations are not needed)
|
||||
- request_user_input: Ask the user short multiple-choice questions
|
||||
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
|
||||
- weather: Get a daily weather forecast for a location
|
||||
- finance: Get the latest price for a stock, fund, index, or crypto
|
||||
- sports: Get schedules or standings for a league
|
||||
- time: Get current time for a UTC offset
|
||||
- calculator: Evaluate a basic arithmetic expression
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
- list_mcp_resources: List MCP resources (optionally filtered by server)
|
||||
- list_mcp_resource_templates: List MCP resource templates
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
You are DeepSeek CLI, an agentic coding assistant.
|
||||
You are DeepSeek TUI, an agentic coding assistant.
|
||||
|
||||
When given a task:
|
||||
1. Understand the goal, constraints, and acceptance criteria first.
|
||||
@@ -15,7 +15,7 @@ Tool selection guidance:
|
||||
- Use web.run for time-sensitive or uncertain facts; include citations as [cite:ref_id].
|
||||
- Use multi_tool_use.parallel for multiple read-only tool calls that can run together.
|
||||
- Use request_user_input to ask short multiple-choice questions when needed.
|
||||
- Use weather/finance/sports/time/calculator tools for their respective domains when applicable.
|
||||
|
||||
|
||||
Planning and progress:
|
||||
- For non-trivial tasks, publish a checklist with update_plan.
|
||||
@@ -1,6 +1,6 @@
|
||||
You are DeepSeek CLI, a helpful coding assistant running in NORMAL mode.
|
||||
You are DeepSeek TUI, a helpful coding assistant running in NORMAL mode.
|
||||
|
||||
IMPORTANT: You are ALREADY running inside the DeepSeek CLI TUI. You have direct access to all tools below - do NOT try to run or launch the CLI binary.
|
||||
IMPORTANT: You are ALREADY running inside the DeepSeek TUI. You have direct access to all tools below - do NOT try to run or launch the CLI binary.
|
||||
|
||||
You help users with coding questions, explanations, debugging, and general programming assistance.
|
||||
|
||||
@@ -15,11 +15,11 @@ Available tools in this mode:
|
||||
- web_search: Quick web search (fallback when citations are not needed)
|
||||
- request_user_input: Ask the user short multiple-choice questions
|
||||
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
|
||||
- weather: Get a daily weather forecast for a location
|
||||
- finance: Get the latest price for a stock, fund, index, or crypto
|
||||
- sports: Get schedules or standings for a league
|
||||
- time: Get current time for a UTC offset
|
||||
- calculator: Evaluate a basic arithmetic expression
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
- list_mcp_resources: List MCP resources (optionally filtered by server)
|
||||
- list_mcp_resource_templates: List MCP resource templates
|
||||
- git_status: Inspect repository status safely
|
||||
@@ -1,4 +1,4 @@
|
||||
You are DeepSeek CLI in PLAN mode. Design before implementing.
|
||||
You are DeepSeek TUI in PLAN mode. Design before implementing.
|
||||
|
||||
This mode is read-only: you can analyze and plan, but you cannot edit files or run shell commands.
|
||||
|
||||
@@ -28,11 +28,11 @@ EXPLORATION:
|
||||
- web_search: Quick web search (fallback when citations are not needed)
|
||||
- request_user_input: Ask the user short multiple-choice questions
|
||||
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
|
||||
- weather: Get a daily weather forecast for a location
|
||||
- finance: Get the latest price for a stock, fund, index, or crypto
|
||||
- sports: Get schedules or standings for a league
|
||||
- time: Get current time for a UTC offset
|
||||
- calculator: Evaluate a basic arithmetic expression
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
- list_mcp_resources: List MCP resources (optionally filtered by server)
|
||||
- list_mcp_resource_templates: List MCP resource templates
|
||||
- git_status: Inspect repository status safely
|
||||
@@ -1,6 +1,6 @@
|
||||
You are DeepSeek CLI, an agentic coding assistant with full tool access running in YOLO mode.
|
||||
You are DeepSeek TUI, an agentic coding assistant with full tool access running in YOLO mode.
|
||||
|
||||
IMPORTANT: You are ALREADY running inside the DeepSeek CLI TUI. You have direct access to all tools below - do NOT try to run or launch the CLI binary. Your tools execute directly in the current session.
|
||||
IMPORTANT: You are ALREADY running inside the DeepSeek TUI. You have direct access to all tools below - do NOT try to run or launch the CLI binary. Your tools execute directly in the current session.
|
||||
|
||||
When given a task:
|
||||
1. Understand the goal, constraints, and acceptance criteria first.
|
||||
@@ -46,11 +46,11 @@ FILE OPERATIONS:
|
||||
- web_search: Quick web search (fallback when citations are not needed)
|
||||
- request_user_input: Ask the user short multiple-choice questions
|
||||
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
|
||||
- weather: Get a daily weather forecast for a location
|
||||
- finance: Get the latest price for a stock, fund, index, or crypto
|
||||
- sports: Get schedules or standings for a league
|
||||
- time: Get current time for a UTC offset
|
||||
- calculator: Evaluate a basic arithmetic expression
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
- list_mcp_resources: List MCP resources (optionally filtered by server)
|
||||
- list_mcp_resource_templates: List MCP resource templates
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -1055,7 +1055,7 @@ async fn stream_turn(
|
||||
let mode = req.mode.clone().unwrap_or_else(|| "agent".to_string());
|
||||
let allow_shell = req.allow_shell.unwrap_or(state.config.allow_shell());
|
||||
let trust_mode = req.trust_mode.unwrap_or(false);
|
||||
let auto_approve = req.auto_approve.unwrap_or(true);
|
||||
let auto_approve = req.auto_approve.unwrap_or(false);
|
||||
let prompt = req.prompt;
|
||||
|
||||
let thread = state
|
||||
@@ -35,6 +35,7 @@ const EVENT_CHANNEL_CAPACITY: usize = 1024;
|
||||
const MAX_ACTIVE_THREADS_DEFAULT: usize = 8;
|
||||
const SUMMARY_LIMIT: usize = 280;
|
||||
const CURRENT_RUNTIME_SCHEMA_VERSION: u32 = 1;
|
||||
const RUNTIME_RESTART_REASON: &str = "Interrupted by process restart";
|
||||
|
||||
const fn default_runtime_schema_version() -> u32 {
|
||||
CURRENT_RUNTIME_SCHEMA_VERSION
|
||||
@@ -581,6 +582,13 @@ pub struct RuntimeThreadManager {
|
||||
cancel_token: CancellationToken,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RuntimeApprovalDecision {
|
||||
ApproveTool,
|
||||
DenyTool,
|
||||
RetryWithFullAccess,
|
||||
}
|
||||
|
||||
impl RuntimeThreadManager {
|
||||
pub fn open(
|
||||
config: Config,
|
||||
@@ -589,7 +597,7 @@ impl RuntimeThreadManager {
|
||||
) -> Result<Self> {
|
||||
let store = RuntimeThreadStore::open(manager_cfg.data_dir.clone())?;
|
||||
let (event_tx, _event_rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||
Ok(Self {
|
||||
let manager = Self {
|
||||
config,
|
||||
workspace,
|
||||
store,
|
||||
@@ -597,7 +605,9 @@ impl RuntimeThreadManager {
|
||||
event_tx,
|
||||
manager_cfg,
|
||||
cancel_token: CancellationToken::new(),
|
||||
})
|
||||
};
|
||||
manager.recover_interrupted_state()?;
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Public API for external callers (runtime API, task manager)
|
||||
@@ -650,7 +660,7 @@ impl RuntimeThreadManager {
|
||||
.unwrap_or_else(|| "agent".to_string());
|
||||
let allow_shell = req.allow_shell.unwrap_or_else(|| self.config.allow_shell());
|
||||
let trust_mode = req.trust_mode.unwrap_or(false);
|
||||
let auto_approve = req.auto_approve.unwrap_or(true);
|
||||
let auto_approve = req.auto_approve.unwrap_or(false);
|
||||
|
||||
let thread = ThreadRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
@@ -1027,6 +1037,7 @@ impl RuntimeThreadManager {
|
||||
let model = req.model.unwrap_or_else(|| thread.model.clone());
|
||||
let allow_shell = req.allow_shell.unwrap_or(thread.allow_shell);
|
||||
let trust_mode = req.trust_mode.unwrap_or(thread.trust_mode);
|
||||
let auto_approve = req.auto_approve.unwrap_or(thread.auto_approve);
|
||||
|
||||
engine
|
||||
.send(Op::send(
|
||||
@@ -1035,6 +1046,7 @@ impl RuntimeThreadManager {
|
||||
model.clone(),
|
||||
allow_shell,
|
||||
trust_mode,
|
||||
auto_approve,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to start turn: {e}"))?;
|
||||
@@ -1238,7 +1250,7 @@ impl RuntimeThreadManager {
|
||||
state.active_turn = Some(ActiveTurnState {
|
||||
turn_id: turn_id.clone(),
|
||||
interrupt_requested: false,
|
||||
auto_approve: true,
|
||||
auto_approve: thread.auto_approve,
|
||||
trust_mode: thread.trust_mode,
|
||||
});
|
||||
touch_lru(&mut active.lru, thread_id);
|
||||
@@ -1616,7 +1628,9 @@ impl RuntimeThreadManager {
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
EngineEvent::CompactionCompleted { id, auto, message } => {
|
||||
EngineEvent::CompactionCompleted {
|
||||
id, auto, message, ..
|
||||
} => {
|
||||
if let Some(item_id) = compaction_items.remove(&id) {
|
||||
let mut item = self.store.load_item(&item_id)?;
|
||||
item.status = TurnItemLifecycleStatus::Completed;
|
||||
@@ -1831,12 +1845,16 @@ impl RuntimeThreadManager {
|
||||
.iter()
|
||||
.filter(|agent| matches!(agent.status, SubAgentStatus::Running))
|
||||
.count();
|
||||
let interrupted = agents
|
||||
.iter()
|
||||
.filter(|agent| matches!(agent.status, SubAgentStatus::Interrupted(_)))
|
||||
.count();
|
||||
let completed = agents
|
||||
.iter()
|
||||
.filter(|agent| matches!(agent.status, SubAgentStatus::Completed))
|
||||
.count();
|
||||
let message = format!(
|
||||
"Sub-agent list refreshed: {} total ({running} running, {completed} completed)",
|
||||
"Sub-agent list refreshed: {} total ({running} running, {interrupted} interrupted, {completed} completed)",
|
||||
agents.len()
|
||||
);
|
||||
let item = TurnItemRecord {
|
||||
@@ -1883,14 +1901,15 @@ impl RuntimeThreadManager {
|
||||
let (auto_approve, trust_mode) = self
|
||||
.active_turn_flags(&thread_id, &turn_id)
|
||||
.await
|
||||
.unwrap_or((true, false));
|
||||
if auto_approve {
|
||||
let _ = engine.approve_tool_call(id).await;
|
||||
} else {
|
||||
let _ = engine.deny_tool_call(id).await;
|
||||
}
|
||||
if trust_mode {
|
||||
let _ = trust_mode;
|
||||
.unwrap_or((false, false));
|
||||
match Self::approval_decision(auto_approve, trust_mode, false) {
|
||||
RuntimeApprovalDecision::ApproveTool => {
|
||||
let _ = engine.approve_tool_call(id).await;
|
||||
}
|
||||
RuntimeApprovalDecision::DenyTool
|
||||
| RuntimeApprovalDecision::RetryWithFullAccess => {
|
||||
let _ = engine.deny_tool_call(id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
EngineEvent::ElevationRequired {
|
||||
@@ -1914,16 +1933,20 @@ impl RuntimeThreadManager {
|
||||
let (auto_approve, trust_mode) = self
|
||||
.active_turn_flags(&thread_id, &turn_id)
|
||||
.await
|
||||
.unwrap_or((true, false));
|
||||
if auto_approve && trust_mode {
|
||||
let _ = engine
|
||||
.retry_tool_with_policy(
|
||||
tool_id,
|
||||
crate::sandbox::SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
let _ = engine.deny_tool_call(tool_id).await;
|
||||
.unwrap_or((false, false));
|
||||
match Self::approval_decision(auto_approve, trust_mode, true) {
|
||||
RuntimeApprovalDecision::RetryWithFullAccess => {
|
||||
let _ = engine
|
||||
.retry_tool_with_policy(
|
||||
tool_id,
|
||||
crate::sandbox::SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
RuntimeApprovalDecision::ApproveTool
|
||||
| RuntimeApprovalDecision::DenyTool => {
|
||||
let _ = engine.deny_tool_call(tool_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
EngineEvent::Status { message } => {
|
||||
@@ -2098,6 +2121,70 @@ impl RuntimeThreadManager {
|
||||
Some((turn.auto_approve, turn.trust_mode))
|
||||
}
|
||||
|
||||
fn approval_decision(
|
||||
auto_approve: bool,
|
||||
trust_mode: bool,
|
||||
requires_full_access: bool,
|
||||
) -> RuntimeApprovalDecision {
|
||||
if !auto_approve {
|
||||
return RuntimeApprovalDecision::DenyTool;
|
||||
}
|
||||
if requires_full_access {
|
||||
if trust_mode {
|
||||
RuntimeApprovalDecision::RetryWithFullAccess
|
||||
} else {
|
||||
RuntimeApprovalDecision::DenyTool
|
||||
}
|
||||
} else {
|
||||
RuntimeApprovalDecision::ApproveTool
|
||||
}
|
||||
}
|
||||
|
||||
fn recover_interrupted_state(&self) -> Result<()> {
|
||||
let now = Utc::now();
|
||||
for mut thread in self.store.list_threads()? {
|
||||
let mut thread_changed = false;
|
||||
for mut turn in self.store.list_turns_for_thread(&thread.id)? {
|
||||
if !matches!(
|
||||
turn.status,
|
||||
RuntimeTurnStatus::Queued | RuntimeTurnStatus::InProgress
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
turn.status = RuntimeTurnStatus::Interrupted;
|
||||
turn.error = Some(RUNTIME_RESTART_REASON.to_string());
|
||||
turn.ended_at = Some(now);
|
||||
if let Some(started_at) = turn.started_at {
|
||||
let elapsed = now.signed_duration_since(started_at);
|
||||
turn.duration_ms = Some(elapsed.num_milliseconds().max(0) as u64);
|
||||
}
|
||||
self.store.save_turn(&turn)?;
|
||||
|
||||
for item_id in &turn.item_ids {
|
||||
let mut item = self.store.load_item(item_id)?;
|
||||
if matches!(
|
||||
item.status,
|
||||
TurnItemLifecycleStatus::Queued | TurnItemLifecycleStatus::InProgress
|
||||
) {
|
||||
item.status = TurnItemLifecycleStatus::Interrupted;
|
||||
item.ended_at = Some(now);
|
||||
self.store.save_item(&item)?;
|
||||
}
|
||||
}
|
||||
|
||||
thread.updated_at = now;
|
||||
thread_changed = true;
|
||||
}
|
||||
|
||||
if thread_changed {
|
||||
self.store.save_thread(&thread)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn install_test_engine(
|
||||
&self,
|
||||
@@ -2239,7 +2326,7 @@ fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::engine::mock_engine_handle;
|
||||
use crate::core::engine::{MockApprovalEvent, mock_engine_handle};
|
||||
use crate::core::events::{Event as EngineEvent, TurnOutcomeStatus};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::oneshot;
|
||||
@@ -2265,6 +2352,64 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn sample_thread(thread_id: &str) -> ThreadRecord {
|
||||
let now = Utc::now();
|
||||
ThreadRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: thread_id.to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
model: DEFAULT_TEXT_MODEL.to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
mode: AppMode::Agent.as_setting().to_string(),
|
||||
allow_shell: false,
|
||||
trust_mode: false,
|
||||
auto_approve: false,
|
||||
latest_turn_id: None,
|
||||
latest_response_bookmark: None,
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_turn(thread_id: &str, turn_id: &str, status: RuntimeTurnStatus) -> TurnRecord {
|
||||
let now = Utc::now();
|
||||
TurnRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: turn_id.to_string(),
|
||||
thread_id: thread_id.to_string(),
|
||||
status,
|
||||
input_summary: "sample".to_string(),
|
||||
created_at: now,
|
||||
started_at: Some(now),
|
||||
ended_at: None,
|
||||
duration_ms: None,
|
||||
usage: None,
|
||||
error: None,
|
||||
item_ids: Vec::new(),
|
||||
steer_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_item(
|
||||
turn_id: &str,
|
||||
item_id: &str,
|
||||
status: TurnItemLifecycleStatus,
|
||||
) -> TurnItemRecord {
|
||||
TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: item_id.to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
kind: TurnItemKind::Status,
|
||||
status,
|
||||
summary: "sample item".to_string(),
|
||||
detail: None,
|
||||
artifact_refs: Vec::new(),
|
||||
started_at: Some(Utc::now()),
|
||||
ended_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn install_mock_engine(
|
||||
manager: &RuntimeThreadManager,
|
||||
thread_id: &str,
|
||||
@@ -2345,6 +2490,102 @@ mod tests {
|
||||
assert_eq!(active.lru.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_decision_matches_auto_approve_and_trust_mode() {
|
||||
assert!(matches!(
|
||||
RuntimeThreadManager::approval_decision(false, false, false),
|
||||
RuntimeApprovalDecision::DenyTool
|
||||
));
|
||||
assert!(matches!(
|
||||
RuntimeThreadManager::approval_decision(true, false, false),
|
||||
RuntimeApprovalDecision::ApproveTool
|
||||
));
|
||||
assert!(matches!(
|
||||
RuntimeThreadManager::approval_decision(true, false, true),
|
||||
RuntimeApprovalDecision::DenyTool
|
||||
));
|
||||
assert!(matches!(
|
||||
RuntimeThreadManager::approval_decision(true, true, true),
|
||||
RuntimeApprovalDecision::RetryWithFullAccess
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_recovers_queued_and_in_progress_turns() -> Result<()> {
|
||||
let runtime_dir = test_runtime_dir();
|
||||
let store = RuntimeThreadStore::open(runtime_dir.clone())?;
|
||||
let thread = sample_thread("thr_recover");
|
||||
store.save_thread(&thread)?;
|
||||
|
||||
let mut queued_turn = sample_turn(&thread.id, "turn_queued", RuntimeTurnStatus::Queued);
|
||||
let mut in_progress_turn =
|
||||
sample_turn(&thread.id, "turn_running", RuntimeTurnStatus::InProgress);
|
||||
let completed_turn = sample_turn(&thread.id, "turn_done", RuntimeTurnStatus::Completed);
|
||||
|
||||
let queued_item = sample_item(
|
||||
&queued_turn.id,
|
||||
"item_queued",
|
||||
TurnItemLifecycleStatus::Queued,
|
||||
);
|
||||
let in_progress_item = sample_item(
|
||||
&in_progress_turn.id,
|
||||
"item_running",
|
||||
TurnItemLifecycleStatus::InProgress,
|
||||
);
|
||||
let completed_item = sample_item(
|
||||
&completed_turn.id,
|
||||
"item_done",
|
||||
TurnItemLifecycleStatus::Completed,
|
||||
);
|
||||
|
||||
queued_turn.item_ids = vec![queued_item.id.clone()];
|
||||
in_progress_turn.item_ids = vec![in_progress_item.id.clone()];
|
||||
|
||||
store.save_item(&queued_item)?;
|
||||
store.save_item(&in_progress_item)?;
|
||||
store.save_item(&completed_item)?;
|
||||
store.save_turn(&queued_turn)?;
|
||||
store.save_turn(&in_progress_turn)?;
|
||||
store.save_turn(&completed_turn)?;
|
||||
|
||||
let manager = test_manager(runtime_dir)?;
|
||||
|
||||
let queued_turn = manager.store.load_turn(&queued_turn.id)?;
|
||||
assert_eq!(queued_turn.status, RuntimeTurnStatus::Interrupted);
|
||||
assert_eq!(queued_turn.error.as_deref(), Some(RUNTIME_RESTART_REASON));
|
||||
assert!(queued_turn.ended_at.is_some());
|
||||
assert!(queued_turn.duration_ms.is_some());
|
||||
|
||||
let in_progress_turn = manager.store.load_turn(&in_progress_turn.id)?;
|
||||
assert_eq!(in_progress_turn.status, RuntimeTurnStatus::Interrupted);
|
||||
assert_eq!(
|
||||
in_progress_turn.error.as_deref(),
|
||||
Some(RUNTIME_RESTART_REASON)
|
||||
);
|
||||
assert!(in_progress_turn.ended_at.is_some());
|
||||
assert!(in_progress_turn.duration_ms.is_some());
|
||||
|
||||
let completed_turn = manager.store.load_turn(&completed_turn.id)?;
|
||||
assert_eq!(completed_turn.status, RuntimeTurnStatus::Completed);
|
||||
assert!(completed_turn.error.is_none());
|
||||
|
||||
let queued_item = manager.store.load_item("item_queued")?;
|
||||
assert_eq!(queued_item.status, TurnItemLifecycleStatus::Interrupted);
|
||||
assert!(queued_item.ended_at.is_some());
|
||||
|
||||
let in_progress_item = manager.store.load_item("item_running")?;
|
||||
assert_eq!(
|
||||
in_progress_item.status,
|
||||
TurnItemLifecycleStatus::Interrupted
|
||||
);
|
||||
assert!(in_progress_item.ended_at.is_some());
|
||||
|
||||
let completed_item = manager.store.load_item("item_done")?;
|
||||
assert_eq!(completed_item.status, TurnItemLifecycleStatus::Completed);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_lifecycle_persists_across_restart() -> Result<()> {
|
||||
let runtime_dir = test_runtime_dir();
|
||||
@@ -2431,6 +2672,191 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_thread_defaults_auto_approve_to_false() -> Result<()> {
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest {
|
||||
model: None,
|
||||
workspace: None,
|
||||
mode: None,
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
assert!(!thread.auto_approve);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_turn_passes_effective_auto_approve_to_engine() -> Result<()> {
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest {
|
||||
model: None,
|
||||
workspace: None,
|
||||
mode: None,
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: Some(false),
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let harness = install_mock_engine(&manager, &thread.id).await;
|
||||
let mut rx_op = harness.rx_op;
|
||||
|
||||
let _turn = manager
|
||||
.start_turn(
|
||||
&thread.id,
|
||||
StartTurnRequest {
|
||||
prompt: "override approval".to_string(),
|
||||
input_summary: None,
|
||||
model: None,
|
||||
mode: None,
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: Some(true),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
match rx_op.recv().await {
|
||||
Some(Op::SendMessage { auto_approve, .. }) => assert!(auto_approve),
|
||||
other => panic!("expected SendMessage op, got {other:?}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_turn_can_override_thread_auto_approve_to_false() -> Result<()> {
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest {
|
||||
model: None,
|
||||
workspace: None,
|
||||
mode: None,
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: Some(true),
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let harness = install_mock_engine(&manager, &thread.id).await;
|
||||
let mut rx_op = harness.rx_op;
|
||||
|
||||
let _turn = manager
|
||||
.start_turn(
|
||||
&thread.id,
|
||||
StartTurnRequest {
|
||||
prompt: "disable approval".to_string(),
|
||||
input_summary: None,
|
||||
model: None,
|
||||
mode: None,
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: Some(false),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
match rx_op.recv().await {
|
||||
Some(Op::SendMessage { auto_approve, .. }) => assert!(!auto_approve),
|
||||
other => panic!("expected SendMessage op, got {other:?}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compact_thread_preserves_thread_auto_approve_policy() -> Result<()> {
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest {
|
||||
model: None,
|
||||
workspace: None,
|
||||
mode: None,
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: Some(false),
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let harness = install_mock_engine(&manager, &thread.id).await;
|
||||
let mut rx_op = harness.rx_op;
|
||||
|
||||
let turn = manager
|
||||
.compact_thread(&thread.id, CompactThreadRequest::default())
|
||||
.await?;
|
||||
|
||||
assert!(matches!(rx_op.recv().await, Some(Op::CompactContext)));
|
||||
assert_eq!(
|
||||
manager.active_turn_flags(&thread.id, &turn.id).await,
|
||||
Some((false, false))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compact_thread_with_real_engine_reaches_terminal_status() -> Result<()> {
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest {
|
||||
model: None,
|
||||
workspace: None,
|
||||
mode: None,
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let turn = manager
|
||||
.compact_thread(&thread.id, CompactThreadRequest::default())
|
||||
.await?;
|
||||
let terminal = wait_for_terminal_turn(&manager, &turn.id, Duration::from_secs(2)).await?;
|
||||
|
||||
assert!(matches!(
|
||||
terminal.status,
|
||||
RuntimeTurnStatus::Completed | RuntimeTurnStatus::Failed
|
||||
));
|
||||
assert!(
|
||||
terminal.ended_at.is_some(),
|
||||
"manual compaction should reach a terminal turn state"
|
||||
);
|
||||
assert_eq!(manager.active_turn_flags(&thread.id, &turn.id).await, None);
|
||||
|
||||
let expected_status = match terminal.status {
|
||||
RuntimeTurnStatus::Completed => "completed",
|
||||
RuntimeTurnStatus::Failed => "failed",
|
||||
other => panic!("unexpected non-terminal compaction status: {other:?}"),
|
||||
};
|
||||
let events = manager.events_since(&thread.id, None)?;
|
||||
assert!(events.iter().any(|ev| {
|
||||
ev.event == "turn.completed"
|
||||
&& ev
|
||||
.payload
|
||||
.get("turn")
|
||||
.and_then(|turn| turn.get("status"))
|
||||
.and_then(Value::as_str)
|
||||
== Some(expected_status)
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_turn_continuity_same_thread() -> Result<()> {
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
@@ -2643,6 +3069,167 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approval_required_with_stale_active_turn_is_denied() -> Result<()> {
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest {
|
||||
model: None,
|
||||
workspace: None,
|
||||
mode: None,
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: Some(true),
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut harness = install_mock_engine(&manager, &thread.id).await;
|
||||
let turn = manager
|
||||
.start_turn(
|
||||
&thread.id,
|
||||
StartTurnRequest {
|
||||
prompt: "needs approval".to_string(),
|
||||
input_summary: None,
|
||||
model: None,
|
||||
mode: None,
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: Some(true),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(matches!(
|
||||
harness.rx_op.recv().await,
|
||||
Some(Op::SendMessage { .. })
|
||||
));
|
||||
{
|
||||
let mut active = manager.active.lock().await;
|
||||
let state = active
|
||||
.engines
|
||||
.get_mut(&thread.id)
|
||||
.context("missing active thread state")?;
|
||||
state.active_turn = None;
|
||||
}
|
||||
|
||||
harness
|
||||
.tx_event
|
||||
.send(EngineEvent::ApprovalRequired {
|
||||
id: "tool_stale".to_string(),
|
||||
tool_name: "exec_command".to_string(),
|
||||
description: "stale approval".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
harness.recv_approval_event().await,
|
||||
Some(MockApprovalEvent::Denied {
|
||||
id: "tool_stale".to_string(),
|
||||
})
|
||||
);
|
||||
|
||||
harness
|
||||
.tx_event
|
||||
.send(EngineEvent::TurnComplete {
|
||||
usage: Usage {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
server_tool_use: None,
|
||||
},
|
||||
status: TurnOutcomeStatus::Completed,
|
||||
error: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let terminal = wait_for_terminal_turn(&manager, &turn.id, Duration::from_secs(2)).await?;
|
||||
assert_eq!(terminal.status, RuntimeTurnStatus::Completed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn elevation_required_with_stale_active_turn_is_denied() -> Result<()> {
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest {
|
||||
model: None,
|
||||
workspace: None,
|
||||
mode: None,
|
||||
allow_shell: None,
|
||||
trust_mode: Some(true),
|
||||
auto_approve: Some(true),
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut harness = install_mock_engine(&manager, &thread.id).await;
|
||||
let turn = manager
|
||||
.start_turn(
|
||||
&thread.id,
|
||||
StartTurnRequest {
|
||||
prompt: "needs elevation".to_string(),
|
||||
input_summary: None,
|
||||
model: None,
|
||||
mode: None,
|
||||
allow_shell: None,
|
||||
trust_mode: Some(true),
|
||||
auto_approve: Some(true),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(matches!(
|
||||
harness.rx_op.recv().await,
|
||||
Some(Op::SendMessage { .. })
|
||||
));
|
||||
{
|
||||
let mut active = manager.active.lock().await;
|
||||
let state = active
|
||||
.engines
|
||||
.get_mut(&thread.id)
|
||||
.context("missing active thread state")?;
|
||||
state.active_turn = None;
|
||||
}
|
||||
|
||||
harness
|
||||
.tx_event
|
||||
.send(EngineEvent::ElevationRequired {
|
||||
tool_id: "tool_stale_elevated".to_string(),
|
||||
tool_name: "exec_command".to_string(),
|
||||
command: None,
|
||||
denial_reason: "sandbox denied".to_string(),
|
||||
blocked_network: false,
|
||||
blocked_write: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
harness.recv_approval_event().await,
|
||||
Some(MockApprovalEvent::Denied {
|
||||
id: "tool_stale_elevated".to_string(),
|
||||
})
|
||||
);
|
||||
|
||||
harness
|
||||
.tx_event
|
||||
.send(EngineEvent::TurnComplete {
|
||||
usage: Usage {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
server_tool_use: None,
|
||||
},
|
||||
status: TurnOutcomeStatus::Completed,
|
||||
error: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let terminal = wait_for_terminal_turn(&manager, &turn.id, Duration::from_secs(2)).await?;
|
||||
assert_eq!(terminal.status, RuntimeTurnStatus::Completed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steer_turn_on_active_turn_records_item_and_event() -> Result<()> {
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
@@ -2791,6 +3378,8 @@ mod tests {
|
||||
id: "auto_compact_1".to_string(),
|
||||
auto: true,
|
||||
message: "auto compact done".to_string(),
|
||||
messages_before: None,
|
||||
messages_after: None,
|
||||
})
|
||||
.await;
|
||||
let _ = tx_event
|
||||
@@ -2819,6 +3408,8 @@ mod tests {
|
||||
id: "manual_compact_1".to_string(),
|
||||
auto: false,
|
||||
message: "manual compact done".to_string(),
|
||||
messages_before: None,
|
||||
messages_after: None,
|
||||
})
|
||||
.await;
|
||||
let _ = tx_event
|
||||
@@ -2901,6 +3492,174 @@ mod tests {
|
||||
assert_eq!(out, "abcdefg...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_decision_requires_auto_approve_and_trust_for_full_access() {
|
||||
assert_eq!(
|
||||
RuntimeThreadManager::approval_decision(false, false, false),
|
||||
RuntimeApprovalDecision::DenyTool
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeThreadManager::approval_decision(true, false, false),
|
||||
RuntimeApprovalDecision::ApproveTool
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeThreadManager::approval_decision(true, false, true),
|
||||
RuntimeApprovalDecision::DenyTool
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeThreadManager::approval_decision(true, true, true),
|
||||
RuntimeApprovalDecision::RetryWithFullAccess
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> {
|
||||
let data_dir = test_runtime_dir();
|
||||
let manager = test_manager(data_dir.clone())?;
|
||||
let started_at = Utc::now() - chrono::Duration::seconds(5);
|
||||
let created_at = started_at - chrono::Duration::seconds(1);
|
||||
|
||||
let thread = ThreadRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: "thr_restart".to_string(),
|
||||
created_at,
|
||||
updated_at: created_at,
|
||||
model: DEFAULT_TEXT_MODEL.to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
mode: "agent".to_string(),
|
||||
allow_shell: false,
|
||||
trust_mode: false,
|
||||
auto_approve: false,
|
||||
latest_turn_id: Some("turn_in_progress".to_string()),
|
||||
latest_response_bookmark: None,
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
};
|
||||
manager.store.save_thread(&thread)?;
|
||||
|
||||
let completed_item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: "item_completed".to_string(),
|
||||
turn_id: "turn_in_progress".to_string(),
|
||||
kind: TurnItemKind::Status,
|
||||
status: TurnItemLifecycleStatus::Completed,
|
||||
summary: "done".to_string(),
|
||||
detail: None,
|
||||
artifact_refs: Vec::new(),
|
||||
started_at: Some(started_at),
|
||||
ended_at: Some(started_at + chrono::Duration::seconds(1)),
|
||||
};
|
||||
let in_progress_item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: "item_in_progress".to_string(),
|
||||
turn_id: "turn_in_progress".to_string(),
|
||||
kind: TurnItemKind::ToolCall,
|
||||
status: TurnItemLifecycleStatus::InProgress,
|
||||
summary: "running".to_string(),
|
||||
detail: None,
|
||||
artifact_refs: Vec::new(),
|
||||
started_at: Some(started_at),
|
||||
ended_at: None,
|
||||
};
|
||||
let queued_item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: "item_queued".to_string(),
|
||||
turn_id: "turn_queued".to_string(),
|
||||
kind: TurnItemKind::ToolCall,
|
||||
status: TurnItemLifecycleStatus::Queued,
|
||||
summary: "queued".to_string(),
|
||||
detail: None,
|
||||
artifact_refs: Vec::new(),
|
||||
started_at: None,
|
||||
ended_at: None,
|
||||
};
|
||||
manager.store.save_item(&completed_item)?;
|
||||
manager.store.save_item(&in_progress_item)?;
|
||||
manager.store.save_item(&queued_item)?;
|
||||
|
||||
manager.store.save_turn(&TurnRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: "turn_in_progress".to_string(),
|
||||
thread_id: thread.id.clone(),
|
||||
status: RuntimeTurnStatus::InProgress,
|
||||
input_summary: "hello".to_string(),
|
||||
created_at,
|
||||
started_at: Some(started_at),
|
||||
ended_at: None,
|
||||
duration_ms: None,
|
||||
usage: None,
|
||||
error: None,
|
||||
item_ids: vec![completed_item.id.clone(), in_progress_item.id.clone()],
|
||||
steer_count: 0,
|
||||
})?;
|
||||
manager.store.save_turn(&TurnRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: "turn_queued".to_string(),
|
||||
thread_id: thread.id.clone(),
|
||||
status: RuntimeTurnStatus::Queued,
|
||||
input_summary: "later".to_string(),
|
||||
created_at,
|
||||
started_at: None,
|
||||
ended_at: None,
|
||||
duration_ms: None,
|
||||
usage: None,
|
||||
error: None,
|
||||
item_ids: vec![queued_item.id.clone()],
|
||||
steer_count: 0,
|
||||
})?;
|
||||
drop(manager);
|
||||
|
||||
let recovered = test_manager(data_dir)?;
|
||||
|
||||
let recovered_thread = recovered.store.load_thread(&thread.id)?;
|
||||
assert!(recovered_thread.updated_at >= thread.updated_at);
|
||||
|
||||
let recovered_in_progress_turn = recovered.store.load_turn("turn_in_progress")?;
|
||||
assert_eq!(
|
||||
recovered_in_progress_turn.status,
|
||||
RuntimeTurnStatus::Interrupted
|
||||
);
|
||||
assert_eq!(
|
||||
recovered_in_progress_turn.error.as_deref(),
|
||||
Some(RUNTIME_RESTART_REASON)
|
||||
);
|
||||
assert!(recovered_in_progress_turn.ended_at.is_some());
|
||||
assert!(
|
||||
recovered_in_progress_turn
|
||||
.duration_ms
|
||||
.is_some_and(|duration| duration >= 5_000)
|
||||
);
|
||||
|
||||
let recovered_queued_turn = recovered.store.load_turn("turn_queued")?;
|
||||
assert_eq!(recovered_queued_turn.status, RuntimeTurnStatus::Interrupted);
|
||||
assert_eq!(
|
||||
recovered_queued_turn.error.as_deref(),
|
||||
Some(RUNTIME_RESTART_REASON)
|
||||
);
|
||||
assert!(recovered_queued_turn.ended_at.is_some());
|
||||
assert_eq!(recovered_queued_turn.duration_ms, None);
|
||||
|
||||
assert_eq!(
|
||||
recovered.store.load_item(&completed_item.id)?.status,
|
||||
TurnItemLifecycleStatus::Completed
|
||||
);
|
||||
let recovered_in_progress_item = recovered.store.load_item(&in_progress_item.id)?;
|
||||
assert_eq!(
|
||||
recovered_in_progress_item.status,
|
||||
TurnItemLifecycleStatus::Interrupted
|
||||
);
|
||||
assert!(recovered_in_progress_item.ended_at.is_some());
|
||||
|
||||
let recovered_queued_item = recovered.store.load_item(&queued_item.id)?;
|
||||
assert_eq!(
|
||||
recovered_queued_item.status,
|
||||
TurnItemLifecycleStatus::Interrupted
|
||||
);
|
||||
assert!(recovered_queued_item.ended_at.is_some());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mode_defaults_to_agent() {
|
||||
assert_eq!(parse_mode("unknown"), AppMode::Agent);
|
||||
File renamed without changes.
@@ -6,7 +6,7 @@
|
||||
|
||||
//!
|
||||
//! This module provides sandboxing capabilities for shell commands executed by
|
||||
//! deepseek-cli. Sandboxing restricts what system resources a command can access,
|
||||
//! DeepSeek TUI. Sandboxing restricts what system resources a command can access,
|
||||
//! preventing accidental or malicious damage to the system.
|
||||
//!
|
||||
//! # Platform Support
|
||||
@@ -34,7 +34,7 @@ pub enum SandboxPolicy {
|
||||
|
||||
/// Indicates the process is already running in an external sandbox.
|
||||
///
|
||||
/// Use this when deepseek-cli is itself running inside a container,
|
||||
/// Use this when DeepSeek TUI is itself running inside a container,
|
||||
/// VM, or other sandboxed environment. This avoids double-sandboxing
|
||||
/// which can cause issues.
|
||||
#[serde(rename = "external-sandbox")]
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -21,7 +21,7 @@ pub struct Settings {
|
||||
pub show_thinking: bool,
|
||||
/// Show detailed tool output
|
||||
pub show_tool_details: bool,
|
||||
/// Default mode: "agent", "plan", "yolo"
|
||||
/// Default mode: "normal", "agent", "plan", "yolo"
|
||||
pub default_mode: String,
|
||||
/// Sidebar width as percentage of terminal width
|
||||
pub sidebar_width_percent: u16,
|
||||
@@ -117,9 +117,9 @@ impl Settings {
|
||||
}
|
||||
"default_mode" | "mode" => {
|
||||
let normalized = normalize_mode(value);
|
||||
if !["agent", "plan", "yolo"].contains(&normalized) {
|
||||
if !["normal", "agent", "plan", "yolo"].contains(&normalized) {
|
||||
anyhow::bail!(
|
||||
"Failed to update setting: invalid mode '{value}'. Expected: agent, plan, yolo."
|
||||
"Failed to update setting: invalid mode '{value}'. Expected: normal, agent, plan, yolo."
|
||||
);
|
||||
}
|
||||
self.default_mode = normalized.to_string();
|
||||
@@ -224,7 +224,7 @@ impl Settings {
|
||||
("auto_compact", "Auto-compact conversations: on/off"),
|
||||
("show_thinking", "Show model thinking: on/off"),
|
||||
("show_tool_details", "Show detailed tool output: on/off"),
|
||||
("default_mode", "Default mode: agent, plan, yolo"),
|
||||
("default_mode", "Default mode: normal, agent, plan, yolo"),
|
||||
("sidebar_width", "Sidebar width percentage: 10-50"),
|
||||
(
|
||||
"sidebar_focus",
|
||||
@@ -251,8 +251,12 @@ fn parse_bool(value: &str) -> Result<bool> {
|
||||
}
|
||||
|
||||
fn normalize_mode(value: &str) -> &str {
|
||||
match value {
|
||||
"edit" | "normal" => "agent",
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"edit" => "agent",
|
||||
"normal" => "normal",
|
||||
"agent" => "agent",
|
||||
"plan" => "plan",
|
||||
"yolo" => "yolo",
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user