mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 17:10:18 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba6bc351da | ||
|
|
ebdda09c29 | ||
|
|
f42f94207c | ||
|
|
d79178a926 | ||
|
|
bcf6ba9a8e | ||
|
|
7a85f182e2 | ||
|
|
f7fe5e09a5 | ||
|
|
24b8945010 | ||
|
|
82e4a564aa | ||
|
|
07ae792068 | ||
|
|
ba40ae4aac | ||
|
|
7f2c382343 | ||
|
|
017ac97d0d | ||
|
|
0a394e1587 | ||
|
|
fafb76063d | ||
|
|
9e7cfc951a | ||
|
|
1be18e691b | ||
|
|
ccc9554ef4 | ||
|
|
97f245d5ac | ||
|
|
19f8d83d3b | ||
|
|
67b232b063 | ||
|
|
41c54f08aa | ||
|
|
56ea63e2c6 | ||
|
|
94834b2eb4 | ||
|
|
dc8f8496d6 | ||
|
|
5c1086fe9e | ||
|
|
dff592bc6d | ||
|
|
29141bc89b | ||
|
|
fe4f261a5d | ||
|
|
7f0444d26b | ||
|
|
298f5c6c51 | ||
|
|
853a39138c | ||
|
|
6ad3727fa0 | ||
|
|
16f62f7abf | ||
|
|
d0dc26ce25 | ||
|
|
7d8c40893f | ||
|
|
411153c549 | ||
|
|
e06071c177 | ||
|
|
f377b0f9d2 | ||
|
|
f3df8f5f26 | ||
|
|
8323bedfb7 | ||
|
|
6c14e2e1f4 | ||
|
|
c4f9078712 | ||
|
|
d713032b9f | ||
|
|
d89b33330f | ||
|
|
ffa75f07e5 | ||
|
|
35595f8edc | ||
|
|
b7bd02d814 | ||
|
|
dc8e94d705 | ||
|
|
f4dbf828c9 | ||
|
|
cbcf35c1bd | ||
|
|
3db7f05c60 | ||
|
|
5c82f08c58 | ||
|
|
ea3cc3d0d2 | ||
|
|
b172b8d306 | ||
|
|
14bb2bfd98 | ||
|
|
fc4884b0c8 | ||
|
|
7b91169017 | ||
|
|
cc0ac49822 | ||
|
|
f2881e7e3d | ||
|
|
f269147286 | ||
|
|
ad76ee39ba | ||
|
|
351b309c7d | ||
|
|
37186c3d95 | ||
|
|
6021fc56c8 | ||
|
|
8bdf7005b8 | ||
|
|
698ec1099c | ||
|
|
d80d31aafb | ||
|
|
5e52b59005 | ||
|
|
70beb250e7 | ||
|
|
9b87804057 | ||
|
|
b3e765cc70 | ||
|
|
c69a73b644 | ||
|
|
7397fb0076 | ||
|
|
a94f29b47b | ||
|
|
7a6be37e2d | ||
|
|
8d904129b5 | ||
|
|
64e29c1b73 | ||
|
|
b88ce88a42 | ||
|
|
6130617b8d | ||
|
|
b04f2860fe | ||
|
|
07531903e7 | ||
|
|
9b647edf92 | ||
|
|
8869541fbe | ||
|
|
304644e448 | ||
|
|
d55a9c1231 | ||
|
|
f3fe107b8e | ||
|
|
8b5f1bc83f | ||
|
|
cfcdce3d03 | ||
|
|
cd41e381e7 | ||
|
|
d0a3d1d7aa | ||
|
|
605591d064 | ||
|
|
6fedee021e | ||
|
|
d2b64b260e | ||
|
|
c81287cdfd | ||
|
|
91721d8a17 | ||
|
|
1a04659a95 | ||
|
|
87884a1e84 | ||
|
|
ccefab8d44 | ||
|
|
5f90e1685e | ||
|
|
b556559fb4 | ||
|
|
19a9724008 | ||
|
|
4212ba9f26 | ||
|
|
6c05c3b3ac | ||
|
|
ab2c708ca7 | ||
|
|
fa3905041d | ||
|
|
ab87765c26 | ||
|
|
b20937ba55 | ||
|
|
6c55d80dea | ||
|
|
4e516a7c1f | ||
|
|
702f9c353c | ||
|
|
4ded9e2a4f | ||
|
|
9f47d78a03 | ||
|
|
67b371ca95 | ||
|
|
858649d458 | ||
|
|
433d7cb6e9 | ||
|
|
e0bccecd5c | ||
|
|
02ecc51e9c | ||
|
|
72d1c43d50 | ||
|
|
9e3aafeabb | ||
|
|
325aaefc00 | ||
|
|
62d2877b3b | ||
|
|
3f8d04178c | ||
|
|
18ce9f96f1 | ||
|
|
5466cd7a19 | ||
|
|
3c3c3f49ce | ||
|
|
a7c5ef9374 | ||
|
|
55148906f2 | ||
|
|
df45aece8f | ||
|
|
32443ae91b | ||
|
|
4ae8c8a3fa | ||
|
|
5640a00b03 | ||
|
|
9bbe82e7f4 | ||
|
|
cbe2a30ea4 | ||
|
|
a5c02c0eb4 | ||
|
|
3204f556af | ||
|
|
12aeca7171 | ||
|
|
5d3408f632 | ||
|
|
5092f67557 | ||
|
|
5a9d20a5c4 | ||
|
|
19d168b577 |
No files matched your search
@@ -0,0 +1,111 @@
|
||||
You are working in /Volumes/VIXinSSD/deepseek-tui.
|
||||
|
||||
Goal:
|
||||
Continue improving deepseek-tui as a first-class DeepSeek V4 coding harness, building directly on the previous overnight slice (commit f190ab74 + merge aa356e8b + import fix f9ede57c, plus release commit d7944421 / tag v0.4.6). This time, ACTUALLY read /Volumes/VIXinSSD/mathcode — the previous run claimed it wasn't mounted and adapted patterns from the work-order description instead. mathcode IS mounted; the directory exists and is readable.
|
||||
|
||||
What already shipped (do not redo):
|
||||
- Slice A: setup --status, setup --clean (with --force gate), setup --tools, setup --plugins, doctor --json, init_tools_dir / init_plugins_dir scaffolding with frontmatter examples (# name: / # description: / # usage: in tools/example.sh; ----delimited YAML in plugins/example/PLUGIN.md). Code in crates/tui/src/main.rs.
|
||||
- Slice B: fake-wrapper hostility — TOOL_CALL_START_MARKERS/END_MARKERS + filter_tool_call_delta promoted to pub(crate); FAKE_WRAPPER_NOTICE + contains_fake_tool_wrapper(); per-turn fake_wrapper_notice_emitted flag in handle_deepseek_turn. Code in crates/tui/src/core/engine.rs. Locked by crates/tui/tests/protocol_recovery.rs.
|
||||
- Slice C: docs in docs/CONFIGURATION.md (Setup status, clean, and extension dirs section + Why the engine strips XML/[TOOL_CALL] text section) and README.md quick-checks block.
|
||||
- 37 new tests landed (23 unit + 14 integration). All green.
|
||||
|
||||
Read first (in this order):
|
||||
1. AGENTS.md
|
||||
2. git status --short --branch (expect clean tree on main, 0 ahead of origin/main; a stash from a prior session may exist — leave it alone)
|
||||
3. README.md
|
||||
4. docs/ARCHITECTURE.md
|
||||
5. docs/CONFIGURATION.md (note the new Setup status / extension dirs / Why the engine strips ... sections)
|
||||
6. docs/RUNTIME_API.md
|
||||
7. crates/tui/src/main.rs (large file; pay attention to the new run_setup_status, run_setup_clean, init_tools_dir, init_plugins_dir, run_doctor, run_doctor_json helpers — do not duplicate them)
|
||||
8. crates/tui/src/core/engine.rs (new: TOOL_CALL_START_MARKERS, TOOL_CALL_END_MARKERS, filter_tool_call_delta, FAKE_WRAPPER_NOTICE, contains_fake_tool_wrapper)
|
||||
9. crates/tui/src/core/engine/tests.rs
|
||||
10. crates/tui/tests/protocol_recovery.rs
|
||||
11. crates/tui/src/client.rs
|
||||
12. crates/tui/src/tui/widgets/mod.rs
|
||||
13. crates/tui/src/core/capacity.rs (existing compaction decisions — relevant for issue #6)
|
||||
|
||||
Then ACTUALLY read mathcode (it IS mounted):
|
||||
14. /Volumes/VIXinSSD/mathcode/README.md
|
||||
15. /Volumes/VIXinSSD/mathcode/setup.sh (~10KB; full status/clean/help flag matrix and error messages worth borrowing)
|
||||
16. /Volumes/VIXinSSD/mathcode/run (763B; thin wrapper that loads .env and dispatches)
|
||||
17. /Volumes/VIXinSSD/mathcode/.env.example (~6KB; clear provider/backend comments — note our codebase only loads .env via dotenvy::dotenv() in crates/tui/src/main.rs:475 and has no .env.example)
|
||||
18. /Volumes/VIXinSSD/mathcode/skills/README.md
|
||||
19. /Volumes/VIXinSSD/mathcode/plugins/README.md
|
||||
20. /Volumes/VIXinSSD/mathcode/tools/ (list contents; look for self-describing tool scripts)
|
||||
21. /Volumes/VIXinSSD/mathcode/bin/ (list contents)
|
||||
|
||||
Open GitHub issue spine (use as roadmap):
|
||||
- #6 Coherence as plain-language session health ← previous slice's recommended next step
|
||||
- #7 Long-session evals for coherence/context handling
|
||||
- #8 Workspace extraction/runtime seams
|
||||
- #9 Thread/turn/item protocol stability
|
||||
- #10 Sandboxing, approvals, server safety
|
||||
- #11 Skills/plugins/MCP installability and management (partly done; doctor + status now report tools/plugins; install/manage UX still missing)
|
||||
- #12 DeepSeek API key setup/provider drift (partly done; setup --status surfaces source — but no .env.example, no provider-aware setup wizard)
|
||||
- #13 Observability for agent quality/coherence events (partly done; fake-wrapper strip emits a status event — coherence ladder still missing)
|
||||
- #14 Whale/DeepSeek TUI design system
|
||||
- #15 Thin IDE companions over runtime API
|
||||
- #16 Public roadmap/docs rewrite
|
||||
|
||||
Pick 1-3 high-leverage slices that can be finished and tested tonight. Strongly preferred (in priority order):
|
||||
|
||||
Slice 1 (recommended — issue #6 + #13): Plain-language coherence ladder.
|
||||
- The runtime already emits compaction events (CompactionStarted/Completed/Failed) and capacity decisions live in crates/tui/src/core/capacity.rs.
|
||||
- Add an Event::CoherenceState variant with a small fixed ladder: Healthy / GettingCrowded / RefreshingContext / VerifyingRecentWork / ResettingPlan.
|
||||
- Emit it from one place (engine), driven by existing capacity decisions and compaction events. Do NOT add a new background task.
|
||||
- Surface it in the TUI footer as a single chip (right-aligned, terminal-native, no emoji) and on the runtime API thread shape.
|
||||
- Snapshot-test the footer chip for each state and unit-test the state transitions from a synthetic capacity event log.
|
||||
- Land docs in docs/RUNTIME_API.md (CoherenceState shape) and docs/CONFIGURATION.md (footer chip).
|
||||
|
||||
Slice 2 (issue #12 + mathcode reference): .env.example + setup wizard messages.
|
||||
- Adapt /Volumes/VIXinSSD/mathcode/.env.example structure (provider/backend grouping, comment style) to a deepseek-tui .env.example at repo root.
|
||||
- Cover: DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL (global vs china), DEEPSEEK_MODEL, NVIDIA_API_KEY (NIM), NIM_BASE_URL, RUST_LOG, sandbox toggles.
|
||||
- Update setup --status to point at .env.example when no .env is found ("Run `cp .env.example .env` and edit").
|
||||
- Do NOT add a `run` wrapper — dotenvy handles .env loading inside the binary, the wrapper is redundant and introduces drift.
|
||||
- Lock the .env.example shape with a test that asserts every documented variable is referenced from at least one source file.
|
||||
|
||||
Slice 3 (issue #14 / TUI render polish): Whale/DeepSeek design tokens.
|
||||
- Extract the current TUI color/border/padding choices into a single deepseek_theme module (light + dark variants), without changing any visible output yet.
|
||||
- Add a snapshot test or two that lock the existing rendering of one plan cell and one tool cell.
|
||||
- Out of scope for tonight: actually changing colors. Goal is to make a future skin-swap a 5-line change.
|
||||
|
||||
DO NOT pick:
|
||||
- Anything that re-touches NIM provider, V4 reasoning context accounting, or tool-call routing — that work is finished and protected by tests.
|
||||
- Anything that re-introduces multi_tool_use.parallel.
|
||||
- Anything that introduces an alternate fake-wrapper code path (XML, [TOOL_CALL], <function_calls>, ```tool_code, ```python, etc.). The engine's contract is: API tool channel only, all five wrapper shapes get stripped + emit a notice.
|
||||
- Broad rewrites of main.rs, the engine, or the TUI app loop.
|
||||
- A `run` shell wrapper. (.env loading is already in-binary via dotenvy.)
|
||||
|
||||
Hard rules / hostility budget:
|
||||
- Prompts must never instruct or imply that the assistant should emit XML/markdown/Replit-style fake tool calls. If you touch crates/tui/src/prompts/*.txt, the rule is: API tool channel only, never wrapped tool calls in assistant text.
|
||||
- Protocol recovery must remain visible. If you change how filter_tool_call_delta or FAKE_WRAPPER_NOTICE works, update crates/tui/tests/protocol_recovery.rs in the same commit and keep at least one assertion that all 5 marker pairs are present.
|
||||
- UI must stay dense and terminal-native. No emoji, no decorative box drawing for data, no gratuitous color. DeepSeek-specific copy (Whale, V4) is welcome.
|
||||
- Show, don't hide, runtime problems. New events should be compact (single-line where possible) and machine-readable as structured fields.
|
||||
|
||||
Stop rules:
|
||||
- Do not revert any user/unrelated work; the previous run already merged and pushed (current main HEAD: d7944421, tag v0.4.6).
|
||||
- Do not push tags or trigger releases. Local commits + push to a feature branch is fine; the maintainer handles release decisions.
|
||||
- Do not delete or modify the existing stash (if any).
|
||||
- If a live provider is down/unpaid, use fixtures/mocks and say so explicitly in the report.
|
||||
- If a gate fails from pre-existing unrelated work, isolate it with evidence — don't paper over.
|
||||
- Pre-existing test cluster (8 tests in tools::git*, tools::diagnostics, tui::ui::tests::workspace_context_refresh*) fails because of a sandbox commit-signing issue with `git commit ... -S`. Document but do not "fix" by globally setting commit.gpgsign=false — fix narrowly inside init_git_repo at crates/tui/src/tui/ui/tests.rs:158 if you touch it.
|
||||
|
||||
Required verification before final report:
|
||||
- cargo fmt --all -- --check
|
||||
- cargo check --workspace --all-targets --locked
|
||||
- cargo test --workspace --all-features --locked
|
||||
- cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
- cargo build --release --locked -p deepseek-tui-cli -p deepseek-tui
|
||||
|
||||
Live smoke (optional, time-boxed at 60s):
|
||||
- If DEEPSEEK_API_KEY or NVIDIA_API_KEY is set in the env, run a single bounded turn against the relevant provider with a hard token cap and report the first 30 lines of output. Otherwise skip and say so.
|
||||
|
||||
Final report headings (use exactly these):
|
||||
1. Summary
|
||||
2. Implemented Changes
|
||||
3. GitHub Issues Advanced
|
||||
4. MathCode Reference Points Used (be honest: list which mathcode files you actually read and which patterns you adapted vs deliberately skipped, with one-sentence rationale per skip)
|
||||
5. Tests/Gates
|
||||
6. Live Smoke Result
|
||||
7. Remaining Risks
|
||||
8. Next Best Slice
|
||||
@@ -0,0 +1,44 @@
|
||||
# DeepSeek TUI environment
|
||||
# Shell-exported variables override values in this file.
|
||||
# Copy this file to `.env`, then uncomment only the values you want to use.
|
||||
|
||||
# DeepSeek API (default provider)
|
||||
# Get an API key from DeepSeek, then keep it local in `.env`.
|
||||
# DEEPSEEK_API_KEY=
|
||||
|
||||
# Global endpoint:
|
||||
# DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
# China endpoint:
|
||||
# DEEPSEEK_BASE_URL=https://api.deepseeki.com
|
||||
|
||||
# V4 model selection. Compatibility aliases such as `deepseek-chat` normalize
|
||||
# to the current V4 flash model in the TUI.
|
||||
# DEEPSEEK_MODEL=deepseek-v4-pro
|
||||
# DEEPSEEK_MODEL=deepseek-v4-flash
|
||||
|
||||
# NVIDIA NIM-hosted DeepSeek V4
|
||||
# Use this provider when routing through NVIDIA's OpenAI-compatible endpoint.
|
||||
# DEEPSEEK_PROVIDER=nvidia-nim
|
||||
# NVIDIA_API_KEY=
|
||||
# NVIDIA_NIM_API_KEY=
|
||||
# NVIDIA_NIM_BASE_URL=https://integrate.api.nvidia.com/v1
|
||||
# NIM_BASE_URL=https://integrate.api.nvidia.com/v1
|
||||
# NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
|
||||
# NVIDIA_NIM_MODEL=deepseek-ai/deepseek-v4-pro
|
||||
|
||||
# Logging
|
||||
# `DEEPSEEK_LOG_LEVEL` is forwarded by the facade; `RUST_LOG` enables the
|
||||
# TUI's lightweight verbose logs for info/debug/trace directives.
|
||||
# DEEPSEEK_LOG_LEVEL=debug
|
||||
# RUST_LOG=deepseek_tui=debug
|
||||
|
||||
# Agent safety defaults
|
||||
# `on-request` asks before higher-risk work; `workspace-write` keeps writes
|
||||
# inside the workspace unless a sandbox elevation path is explicitly used.
|
||||
# DEEPSEEK_APPROVAL_POLICY=on-request
|
||||
# DEEPSEEK_SANDBOX_MODE=workspace-write
|
||||
# DEEPSEEK_ALLOW_SHELL=true
|
||||
|
||||
# Optional extension paths
|
||||
# DEEPSEEK_SKILLS_DIR=~/.deepseek/skills
|
||||
# DEEPSEEK_MCP_CONFIG=~/.deepseek/mcp.json
|
||||
@@ -11,6 +11,18 @@ env:
|
||||
RUSTFLAGS: -Dwarnings
|
||||
|
||||
jobs:
|
||||
versions:
|
||||
name: Version drift
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Check version drift
|
||||
run: ./scripts/release/check-versions.sh
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
@@ -23,7 +35,7 @@ jobs:
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets --all-features
|
||||
run: cargo clippy --workspace --all-targets --all-features
|
||||
|
||||
test:
|
||||
name: Test
|
||||
@@ -36,7 +48,9 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Run tests
|
||||
run: cargo test --all-features
|
||||
run: cargo test --workspace --all-features
|
||||
- name: Run Offline Eval Harness
|
||||
run: cargo run -p deepseek-tui --all-features -- eval
|
||||
|
||||
build:
|
||||
name: Build
|
||||
@@ -51,6 +65,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-latest]
|
||||
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
|
||||
@@ -60,6 +119,6 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build docs
|
||||
run: cargo doc --no-deps
|
||||
run: cargo doc --workspace --no-deps
|
||||
env:
|
||||
RUSTDOCFLAGS: -Dwarnings
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,58 @@
|
||||
name: parity
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Dwarnings
|
||||
|
||||
jobs:
|
||||
parity:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Cache Cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Compile check
|
||||
run: cargo check --workspace --all-targets --locked
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
|
||||
- name: Unit and parity tests
|
||||
run: cargo test --workspace --all-features --locked
|
||||
|
||||
- name: TUI snapshot parity
|
||||
run: cargo test -p deepseek-tui-core --test snapshot --locked
|
||||
|
||||
- name: Protocol schema sanity
|
||||
run: cargo test -p deepseek-protocol --test parity_protocol --locked
|
||||
|
||||
- name: State persistence sanity
|
||||
run: cargo test -p deepseek-state --test parity_state --locked
|
||||
|
||||
- name: Lockfile drift guard
|
||||
run: git diff --exit-code -- Cargo.lock
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Publish npm
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Package/release version to publish, without the leading v'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
# Prefer the npm-only workflow_dispatch path in release.yml. This workflow
|
||||
# works only if npm Trusted Publishing is explicitly configured for
|
||||
# `publish-npm.yml` instead of `release.yml`.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Verify package version
|
||||
working-directory: npm/deepseek-tui
|
||||
run: |
|
||||
actual="$(node -p "require('./package.json').version")"
|
||||
expected="${{ inputs.version }}"
|
||||
if [ "${actual}" != "${expected}" ]; then
|
||||
echo "package.json version ${actual} does not match requested ${expected}" >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Publish wrapper to npm
|
||||
working-directory: npm/deepseek-tui
|
||||
run: npm publish --access public
|
||||
@@ -3,12 +3,50 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Package/release version to publish to npm, without the leading v'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Dwarnings
|
||||
|
||||
jobs:
|
||||
parity:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Format check
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Compile check
|
||||
run: cargo check --workspace --all-targets --locked
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
- name: Workspace tests
|
||||
run: cargo test --workspace --all-features --locked
|
||||
- name: TUI snapshot parity
|
||||
run: cargo test -p deepseek-tui-core --test snapshot --locked
|
||||
- name: Protocol schema parity
|
||||
run: cargo test -p deepseek-protocol --test parity_protocol --locked
|
||||
- name: State persistence parity
|
||||
run: cargo test -p deepseek-state --test parity_state --locked
|
||||
- name: Lockfile drift guard
|
||||
run: git diff --exit-code -- Cargo.lock
|
||||
|
||||
build:
|
||||
needs: parity
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
# --- deepseek (cli) ---
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: deepseek
|
||||
@@ -25,13 +63,30 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: deepseek.exe
|
||||
artifact_name: deepseek-windows-x64.exe
|
||||
# --- deepseek-tui (TUI) ---
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: deepseek-tui
|
||||
artifact_name: deepseek-tui-linux-x64
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
binary: deepseek-tui
|
||||
artifact_name: deepseek-tui-macos-x64
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
binary: deepseek-tui
|
||||
artifact_name: deepseek-tui-macos-arm64
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: deepseek-tui.exe
|
||||
artifact_name: deepseek-tui-windows-x64.exe
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- run: cargo build --release --target ${{ matrix.target }}
|
||||
- run: cargo build --release --locked --target ${{ matrix.target }}
|
||||
- name: Rename binary
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -51,7 +106,73 @@ 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/*/*
|
||||
prerelease: false
|
||||
|
||||
publish-npm:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
# Trusted Publishing via OIDC. Configure npm to trust this workflow
|
||||
# filename (`release.yml`) for Hmbown/DeepSeek-TUI.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Verify package version
|
||||
working-directory: npm/deepseek-tui
|
||||
run: |
|
||||
actual="$(node -p "require('./package.json').version")"
|
||||
expected="${GITHUB_REF_NAME#v}"
|
||||
if [ "${actual}" != "${expected}" ]; then
|
||||
echo "package.json version ${actual} does not match tag ${expected}" >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Publish wrapper to npm
|
||||
working-directory: npm/deepseek-tui
|
||||
run: npm publish --access public
|
||||
|
||||
publish-npm-manual:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
# npm can trust only one workflow filename; keep npm-only retries here so
|
||||
# the trusted publisher can remain `release.yml`.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Verify package version
|
||||
working-directory: npm/deepseek-tui
|
||||
run: |
|
||||
actual="$(node -p "require('./package.json').version")"
|
||||
expected="${{ inputs.version }}"
|
||||
if [ "${actual}" != "${expected}" ]; then
|
||||
echo "package.json version ${actual} does not match requested ${expected}" >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Publish wrapper to npm
|
||||
working-directory: npm/deepseek-tui
|
||||
run: npm publish --access public
|
||||
+17
-7
@@ -11,6 +11,8 @@
|
||||
# Development
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
@@ -33,24 +35,32 @@ dist/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
firebase-debug.log
|
||||
|
||||
# Generated
|
||||
outputs/
|
||||
|
||||
# Rust
|
||||
# Note: Cargo.lock is intentionally NOT ignored for reproducible builds
|
||||
firebase-debug.log
|
||||
tmp/
|
||||
|
||||
# Note: Cargo.lock is intentionally NOT ignored for reproducible builds
|
||||
|
||||
# Local dev scripts and temp files
|
||||
*.sh
|
||||
!scripts/**
|
||||
test.txt
|
||||
TODO*.md
|
||||
todo*.md
|
||||
CLAUDE.md
|
||||
NEXT_SESSION.md
|
||||
|
||||
AI_HANDOFF.md
|
||||
result.json
|
||||
count_deps.py
|
||||
project_overhaul_prompt.md
|
||||
.codex/
|
||||
docs/rlm-paper.txt
|
||||
.context/
|
||||
|
||||
# Local runtime state
|
||||
.deepseek/
|
||||
**/session_*.json
|
||||
*.db
|
||||
|
||||
# Companion app (tracked separately)
|
||||
apps/
|
||||
@@ -0,0 +1,16 @@
|
||||
Hunter Bown <[email protected]> devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> GitHub <[email protected]>
|
||||
Hunter Bown <[email protected]> Claude <[email protected]>
|
||||
Hunter Bown <[email protected]> Claude Opus 4.6 <[email protected]>
|
||||
Hunter Bown <[email protected]> Claude Opus 4.6 (1M context) <[email protected]>
|
||||
Hunter Bown <[email protected]> Claude Opus 4.7 (1M context) <[email protected]>
|
||||
Hunter Bown <[email protected]> Copilot <[email protected]>
|
||||
Hunter Bown <[email protected]> Copilot <1693627+github-copilot-cli[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> Copilot <946600+copilot-pull-request-reviewer[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> Qwen-Coder <[email protected]>
|
||||
Hunter Bown <[email protected]> qwencoder <[email protected]>
|
||||
Hunter Bown <[email protected]> Cursor Agent <[email protected]>
|
||||
Hunter Bown <[email protected]> cursoragent <[email protected]>
|
||||
Hunter Bown <[email protected]> gemini-code-assist[bot] <gemini-code-assist[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> gemini-code-assist[bot] <956858+gemini-code-assist[bot]@users.noreply.github.com>
|
||||
@@ -6,28 +6,23 @@ This file provides context for AI assistants working on this project.
|
||||
|
||||
### Commands
|
||||
- Build: `cargo build`
|
||||
- Test: `cargo test`
|
||||
- Run: `cargo run`
|
||||
- Check: `cargo check`
|
||||
- Format: `cargo fmt`
|
||||
- Lint: `cargo clippy`
|
||||
|
||||
### Project: deepseek-cli
|
||||
- Test: `cargo test --workspace --all-features`
|
||||
- Lint: `cargo clippy --workspace --all-targets --all-features`
|
||||
- Format: `cargo fmt --all`
|
||||
- Run: `cargo run -p deepseek-tui`
|
||||
|
||||
### Documentation
|
||||
See README.md for project overview.
|
||||
See README.md for project overview, docs/ARCHITECTURE.md for internals.
|
||||
|
||||
### Version Control
|
||||
This project uses Git. See .gitignore for excluded files.
|
||||
## DeepSeek-Specific Notes
|
||||
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Follow existing code style and patterns
|
||||
- Write tests for new functionality
|
||||
- Keep changes focused and atomic
|
||||
- Document public APIs
|
||||
- **Thinking Tokens**: DeepSeek models output thinking blocks (`ContentBlock::Thinking`) before final answers. The TUI streams and displays these with visual distinction.
|
||||
- **Reasoning Models**: `deepseek-v4-pro` and `deepseek-v4-flash` are the documented V4 model IDs. Legacy `deepseek-chat` and `deepseek-reasoner` are compatibility aliases for `deepseek-v4-flash`.
|
||||
- **Large Context Window**: DeepSeek V4 models have 1M-token context windows. Use search tools to navigate efficiently.
|
||||
- **API**: OpenAI-compatible Chat Completions (`/chat/completions`) is the documented DeepSeek API path. Base URL configurable for global (`api.deepseek.com`) or China (`api.deepseeki.com`); `/v1` is accepted for OpenAI SDK compatibility, and `/beta` is only needed for beta features such as strict tool mode, chat prefix completion, and FIM completion.
|
||||
- **Thinking + Tool Calls**: In V4 thinking mode, assistant messages that contain tool calls must replay their `reasoning_content` in all subsequent requests or the API returns HTTP 400.
|
||||
|
||||
## Important Notes
|
||||
|
||||
<!-- Add project-specific notes here -->
|
||||
- **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.
|
||||
- **Modes**: Three modes — Plan (read-only investigation), Agent (tool use with approval), YOLO (auto-approved). See `docs/MODES.md` for details.
|
||||
+464
-9
@@ -7,6 +7,437 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.2] - 2026-04-25
|
||||
|
||||
### Added
|
||||
- **`/model` opens a Pro/Flash + thinking-effort picker (#39).** Typing `/model` with no argument now pops a two-pane modal: model on the left (`deepseek-v4-pro` flagship, `deepseek-v4-flash` fast/cheap, plus a "current (custom)" row when the active id isn't one of the listed defaults), and thinking effort on the right. Tab/←/→ swaps panes, ↑/↓ moves within the focused pane, Enter applies both selections, Esc cancels. The effort pane intentionally exposes only **Off / High / Max** because [DeepSeek's Thinking Mode docs](https://api-docs.deepseek.com/guides/reasoning_model) state `low`/`medium` are mapped to `high` server-side and `xhigh` is mapped to `max` — the legacy variants stay valid in `~/.deepseek/settings.toml` for back-compat, the picker just doesn't surface them. Apply path persists `default_model` and `reasoning_effort` to settings, forwards `Op::SetModel` + `Op::SetCompaction` to the running engine so the next turn picks up the change without a restart, and resets the per-turn token gauges (cache, replay) so the footer numbers reflect the new model. `/model <id>` keeps working unchanged for power users.
|
||||
|
||||
## [0.5.1] - 2026-04-25
|
||||
|
||||
### Added
|
||||
- **`fetch_url` tool** for direct HTTP GET on a known URL — complements `web_search` for cases where the link is already known. Supports `format` (`markdown` / `text` / `raw`), `max_bytes` (default 1 MB, hard cap 10 MB), `timeout_ms` (default 15 s, max 60 s), redirect following, and structured `{url, status, content_type, content, truncated}` responses. 4xx/5xx bodies are returned (with `success: false`) so the caller can read JSON error envelopes. (#33)
|
||||
- **PDF support in `read_file`.** PDFs are auto-detected by extension or `%PDF-` magic bytes and extracted via `pdftotext -layout` (poppler) when available. New optional `pages` arg (`"5"` or `"1-10"`) reads page slices. Without `pdftotext`, returns a structured `{type: "binary_unavailable", kind: "pdf", reason, hint}` with install commands for macOS/Debian. (#34)
|
||||
- **Reasoning-content replay telemetry, end-to-end (#30).** The chat-completions sanitizer now estimates replayed `reasoning_content` tokens (~4 chars/token), threads the value through the streaming `Usage` payload, stores it on the App, and renders an `rsn N.Nk` chip in the footer next to the cache hit-rate. The chip turns warning-coloured when replay tokens exceed 50% of the input budget, so users on long thinking-mode loops can see at a glance how much of their context window is going to V4's "Interleaved Thinking" replay (paper §5.1.1). Logged at `RUST_LOG=deepseek_tui=info` for tail-friendly diagnosis.
|
||||
- **`@file` Tab-completion (#28).** Typing `@<partial>` and pressing Tab now resolves the mention against the workspace using the existing `ignore::WalkBuilder`. A unique match is spliced into the input; multiple matches with a longer common prefix extend the partial; remaining ambiguity is surfaced via the status line. The mention-expansion path that ships file contents to the model is unchanged — this is purely a discovery aid for typing the path. Inline-contents and a fuzzy popup picker are queued for v0.5.2.
|
||||
- **Per-workspace external trust list (#29).** `~/.deepseek/workspace-trust.json` now records, for each workspace, the absolute paths the user has opted into reading/writing from outside that workspace. The new `/trust` slash command supports `add <path>`, `remove <path>`, `list`, `on`, `off`, and a status read with no args; the engine consults the list when constructing every `ToolContext` so changes apply on the next tool call without restart. `/diagnostics` surfaces the list. The interactive "Allow once / Always allow / Deny" approval prompt is deferred — for now grant access ahead of the turn with `/trust add <path>`.
|
||||
|
||||
### Fixed
|
||||
- **TUI sidebar gutter bleed regression test (#36).** Snapshot tests now lock in that long single-line tool results — including a `todo_write` echo of a multi-kilobyte JSON payload — never write any cells outside `chat_area` at the widths reported in the bug (80, 120, 165, 200 cols). A second test verifies the scrollbar coexists with content along the right edge instead of overdrawing the penultimate column.
|
||||
- **Version drift caught in CI.** New `versions` job in `.github/workflows/ci.yml` runs `scripts/release/check-versions.sh` on every push/PR, verifying every per-crate `Cargo.toml` inherits the workspace version, the npm wrapper matches the workspace version, and `Cargo.lock` is in sync. The release runbook now lists `check-versions.sh` as the first preflight step. (#31)
|
||||
- **Per-mode soft context budget for V4 compaction trigger** (#27).
|
||||
- **Phantom `web.run` references stripped** from prompts and the `web_search` tool surface (#25).
|
||||
- **Unused import + `cargo fmt` drift** that landed with `feat(#27)` and broke Build / Test / npm wrapper smoke under `-Dwarnings`.
|
||||
|
||||
## [0.5.0] - 2026-04-25
|
||||
|
||||
### Fixed
|
||||
- Multi-turn tool calls on thinking-mode models no longer return HTTP 400. Every assistant message in the conversation now carries `reasoning_content` when thinking is enabled — not just tool-call rounds — matching DeepSeek's actual API validation, which rejects any assistant message missing the field even though the docs describe non-tool-call reasoning as "ignored".
|
||||
- Added a final-pass wire-payload sanitizer in the chat-completions client that forces a non-empty `reasoning_content` placeholder onto any assistant message still missing one at request time. This is the last line of defense after engine-side and build-side substitution, so sessions restored from older checkpoints, sub-agents that append messages directly, and cached prefix mismatches all produce a valid request.
|
||||
- On a `reasoning_content`-related 400, the client now logs the offending message indices to make future regressions diagnosable.
|
||||
- Stripped phantom `web.run` references from prompts and the `web_search` tool surface ([#25](https://github.com/Hmbown/DeepSeek-TUI/issues/25)).
|
||||
|
||||
### Changed
|
||||
- Header/UI widget refactor in the TUI (`crates/tui/src/tui/ui.rs`, `widgets/header.rs`) — internal cleanup, no user-visible behavior change.
|
||||
|
||||
## [0.4.9] - 2026-04-27
|
||||
|
||||
### Fixed
|
||||
- DeepSeek thinking-mode tool-call rounds now always replay `reasoning_content` in all subsequent requests (including across new user turns), matching DeepSeek's documented API contract that assistant messages with tool calls must retain their reasoning content forever.
|
||||
- Missing `reasoning_content` on a tool-call assistant message now substitutes a safe placeholder (`"(reasoning omitted)"`) instead of dropping the tool calls and their matching tool results, preventing orphaned conversation chains and API 400 errors.
|
||||
- Session checkpoint now persists a Thinking-block placeholder for tool-call turns that produced no streamed reasoning text, keeping on-disk sessions structurally correct so subsequent requests avoid HTTP 400 rejections.
|
||||
- Token estimation for compaction now counts thinking tokens across all tool-call rounds (not just the current user turn), aligning with the updated reasoning_content replay rule.
|
||||
|
||||
## [0.4.8] - 2026-04-25
|
||||
|
||||
### Fixed
|
||||
- DeepSeek V4 Pro cost estimates now use DeepSeek's current limited-time 75% discount until 2026-05-05 15:59 UTC, then automatically fall back to the base Pro rates.
|
||||
|
||||
## [0.4.5] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- Alternate-screen TUI sessions now capture mouse input by default so wheel scrolling moves the transcript instead of exposing terminal scrollback from before the TUI started. Use `--no-mouse-capture` or `tui.mouse_capture = false` when terminal-native drag selection is preferred.
|
||||
|
||||
## [0.4.2] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- DeepSeek V4 thinking-mode tool turns now checkpoint the engine's authoritative API transcript, including assistant `reasoning_content` on reasoning-to-tool-call turns with no visible assistant text.
|
||||
- Chat Completions request building now drops stale V4 tool-call rounds that are missing required `reasoning_content`, preventing old corrupted checkpoints from triggering DeepSeek HTTP 400 replay errors.
|
||||
- Web search now falls back to Bing HTML results when DuckDuckGo returns a bot challenge or otherwise yields no parseable results.
|
||||
|
||||
## [0.4.1] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- DeepSeek V4 tool-result context now preserves large file reads and command outputs instead of compacting noisy tools to a 900-character snippet after 2k characters.
|
||||
- Capacity guardrail refresh no longer performs destructive summary compaction unless the normal model-aware compaction thresholds are actually crossed.
|
||||
- V4 compaction summaries retain larger tool-result excerpts and summary input when compaction is genuinely needed.
|
||||
- The transcript now follows the bottom again when sending a new message, shows an in-app scrollbar when internally scrolled, and leaves mouse capture off in `--no-alt-screen` mode so terminal-native scrolling can work.
|
||||
|
||||
## [0.4.0] - 2026-04-23
|
||||
|
||||
### Added
|
||||
- **DeepSeek V4 support**: `deepseek-v4-pro` (flagship) and `deepseek-v4-flash` (fast/cheap) are now first-class model IDs with 1M context windows.
|
||||
- **Reasoning-effort tier**: new `reasoning_effort` config field (`off | low | medium | high | max`) mapped to DeepSeek's `reasoning_effort` + `thinking` request fields. Defaults to `max`.
|
||||
- **Shift+Tab cycles reasoning-effort** through the three behaviorally distinct tiers (`off → high → max`). The current tier is shown as a ⚡ chip in the header.
|
||||
- Per-model pricing table: `deepseek-v4-pro` priced at $0.145/$1.74/$3.48 per 1M tokens (cache-hit/miss/output); `deepseek-v4-flash` and legacy aliases at $0.028/$0.14/$0.28.
|
||||
|
||||
### Changed
|
||||
- **Default model flipped to `deepseek-v4-pro`** (from `deepseek-reasoner`).
|
||||
- `deepseek-chat` / `deepseek-reasoner` remain as silent aliases of `deepseek-v4-flash` for API compatibility; priced identically.
|
||||
- **Context compaction**: 1M-context V4 models now compact at 800k input tokens or 2,000 messages, so short/tool-heavy sessions do not compact as if they were 128k-context runs.
|
||||
- Cycling modes is now Tab-only; Shift+Tab is repurposed for reasoning-effort (reverse-mode cycle was low-value with only three modes).
|
||||
- Updated help/hint strings, validator error messages, and the model picker to reference V4 IDs.
|
||||
|
||||
### Fixed
|
||||
- `requires_reasoning_content` now recognizes `deepseek-v4*` so thinking streams render correctly on V4 models.
|
||||
- DeepSeek V4 thinking-mode tool calls now preserve prior assistant `reasoning_content` whenever a tool call is replayed, matching DeepSeek's multi-turn contract and avoiding HTTP 400 rejections on later turns.
|
||||
- Raw Chat Completions requests now send DeepSeek's top-level `thinking` parameter instead of the OpenAI SDK-only `extra_body` wrapper.
|
||||
- Config, env, and UI model selection now normalize legacy DeepSeek aliases to `deepseek-v4-flash` instead of preserving old model labels.
|
||||
- npm wrapper first-run downloads now use process-unique temp files so concurrent `deepseek` / `deepseek-tui` invocations do not race on `*.download` files.
|
||||
|
||||
## [0.3.33] - 2026-04-11
|
||||
|
||||
### Changed
|
||||
- Footer polish: simplified footer rendering, removed footer clock label, updated status line layout
|
||||
- Palette cleanup: removed `FOOTER_HINT` color constant
|
||||
|
||||
### Removed
|
||||
- `FOOTER_HINT` color constant from palette (use `TEXT_MUTED` or `TEXT_HINT` instead)
|
||||
|
||||
### Fixed
|
||||
- Test updates to align with simplified footer logic
|
||||
- Empty state placeholder text removed for cleaner UI
|
||||
|
||||
## [0.3.32] - 2026-04-11
|
||||
|
||||
### Added
|
||||
- Finance tool: Yahoo Finance v8 quote endpoint with chart fallback, supporting stocks, ETFs, indices, forex, and crypto lookups.
|
||||
- Header widget redesign: proportional truncation, context-usage bar with gradient fill, streaming indicator, and graceful narrow-terminal degradation.
|
||||
- Expanded test coverage: 680+ tests including footer state, context spans, plan prompt lifecycle, workspace context refresh, header rendering, and finance tool integration tests with wiremock.
|
||||
- Workspace context refresh with configurable TTL and deferred initial fetch.
|
||||
- Config command additions for runtime settings management.
|
||||
|
||||
### Changed
|
||||
- Redesigned footer status strip with mode/model/status layout, context bar, and narrow-terminal fallback.
|
||||
- Plan prompt now uses numeric selection (1-4) instead of keyword input; old aliases are sent as regular messages.
|
||||
- Archived outdated docs (`workspace_migration_status.md` -> `docs/archive/`).
|
||||
- Trimmed AGENTS.md boilerplate and updated task counts.
|
||||
- Clarified release-surface documentation: crates.io publication may lag the workspace/npm wrapper.
|
||||
|
||||
### Fixed
|
||||
- Header `metadata_spans` now uses `saturating_sub` to prevent underflow on narrow terminals.
|
||||
- Finance tool reuses a single HTTP client instead of rebuilding per request.
|
||||
- Finance tool tests no longer leak temp directories.
|
||||
|
||||
## [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.
|
||||
- Refactored source code into workspace crates for better modularity and dependency management.
|
||||
|
||||
### Fixed
|
||||
- Fixed Plan mode ESC key dismissing the prompt without clearing `plan_prompt_pending`, which prevented the prompt from reappearing on subsequent plan completions.
|
||||
- Fixed clippy lint (collapsible_if) in web browsing session management.
|
||||
|
||||
## [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
|
||||
- Converted the project to a modular Cargo workspace using a `crates/` layout.
|
||||
- Added new crate boundaries mirroring a deepseek architecture (`agent`, `config`, `core`, `execpolicy`, `hooks`, `mcp`, `protocol`, `state`, `tools`, `tui-core`, `tui`, and `app-server`).
|
||||
|
||||
### Changed
|
||||
- Added parity CI coverage with protocol/state/snapshot checks.
|
||||
- Updated release workflow to build both `deepseek` and `deepseek-tui` binaries.
|
||||
|
||||
## [0.3.26] - 2026-03-02
|
||||
|
||||
### Fixed
|
||||
- Resolved SSE stream corruption caused by byte/string position mismatch in streaming parse flow.
|
||||
- Hardened base URL validation to reject non-HTTP/HTTPS schemes.
|
||||
- Prevented multi-byte UTF-8 truncation panics in common-prefix and runtime thread summary paths.
|
||||
- Corrected context usage alert thresholds by separating warning and critical trigger levels.
|
||||
|
||||
### Changed
|
||||
- Removed non-code utility tools from the runtime tool registry (`calculator`, `weather`, `sports`, `finance`, `time`) and related wiring.
|
||||
- Consolidated duplicate URL encoding helpers by delegating to shared `crate::utils::url_encode`.
|
||||
- Replaced broad crate-level lint suppressions with targeted `#[allow(...)]` annotations where justified.
|
||||
- Cleaned up dead APIs, unused struct fields, unused builder helpers, and non-integrated modules.
|
||||
- Addressed clippy findings across the codebase (collapsible conditionals, defaults, indexing helpers, and API signature cleanup).
|
||||
|
||||
## [0.3.24] - 2026-02-25
|
||||
|
||||
### Fixed
|
||||
- Preserve reasoning-only assistant turns for DeepSeek reasoning models (`deepseek-reasoner`, R-series markers) when rebuilding chat history.
|
||||
- Align SSE tool streaming indices so each tool block start/delta/stop uses the same block index.
|
||||
- Prevent transcript auto-scroll-to-bottom when a non-empty transcript selection is active.
|
||||
- Allow session picker search mode to accept the current selection with a single `Enter` press.
|
||||
- Preserve tool output whitespace/indentation while still wrapping long unbroken tokens.
|
||||
- Make transcript selection copy/highlighting display-width aware (wide chars and tabs).
|
||||
- Gate execpolicy behavior on the `exec_policy` feature flag across CLI/tool execution paths.
|
||||
- Run doctor API connectivity checks using the effective loaded config/profile (instead of reloading defaults).
|
||||
- Parse DeepSeek model context-window suffix hints such as `-32k` and `-256k`.
|
||||
- Update README config docs with key environment overrides and a direct link to full configuration docs.
|
||||
|
||||
## [0.3.23] - 2026-02-24
|
||||
|
||||
### Changed
|
||||
- Updated project copy to describe the app as a terminal-native TUI/CLI for DeepSeek models (not pinned to a specific model generation).
|
||||
|
||||
### Fixed
|
||||
- Model selection and config validation now accept any valid `deepseek-*` model ID (including future releases), while still normalizing common aliases like `deepseek-v3.2` and `deepseek-r1`.
|
||||
- Tool-call recovery now auto-loads deferred tools when the model requests them directly, instead of failing with manual `tool_search_*` instructions.
|
||||
- YOLO mode now preloads tools by default (including deferred MCP tools), so model tool calls can run immediately without discovery indirection.
|
||||
- Unknown tool-call failures now include discovery guidance and nearest tool-name suggestions instead of generic availability errors.
|
||||
- Slash-command errors now suggest the closest known command (for example `/modle` -> `/model`) instead of only returning a generic unknown-command message.
|
||||
|
||||
## [0.3.22] - 2026-02-19
|
||||
|
||||
### Added
|
||||
- Interactive `/config` editing modal for runtime settings updates.
|
||||
|
||||
### Changed
|
||||
- Retired user-facing `/set` command path (no longer reachable/discoverable).
|
||||
- Replaced `/deepseek` command behavior with `/links` (aliases: `dashboard`, `api`).
|
||||
|
||||
### Fixed
|
||||
- Legacy `/set` and `/deepseek` inputs now return migration guidance instead of generic unknown-command errors.
|
||||
|
||||
## [0.3.21] - 2026-02-19
|
||||
|
||||
### Added
|
||||
- Parallel tool execution in `multi_tool_use.parallel` for independent task workflows.
|
||||
- Session resume-thread coverage in tests.
|
||||
|
||||
### Changed
|
||||
- Desktop and web parity polish across the TUI and runtime surfaces.
|
||||
- Onboarding and approval UX refinement from prior phase 3 iteration.
|
||||
|
||||
### Fixed
|
||||
- Runtime pre-release startup issues and config-path edge cases.
|
||||
- Clippy lint regressions introduced by the last parity pass.
|
||||
|
||||
### Security/Hardening
|
||||
- General pre-release hardening for runtime app behavior.
|
||||
|
||||
## [0.3.17] - 2026-02-16
|
||||
|
||||
### Fixed
|
||||
- Config loading now expands `~` in `DEEPSEEK_CONFIG_PATH` and `--config` paths.
|
||||
- When `DEEPSEEK_CONFIG_PATH` points to a missing file, config loading now falls back to `~/.deepseek/config.toml` if it exists.
|
||||
|
||||
### Changed
|
||||
- Removed committed transient runtime artifacts (`session_*.json`, `.deepseek/trusted`) and added ignore rules to prevent re-commit.
|
||||
|
||||
## [0.3.16] - 2026-02-15
|
||||
|
||||
### Added
|
||||
- `deepseek models` CLI command to fetch and list models from the configured `/v1/models` endpoint (with `--json` output mode).
|
||||
- `/models` slash command to fetch and display live model IDs in the TUI.
|
||||
- Slash-command autocomplete hints in the composer plus `Tab` completion for `/` commands.
|
||||
- Command palette modal (`Ctrl+K`) for quick insertion of slash commands and skills.
|
||||
- Persistent right sidebar in wide terminals showing live plan/todo/sub-agent state.
|
||||
- Expandable tool payload views (`v` in transcript, `v` in approval modal) for full params/output inspection.
|
||||
- Runtime HTTP/SSE API (`deepseek serve --http`) with durable thread/turn/item lifecycle, interrupt/steer, and replayable event timeline.
|
||||
- Background task queue (`/task add|list|show|cancel` and `POST /v1/tasks`) with persistent storage, bounded worker pool, and timeline/artifact tracking.
|
||||
|
||||
### Changed
|
||||
- Centralized the default text model (`DEFAULT_TEXT_MODEL`) and shared common model list to reduce drift across runtime/config paths.
|
||||
- `/model` now clarifies that any valid DeepSeek model ID is accepted (including future releases), while still showing common model IDs.
|
||||
|
||||
### Fixed
|
||||
- Expanded reasoning-model detection for chat history reconstruction (supports R-series and reasoner-style naming without hardcoding single versions).
|
||||
- Aligned docs/config examples with the then-current runtime default model.
|
||||
|
||||
## [0.3.14] - 2026-02-05
|
||||
|
||||
### Added
|
||||
- `web.run` now supports `image_query` (DuckDuckGo image search)
|
||||
- `multi_tool_use.parallel` now supports safe MCP meta tools (`list_mcp_resources`, `mcp_read_resource`, etc.)
|
||||
|
||||
### Fixed
|
||||
- Encode tool-call function names when rebuilding Chat Completions history (keeps dotted tool names API-safe)
|
||||
|
||||
### Changed
|
||||
- Prompts: stronger `web.run` citation placement and quote-limit guidance
|
||||
|
||||
## [0.3.13] - 2026-02-04
|
||||
|
||||
### Fixed
|
||||
- Restore an in-app scrollbar for the transcript view
|
||||
|
||||
## [0.3.12] - 2026-02-04
|
||||
|
||||
### Fixed
|
||||
- Map dotted tool names to API-safe identifiers for DeepSeek tool calls
|
||||
- Encode any invalid tool names for API tool lists while preserving internal names
|
||||
|
||||
## [0.3.11] - 2026-02-04
|
||||
|
||||
### Fixed
|
||||
- Fix tool name mapping for DeepSeek API
|
||||
|
||||
## [0.3.10] - 2026-02-04
|
||||
|
||||
### Fixed
|
||||
- Always enable mouse wheel scrolling in the TUI (even without alt screen)
|
||||
|
||||
## [0.3.9] - 2026-02-04
|
||||
|
||||
### Removed
|
||||
- RLM mode, tools, and documentation pending a faithful implementation of the MIT RLM design
|
||||
- Duo mode tools and prompts pending a citable research spec
|
||||
|
||||
### Fixed
|
||||
- Footer context usage bar remains visible while status toasts are shown
|
||||
|
||||
### Changed
|
||||
- Updated prompts and docs to reflect the simplified mode/tool surface
|
||||
|
||||
## [0.3.8] - 2026-02-03
|
||||
|
||||
### Fixed
|
||||
- Resolve clippy warnings (CI `-D warnings`) in new tool implementations
|
||||
|
||||
## [0.3.7] - 2026-02-03
|
||||
|
||||
### Added
|
||||
- Tooling parity updates: `weather`, `finance`, `sports`, `time`, `calculator`, `request_user_input`, `multi_tool_use.parallel`, `web.run`
|
||||
- Shell streaming helpers: `exec_shell_wait` and `exec_shell_interact`
|
||||
- Sub-agent controls: `send_input` and `wait` (with aliases)
|
||||
- MCP resource helpers: `list_mcp_resources`, `list_mcp_resource_templates`, and `read_mcp_resource` alias
|
||||
|
||||
### Changed
|
||||
- Skills directory selection now prefers workspace `.agents/skills`, then `./skills`, then global
|
||||
- Docs and prompts updated to reflect new tool surface and parity notes
|
||||
|
||||
## [0.3.6] - 2026-02-02
|
||||
|
||||
### Added
|
||||
- New welcome banner on startup showing "Welcome to DeepSeek TUI!" with directory, session ID, and model info
|
||||
- Visual context progress bar in footer showing usage with block characters [████░░░░░░] and percentage
|
||||
|
||||
### Changed
|
||||
- Removed custom block-character scrollbar from chat area - now uses terminal's native scroll
|
||||
- Simplified header bar: removed context percentage indicator (moved to footer as progress bar)
|
||||
|
||||
## [0.3.5] - 2026-01-30
|
||||
|
||||
### Added
|
||||
- Intelligent context offloading: large tool results (>15k chars) are automatically moved to RLM memory to preserve the context window
|
||||
- Persistent history context: compacted messages are offloaded to RLM `history` variable for recall
|
||||
- Full MCP protocol support: SSE transport, Resources (`resources/list`, `resources/read`), and Prompts (`prompts/list`, `prompts/get`)
|
||||
- `mcp_read_resource` and `mcp_get_prompt` virtual tools exposed to the model
|
||||
- Dialectical Duo mode with specialized TUI rendering (`Player` / `Coach` history cells)
|
||||
- Dynamic system prompt refreshing at each turn for up-to-date RLM/Duo/working-set context
|
||||
- `project_map` tool for automatic codebase structure discovery
|
||||
- `delegate_to_agent` alias for streamlined sub-agent delegation
|
||||
|
||||
### Changed
|
||||
- Default theme changed to 'Whale' with updated color palette
|
||||
- `with_agent_tools` now includes `project_map`, `test_runner`, and conditionally RLM tools for all agent modes
|
||||
- MCP `McpServerConfig.command` is now `Option<String>` to support URL-only (SSE) servers
|
||||
|
||||
### Fixed
|
||||
- MCP test compilation errors for updated `McpServerConfig` struct shape
|
||||
|
||||
## [0.3.4] - 2026-01-29
|
||||
|
||||
### Changed
|
||||
- Updated Cargo.lock dependencies
|
||||
|
||||
### Fixed
|
||||
- Compaction tool-call pairing: enforce bidirectional tool-call/tool-result integrity with fixpoint convergence
|
||||
- Safety net scanning to drop orphan tool results in the request builder
|
||||
- Double-dispatch race in parallel tool execution
|
||||
|
||||
## [0.3.3] - 2026-01-28
|
||||
|
||||
### Added
|
||||
- TUI polish: Kimi-style footer with mode/model/token display
|
||||
- Streaming thinking blocks with dedicated rendering
|
||||
- Loading animation improvements
|
||||
|
||||
## [0.3.2] - 2026-01-28
|
||||
|
||||
### Fixed
|
||||
- Preserve tool-call + tool-result pairing during compaction to avoid invalid tool message sequences
|
||||
- Drop orphan tool results in request builder as a safety net to prevent API 400s
|
||||
|
||||
## [0.3.1] - 2026-01-27
|
||||
|
||||
### Added
|
||||
- `deepseek setup` to bootstrap MCP config and skills directories
|
||||
- `deepseek mcp init` to generate a template `mcp.json` at the configured path
|
||||
|
||||
### Changed
|
||||
- `deepseek doctor` now follows the resolved config path and config-derived MCP/skills locations
|
||||
|
||||
### Fixed
|
||||
- Doctor no longer reports missing MCP/skills when paths are overridden via config or env
|
||||
|
||||
## [0.3.0] - 2026-01-27
|
||||
|
||||
### Added
|
||||
- Repo-aware working set tracking with prompt injection for active paths
|
||||
- Working set signals now pin relevant messages during auto-compaction
|
||||
- Offline eval harness (`deepseek eval`) with CI coverage in the test job
|
||||
- Shell tool now emits stdout/stderr summaries and truncation metadata
|
||||
- Dependency-aware `agent_swarm` tool for orchestrating multiple sub-agents
|
||||
- Expanded sub-agent tool access (apply_patch, web_search, file_search)
|
||||
|
||||
### Changed
|
||||
- Auto-compaction now accounts for pinned budget and preserves working-set context
|
||||
- Apply patch tool validates patch shape, reports per-file summaries, and improves hunk mismatch diagnostics
|
||||
- Eval harness shell step now uses a Windows-safe default command
|
||||
- Increased `max_subagents` clamp to `1..=20`
|
||||
|
||||
## [0.2.2] - 2026-01-22
|
||||
|
||||
### Fixed
|
||||
- Session save no longer panics on serialization errors
|
||||
- Web search regex patterns are now cached for better performance
|
||||
- Improved panic messages for regex compilation failures
|
||||
|
||||
## [0.2.1] - 2026-01-22
|
||||
|
||||
### Fixed
|
||||
- Resolve clippy warnings for Rust 1.92
|
||||
|
||||
## [0.2.0] - 2026-01-20
|
||||
|
||||
### Changed
|
||||
@@ -88,7 +519,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)
|
||||
@@ -99,13 +530,37 @@ 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.2.0...HEAD
|
||||
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.4.9...HEAD
|
||||
[0.4.9]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.4.8...v0.4.9
|
||||
[0.4.8]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.33...v0.4.8
|
||||
[0.3.33]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.32...v0.3.33
|
||||
[0.3.32]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.31...v0.3.32
|
||||
[0.3.31]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.28...v0.3.31
|
||||
[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
|
||||
[0.3.21]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.17...v0.3.21
|
||||
[0.3.17]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.16...v0.3.17
|
||||
[0.3.16]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.14...v0.3.16
|
||||
[0.3.14]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.13...v0.3.14
|
||||
[0.3.13]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.12...v0.3.13
|
||||
[0.3.12]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.11...v0.3.12
|
||||
[0.3.11]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.10...v0.3.11
|
||||
[0.3.10]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
|
||||
[0.3.6]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.5...v0.3.6
|
||||
[0.3.5]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.4...v0.3.5
|
||||
[0.3.4]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.3...v0.3.4
|
||||
[0.3.3]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.2...v0.3.0
|
||||
[0.2.2]: 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
|
||||
+33
-31
@@ -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:
|
||||
@@ -45,8 +45,11 @@ Thank you for your interest in contributing to DeepSeek CLI! This document provi
|
||||
### Testing
|
||||
|
||||
- Write tests for new functionality
|
||||
- Ensure all existing tests pass: `cargo test`
|
||||
- For integration tests, use the `tests/` directory
|
||||
- Ensure all existing tests pass: `cargo test --workspace --all-features`
|
||||
- Colocate unit tests beside the code they cover (standard Rust `#[cfg(test)]`
|
||||
modules), and add integration tests under the owning crate's `tests/`
|
||||
directory (for example `crates/tui/tests/` or `crates/state/tests/`). The
|
||||
repository root `tests/` directory is not used
|
||||
|
||||
### Commit Messages
|
||||
|
||||
@@ -59,35 +62,34 @@ Use clear, descriptive commit messages following conventional commits:
|
||||
- `test:` Adding or updating tests
|
||||
- `chore:` Maintenance tasks
|
||||
|
||||
Example: `feat: add --doctor command for system diagnostics`
|
||||
Example: `feat: add doctor subcommand for system diagnostics`
|
||||
|
||||
## Project Structure
|
||||
|
||||
DeepSeek TUI is a Cargo workspace. The live runtime and the majority of TUI,
|
||||
engine, and tool code currently live in `crates/tui/src/`. Smaller workspace
|
||||
crates provide shared abstractions that are being extracted incrementally.
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs # Entry point and CLI definition
|
||||
├── config.rs # Configuration management
|
||||
├── client.rs # HTTP client for DeepSeek API
|
||||
├── llm_client.rs # LLM abstraction layer
|
||||
├── models.rs # Data structures
|
||||
├── mcp.rs # Model Context Protocol support
|
||||
├── hooks.rs # Hook system for extensibility
|
||||
├── skills.rs # Skills/plugin system
|
||||
├── core/ # Core engine components
|
||||
│ ├── engine.rs # Main agent loop
|
||||
│ ├── session.rs # Session management
|
||||
│ └── ...
|
||||
├── tools/ # Built-in tools
|
||||
│ ├── shell.rs # Shell execution
|
||||
│ ├── file.rs # File operations
|
||||
│ └── ...
|
||||
├── tui/ # Terminal UI
|
||||
│ ├── app.rs # Application state
|
||||
│ ├── ui.rs # Rendering logic
|
||||
│ └── ...
|
||||
└── sandbox/ # Sandbox execution (macOS)
|
||||
crates/
|
||||
├── tui/ deepseek-tui binary (interactive TUI + runtime API)
|
||||
├── cli/ deepseek binary (dispatcher facade)
|
||||
├── app-server/ HTTP/SSE + JSON-RPC transport
|
||||
├── core/ Agent loop / session / turn management
|
||||
├── protocol/ Request/response framing
|
||||
├── config/ Config loading, profiles, env precedence
|
||||
├── state/ SQLite thread/session persistence
|
||||
├── tools/ Typed tool specs and lifecycle
|
||||
├── mcp/ MCP client + stdio server
|
||||
├── hooks/ Lifecycle hooks (stdout/jsonl/webhook)
|
||||
├── execpolicy/ Approval/sandbox policy engine
|
||||
├── agent/ Model/provider registry
|
||||
└── tui-core/ Event-driven TUI state machine scaffold
|
||||
```
|
||||
|
||||
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the live data flow across
|
||||
these crates and [DEPENDENCY_GRAPH.md](DEPENDENCY_GRAPH.md) for build ordering.
|
||||
|
||||
## Submitting Changes
|
||||
|
||||
1. Create a feature branch from `main`:
|
||||
@@ -121,7 +123,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 +134,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
+1230
-18
File diff suppressed because it is too large.
Load diff
+31
-52
@@ -1,64 +1,43 @@
|
||||
[package]
|
||||
name = "deepseek-tui"
|
||||
version = "0.2.0"
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/agent",
|
||||
"crates/app-server",
|
||||
"crates/cli",
|
||||
"crates/config",
|
||||
"crates/core",
|
||||
"crates/execpolicy",
|
||||
"crates/hooks",
|
||||
"crates/mcp",
|
||||
"crates/protocol",
|
||||
"crates/state",
|
||||
"crates/tools",
|
||||
"crates/tui",
|
||||
"crates/tui-core",
|
||||
]
|
||||
default-members = ["crates/cli", "crates/app-server", "crates/tui"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.2"
|
||||
edition = "2024"
|
||||
description = "Unofficial DeepSeek CLI - Just run 'deepseek' to start chatting"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/Hmbown/DeepSeek-TUI"
|
||||
keywords = ["deepseek", "cli", "ai", "agent", "llm"]
|
||||
categories = ["command-line-utilities"]
|
||||
|
||||
[[bin]]
|
||||
name = "deepseek"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0.100"
|
||||
arboard = "3.4"
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1"
|
||||
bytes = "1.11.0"
|
||||
base64 = "0.22.1"
|
||||
async-trait = "0.1.89"
|
||||
axum = { version = "0.8.4", features = ["json"] }
|
||||
chrono = { version = "0.4.43", features = ["serde"] }
|
||||
clap = { version = "4.5.54", features = ["derive"] }
|
||||
clap_complete = "4.5"
|
||||
colored = "3.0.0"
|
||||
crossterm = "0.28"
|
||||
dotenvy = "0.15.7"
|
||||
dirs = "6.0.0"
|
||||
futures-util = "0.3.31"
|
||||
indicatif = "0.18.0"
|
||||
ratatui = "0.29"
|
||||
regex = "1.11"
|
||||
reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "native-tls", "http2"] }
|
||||
rustyline = "15.0.0"
|
||||
reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls"] }
|
||||
rusqlite = { version = "0.32.1", features = ["bundled"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
shellexpand = "3"
|
||||
toml = "0.9.7"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
tokio-util = { version = "0.7.16", features = ["io"] }
|
||||
unicode-width = "0.2"
|
||||
unicode-segmentation = "1.12"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
tokio-stream = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tempfile = "3.16"
|
||||
thiserror = "2.0"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
toml = "0.9.7"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
tracing = "0.1"
|
||||
wait-timeout = "0.2"
|
||||
multimap = "0.10.0"
|
||||
shlex = "1.3.0"
|
||||
starlark = "0.13.0"
|
||||
tiny_http = "0.12"
|
||||
zeroize = "1.8.2"
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = "0.6"
|
||||
pretty_assertions = "1.4"
|
||||
|
||||
# Platform-specific dependencies
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
@@ -0,0 +1,65 @@
|
||||
# Dependency Graph
|
||||
|
||||
## Crate Dependencies (from Cargo.toml)
|
||||
|
||||
```
|
||||
deepseek-tui (binary: `deepseek-tui`)
|
||||
(no workspace deps — monolith source under crates/tui/src/)
|
||||
|
||||
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 (`crates/tui/src/main.rs`). The crate split is
|
||||
structural — source migration into individual workspace 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
|
||||
```
|
||||
|
||||
@@ -1,274 +1,194 @@
|
||||
# DeepSeek CLI 🤖
|
||||
# DeepSeek TUI
|
||||
|
||||
Your AI-powered terminal companion for DeepSeek models
|
||||
|
||||
[](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml)
|
||||
[](https://crates.io/crates/deepseek-tui)
|
||||
|
||||
Unofficial terminal UI (TUI) + CLI for the [DeepSeek platform](https://platform.deepseek.com) — chat with DeepSeek models and collaborate with AI assistants that can read, write, execute, and plan with approval-gated tool access.
|
||||
|
||||
**Not affiliated with DeepSeek Inc.**
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Interactive TUI** with multiple modes (Normal, Plan, Agent, YOLO, RLM, Duo)
|
||||
- **Comprehensive tool access** – File operations, shell execution, task management, and sub-agent systems
|
||||
- **File operations**: List directories, read/write/edit files, apply patches, search files with regex
|
||||
- **Shell execution**: Run commands with timeout support, background execution with task management
|
||||
- **Task management**: Todo lists, implementation plans, persistent notes
|
||||
- **Sub-agent system**: Spawn, manage, and cancel background agents for parallel work
|
||||
- **Web search**: Integrated web search with DuckDuckGo
|
||||
- **Multi‑model support** – DeepSeek‑Reasoner, DeepSeek‑Chat, and other DeepSeek models
|
||||
- **Context‑aware** – loads project‑specific instructions from `AGENTS.md`
|
||||
- **Session management** – resume, fork, and search past conversations
|
||||
- **Skills system** – reusable workflows stored as `SKILL.md` directories
|
||||
- **Model Context Protocol (MCP)** – integrate external tool servers
|
||||
- **Sandboxed execution** (macOS) for safe shell commands
|
||||
- **Git integration** – code review, patch application, diff analysis
|
||||
- **Cross‑platform** – works on macOS, Linux, and Windows
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
1. **Get an API key** from [https://platform.deepseek.com](https://platform.deepseek.com)
|
||||
2. **Install and run**:
|
||||
> **A terminal-native coding agent for [DeepSeek V4](https://platform.deepseek.com) models — with 1M-token context, thinking-mode reasoning, and full tool-use.**
|
||||
|
||||
```bash
|
||||
# Install via Cargo
|
||||
cargo install deepseek-tui --locked
|
||||
npm i -g deepseek-tui
|
||||
```
|
||||
|
||||
# Set your API key
|
||||
export DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY"
|
||||
[](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml)
|
||||
[](https://www.npmjs.com/package/deepseek-tui)
|
||||
|
||||
# Start chatting
|
||||

|
||||
|
||||
---
|
||||
|
||||
## What is it?
|
||||
|
||||
DeepSeek TUI is a coding agent that runs entirely in your terminal. It gives DeepSeek's frontier models direct access to your workspace — reading and editing files, running shell commands, searching the web, managing git, and orchestrating sub-agents — all through a fast, keyboard-driven TUI.
|
||||
|
||||
**Built for DeepSeek V4** (`deepseek-v4-pro` / `deepseek-v4-flash`) with 1M-token context windows and native thinking-mode (chain-of-thought) streaming. See the model's reasoning unfold in real time as it works through your tasks.
|
||||
|
||||
### Key Features
|
||||
|
||||
- 🧠 **Thinking-mode streaming** — watch DeepSeek's chain-of-thought as it reasons about your code
|
||||
- 🔧 **Full tool suite** — file ops, shell execution, git, web search/browse, apply-patch, sub-agents, MCP servers, and more
|
||||
- 🪟 **1M-token context** — feed entire codebases; automatic intelligent compaction when context fills up
|
||||
- 🎛️ **Three interaction modes** — Plan (read-only explore), Agent (interactive with approval), YOLO (auto-approved)
|
||||
- ⚡ **Reasoning-effort tiers** — cycle through `off → high → max` with Shift+Tab
|
||||
- 🔄 **Session save/resume** — checkpoint and resume long sessions, fork conversations
|
||||
- 🌐 **HTTP/SSE runtime API** — `deepseek serve --http` for headless agent workflows
|
||||
- 📦 **MCP protocol** — connect to Model Context Protocol servers for extended tooling
|
||||
- 💰 **Live cost tracking** — per-turn and session-level token usage and cost estimates
|
||||
- 🎨 **Dark & light themes** — with a DeepSeek-blue branded palette
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
npm install -g deepseek-tui
|
||||
deepseek
|
||||
```
|
||||
|
||||
3. Press `F1` or type `/help` for the in‑app command list.
|
||||
|
||||
If anything looks off, run `deepseek doctor` to diagnose configuration issues.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### From crates.io
|
||||
On first launch you'll be prompted for your [DeepSeek API key](https://platform.deepseek.com/api_keys). You can also set it ahead of time:
|
||||
|
||||
```bash
|
||||
cargo install deepseek-tui --locked
|
||||
# via CLI
|
||||
deepseek login --api-key "YOUR_DEEPSEEK_API_KEY"
|
||||
|
||||
# via env var
|
||||
export DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY"
|
||||
deepseek
|
||||
```
|
||||
|
||||
### Build from source
|
||||
### Using NVIDIA NIM
|
||||
|
||||
```bash
|
||||
deepseek auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"
|
||||
deepseek --provider nvidia-nim
|
||||
|
||||
# or per-process:
|
||||
DEEPSEEK_PROVIDER=nvidia-nim NVIDIA_API_KEY="..." deepseek
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Install from source</summary>
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Hmbown/DeepSeek-TUI.git
|
||||
cd DeepSeek-TUI
|
||||
cargo build --release
|
||||
./target/release/deepseek --help
|
||||
cargo install --path crates/tui --locked # requires Rust 1.85+
|
||||
```
|
||||
|
||||
### Direct download
|
||||
|
||||
Download a prebuilt binary from [GitHub Releases](https://github.com/Hmbown/DeepSeek-TUI/releases) and put it on your `PATH` as `deepseek`.
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
On first run, the TUI can prompt for your API key and save it to `~/.deepseek/config.toml`. You can also create the file manually:
|
||||
|
||||
```toml
|
||||
# ~/.deepseek/config.toml
|
||||
api_key = "YOUR_DEEPSEEK_API_KEY" # must be non‑empty
|
||||
default_text_model = "deepseek-reasoner" # optional
|
||||
allow_shell = false # optional
|
||||
max_subagents = 3 # optional (1‑5)
|
||||
```
|
||||
|
||||
Useful environment variables:
|
||||
|
||||
- `DEEPSEEK_API_KEY` (overrides `api_key`)
|
||||
- `DEEPSEEK_BASE_URL` (default: `https://api.deepseek.com`; China users may use `https://api.deepseeki.com`)
|
||||
- `DEEPSEEK_PROFILE` (selects `[profiles.<name>]` from the config; errors if missing)
|
||||
- `DEEPSEEK_CONFIG_PATH` (override config path)
|
||||
- `DEEPSEEK_MCP_CONFIG`, `DEEPSEEK_SKILLS_DIR`, `DEEPSEEK_NOTES_PATH`, `DEEPSEEK_MEMORY_PATH`, `DEEPSEEK_ALLOW_SHELL`, `DEEPSEEK_MAX_SUBAGENTS`
|
||||
|
||||
See `config.example.toml` and `docs/CONFIGURATION.md` for a full reference.
|
||||
|
||||
## 🎮 Modes
|
||||
|
||||
In the TUI, press `Tab` to cycle modes: **Normal → Plan → Agent → YOLO → RLM → Duo → Normal**.
|
||||
|
||||
| Mode | Description | Approval Behavior |
|
||||
|------|-------------|-------------------|
|
||||
| **Normal** | Chat; asks before file writes or shell | Manual approval for writes & shell |
|
||||
| **Plan** | Design‑first prompting; same approvals as Normal | Manual approval for writes & shell |
|
||||
| **Agent** | Multi‑step tool use; asks before shell | Manual approval for shell, auto‑approve file writes |
|
||||
| **YOLO** | Enables shell + trust + auto‑approves all tools (dangerous) | Auto‑approve all tools |
|
||||
| **RLM** | Externalized context + REPL helpers; auto‑approves tools (best for large files) | Auto‑approve tools |
|
||||
| **Duo** | Player‑coach autocoding with iterative validation (based on g3 paper) | Depends on phase |
|
||||
|
||||
Approval behavior is mode‑dependent, but you can also override it at runtime with `/set approval_mode auto|suggest|never`.
|
||||
|
||||
## 🛠️ Tools
|
||||
|
||||
DeepSeek CLI exposes a comprehensive set of tools to the model across 5 categories, with 16+ individual tools available, all with approval gating based on the current mode.
|
||||
|
||||
### Tool Categories
|
||||
|
||||
#### File Operations
|
||||
- **`list_dir`** – List directory contents with file/directory metadata
|
||||
- **`read_file`** – Read UTF‑8 files from the workspace
|
||||
- **`write_file`** – Create or overwrite files
|
||||
- **`edit_file`** – Search and replace text in files
|
||||
- **`apply_patch`** – Apply unified diff patches with fuzzy matching
|
||||
- **`grep_files`** – Search files by regex pattern with context lines
|
||||
- **`web_search`** – Search the web and return concise results
|
||||
|
||||
#### Shell Execution
|
||||
- **`exec_shell`** – Run shell commands with timeout support
|
||||
- **Background execution** – Run commands in background with task ID return
|
||||
|
||||
#### Task Management
|
||||
- **`todo_write`** – Create and update todo lists with status tracking
|
||||
- **`update_plan`** – Manage structured implementation plans
|
||||
- **`note`** – Append persistent notes across sessions
|
||||
|
||||
#### Sub‑Agents
|
||||
- **`agent_spawn`** – Create background sub‑agents for focused tasks
|
||||
- **`agent_result`** – Retrieve results from sub‑agents
|
||||
- **`agent_list`** – List all active and completed agents
|
||||
- **`agent_cancel`** – Cancel running sub‑agents
|
||||
|
||||
### System Behavior
|
||||
|
||||
- **Workspace boundary**: File tools are restricted to `--workspace` unless you enable `/trust` (YOLO enables trust automatically).
|
||||
- **Approvals**: The TUI requests approval depending on mode and tool category (file writes, shell).
|
||||
- **Web search**: `web_search` uses DuckDuckGo HTML results and is auto‑approved.
|
||||
- **Skills**: Reusable workflows stored as `SKILL.md` directories (default: `~/.deepseek/skills`). Use `/skills` and `/skill <name>`.
|
||||
- **MCP**: Load external tool servers via `~/.deepseek/mcp.json` (supports `servers` and `mcpServers`). MCP tools currently execute without TUI approval prompts, so only enable servers you trust. See `docs/MCP.md`.
|
||||
|
||||
## 🧠 RLM (Reasoning & Large‑scale Memory)
|
||||
|
||||
RLM mode is designed for "too big for context" tasks: large files, whole‑doc sweeps, and big pasted blocks.
|
||||
|
||||
- Auto‑switch triggers: "largest file", explicit "RLM", large file requests, and large pastes.
|
||||
- Shortcut: `/rlm` (or `/aleph`) enters RLM mode directly.
|
||||
- In **RLM mode**, `/load @path` loads a file into the external context store (outside RLM mode, `/load` loads a saved chat JSON).
|
||||
- Use `/repl` to enter expression mode (e.g. `search("pattern")`, `lines(1, 80)`).
|
||||
- Power tools: `rlm_load`, `rlm_exec`, `rlm_status`, `rlm_query`.
|
||||
|
||||
`rlm_query` can be expensive: prefer batching and check `/status` if you're doing lots of sub‑queries.
|
||||
|
||||
## 👥 Duo Mode
|
||||
|
||||
> **Note:** Duo mode is experimental and may not work correctly in all cases. Use with caution.
|
||||
|
||||
Duo mode implements the player‑coach autocoding paradigm for iterative development with built‑in validation:
|
||||
|
||||
- **Player**: implements requirements (builder role)
|
||||
- **Coach**: validates implementation against requirements (critic role)
|
||||
- Tools: `duo_init`, `duo_player`, `duo_coach`, `duo_advance`, `duo_status`
|
||||
|
||||
Workflow: `init → player → coach → advance → (repeat until approved)`
|
||||
|
||||
## 📚 Examples
|
||||
|
||||
### Interactive chat
|
||||
|
||||
```bash
|
||||
deepseek
|
||||
```
|
||||
|
||||
### One‑shot prompt (non‑interactive)
|
||||
|
||||
```bash
|
||||
deepseek -p "Write a haiku about Rust"
|
||||
```
|
||||
|
||||
### Agentic execution with tool access
|
||||
|
||||
```bash
|
||||
deepseek exec --auto "Fix lint errors in the current directory"
|
||||
```
|
||||
|
||||
### Resume latest session
|
||||
|
||||
```bash
|
||||
deepseek --continue
|
||||
```
|
||||
|
||||
### Work on a specific project
|
||||
|
||||
```bash
|
||||
deepseek --workspace /path/to/project
|
||||
```
|
||||
|
||||
### Review staged git changes
|
||||
|
||||
```bash
|
||||
deepseek review --staged
|
||||
```
|
||||
|
||||
### Apply a patch file
|
||||
|
||||
```bash
|
||||
deepseek apply patch.diff
|
||||
```
|
||||
|
||||
### List saved sessions
|
||||
|
||||
```bash
|
||||
deepseek sessions --limit 50
|
||||
```
|
||||
|
||||
### Generate shell completions
|
||||
|
||||
```bash
|
||||
deepseek completions zsh > _deepseek
|
||||
deepseek completions bash > deepseek.bash
|
||||
deepseek completions fish > deepseek.fish
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### No API key
|
||||
Set `DEEPSEEK_API_KEY` environment variable or run `deepseek` and complete onboarding.
|
||||
|
||||
### Config not found
|
||||
Check `~/.deepseek/config.toml` (or `DEEPSEEK_CONFIG_PATH`).
|
||||
|
||||
### Wrong region / base URL
|
||||
Set `DEEPSEEK_BASE_URL` to `https://api.deepseeki.com` (China).
|
||||
|
||||
### Session issues
|
||||
Run `deepseek sessions` and try `deepseek --resume latest`.
|
||||
|
||||
### MCP tools missing
|
||||
Validate `~/.deepseek/mcp.json` (or `DEEPSEEK_MCP_CONFIG`) and restart.
|
||||
|
||||
### Sandbox errors (macOS)
|
||||
Ensure `/usr/bin/sandbox-exec` exists (comes with macOS). For other platforms, sandboxing is limited.
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- `docs/CONFIGURATION.md` – Complete configuration reference
|
||||
- `docs/MCP.md` – Model Context Protocol guide
|
||||
- `docs/ARCHITECTURE.md` – Project architecture
|
||||
- `docs/RLM.md` – RLM mode deep‑dive
|
||||
- `docs/MODES.md` – Mode comparison and usage
|
||||
- `CONTRIBUTING.md` – How to contribute to the project
|
||||
|
||||
## 🧪 Development
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
cargo test
|
||||
cargo fmt
|
||||
cargo clippy
|
||||
```
|
||||
|
||||
See `CONTRIBUTING.md` for detailed guidelines.
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
DeepSeek is a trademark of DeepSeek Inc. This is an unofficial project.
|
||||
## What's new in v0.5.0
|
||||
|
||||
- **Multi-turn tool calls no longer 400 on thinking-mode models.** Every assistant message now replays `reasoning_content` (with a safe placeholder when the round produced none), and a final-pass sanitizer guarantees the wire payload satisfies DeepSeek's thinking-mode contract — even for sessions restored from older checkpoints or sub-agents that bypass the engine path.
|
||||
- **Phantom `web.run` references stripped** from prompts and the `web_search` tool ([#25](https://github.com/Hmbown/DeepSeek-TUI/issues/25)).
|
||||
|
||||
Full history: [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
---
|
||||
|
||||
## Models & Pricing
|
||||
|
||||
DeepSeek TUI targets **DeepSeek V4** models with 1M-token context windows by default.
|
||||
|
||||
| Model | Context | Input (cache hit) | Input (cache miss) | Output |
|
||||
|---|---|---|---|---|
|
||||
| `deepseek-v4-pro` | 1M | $0.03625 / 1M* | $0.435 / 1M* | $0.87 / 1M* |
|
||||
| `deepseek-v4-flash` | 1M | $0.028 / 1M | $0.14 / 1M | $0.28 / 1M |
|
||||
|
||||
Legacy aliases `deepseek-chat` and `deepseek-reasoner` silently map to `deepseek-v4-flash`.
|
||||
|
||||
**NVIDIA NIM** hosted variants (`deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash`) use your NVIDIA account terms — no DeepSeek platform billing.
|
||||
|
||||
*\*DeepSeek lists the Pro rates above as a limited-time 75% discount valid until 2026-05-05 15:59 UTC; the TUI cost estimator falls back to base Pro rates after that timestamp.*
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
deepseek # interactive TUI
|
||||
deepseek "explain this function" # one-shot prompt
|
||||
deepseek --model deepseek-v4-flash "summarize" # model override
|
||||
deepseek --yolo # YOLO mode (auto-approve tools)
|
||||
deepseek login --api-key "..." # save API key
|
||||
deepseek doctor # check setup & connectivity
|
||||
deepseek models # list live API models
|
||||
deepseek sessions # list saved sessions
|
||||
deepseek resume --last # resume latest session
|
||||
deepseek serve --http # HTTP/SSE API server
|
||||
```
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `Tab` | Cycle mode: Plan → Agent → YOLO |
|
||||
| `Shift+Tab` | Cycle reasoning-effort: off → high → max |
|
||||
| `F1` | Help |
|
||||
| `Esc` | Back / dismiss |
|
||||
| `Ctrl+K` | Command palette |
|
||||
| `@path` | Attach file/directory context in composer |
|
||||
| `/attach <path>` | Attach image/video media references |
|
||||
|
||||
---
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|---|---|
|
||||
| **Plan** 🔍 | Read-only investigation — model explores and proposes a plan before making changes |
|
||||
| **Agent** 🤖 | Default interactive mode — multi-step tool use with approval gates |
|
||||
| **YOLO** ⚡ | Auto-approve all tools in a trusted workspace (use with caution) |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
`~/.deepseek/config.toml` — see [config.example.toml](config.example.toml) for every option.
|
||||
|
||||
Key environment overrides:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `DEEPSEEK_API_KEY` | API key |
|
||||
| `DEEPSEEK_BASE_URL` | API base URL |
|
||||
| `DEEPSEEK_MODEL` | Default model |
|
||||
| `DEEPSEEK_PROVIDER` | Provider: `deepseek` (default) or `nvidia-nim` |
|
||||
| `DEEPSEEK_PROFILE` | Config profile name |
|
||||
| `NVIDIA_API_KEY` | NVIDIA NIM API key |
|
||||
|
||||
Quick diagnostics:
|
||||
|
||||
```bash
|
||||
deepseek-tui setup --status # read-only status check (API key, MCP, sandbox, .env)
|
||||
deepseek-tui doctor --json # machine-readable doctor output for CI
|
||||
deepseek-tui setup --tools --plugins # scaffold tools/ and plugins/ directories
|
||||
```
|
||||
|
||||
DeepSeek context caching is automatic — when the API returns cache hit/miss token fields, the TUI includes them in usage and cost tracking.
|
||||
|
||||
Full reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md)
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | Topic |
|
||||
|---|---|
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Codebase internals |
|
||||
| [CONFIGURATION.md](docs/CONFIGURATION.md) | Full config reference |
|
||||
| [MODES.md](docs/MODES.md) | Plan / Agent / YOLO modes |
|
||||
| [MCP.md](docs/MCP.md) | Model Context Protocol integration |
|
||||
| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API server |
|
||||
| [RELEASE_RUNBOOK.md](docs/RELEASE_RUNBOOK.md) | Release process |
|
||||
| [OPERATIONS_RUNBOOK.md](docs/OPERATIONS_RUNBOOK.md) | Ops & recovery |
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md). Pull requests welcome!
|
||||
|
||||
*Not affiliated with DeepSeek Inc.*
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 755 KiB |
+95
-27
@@ -1,5 +1,5 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════════════════╗
|
||||
# ║ DeepSeek CLI Configuration ║
|
||||
# ║ DeepSeek TUI Configuration ║
|
||||
# ║ ║
|
||||
# ║ Unofficial CLI for DeepSeek Platform - Not affiliated with DeepSeek Inc. ║
|
||||
# ╚══════════════════════════════════════════════════════════════════════════════╝
|
||||
@@ -7,20 +7,42 @@
|
||||
# See `docs/CONFIGURATION.md` for how config is loaded (profiles, env overrides, etc.).
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# API Keys
|
||||
# Active provider + DeepSeek defaults
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Choose which provider to use by default. Per-provider credentials live in the
|
||||
# `[providers.deepseek]` and `[providers.nvidia_nim]` sections near the bottom of
|
||||
# this file — keeping both stored at once means `/provider deepseek` and
|
||||
# `/provider nvidia-nim` (or `--provider nvidia-nim`) toggle without having to
|
||||
# re-enter keys. Top-level `api_key` / `base_url` are still read as DeepSeek
|
||||
# defaults when `[providers.deepseek]` is absent (backward compatibility).
|
||||
provider = "deepseek" # deepseek | nvidia-nim
|
||||
api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Base URLs
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
base_url = "https://api.deepseek.com"
|
||||
# base_url = "https://api.deepseeki.com" # China users
|
||||
# base_url = "https://api.deepseek.com/beta" # DeepSeek beta features such as strict tool mode
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Default Models
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
default_text_model = "deepseek-reasoner" # also: deepseek-chat, deepseek-r1, deepseek-v3, deepseek-v3.2
|
||||
# DeepSeek V4 family:
|
||||
# deepseek-v4-pro — flagship reasoning model on DeepSeek Platform
|
||||
# deepseek-v4-flash — fast, cost-efficient (legacy aliases: deepseek-chat, deepseek-reasoner)
|
||||
# deepseek-ai/deepseek-v4-pro — NVIDIA NIM-hosted Pro model ID
|
||||
# deepseek-ai/deepseek-v4-flash — NVIDIA NIM-hosted Flash model ID
|
||||
default_text_model = "deepseek-v4-pro"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Thinking Mode (DeepSeek V4 reasoning effort)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# "off" — disables chain-of-thought (thinking.type = disabled)
|
||||
# "low" — compat-maps to "high" server-side
|
||||
# "medium" — compat-maps to "high" server-side
|
||||
# "high" — reasoning_effort = high (DeepSeek default)
|
||||
# "max" — reasoning_effort = max (deepest reasoning)
|
||||
#
|
||||
# Shift+Tab in the TUI cycles between off / high / max. The header shows the
|
||||
# current tier as a ⚡ chip.
|
||||
reasoning_effort = "max"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Paths
|
||||
@@ -29,20 +51,51 @@ skills_dir = "~/.deepseek/skills"
|
||||
mcp_config_path = "~/.deepseek/mcp.json"
|
||||
notes_path = "~/.deepseek/notes.txt"
|
||||
|
||||
memory_path = "~/.deepseek/memory.md"
|
||||
|
||||
# Parsed but currently unused (reserved for future versions):
|
||||
# tools_file = "./tools.json"
|
||||
# memory_path = "~/.deepseek/memory.md"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Security
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
allow_shell = false
|
||||
allow_shell = true
|
||||
approval_policy = "on-request" # on-request | untrusted | never
|
||||
sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access | external-sandbox
|
||||
max_subagents = 5 # optional (1-20)
|
||||
|
||||
# Optional managed policy paths (defaults to /etc/deepseek/*.toml on unix):
|
||||
# managed_config_path = "/etc/deepseek/managed_config.toml"
|
||||
# requirements_path = "/etc/deepseek/requirements.toml"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Per-provider credentials (peer providers — NIM is first-class, not a flag)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Both providers can be stored at once; `provider = "..."` (top of file) or
|
||||
# `/provider deepseek` / `/provider nvidia-nim` switches between them without
|
||||
# having to re-enter keys. Env vars override anything set here:
|
||||
# DeepSeek: DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL
|
||||
# NIM: NVIDIA_API_KEY (or NVIDIA_NIM_API_KEY), NIM_BASE_URL
|
||||
# (or NVIDIA_NIM_BASE_URL / NVIDIA_BASE_URL), NVIDIA_NIM_MODEL
|
||||
|
||||
# DeepSeek Platform (https://platform.deepseek.com)
|
||||
[providers.deepseek]
|
||||
# api_key = "YOUR_DEEPSEEK_API_KEY"
|
||||
# base_url = "https://api.deepseek.com"
|
||||
# model = "deepseek-v4-pro"
|
||||
|
||||
# NVIDIA NIM-hosted DeepSeek V4 (https://build.nvidia.com)
|
||||
[providers.nvidia_nim]
|
||||
# api_key = "YOUR_NVIDIA_API_KEY"
|
||||
# base_url = "https://integrate.api.nvidia.com/v1"
|
||||
# model = "deepseek-ai/deepseek-v4-pro" # or deepseek-ai/deepseek-v4-flash
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# TUI
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
[tui]
|
||||
alternate_screen = "auto" # auto | always | never
|
||||
mouse_capture = true # true keeps wheel scrolling inside the TUI; false allows terminal-native drag selection/copy
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Feature Flags
|
||||
@@ -50,11 +103,9 @@ alternate_screen = "auto" # auto | always | never
|
||||
[features]
|
||||
shell_tool = true
|
||||
subagents = true
|
||||
web_search = true
|
||||
web_search = true # enables canonical web.run plus the compatibility web_search alias
|
||||
apply_patch = true
|
||||
mcp = true
|
||||
rlm = true
|
||||
duo = true
|
||||
exec_policy = true
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -68,24 +119,29 @@ max_delay = 60.0
|
||||
exponential_base = 2.0
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Context Compaction (PLANNED - not yet implemented)
|
||||
# Context Compaction
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# [compaction]
|
||||
# enabled = false # Enable auto-compaction
|
||||
# token_threshold = 50000 # Trigger compaction above this token estimate
|
||||
# message_threshold = 50 # Or above this message count
|
||||
# model = "deepseek-chat" # Model to use for summarization
|
||||
# cache_summary = true # Cache the summary block
|
||||
# Auto-compaction is a saved UI setting edited with `/config` (`auto_compact`).
|
||||
# There is no config-file `[compaction]` table yet; detailed thresholds are
|
||||
# chosen by the TUI from the active model/context budget.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# RLM Sandbox Configuration (PLANNED - not yet implemented)
|
||||
# Capacity Controller (runtime pressure guardrails)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# [rlm]
|
||||
# max_context_chars = 10000000 # Max characters for context (10MB)
|
||||
# max_search_results = 100 # Max search results
|
||||
# default_chunk_size = 2000 # Default chunk size
|
||||
# default_overlap = 200 # Default chunk overlap
|
||||
# session_dir = "~/.deepseek/rlm" # Directory for RLM sessions
|
||||
[capacity]
|
||||
enabled = true
|
||||
low_risk_max = 0.34
|
||||
medium_risk_max = 0.62
|
||||
severe_min_slack = -0.25
|
||||
severe_violation_ratio = 0.40
|
||||
refresh_cooldown_turns = 2
|
||||
replan_cooldown_turns = 5
|
||||
max_replay_per_turn = 1
|
||||
min_turns_before_guardrail = 2
|
||||
profile_window = 8
|
||||
deepseek_v3_2_chat_prior = 3.9
|
||||
deepseek_v3_2_reasoner_prior = 4.1
|
||||
fallback_default_prior = 3.8
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Profile Example (for multiple environments)
|
||||
@@ -99,6 +155,12 @@ base_url = "https://api.deepseek.com"
|
||||
api_key = "DEV_DEEPSEEK_API_KEY"
|
||||
allow_shell = true
|
||||
|
||||
[profiles.nvidia-nim]
|
||||
provider = "nvidia-nim"
|
||||
api_key = "YOUR_NVIDIA_API_KEY"
|
||||
base_url = "https://integrate.api.nvidia.com/v1"
|
||||
default_text_model = "deepseek-ai/deepseek-v4-pro"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Hooks (optional)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -111,4 +173,10 @@ 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
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# allowed_approval_policies = ["on-request", "untrusted", "never"]
|
||||
# allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "deepseek-agent"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
deepseek-config = { path = "../config", version = "0.5.0" }
|
||||
serde.workspace = true
|
||||
@@ -0,0 +1,227 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use deepseek_config::ProviderKind;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelInfo {
|
||||
pub id: String,
|
||||
pub provider: ProviderKind,
|
||||
pub aliases: Vec<String>,
|
||||
pub supports_tools: bool,
|
||||
pub supports_reasoning: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelResolution {
|
||||
pub requested: Option<String>,
|
||||
pub resolved: ModelInfo,
|
||||
pub used_fallback: bool,
|
||||
pub fallback_chain: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelRegistry {
|
||||
models: Vec<ModelInfo>,
|
||||
alias_map: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Default for ModelRegistry {
|
||||
fn default() -> Self {
|
||||
let models = vec![
|
||||
ModelInfo {
|
||||
id: "deepseek-v4-pro".to_string(),
|
||||
provider: ProviderKind::Deepseek,
|
||||
aliases: vec![],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "deepseek-v4-flash".to_string(),
|
||||
provider: ProviderKind::Deepseek,
|
||||
aliases: vec![
|
||||
"deepseek-chat".to_string(),
|
||||
"deepseek-reasoner".to_string(),
|
||||
"deepseek-r1".to_string(),
|
||||
"deepseek-v3".to_string(),
|
||||
"deepseek-v3.2".to_string(),
|
||||
],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "deepseek-ai/deepseek-v4-pro".to_string(),
|
||||
provider: ProviderKind::NvidiaNim,
|
||||
aliases: vec![
|
||||
"deepseek-v4-pro".to_string(),
|
||||
"nvidia-deepseek-v4-pro".to_string(),
|
||||
"nim-deepseek-v4-pro".to_string(),
|
||||
],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "deepseek-ai/deepseek-v4-flash".to_string(),
|
||||
provider: ProviderKind::NvidiaNim,
|
||||
aliases: vec![
|
||||
"deepseek-v4-flash".to_string(),
|
||||
"deepseek-chat".to_string(),
|
||||
"deepseek-reasoner".to_string(),
|
||||
"nvidia-deepseek-v4-flash".to_string(),
|
||||
"nim-deepseek-v4-flash".to_string(),
|
||||
],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "gpt-4.1".to_string(),
|
||||
provider: ProviderKind::Openai,
|
||||
aliases: vec!["gpt4.1".to_string(), "gpt-4o".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "gpt-4.1-mini".to_string(),
|
||||
provider: ProviderKind::Openai,
|
||||
aliases: vec!["gpt-4o-mini".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: false,
|
||||
},
|
||||
];
|
||||
Self::new(models)
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelRegistry {
|
||||
#[must_use]
|
||||
pub fn new(models: Vec<ModelInfo>) -> Self {
|
||||
let mut alias_map = HashMap::new();
|
||||
for (idx, model) in models.iter().enumerate() {
|
||||
alias_map.entry(normalize(&model.id)).or_insert(idx);
|
||||
for alias in &model.aliases {
|
||||
alias_map.entry(normalize(alias)).or_insert(idx);
|
||||
}
|
||||
}
|
||||
Self { models, alias_map }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn list(&self) -> Vec<ModelInfo> {
|
||||
self.models.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn resolve(
|
||||
&self,
|
||||
requested: Option<&str>,
|
||||
provider_hint: Option<ProviderKind>,
|
||||
) -> ModelResolution {
|
||||
let mut fallback_chain = Vec::new();
|
||||
|
||||
if let Some(name) = requested {
|
||||
fallback_chain.push(format!("requested:{name}"));
|
||||
if let Some(provider) = provider_hint
|
||||
&& let Some(model) = self
|
||||
.models
|
||||
.iter()
|
||||
.find(|m| m.provider == provider && model_matches(m, name))
|
||||
.cloned()
|
||||
{
|
||||
return ModelResolution {
|
||||
requested: Some(name.to_string()),
|
||||
resolved: model,
|
||||
used_fallback: false,
|
||||
fallback_chain,
|
||||
};
|
||||
}
|
||||
if let Some(idx) = self.alias_map.get(&normalize(name)) {
|
||||
return ModelResolution {
|
||||
requested: Some(name.to_string()),
|
||||
resolved: self.models[*idx].clone(),
|
||||
used_fallback: false,
|
||||
fallback_chain,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let provider = provider_hint.unwrap_or(ProviderKind::Deepseek);
|
||||
fallback_chain.push(format!("provider_default:{}", provider.as_str()));
|
||||
if let Some(model) = self.models.iter().find(|m| m.provider == provider).cloned() {
|
||||
return ModelResolution {
|
||||
requested: requested.map(ToOwned::to_owned),
|
||||
resolved: model,
|
||||
used_fallback: true,
|
||||
fallback_chain,
|
||||
};
|
||||
}
|
||||
|
||||
let final_fallback = self.models.first().cloned().unwrap_or(ModelInfo {
|
||||
id: "deepseek-v4-pro".to_string(),
|
||||
provider: ProviderKind::Deepseek,
|
||||
aliases: Vec::new(),
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
});
|
||||
fallback_chain.push("global_default:deepseek-v4-pro".to_string());
|
||||
ModelResolution {
|
||||
requested: requested.map(ToOwned::to_owned),
|
||||
resolved: final_fallback,
|
||||
used_fallback: true,
|
||||
fallback_chain,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn model_matches(model: &ModelInfo, requested: &str) -> bool {
|
||||
let requested = normalize(requested);
|
||||
normalize(&model.id) == requested
|
||||
|| model
|
||||
.aliases
|
||||
.iter()
|
||||
.any(|alias| normalize(alias) == requested)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_pro_alias_stays_deepseek_by_default() {
|
||||
let registry = ModelRegistry::default();
|
||||
let resolved = registry.resolve(Some("deepseek-v4-pro"), None);
|
||||
|
||||
assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
|
||||
assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_pro_alias_resolves_to_nvidia_nim_when_provider_hinted() {
|
||||
let registry = ModelRegistry::default();
|
||||
let resolved = registry.resolve(Some("deepseek-v4-pro"), Some(ProviderKind::NvidiaNim));
|
||||
|
||||
assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_default_uses_catalog_model_id() {
|
||||
let registry = ModelRegistry::default();
|
||||
let resolved = registry.resolve(None, Some(ProviderKind::NvidiaNim));
|
||||
|
||||
assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_flash_alias_resolves_to_nvidia_nim_when_provider_hinted() {
|
||||
let registry = ModelRegistry::default();
|
||||
let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::NvidiaNim));
|
||||
|
||||
assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "deepseek-app-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Codex-style app-server transport for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
deepseek-agent = { path = "../agent", version = "0.5.0" }
|
||||
deepseek-config = { path = "../config", version = "0.5.0" }
|
||||
deepseek-core = { path = "../core", version = "0.5.0" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.5.0" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.5.0" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.5.0" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
|
||||
deepseek-state = { path = "../state", version = "0.5.0" }
|
||||
deepseek-tools = { path = "../tools", version = "0.5.0" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
@@ -0,0 +1,783 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::extract::State;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use deepseek_agent::ModelRegistry;
|
||||
use deepseek_config::{CliRuntimeOverrides, ConfigStore};
|
||||
use deepseek_core::Runtime;
|
||||
use deepseek_execpolicy::ExecPolicyEngine;
|
||||
use deepseek_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink};
|
||||
use deepseek_mcp::McpManager;
|
||||
use deepseek_protocol::{
|
||||
AppRequest, AppResponse, PromptRequest, PromptResponse, ThreadRequest, ThreadResponse,
|
||||
};
|
||||
use deepseek_state::StateStore;
|
||||
use deepseek_tools::{ToolCall, ToolRegistry};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppServerOptions {
|
||||
pub listen: SocketAddr,
|
||||
pub config_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
config_path: Option<PathBuf>,
|
||||
config: Arc<RwLock<deepseek_config::ConfigToml>>,
|
||||
runtime: Arc<Mutex<Runtime>>,
|
||||
registry: ModelRegistry,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ToolCallRequest {
|
||||
call: ToolCall,
|
||||
#[serde(default)]
|
||||
cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JsonRpcRequest {
|
||||
#[serde(default)]
|
||||
jsonrpc: Option<String>,
|
||||
#[serde(default)]
|
||||
id: Option<Value>,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct JsonRpcError {
|
||||
code: i64,
|
||||
message: String,
|
||||
data: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StdioDispatchResult {
|
||||
result: Value,
|
||||
should_exit: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ConfigGetParams {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ConfigSetParams {
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ThreadIdParams {
|
||||
thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ThreadMessageParams {
|
||||
thread_id: String,
|
||||
input: String,
|
||||
}
|
||||
|
||||
pub async fn run(options: AppServerOptions) -> Result<()> {
|
||||
let state = build_state(options.config_path.clone())?;
|
||||
|
||||
let app = Router::new()
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/thread", post(thread_handler))
|
||||
.route("/app", post(app_handler))
|
||||
.route("/prompt", post(prompt_handler))
|
||||
.route("/tool", post(tool_handler))
|
||||
.route("/jobs", get(jobs_handler))
|
||||
.route("/mcp/startup", post(mcp_startup_handler))
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(options.listen).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_stdio(config_path: Option<PathBuf>) -> Result<()> {
|
||||
let state = build_state(config_path)?;
|
||||
let stdin = tokio::io::stdin();
|
||||
let stdout = tokio::io::stdout();
|
||||
let mut reader = BufReader::new(stdin).lines();
|
||||
let mut writer = tokio::io::BufWriter::new(stdout);
|
||||
while let Some(line) = reader.next_line().await? {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let request: JsonRpcRequest = match serde_json::from_str(&line) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
let response = jsonrpc_error(
|
||||
None,
|
||||
JsonRpcError::parse_error(format!("invalid json: {err}")),
|
||||
);
|
||||
writer.write_all(response.to_string().as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if request
|
||||
.jsonrpc
|
||||
.as_deref()
|
||||
.is_some_and(|version| version != "2.0")
|
||||
{
|
||||
let response = jsonrpc_error(
|
||||
request.id,
|
||||
JsonRpcError::invalid_request("jsonrpc version must be 2.0"),
|
||||
);
|
||||
writer.write_all(response.to_string().as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let response = match dispatch_stdio_request(&state, &request.method, request.params).await {
|
||||
Ok(dispatch) => {
|
||||
let encoded = jsonrpc_result(request.id, dispatch.result);
|
||||
writer.write_all(encoded.to_string().as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
if dispatch.should_exit {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(err) => jsonrpc_error(request.id, err),
|
||||
};
|
||||
|
||||
writer.write_all(response.to_string().as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn healthz() -> Json<Value> {
|
||||
Json(json!({
|
||||
"status": "ok",
|
||||
"protocol": "v2",
|
||||
"service": "deepseek-app-server"
|
||||
}))
|
||||
}
|
||||
|
||||
async fn thread_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ThreadRequest>,
|
||||
) -> Json<ThreadResponse> {
|
||||
let mut runtime = state.runtime.lock().await;
|
||||
match runtime.handle_thread(req).await {
|
||||
Ok(res) => Json(res),
|
||||
Err(err) => Json(ThreadResponse {
|
||||
thread_id: "error".to_string(),
|
||||
status: format!("error:{err}"),
|
||||
thread: None,
|
||||
threads: Vec::new(),
|
||||
model: None,
|
||||
model_provider: None,
|
||||
cwd: None,
|
||||
approval_policy: None,
|
||||
sandbox: None,
|
||||
events: Vec::new(),
|
||||
data: json!({}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn prompt_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<PromptRequest>,
|
||||
) -> Json<PromptResponse> {
|
||||
let mut runtime = state.runtime.lock().await;
|
||||
let overrides = CliRuntimeOverrides::default();
|
||||
match runtime.handle_prompt(req, &overrides).await {
|
||||
Ok(res) => Json(res),
|
||||
Err(err) => Json(PromptResponse {
|
||||
output: err.to_string(),
|
||||
model: "unknown".to_string(),
|
||||
events: Vec::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn tool_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ToolCallRequest>,
|
||||
) -> Json<Value> {
|
||||
let runtime = state.runtime.lock().await;
|
||||
let cwd = req
|
||||
.cwd
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||
match runtime
|
||||
.invoke_tool(
|
||||
req.call,
|
||||
deepseek_execpolicy::AskForApproval::OnRequest,
|
||||
&cwd,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(value) => Json(value),
|
||||
Err(err) => Json(json!({ "ok": false, "error": err.to_string() })),
|
||||
}
|
||||
}
|
||||
|
||||
async fn jobs_handler(State(state): State<AppState>) -> Json<AppResponse> {
|
||||
let runtime = state.runtime.lock().await;
|
||||
Json(runtime.app_status())
|
||||
}
|
||||
|
||||
async fn mcp_startup_handler(State(state): State<AppState>) -> Json<Value> {
|
||||
let runtime = state.runtime.lock().await;
|
||||
let summary = runtime.mcp_startup().await;
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"summary": summary
|
||||
}))
|
||||
}
|
||||
|
||||
async fn app_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<AppRequest>,
|
||||
) -> Json<AppResponse> {
|
||||
Json(process_app_request(&state, req).await)
|
||||
}
|
||||
|
||||
fn build_state(config_path: Option<PathBuf>) -> Result<AppState> {
|
||||
let store = ConfigStore::load(config_path.clone())?;
|
||||
let config = store.config.clone();
|
||||
let registry = ModelRegistry::default();
|
||||
|
||||
let state_db_path = config_path
|
||||
.as_ref()
|
||||
.and_then(|p| p.parent().map(|parent| parent.join("state.db")));
|
||||
let state_store = StateStore::open(state_db_path)?;
|
||||
|
||||
let mut hooks = HookDispatcher::default();
|
||||
hooks.add_sink(Arc::new(StdoutHookSink));
|
||||
let hook_log_path = config_path
|
||||
.as_ref()
|
||||
.and_then(|p| p.parent().map(|parent| parent.join("events.jsonl")))
|
||||
.unwrap_or_else(|| PathBuf::from(".deepseek/events.jsonl"));
|
||||
hooks.add_sink(Arc::new(JsonlHookSink::new(hook_log_path)));
|
||||
|
||||
let runtime = Runtime::new(
|
||||
config.clone(),
|
||||
registry.clone(),
|
||||
state_store,
|
||||
Arc::new(ToolRegistry::default()),
|
||||
Arc::new(McpManager::default()),
|
||||
ExecPolicyEngine::new(Vec::new(), Vec::new()),
|
||||
hooks,
|
||||
);
|
||||
|
||||
Ok(AppState {
|
||||
config_path,
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
runtime: Arc::new(Mutex::new(runtime)),
|
||||
registry,
|
||||
})
|
||||
}
|
||||
|
||||
fn params_or_object(params: Value) -> Value {
|
||||
if params.is_null() { json!({}) } else { params }
|
||||
}
|
||||
|
||||
fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
|
||||
serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
|
||||
}
|
||||
|
||||
fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id.unwrap_or(Value::Null),
|
||||
"result": result
|
||||
})
|
||||
}
|
||||
|
||||
fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id.unwrap_or(Value::Null),
|
||||
"error": {
|
||||
"code": err.code,
|
||||
"message": err.message,
|
||||
"data": err.data
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl JsonRpcError {
|
||||
fn parse_error(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32700,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_request(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32600,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn method_not_found(method: &str) -> Self {
|
||||
Self {
|
||||
code: -32601,
|
||||
message: format!("unsupported method: {method}"),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_params(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32602,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn internal(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32603,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_thread_request(
|
||||
state: &AppState,
|
||||
req: ThreadRequest,
|
||||
) -> std::result::Result<ThreadResponse, JsonRpcError> {
|
||||
let mut runtime = state.runtime.lock().await;
|
||||
runtime
|
||||
.handle_thread(req)
|
||||
.await
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))
|
||||
}
|
||||
|
||||
async fn handle_prompt_request(
|
||||
state: &AppState,
|
||||
req: PromptRequest,
|
||||
) -> std::result::Result<PromptResponse, JsonRpcError> {
|
||||
let mut runtime = state.runtime.lock().await;
|
||||
runtime
|
||||
.handle_prompt(req, &CliRuntimeOverrides::default())
|
||||
.await
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))
|
||||
}
|
||||
|
||||
async fn dispatch_stdio_request(
|
||||
state: &AppState,
|
||||
method: &str,
|
||||
params: Value,
|
||||
) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
|
||||
let outcome = match method {
|
||||
"healthz" | "app/healthz" => StdioDispatchResult {
|
||||
result: json!({
|
||||
"status": "ok",
|
||||
"service": "deepseek-app-server",
|
||||
"transport": "stdio"
|
||||
}),
|
||||
should_exit: false,
|
||||
},
|
||||
"capabilities" => StdioDispatchResult {
|
||||
result: json!({
|
||||
"transport": "stdio",
|
||||
"families": ["thread/*", "app/*", "prompt/*"],
|
||||
"methods": [
|
||||
"healthz",
|
||||
"thread/capabilities",
|
||||
"thread/request",
|
||||
"thread/create",
|
||||
"thread/start",
|
||||
"thread/resume",
|
||||
"thread/fork",
|
||||
"thread/list",
|
||||
"thread/read",
|
||||
"thread/set_name",
|
||||
"thread/archive",
|
||||
"thread/unarchive",
|
||||
"thread/message",
|
||||
"app/capabilities",
|
||||
"app/request",
|
||||
"app/config/get",
|
||||
"app/config/set",
|
||||
"app/config/unset",
|
||||
"app/config/list",
|
||||
"app/models",
|
||||
"app/thread_loaded_list",
|
||||
"prompt/capabilities",
|
||||
"prompt/request",
|
||||
"prompt/run",
|
||||
"shutdown"
|
||||
]
|
||||
}),
|
||||
should_exit: false,
|
||||
},
|
||||
"thread/capabilities" => StdioDispatchResult {
|
||||
result: json!({
|
||||
"methods": [
|
||||
"thread/request",
|
||||
"thread/create",
|
||||
"thread/start",
|
||||
"thread/resume",
|
||||
"thread/fork",
|
||||
"thread/list",
|
||||
"thread/read",
|
||||
"thread/set_name",
|
||||
"thread/archive",
|
||||
"thread/unarchive",
|
||||
"thread/message"
|
||||
]
|
||||
}),
|
||||
should_exit: false,
|
||||
},
|
||||
"thread/request" => {
|
||||
let request: ThreadRequest = parse_params(params)?;
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/create" => {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateParams {
|
||||
#[serde(default)]
|
||||
metadata: Value,
|
||||
}
|
||||
let parsed: CreateParams = parse_params(params_or_object(params))?;
|
||||
let response = handle_thread_request(
|
||||
state,
|
||||
ThreadRequest::Create {
|
||||
metadata: parsed.metadata,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/start" => {
|
||||
let request = ThreadRequest::Start(parse_params(params_or_object(params))?);
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/resume" => {
|
||||
let request = ThreadRequest::Resume(parse_params(params_or_object(params))?);
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/fork" => {
|
||||
let request = ThreadRequest::Fork(parse_params(params_or_object(params))?);
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/list" => {
|
||||
let request = ThreadRequest::List(parse_params(params_or_object(params))?);
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/read" => {
|
||||
let request = ThreadRequest::Read(parse_params(params_or_object(params))?);
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/set_name" | "thread/set-name" => {
|
||||
let request = ThreadRequest::SetName(parse_params(params_or_object(params))?);
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/archive" => {
|
||||
let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
|
||||
let response = handle_thread_request(
|
||||
state,
|
||||
ThreadRequest::Archive {
|
||||
thread_id: parsed.thread_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/unarchive" => {
|
||||
let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
|
||||
let response = handle_thread_request(
|
||||
state,
|
||||
ThreadRequest::Unarchive {
|
||||
thread_id: parsed.thread_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/message" => {
|
||||
let parsed: ThreadMessageParams = parse_params(params_or_object(params))?;
|
||||
let response = handle_thread_request(
|
||||
state,
|
||||
ThreadRequest::Message {
|
||||
thread_id: parsed.thread_id,
|
||||
input: parsed.input,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/capabilities" => {
|
||||
let response = process_app_request(state, AppRequest::Capabilities).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/request" => {
|
||||
let request: AppRequest = parse_params(params)?;
|
||||
let response = process_app_request(state, request).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/config/get" => {
|
||||
let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
|
||||
let response =
|
||||
process_app_request(state, AppRequest::ConfigGet { key: parsed.key }).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/config/set" => {
|
||||
let parsed: ConfigSetParams = parse_params(params_or_object(params))?;
|
||||
let response = process_app_request(
|
||||
state,
|
||||
AppRequest::ConfigSet {
|
||||
key: parsed.key,
|
||||
value: parsed.value,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/config/unset" => {
|
||||
let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
|
||||
let response =
|
||||
process_app_request(state, AppRequest::ConfigUnset { key: parsed.key }).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/config/list" => {
|
||||
let response = process_app_request(state, AppRequest::ConfigList).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/models" => {
|
||||
let response = process_app_request(state, AppRequest::Models).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/thread_loaded_list" | "app/thread-loaded-list" => {
|
||||
let response = process_app_request(state, AppRequest::ThreadLoadedList).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"prompt/capabilities" => StdioDispatchResult {
|
||||
result: json!({
|
||||
"methods": ["prompt/request", "prompt/run"]
|
||||
}),
|
||||
should_exit: false,
|
||||
},
|
||||
"prompt/request" | "prompt/run" => {
|
||||
let request: PromptRequest = parse_params(params)?;
|
||||
let response = handle_prompt_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"shutdown" => StdioDispatchResult {
|
||||
result: json!({"ok": true, "status": "stopped"}),
|
||||
should_exit: true,
|
||||
},
|
||||
_ => return Err(JsonRpcError::method_not_found(method)),
|
||||
};
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
async fn process_app_request(state: &AppState, req: AppRequest) -> AppResponse {
|
||||
match req {
|
||||
AppRequest::Capabilities => AppResponse {
|
||||
ok: true,
|
||||
data: json!({
|
||||
"routes": ["/thread", "/app", "/prompt", "/tool", "/jobs", "/mcp/startup"],
|
||||
"config": ["get", "set", "unset", "list"],
|
||||
"events": ["response_start", "response_delta", "response_end", "tool_call_start", "tool_call_result", "mcp_startup_update", "mcp_startup_complete"],
|
||||
"transport": "stdio+http",
|
||||
"config_path": state.config_path.as_ref().map(|p| p.display().to_string()),
|
||||
}),
|
||||
events: Vec::new(),
|
||||
},
|
||||
AppRequest::ConfigGet { key } => {
|
||||
let cfg = state.config.read().await;
|
||||
AppResponse {
|
||||
ok: true,
|
||||
data: json!({ "key": key, "value": cfg.get_value(&key) }),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
AppRequest::ConfigSet { key, value } => {
|
||||
let mut cfg = state.config.write().await;
|
||||
let result = cfg.set_value(&key, &value);
|
||||
let ok = result.is_ok();
|
||||
let message = result.err().map(|e| e.to_string());
|
||||
let snapshot = cfg.clone();
|
||||
drop(cfg);
|
||||
let _ = persist_config(state, snapshot).await;
|
||||
AppResponse {
|
||||
ok,
|
||||
data: json!({ "key": key, "value": value, "error": message }),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
AppRequest::ConfigUnset { key } => {
|
||||
let mut cfg = state.config.write().await;
|
||||
let result = cfg.unset_value(&key);
|
||||
let ok = result.is_ok();
|
||||
let message = result.err().map(|e| e.to_string());
|
||||
let snapshot = cfg.clone();
|
||||
drop(cfg);
|
||||
let _ = persist_config(state, snapshot).await;
|
||||
AppResponse {
|
||||
ok,
|
||||
data: json!({ "key": key, "error": message }),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
AppRequest::ConfigList => {
|
||||
let cfg = state.config.read().await;
|
||||
AppResponse {
|
||||
ok: true,
|
||||
data: json!({ "values": cfg.list_values() }),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
AppRequest::Models => AppResponse {
|
||||
ok: true,
|
||||
data: json!({ "models": state.registry.list() }),
|
||||
events: Vec::new(),
|
||||
},
|
||||
AppRequest::ThreadLoadedList => {
|
||||
let mut runtime = state.runtime.lock().await;
|
||||
let response = runtime
|
||||
.handle_thread(deepseek_protocol::ThreadRequest::List(
|
||||
deepseek_protocol::ThreadListParams {
|
||||
include_archived: false,
|
||||
limit: Some(50),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
match response {
|
||||
Ok(thread_resp) => AppResponse {
|
||||
ok: true,
|
||||
data: json!({ "threads": thread_resp.threads }),
|
||||
events: thread_resp.events,
|
||||
},
|
||||
Err(err) => AppResponse {
|
||||
ok: false,
|
||||
data: json!({ "error": err.to_string() }),
|
||||
events: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_config(state: &AppState, config: deepseek_config::ConfigToml) -> Result<()> {
|
||||
if state.config_path.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut store = ConfigStore::load(state.config_path.clone())?;
|
||||
store.config = config;
|
||||
store.save()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use deepseek_app_server::{AppServerOptions, run};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "deepseek-app-server",
|
||||
about = "Run the DeepSeek app-server transport"
|
||||
)]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = "127.0.0.1")]
|
||||
host: String,
|
||||
#[arg(long, default_value_t = 8787)]
|
||||
port: u16,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let listen: SocketAddr = format!("{}:{}", cli.host, cli.port)
|
||||
.parse()
|
||||
.with_context(|| format!("invalid listen address {}:{}", cli.host, cli.port))?;
|
||||
run(AppServerOptions {
|
||||
listen,
|
||||
config_path: cli.config,
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "deepseek-tui-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Codex-style CLI facade for DeepSeek workspace architecture"
|
||||
|
||||
[[bin]]
|
||||
name = "deepseek"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
clap_complete.workspace = true
|
||||
deepseek-agent = { path = "../agent", version = "0.5.0" }
|
||||
deepseek-app-server = { path = "../app-server", version = "0.5.0" }
|
||||
deepseek-config = { path = "../config", version = "0.5.0" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.5.0" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.5.0" }
|
||||
deepseek-state = { path = "../state", version = "0.5.0" }
|
||||
chrono.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "deepseek-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Config schema and precedence model for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
@@ -0,0 +1,840 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const CONFIG_FILE_NAME: &str = "config.toml";
|
||||
const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro";
|
||||
const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
|
||||
const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
|
||||
const DEFAULT_OPENAI_MODEL: &str = "gpt-4.1";
|
||||
const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com";
|
||||
const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
|
||||
const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ProviderKind {
|
||||
#[default]
|
||||
Deepseek,
|
||||
NvidiaNim,
|
||||
Openai,
|
||||
}
|
||||
|
||||
impl ProviderKind {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Deepseek => "deepseek",
|
||||
Self::NvidiaNim => "nvidia-nim",
|
||||
Self::Openai => "openai",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"deepseek" | "deep-seek" => Some(Self::Deepseek),
|
||||
"nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => Some(Self::NvidiaNim),
|
||||
"openai" | "open-ai" => Some(Self::Openai),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderConfigToml {
|
||||
pub api_key: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProvidersToml {
|
||||
#[serde(default)]
|
||||
pub deepseek: ProviderConfigToml,
|
||||
#[serde(default)]
|
||||
pub nvidia_nim: ProviderConfigToml,
|
||||
#[serde(default)]
|
||||
pub openai: ProviderConfigToml,
|
||||
}
|
||||
|
||||
impl ProvidersToml {
|
||||
#[must_use]
|
||||
pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
|
||||
match provider {
|
||||
ProviderKind::Deepseek => &self.deepseek,
|
||||
ProviderKind::NvidiaNim => &self.nvidia_nim,
|
||||
ProviderKind::Openai => &self.openai,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
|
||||
match provider {
|
||||
ProviderKind::Deepseek => &mut self.deepseek,
|
||||
ProviderKind::NvidiaNim => &mut self.nvidia_nim,
|
||||
ProviderKind::Openai => &mut self.openai,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ConfigToml {
|
||||
/// TUI-compatible DeepSeek API key. Kept at the root so both `deepseek`
|
||||
/// and `deepseek-tui` can share a single config file.
|
||||
pub api_key: Option<String>,
|
||||
/// TUI-compatible DeepSeek base URL.
|
||||
pub base_url: Option<String>,
|
||||
/// TUI-compatible default DeepSeek model.
|
||||
pub default_text_model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub provider: ProviderKind,
|
||||
pub model: Option<String>,
|
||||
pub auth_mode: Option<String>,
|
||||
pub chatgpt_access_token: Option<String>,
|
||||
pub device_code_session: Option<String>,
|
||||
pub output_mode: Option<String>,
|
||||
pub log_level: Option<String>,
|
||||
pub telemetry: Option<bool>,
|
||||
pub approval_policy: Option<String>,
|
||||
pub sandbox_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub providers: ProvidersToml,
|
||||
#[serde(flatten)]
|
||||
pub extras: BTreeMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
impl ConfigToml {
|
||||
#[must_use]
|
||||
pub fn get_value(&self, key: &str) -> Option<String> {
|
||||
match key {
|
||||
"provider" => Some(self.provider.as_str().to_string()),
|
||||
"api_key" => self.api_key.clone(),
|
||||
"base_url" => self.base_url.clone(),
|
||||
"default_text_model" => self.default_text_model.clone(),
|
||||
"model" => self.model.clone(),
|
||||
"auth.mode" => self.auth_mode.clone(),
|
||||
"auth.chatgpt_access_token" => self.chatgpt_access_token.clone(),
|
||||
"auth.device_code_session" => self.device_code_session.clone(),
|
||||
"output_mode" => self.output_mode.clone(),
|
||||
"log_level" => self.log_level.clone(),
|
||||
"telemetry" => self.telemetry.map(|v| v.to_string()),
|
||||
"approval_policy" => self.approval_policy.clone(),
|
||||
"sandbox_mode" => self.sandbox_mode.clone(),
|
||||
"providers.deepseek.api_key" => self.providers.deepseek.api_key.clone(),
|
||||
"providers.deepseek.base_url" => self.providers.deepseek.base_url.clone(),
|
||||
"providers.deepseek.model" => self.providers.deepseek.model.clone(),
|
||||
"providers.nvidia_nim.api_key" => self.providers.nvidia_nim.api_key.clone(),
|
||||
"providers.nvidia_nim.base_url" => self.providers.nvidia_nim.base_url.clone(),
|
||||
"providers.nvidia_nim.model" => self.providers.nvidia_nim.model.clone(),
|
||||
"providers.openai.api_key" => self.providers.openai.api_key.clone(),
|
||||
"providers.openai.base_url" => self.providers.openai.base_url.clone(),
|
||||
"providers.openai.model" => self.providers.openai.model.clone(),
|
||||
_ => self.extras.get(key).map(toml::Value::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
|
||||
match key {
|
||||
"provider" => {
|
||||
self.provider = ProviderKind::parse(value)
|
||||
.with_context(|| format!("unknown provider '{value}'"))?;
|
||||
}
|
||||
"api_key" => self.api_key = Some(value.to_string()),
|
||||
"base_url" => self.base_url = Some(value.to_string()),
|
||||
"default_text_model" => self.default_text_model = Some(value.to_string()),
|
||||
"model" => self.model = Some(value.to_string()),
|
||||
"auth.mode" => self.auth_mode = Some(value.to_string()),
|
||||
"auth.chatgpt_access_token" => self.chatgpt_access_token = Some(value.to_string()),
|
||||
"auth.device_code_session" => self.device_code_session = Some(value.to_string()),
|
||||
"output_mode" => self.output_mode = Some(value.to_string()),
|
||||
"log_level" => self.log_level = Some(value.to_string()),
|
||||
"telemetry" => {
|
||||
self.telemetry = Some(parse_bool(value)?);
|
||||
}
|
||||
"approval_policy" => self.approval_policy = Some(value.to_string()),
|
||||
"sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
|
||||
"providers.deepseek.api_key" => {
|
||||
let value = value.to_string();
|
||||
self.providers.deepseek.api_key = Some(value.clone());
|
||||
self.api_key = Some(value);
|
||||
}
|
||||
"providers.deepseek.base_url" => {
|
||||
let value = value.to_string();
|
||||
self.providers.deepseek.base_url = Some(value.clone());
|
||||
self.base_url = Some(value);
|
||||
}
|
||||
"providers.deepseek.model" => {
|
||||
let value = value.to_string();
|
||||
self.providers.deepseek.model = Some(value.clone());
|
||||
self.default_text_model = Some(value);
|
||||
}
|
||||
"providers.openai.api_key" => self.providers.openai.api_key = Some(value.to_string()),
|
||||
"providers.openai.base_url" => self.providers.openai.base_url = Some(value.to_string()),
|
||||
"providers.openai.model" => self.providers.openai.model = Some(value.to_string()),
|
||||
"providers.nvidia_nim.api_key" => {
|
||||
self.providers.nvidia_nim.api_key = Some(value.to_string());
|
||||
}
|
||||
"providers.nvidia_nim.base_url" => {
|
||||
self.providers.nvidia_nim.base_url = Some(value.to_string());
|
||||
}
|
||||
"providers.nvidia_nim.model" => {
|
||||
self.providers.nvidia_nim.model = Some(value.to_string());
|
||||
}
|
||||
_ => {
|
||||
self.extras
|
||||
.insert(key.to_string(), toml::Value::String(value.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unset_value(&mut self, key: &str) -> Result<()> {
|
||||
match key {
|
||||
"provider" => self.provider = ProviderKind::Deepseek,
|
||||
"api_key" => self.api_key = None,
|
||||
"base_url" => self.base_url = None,
|
||||
"default_text_model" => self.default_text_model = None,
|
||||
"model" => self.model = None,
|
||||
"auth.mode" => self.auth_mode = None,
|
||||
"auth.chatgpt_access_token" => self.chatgpt_access_token = None,
|
||||
"auth.device_code_session" => self.device_code_session = None,
|
||||
"output_mode" => self.output_mode = None,
|
||||
"log_level" => self.log_level = None,
|
||||
"telemetry" => self.telemetry = None,
|
||||
"approval_policy" => self.approval_policy = None,
|
||||
"sandbox_mode" => self.sandbox_mode = None,
|
||||
"providers.deepseek.api_key" => {
|
||||
self.providers.deepseek.api_key = None;
|
||||
self.api_key = None;
|
||||
}
|
||||
"providers.deepseek.base_url" => {
|
||||
self.providers.deepseek.base_url = None;
|
||||
self.base_url = None;
|
||||
}
|
||||
"providers.deepseek.model" => {
|
||||
self.providers.deepseek.model = None;
|
||||
self.default_text_model = None;
|
||||
}
|
||||
"providers.openai.api_key" => self.providers.openai.api_key = None,
|
||||
"providers.openai.base_url" => self.providers.openai.base_url = None,
|
||||
"providers.openai.model" => self.providers.openai.model = None,
|
||||
"providers.nvidia_nim.api_key" => self.providers.nvidia_nim.api_key = None,
|
||||
"providers.nvidia_nim.base_url" => self.providers.nvidia_nim.base_url = None,
|
||||
"providers.nvidia_nim.model" => self.providers.nvidia_nim.model = None,
|
||||
_ => {
|
||||
self.extras.remove(key);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn list_values(&self) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
out.insert("provider".to_string(), self.provider.as_str().to_string());
|
||||
|
||||
if let Some(v) = self.api_key.as_ref() {
|
||||
out.insert("api_key".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.base_url.as_ref() {
|
||||
out.insert("base_url".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.default_text_model.as_ref() {
|
||||
out.insert("default_text_model".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.model.as_ref() {
|
||||
out.insert("model".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.auth_mode.as_ref() {
|
||||
out.insert("auth.mode".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.chatgpt_access_token.as_ref() {
|
||||
out.insert("auth.chatgpt_access_token".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.device_code_session.as_ref() {
|
||||
out.insert("auth.device_code_session".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.output_mode.as_ref() {
|
||||
out.insert("output_mode".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.log_level.as_ref() {
|
||||
out.insert("log_level".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.telemetry {
|
||||
out.insert("telemetry".to_string(), v.to_string());
|
||||
}
|
||||
if let Some(v) = self.approval_policy.as_ref() {
|
||||
out.insert("approval_policy".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.sandbox_mode.as_ref() {
|
||||
out.insert("sandbox_mode".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.providers.deepseek.api_key.as_ref() {
|
||||
out.insert("providers.deepseek.api_key".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.providers.deepseek.base_url.as_ref() {
|
||||
out.insert("providers.deepseek.base_url".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.providers.deepseek.model.as_ref() {
|
||||
out.insert("providers.deepseek.model".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.providers.openai.api_key.as_ref() {
|
||||
out.insert("providers.openai.api_key".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.providers.openai.base_url.as_ref() {
|
||||
out.insert("providers.openai.base_url".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.providers.openai.model.as_ref() {
|
||||
out.insert("providers.openai.model".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.providers.nvidia_nim.api_key.as_ref() {
|
||||
out.insert("providers.nvidia_nim.api_key".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.providers.nvidia_nim.base_url.as_ref() {
|
||||
out.insert("providers.nvidia_nim.base_url".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.providers.nvidia_nim.model.as_ref() {
|
||||
out.insert("providers.nvidia_nim.model".to_string(), v.clone());
|
||||
}
|
||||
|
||||
for (k, v) in &self.extras {
|
||||
out.insert(k.clone(), v.to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
|
||||
let env = EnvRuntimeOverrides::load();
|
||||
let provider = cli.provider.or(env.provider).unwrap_or(self.provider);
|
||||
|
||||
let provider_cfg = self.providers.for_provider(provider);
|
||||
let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
|
||||
.then(|| self.api_key.clone())
|
||||
.flatten();
|
||||
let root_deepseek_base_url = (provider == ProviderKind::Deepseek)
|
||||
.then(|| self.base_url.clone())
|
||||
.flatten();
|
||||
let root_deepseek_model = (provider == ProviderKind::Deepseek)
|
||||
.then(|| self.default_text_model.clone())
|
||||
.flatten();
|
||||
let api_key = cli
|
||||
.api_key
|
||||
.clone()
|
||||
.or_else(|| env.api_key_for(provider))
|
||||
.or_else(|| provider_cfg.api_key.clone())
|
||||
.or(root_deepseek_api_key);
|
||||
|
||||
let base_url = cli
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| env.base_url_for(provider))
|
||||
.or_else(|| provider_cfg.base_url.clone())
|
||||
.or(root_deepseek_base_url)
|
||||
.unwrap_or_else(|| match provider {
|
||||
ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
|
||||
ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
|
||||
ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
|
||||
});
|
||||
|
||||
let model = cli
|
||||
.model
|
||||
.clone()
|
||||
.or_else(|| env.model.clone())
|
||||
.or_else(|| provider_cfg.model.clone())
|
||||
.or(root_deepseek_model)
|
||||
.or_else(|| self.model.clone())
|
||||
.unwrap_or_else(|| match provider {
|
||||
ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL.to_string(),
|
||||
ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL.to_string(),
|
||||
ProviderKind::Openai => DEFAULT_OPENAI_MODEL.to_string(),
|
||||
});
|
||||
let model = normalize_model_for_provider(provider, &model);
|
||||
|
||||
let output_mode = cli
|
||||
.output_mode
|
||||
.clone()
|
||||
.or_else(|| env.output_mode.clone())
|
||||
.or_else(|| self.output_mode.clone());
|
||||
let auth_mode = cli
|
||||
.auth_mode
|
||||
.clone()
|
||||
.or_else(|| env.auth_mode.clone())
|
||||
.or_else(|| self.auth_mode.clone());
|
||||
let log_level = cli
|
||||
.log_level
|
||||
.clone()
|
||||
.or_else(|| env.log_level.clone())
|
||||
.or_else(|| self.log_level.clone());
|
||||
let telemetry = cli
|
||||
.telemetry
|
||||
.or(env.telemetry)
|
||||
.or(self.telemetry)
|
||||
.unwrap_or(false);
|
||||
let approval_policy = cli
|
||||
.approval_policy
|
||||
.clone()
|
||||
.or_else(|| env.approval_policy.clone())
|
||||
.or_else(|| self.approval_policy.clone());
|
||||
let sandbox_mode = cli
|
||||
.sandbox_mode
|
||||
.clone()
|
||||
.or_else(|| env.sandbox_mode.clone())
|
||||
.or_else(|| self.sandbox_mode.clone());
|
||||
|
||||
ResolvedRuntimeOptions {
|
||||
provider,
|
||||
model,
|
||||
api_key,
|
||||
base_url,
|
||||
auth_mode,
|
||||
output_mode,
|
||||
log_level,
|
||||
telemetry,
|
||||
approval_policy,
|
||||
sandbox_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
|
||||
let normalized = model.trim().to_ascii_lowercase();
|
||||
match (provider, normalized.as_str()) {
|
||||
(ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
|
||||
DEFAULT_NVIDIA_NIM_MODEL.to_string()
|
||||
}
|
||||
(
|
||||
ProviderKind::NvidiaNim,
|
||||
"deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
|
||||
| "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
|
||||
) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
|
||||
_ => model.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CliRuntimeOverrides {
|
||||
pub provider: Option<ProviderKind>,
|
||||
pub model: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub auth_mode: Option<String>,
|
||||
pub output_mode: Option<String>,
|
||||
pub log_level: Option<String>,
|
||||
pub telemetry: Option<bool>,
|
||||
pub approval_policy: Option<String>,
|
||||
pub sandbox_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedRuntimeOptions {
|
||||
pub provider: ProviderKind,
|
||||
pub model: String,
|
||||
pub api_key: Option<String>,
|
||||
pub base_url: String,
|
||||
pub auth_mode: Option<String>,
|
||||
pub output_mode: Option<String>,
|
||||
pub log_level: Option<String>,
|
||||
pub telemetry: bool,
|
||||
pub approval_policy: Option<String>,
|
||||
pub sandbox_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfigStore {
|
||||
path: PathBuf,
|
||||
pub config: ConfigToml,
|
||||
}
|
||||
|
||||
impl ConfigStore {
|
||||
pub fn load(path: Option<PathBuf>) -> Result<Self> {
|
||||
let path = resolve_config_path(path)?;
|
||||
if !path.exists() {
|
||||
return Ok(Self {
|
||||
path,
|
||||
config: ConfigToml::default(),
|
||||
});
|
||||
}
|
||||
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read config at {}", path.display()))?;
|
||||
let parsed: ConfigToml = toml::from_str(&raw)
|
||||
.with_context(|| format!("failed to parse config at {}", path.display()))?;
|
||||
|
||||
Ok(Self {
|
||||
path,
|
||||
config: parsed,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!("failed to create config directory {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
let body = toml::to_string_pretty(&self.config).context("failed to serialize config")?;
|
||||
fs::write(&self.path, body)
|
||||
.with_context(|| format!("failed to write config at {}", self.path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
|
||||
if let Some(path) = explicit {
|
||||
return Ok(path);
|
||||
}
|
||||
if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
|
||||
let trimmed = path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(PathBuf::from(trimmed));
|
||||
}
|
||||
}
|
||||
default_config_path()
|
||||
}
|
||||
|
||||
pub fn default_config_path() -> Result<PathBuf> {
|
||||
let home = dirs::home_dir().context("failed to resolve home directory for config path")?;
|
||||
Ok(home.join(".deepseek").join(CONFIG_FILE_NAME))
|
||||
}
|
||||
|
||||
fn parse_bool(raw: &str) -> Result<bool> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" | "enabled" => Ok(true),
|
||||
"0" | "false" | "no" | "off" | "disabled" => Ok(false),
|
||||
_ => bail!("invalid boolean '{raw}'"),
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_secret(secret: &str) -> String {
|
||||
if secret.len() <= 8 {
|
||||
return "********".to_string();
|
||||
}
|
||||
format!("{}***{}", &secret[..4], &secret[secret.len() - 4..])
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct EnvRuntimeOverrides {
|
||||
provider: Option<ProviderKind>,
|
||||
model: Option<String>,
|
||||
output_mode: Option<String>,
|
||||
auth_mode: Option<String>,
|
||||
log_level: Option<String>,
|
||||
telemetry: Option<bool>,
|
||||
approval_policy: Option<String>,
|
||||
sandbox_mode: Option<String>,
|
||||
deepseek_api_key: Option<String>,
|
||||
openai_api_key: Option<String>,
|
||||
nvidia_api_key: Option<String>,
|
||||
deepseek_base_url: Option<String>,
|
||||
nvidia_base_url: Option<String>,
|
||||
openai_base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvRuntimeOverrides {
|
||||
fn load() -> Self {
|
||||
Self {
|
||||
provider: std::env::var("DEEPSEEK_PROVIDER")
|
||||
.ok()
|
||||
.and_then(|v| ProviderKind::parse(&v)),
|
||||
model: std::env::var("DEEPSEEK_MODEL").ok(),
|
||||
output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(),
|
||||
auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(),
|
||||
log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(),
|
||||
telemetry: std::env::var("DEEPSEEK_TELEMETRY")
|
||||
.ok()
|
||||
.and_then(|v| parse_bool(&v).ok()),
|
||||
approval_policy: std::env::var("DEEPSEEK_APPROVAL_POLICY").ok(),
|
||||
sandbox_mode: std::env::var("DEEPSEEK_SANDBOX_MODE").ok(),
|
||||
deepseek_api_key: std::env::var("DEEPSEEK_API_KEY")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
openai_api_key: std::env::var("OPENAI_API_KEY")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
nvidia_api_key: std::env::var("NVIDIA_API_KEY")
|
||||
.or_else(|_| std::env::var("NVIDIA_NIM_API_KEY"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
deepseek_base_url: std::env::var("DEEPSEEK_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
|
||||
.or_else(|_| std::env::var("NIM_BASE_URL"))
|
||||
.or_else(|_| std::env::var("NVIDIA_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
openai_base_url: std::env::var("OPENAI_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
fn api_key_for(&self, provider: ProviderKind) -> Option<String> {
|
||||
match provider {
|
||||
ProviderKind::Deepseek => self.deepseek_api_key.clone(),
|
||||
ProviderKind::NvidiaNim => self
|
||||
.nvidia_api_key
|
||||
.clone()
|
||||
.or_else(|| self.deepseek_api_key.clone()),
|
||||
ProviderKind::Openai => self.openai_api_key.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
|
||||
match provider {
|
||||
ProviderKind::Deepseek => self.deepseek_base_url.clone(),
|
||||
ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
|
||||
ProviderKind::Openai => self.openai_base_url.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
|
||||
}
|
||||
|
||||
struct EnvGuard {
|
||||
deepseek_api_key: Option<OsString>,
|
||||
deepseek_base_url: Option<OsString>,
|
||||
deepseek_model: Option<OsString>,
|
||||
deepseek_provider: Option<OsString>,
|
||||
nvidia_api_key: Option<OsString>,
|
||||
nvidia_nim_api_key: Option<OsString>,
|
||||
nim_base_url: Option<OsString>,
|
||||
nvidia_base_url: Option<OsString>,
|
||||
nvidia_nim_base_url: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn without_deepseek_runtime_overrides() -> Self {
|
||||
let guard = Self {
|
||||
deepseek_api_key: env::var_os("DEEPSEEK_API_KEY"),
|
||||
deepseek_base_url: env::var_os("DEEPSEEK_BASE_URL"),
|
||||
deepseek_model: env::var_os("DEEPSEEK_MODEL"),
|
||||
deepseek_provider: env::var_os("DEEPSEEK_PROVIDER"),
|
||||
nvidia_api_key: env::var_os("NVIDIA_API_KEY"),
|
||||
nvidia_nim_api_key: env::var_os("NVIDIA_NIM_API_KEY"),
|
||||
nim_base_url: env::var_os("NIM_BASE_URL"),
|
||||
nvidia_base_url: env::var_os("NVIDIA_BASE_URL"),
|
||||
nvidia_nim_base_url: env::var_os("NVIDIA_NIM_BASE_URL"),
|
||||
};
|
||||
// Safety: test-only environment mutation guarded by a module mutex.
|
||||
unsafe {
|
||||
env::remove_var("DEEPSEEK_API_KEY");
|
||||
env::remove_var("DEEPSEEK_BASE_URL");
|
||||
env::remove_var("DEEPSEEK_MODEL");
|
||||
env::remove_var("DEEPSEEK_PROVIDER");
|
||||
env::remove_var("NVIDIA_API_KEY");
|
||||
env::remove_var("NVIDIA_NIM_API_KEY");
|
||||
env::remove_var("NIM_BASE_URL");
|
||||
env::remove_var("NVIDIA_BASE_URL");
|
||||
env::remove_var("NVIDIA_NIM_BASE_URL");
|
||||
}
|
||||
guard
|
||||
}
|
||||
|
||||
unsafe fn restore_var(key: &str, value: Option<OsString>) {
|
||||
if let Some(value) = value {
|
||||
unsafe { env::set_var(key, value) };
|
||||
} else {
|
||||
unsafe { env::remove_var(key) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
// Safety: test-only environment mutation guarded by a module mutex.
|
||||
unsafe {
|
||||
Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take());
|
||||
Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take());
|
||||
Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take());
|
||||
Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take());
|
||||
Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take());
|
||||
Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take());
|
||||
Self::restore_var("NIM_BASE_URL", self.nim_base_url.take());
|
||||
Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take());
|
||||
Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_deepseek_fields_are_runtime_fallbacks() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
let config = ConfigToml {
|
||||
api_key: Some("root-key".to_string()),
|
||||
base_url: Some("https://api.deepseek.com".to_string()),
|
||||
default_text_model: Some("deepseek-v4-pro".to_string()),
|
||||
..ConfigToml::default()
|
||||
};
|
||||
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
|
||||
assert_eq!(resolved.provider, ProviderKind::Deepseek);
|
||||
assert_eq!(resolved.api_key.as_deref(), Some("root-key"));
|
||||
assert_eq!(resolved.base_url, "https://api.deepseek.com");
|
||||
assert_eq!(resolved.model, "deepseek-v4-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_specific_deepseek_fields_override_tui_compat_fields() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
let mut config = ConfigToml {
|
||||
api_key: Some("root-key".to_string()),
|
||||
base_url: Some("https://api.deepseek.com".to_string()),
|
||||
default_text_model: Some("deepseek-v4-pro".to_string()),
|
||||
..ConfigToml::default()
|
||||
};
|
||||
config.providers.deepseek.api_key = Some("provider-key".to_string());
|
||||
config.providers.deepseek.base_url = Some("https://api.deepseeki.com".to_string());
|
||||
config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
|
||||
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
|
||||
assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
|
||||
assert_eq!(resolved.base_url, "https://api.deepseeki.com");
|
||||
assert_eq!(resolved.model, "deepseek-v4-flash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_provider_defaults_to_catalog_endpoint_and_model() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
let config = ConfigToml {
|
||||
provider: ProviderKind::NvidiaNim,
|
||||
..ConfigToml::default()
|
||||
};
|
||||
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
|
||||
assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.base_url, DEFAULT_NVIDIA_NIM_BASE_URL);
|
||||
assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_provider_uses_provider_specific_credentials() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
let mut config = ConfigToml {
|
||||
provider: ProviderKind::NvidiaNim,
|
||||
..ConfigToml::default()
|
||||
};
|
||||
config.providers.nvidia_nim.api_key = Some("nim-key".to_string());
|
||||
config.providers.nvidia_nim.base_url = Some("https://nim.example/v1".to_string());
|
||||
config.providers.nvidia_nim.model = Some("deepseek-ai/deepseek-v4-pro".to_string());
|
||||
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
|
||||
assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.api_key.as_deref(), Some("nim-key"));
|
||||
assert_eq!(resolved.base_url, "https://nim.example/v1");
|
||||
assert_eq!(resolved.model, "deepseek-ai/deepseek-v4-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_provider_normalizes_flash_aliases() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
let cli = CliRuntimeOverrides {
|
||||
provider: Some(ProviderKind::NvidiaNim),
|
||||
model: Some("deepseek-v4-flash".to_string()),
|
||||
..CliRuntimeOverrides::default()
|
||||
};
|
||||
|
||||
let resolved = ConfigToml::default().resolve_runtime_options(&cli);
|
||||
|
||||
assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_FLASH_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_provider_uses_nvidia_env_credentials() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
// Safety: test-only environment mutation guarded by a module mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
|
||||
env::set_var("NVIDIA_API_KEY", "nim-env-key");
|
||||
env::set_var("NVIDIA_NIM_BASE_URL", "https://nim-env.example/v1");
|
||||
}
|
||||
|
||||
let config = ConfigToml::default();
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
|
||||
assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.api_key.as_deref(), Some("nim-env-key"));
|
||||
assert_eq!(resolved.base_url, "https://nim-env.example/v1");
|
||||
assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_provider_accepts_short_nim_base_url_alias() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
// Safety: test-only environment mutation guarded by a module mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
|
||||
env::set_var("NVIDIA_API_KEY", "nim-env-key");
|
||||
env::set_var("NIM_BASE_URL", "https://short-nim.example/v1");
|
||||
}
|
||||
|
||||
let config = ConfigToml::default();
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
|
||||
assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.base_url, "https://short-nim.example/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_provider_can_fallback_to_deepseek_api_key_env() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
// Safety: test-only environment mutation guarded by a module mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
|
||||
env::set_var("DEEPSEEK_API_KEY", "deepseek-compat-key");
|
||||
}
|
||||
|
||||
let config = ConfigToml::default();
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
|
||||
assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.api_key.as_deref(), Some("deepseek-compat-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_values_redacts_root_api_key() {
|
||||
let config = ConfigToml {
|
||||
api_key: Some("sk-deepseek-secret".to_string()),
|
||||
..ConfigToml::default()
|
||||
};
|
||||
|
||||
let values = config.list_values();
|
||||
|
||||
assert_eq!(
|
||||
values.get("api_key").map(String::as_str),
|
||||
Some("sk-d***cret")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "deepseek-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Core runtime boundaries for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
deepseek-agent = { path = "../agent", version = "0.5.0" }
|
||||
deepseek-config = { path = "../config", version = "0.5.0" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.5.0" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.5.0" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.5.0" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
|
||||
deepseek-state = { path = "../state", version = "0.5.0" }
|
||||
deepseek-tools = { path = "../tools", version = "0.5.0" }
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
uuid.workspace = true
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "deepseek-execpolicy"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Execution policy and approval model parity for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
|
||||
serde.workspace = true
|
||||
@@ -0,0 +1,191 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::Result;
|
||||
use deepseek_protocol::{NetworkPolicyAmendment, NetworkPolicyRuleAction};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AskForApproval {
|
||||
UnlessTrusted,
|
||||
OnFailure,
|
||||
OnRequest,
|
||||
Reject {
|
||||
sandbox_approval: bool,
|
||||
rules: bool,
|
||||
mcp_elicitations: bool,
|
||||
},
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ExecPolicyAmendment {
|
||||
pub prefixes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ExecApprovalRequirement {
|
||||
Skip {
|
||||
bypass_sandbox: bool,
|
||||
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
},
|
||||
NeedsApproval {
|
||||
reason: String,
|
||||
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
proposed_network_policy_amendments: Vec<NetworkPolicyAmendment>,
|
||||
},
|
||||
Forbidden {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ExecApprovalRequirement {
|
||||
pub fn reason(&self) -> &str {
|
||||
match self {
|
||||
ExecApprovalRequirement::Skip { .. } => "Execution allowed by policy.",
|
||||
ExecApprovalRequirement::NeedsApproval { reason, .. } => reason,
|
||||
ExecApprovalRequirement::Forbidden { reason } => reason,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn phase(&self) -> &'static str {
|
||||
match self {
|
||||
ExecApprovalRequirement::Skip { .. } => "allowed",
|
||||
ExecApprovalRequirement::NeedsApproval { .. } => "needs_approval",
|
||||
ExecApprovalRequirement::Forbidden { .. } => "forbidden",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ExecPolicyDecision {
|
||||
pub allow: bool,
|
||||
pub requires_approval: bool,
|
||||
pub requirement: ExecApprovalRequirement,
|
||||
pub matched_rule: Option<String>,
|
||||
}
|
||||
|
||||
impl ExecPolicyDecision {
|
||||
pub fn reason(&self) -> &str {
|
||||
self.requirement.reason()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecPolicyContext<'a> {
|
||||
pub command: &'a str,
|
||||
pub cwd: &'a str,
|
||||
pub ask_for_approval: AskForApproval,
|
||||
pub sandbox_mode: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ExecPolicyEngine {
|
||||
trusted_prefixes: Vec<String>,
|
||||
denied_prefixes: Vec<String>,
|
||||
approved_for_session: HashSet<String>,
|
||||
}
|
||||
|
||||
impl ExecPolicyEngine {
|
||||
pub fn new(trusted_prefixes: Vec<String>, denied_prefixes: Vec<String>) -> Self {
|
||||
Self {
|
||||
trusted_prefixes,
|
||||
denied_prefixes,
|
||||
approved_for_session: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remember_session_approval(&mut self, approval_key: String) {
|
||||
self.approved_for_session.insert(approval_key);
|
||||
}
|
||||
|
||||
pub fn is_session_approved(&self, approval_key: &str) -> bool {
|
||||
self.approved_for_session.contains(approval_key)
|
||||
}
|
||||
|
||||
pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result<ExecPolicyDecision> {
|
||||
let normalized = normalize_command(ctx.command);
|
||||
if let Some(rule) = self
|
||||
.denied_prefixes
|
||||
.iter()
|
||||
.find(|rule| normalized.starts_with(&normalize_command(rule)))
|
||||
{
|
||||
return Ok(ExecPolicyDecision {
|
||||
allow: false,
|
||||
requires_approval: false,
|
||||
matched_rule: Some(rule.clone()),
|
||||
requirement: ExecApprovalRequirement::Forbidden {
|
||||
reason: format!("Command blocked by denied prefix rule '{rule}'"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let trusted_rule = self
|
||||
.trusted_prefixes
|
||||
.iter()
|
||||
.find(|rule| normalized.starts_with(&normalize_command(rule)))
|
||||
.cloned();
|
||||
let is_trusted = trusted_rule.is_some();
|
||||
|
||||
let requirement = match ctx.ask_for_approval {
|
||||
AskForApproval::Never => ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
AskForApproval::UnlessTrusted if is_trusted => ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
AskForApproval::OnFailure => ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
AskForApproval::Reject { rules, .. } if rules => ExecApprovalRequirement::Forbidden {
|
||||
reason: "Policy is configured to reject rule-exceptions.".to_string(),
|
||||
},
|
||||
_ => ExecApprovalRequirement::NeedsApproval {
|
||||
reason: if is_trusted {
|
||||
"Approval requested by policy mode.".to_string()
|
||||
} else {
|
||||
"Unmatched command prefix requires approval.".to_string()
|
||||
},
|
||||
proposed_execpolicy_amendment: if is_trusted {
|
||||
None
|
||||
} else {
|
||||
Some(ExecPolicyAmendment {
|
||||
prefixes: vec![first_token(ctx.command)],
|
||||
})
|
||||
},
|
||||
proposed_network_policy_amendments: vec![NetworkPolicyAmendment {
|
||||
host: ctx.cwd.to_string(),
|
||||
action: NetworkPolicyRuleAction::Allow,
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
let (allow, requires_approval) = match requirement {
|
||||
ExecApprovalRequirement::Skip { .. } => (true, false),
|
||||
ExecApprovalRequirement::NeedsApproval { .. } => (true, true),
|
||||
ExecApprovalRequirement::Forbidden { .. } => (false, false),
|
||||
};
|
||||
|
||||
Ok(ExecPolicyDecision {
|
||||
allow,
|
||||
requires_approval,
|
||||
matched_rule: trusted_rule,
|
||||
requirement,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_command(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn first_token(command: &str) -> String {
|
||||
command
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "deepseek-hooks"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Hook dispatch and notifications parity for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
chrono.workspace = true
|
||||
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use deepseek_protocol::EventFrame;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum HookEvent {
|
||||
ResponseStart {
|
||||
response_id: String,
|
||||
},
|
||||
ResponseDelta {
|
||||
response_id: String,
|
||||
delta: String,
|
||||
},
|
||||
ResponseEnd {
|
||||
response_id: String,
|
||||
},
|
||||
ToolLifecycle {
|
||||
response_id: String,
|
||||
tool_name: String,
|
||||
phase: String,
|
||||
payload: Value,
|
||||
},
|
||||
JobLifecycle {
|
||||
job_id: String,
|
||||
phase: String,
|
||||
progress: Option<u8>,
|
||||
detail: Option<String>,
|
||||
},
|
||||
ApprovalLifecycle {
|
||||
approval_id: String,
|
||||
phase: String,
|
||||
reason: Option<String>,
|
||||
},
|
||||
GenericEventFrame {
|
||||
frame: EventFrame,
|
||||
},
|
||||
}
|
||||
|
||||
impl HookEvent {
|
||||
pub fn to_json(&self) -> Value {
|
||||
serde_json::to_value(self).unwrap_or_else(|_| json!({"type":"serialization_error"}))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait HookSink: Send + Sync {
|
||||
async fn emit(&self, event: &HookEvent) -> Result<()>;
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct StdoutHookSink;
|
||||
|
||||
#[async_trait]
|
||||
impl HookSink for StdoutHookSink {
|
||||
async fn emit(&self, event: &HookEvent) -> Result<()> {
|
||||
println!("{}", event.to_json());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct JsonlHookSink {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl JsonlHookSink {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self { path }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HookSink for JsonlHookSink {
|
||||
async fn emit(&self, event: &HookEvent) -> Result<()> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.with_context(|| {
|
||||
format!("failed to create hook log directory {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)
|
||||
.await
|
||||
.with_context(|| format!("failed to open hook log {}", self.path.display()))?;
|
||||
let payload = json!({
|
||||
"at": Utc::now().to_rfc3339(),
|
||||
"event": event
|
||||
});
|
||||
let encoded = serde_json::to_string(&payload).context("failed to encode hook event")?;
|
||||
file.write_all(encoded.as_bytes())
|
||||
.await
|
||||
.context("failed to write hook event")?;
|
||||
file.write_all(b"\n")
|
||||
.await
|
||||
.context("failed to write hook event newline")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WebhookHookSink {
|
||||
url: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl WebhookHookSink {
|
||||
pub fn new(url: String) -> Self {
|
||||
Self {
|
||||
url,
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HookSink for WebhookHookSink {
|
||||
async fn emit(&self, event: &HookEvent) -> Result<()> {
|
||||
let mut retries = 0usize;
|
||||
loop {
|
||||
let resp = self
|
||||
.client
|
||||
.post(&self.url)
|
||||
.json(&json!({
|
||||
"at": Utc::now().to_rfc3339(),
|
||||
"event": event,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
match resp {
|
||||
Ok(response) if response.status().is_success() => return Ok(()),
|
||||
Ok(response) => {
|
||||
if retries >= 2 {
|
||||
anyhow::bail!("webhook returned non-success status {}", response.status());
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if retries >= 2 {
|
||||
return Err(err).context("webhook request failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
retries += 1;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200 * retries as u64)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct HookDispatcher {
|
||||
sinks: Vec<Arc<dyn HookSink>>,
|
||||
}
|
||||
|
||||
impl HookDispatcher {
|
||||
pub fn add_sink(&mut self, sink: Arc<dyn HookSink>) {
|
||||
self.sinks.push(sink);
|
||||
}
|
||||
|
||||
pub async fn emit(&self, event: HookEvent) {
|
||||
for sink in &self.sinks {
|
||||
let _ = sink.emit(&event).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "deepseek-mcp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "MCP server lifecycle and tool proxy compatibility for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,893 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerConfig {
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ToolFilter {
|
||||
#[serde(default)]
|
||||
pub allow: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub deny: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerDefinition {
|
||||
pub config: McpServerConfig,
|
||||
#[serde(default)]
|
||||
pub filter: ToolFilter,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum McpStartupStatus {
|
||||
Starting,
|
||||
Ready,
|
||||
Failed { error: String },
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpStartupUpdateEvent {
|
||||
pub server_name: String,
|
||||
pub status: McpStartupStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpStartupFailure {
|
||||
pub server_name: String,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpStartupCompleteEvent {
|
||||
pub ready: Vec<String>,
|
||||
pub failed: Vec<McpStartupFailure>,
|
||||
pub cancelled: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolDescriptor {
|
||||
pub server_name: String,
|
||||
pub tool_name: String,
|
||||
pub qualified_name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpResourceDescriptor {
|
||||
pub server_name: String,
|
||||
pub uri: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub trait McpManagedClient: Send + Sync {
|
||||
fn list_tools(&self) -> Result<Vec<McpToolDescriptor>>;
|
||||
fn call_tool(&self, tool_name: &str, arguments: Value) -> Result<Value>;
|
||||
fn list_resources(&self) -> Result<Vec<McpResourceDescriptor>>;
|
||||
fn read_resource(&self, uri: &str) -> Result<Value>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InMemoryMcpClient {
|
||||
tools: HashMap<String, Value>,
|
||||
resources: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
impl InMemoryMcpClient {
|
||||
pub fn with_tool(mut self, name: &str, sample_result: Value) -> Self {
|
||||
self.tools.insert(name.to_string(), sample_result);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_resource(mut self, uri: &str, data: Value) -> Self {
|
||||
self.resources.insert(uri.to_string(), data);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl McpManagedClient for InMemoryMcpClient {
|
||||
fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||
Ok(self
|
||||
.tools
|
||||
.keys()
|
||||
.map(|name| McpToolDescriptor {
|
||||
server_name: "in-memory".to_string(),
|
||||
tool_name: name.clone(),
|
||||
qualified_name: name.clone(),
|
||||
description: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn call_tool(&self, tool_name: &str, _arguments: Value) -> Result<Value> {
|
||||
self.tools
|
||||
.get(tool_name)
|
||||
.cloned()
|
||||
.with_context(|| format!("tool '{tool_name}' not found"))
|
||||
}
|
||||
|
||||
fn list_resources(&self) -> Result<Vec<McpResourceDescriptor>> {
|
||||
Ok(self
|
||||
.resources
|
||||
.keys()
|
||||
.map(|uri| McpResourceDescriptor {
|
||||
server_name: "in-memory".to_string(),
|
||||
uri: uri.clone(),
|
||||
description: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn read_resource(&self, uri: &str) -> Result<Value> {
|
||||
self.resources
|
||||
.get(uri)
|
||||
.cloned()
|
||||
.with_context(|| format!("resource '{uri}' not found"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct McpManager {
|
||||
configs: HashMap<String, (McpServerConfig, ToolFilter)>,
|
||||
clients: HashMap<String, Box<dyn McpManagedClient>>,
|
||||
}
|
||||
|
||||
impl McpManager {
|
||||
pub fn register_server(
|
||||
&mut self,
|
||||
config: McpServerConfig,
|
||||
filter: ToolFilter,
|
||||
client: Box<dyn McpManagedClient>,
|
||||
) {
|
||||
self.clients.insert(config.name.clone(), client);
|
||||
self.configs.insert(config.name.clone(), (config, filter));
|
||||
}
|
||||
|
||||
pub fn start_all<F>(&self, mut emit: F) -> McpStartupCompleteEvent
|
||||
where
|
||||
F: FnMut(McpStartupUpdateEvent),
|
||||
{
|
||||
let mut ready = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let mut cancelled = Vec::new();
|
||||
for (server_name, (cfg, _)) in &self.configs {
|
||||
if !cfg.enabled {
|
||||
emit(McpStartupUpdateEvent {
|
||||
server_name: server_name.clone(),
|
||||
status: McpStartupStatus::Cancelled,
|
||||
});
|
||||
cancelled.push(server_name.clone());
|
||||
continue;
|
||||
}
|
||||
emit(McpStartupUpdateEvent {
|
||||
server_name: server_name.clone(),
|
||||
status: McpStartupStatus::Starting,
|
||||
});
|
||||
if self.clients.contains_key(server_name) {
|
||||
emit(McpStartupUpdateEvent {
|
||||
server_name: server_name.clone(),
|
||||
status: McpStartupStatus::Ready,
|
||||
});
|
||||
ready.push(server_name.clone());
|
||||
} else {
|
||||
let error = "client not registered".to_string();
|
||||
emit(McpStartupUpdateEvent {
|
||||
server_name: server_name.clone(),
|
||||
status: McpStartupStatus::Failed {
|
||||
error: error.clone(),
|
||||
},
|
||||
});
|
||||
failed.push(McpStartupFailure {
|
||||
server_name: server_name.clone(),
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
McpStartupCompleteEvent {
|
||||
ready,
|
||||
failed,
|
||||
cancelled,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_server(&mut self, server_name: &str) -> Result<()> {
|
||||
self.clients
|
||||
.remove(server_name)
|
||||
.with_context(|| format!("server '{server_name}' is not running"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unregister_server(&mut self, server_name: &str) -> Result<()> {
|
||||
let had_config = self.configs.remove(server_name).is_some();
|
||||
self.clients.remove(server_name);
|
||||
if !had_config {
|
||||
bail!("server '{server_name}' is not registered");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||
let mut out = Vec::new();
|
||||
for (server_name, (_, filter)) in &self.configs {
|
||||
let Some(client) = self.clients.get(server_name) else {
|
||||
continue;
|
||||
};
|
||||
let tools = client.list_tools()?;
|
||||
for tool in tools {
|
||||
if !allowed_by_filter(&tool.tool_name, filter) {
|
||||
continue;
|
||||
}
|
||||
let qualified_name = qualify_tool_name(server_name, &tool.tool_name);
|
||||
out.push(McpToolDescriptor {
|
||||
server_name: server_name.clone(),
|
||||
tool_name: tool.tool_name,
|
||||
qualified_name,
|
||||
description: tool.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn call_tool(&self, server_name: &str, tool_name: &str, arguments: Value) -> Result<Value> {
|
||||
let client = self
|
||||
.clients
|
||||
.get(server_name)
|
||||
.with_context(|| format!("MCP server '{server_name}' not available"))?;
|
||||
client.call_tool(tool_name, arguments)
|
||||
}
|
||||
|
||||
pub fn call_qualified_tool(
|
||||
&self,
|
||||
qualified_tool_name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<Value> {
|
||||
let (server_name, tool_name) = parse_qualified_tool_name(qualified_tool_name)
|
||||
.with_context(|| format!("invalid qualified MCP tool name: {qualified_tool_name}"))?;
|
||||
self.call_tool(&server_name, &tool_name, arguments)
|
||||
}
|
||||
|
||||
pub fn list_resources(&self) -> Result<Vec<McpResourceDescriptor>> {
|
||||
let mut out = Vec::new();
|
||||
for server_name in self.configs.keys() {
|
||||
let Some(client) = self.clients.get(server_name) else {
|
||||
continue;
|
||||
};
|
||||
for mut resource in client.list_resources()? {
|
||||
resource.server_name = server_name.clone();
|
||||
out.push(resource);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn read_resource(&self, server_name: &str, uri: &str) -> Result<Value> {
|
||||
let client = self
|
||||
.clients
|
||||
.get(server_name)
|
||||
.with_context(|| format!("MCP server '{server_name}' not available"))?;
|
||||
client.read_resource(uri)
|
||||
}
|
||||
|
||||
pub fn update_sandbox_state(&self, sandbox_mode: &str, cwd: &str) -> Result<Vec<Value>> {
|
||||
let mut notices = Vec::new();
|
||||
for server_name in self.configs.keys() {
|
||||
notices.push(json!({
|
||||
"server_name": server_name,
|
||||
"method": "codex/sandbox-state/update",
|
||||
"params": {
|
||||
"sandbox_mode": sandbox_mode,
|
||||
"cwd": cwd
|
||||
}
|
||||
}));
|
||||
}
|
||||
Ok(notices)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn allowed_by_filter(name: &str, filter: &ToolFilter) -> bool {
|
||||
if filter.deny.iter().any(|pattern| pattern == name) {
|
||||
return false;
|
||||
}
|
||||
if filter.allow.is_empty() {
|
||||
return true;
|
||||
}
|
||||
filter.allow.iter().any(|pattern| pattern == name)
|
||||
}
|
||||
|
||||
fn sanitize_component(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' {
|
||||
ch.to_ascii_lowercase()
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn qualify_tool_name(server: &str, tool: &str) -> String {
|
||||
let mut name = format!(
|
||||
"mcp__{}__{}",
|
||||
sanitize_component(server),
|
||||
sanitize_component(tool)
|
||||
);
|
||||
if name.len() > 64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
name.hash(&mut hasher);
|
||||
let hash = format!("{:x}", hasher.finish());
|
||||
name.truncate(48);
|
||||
name.push('_');
|
||||
name.push_str(&hash[..12]);
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
fn parse_qualified_tool_name(value: &str) -> Result<(String, String)> {
|
||||
let Some(stripped) = value.strip_prefix("mcp__") else {
|
||||
bail!("missing mcp__ prefix");
|
||||
};
|
||||
let mut split = stripped.splitn(2, "__");
|
||||
let server = split
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.context("missing server segment")?;
|
||||
let tool = split
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.context("missing tool segment")?;
|
||||
Ok((server, tool))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JsonRpcRequest {
|
||||
#[serde(default)]
|
||||
jsonrpc: Option<String>,
|
||||
#[serde(default)]
|
||||
id: Option<Value>,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct JsonRpcError {
|
||||
code: i64,
|
||||
message: String,
|
||||
data: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ToolsListParams {
|
||||
#[serde(default)]
|
||||
server: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ToolsCallParams {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
tool: Option<String>,
|
||||
#[serde(default)]
|
||||
server: Option<String>,
|
||||
#[serde(default)]
|
||||
arguments: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResourcesListParams {
|
||||
#[serde(default)]
|
||||
server: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResourcesReadParams {
|
||||
#[serde(default)]
|
||||
server: Option<String>,
|
||||
uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ServerRegisterParams {
|
||||
server: McpServerConfig,
|
||||
#[serde(default)]
|
||||
filter: ToolFilter,
|
||||
#[serde(default = "default_true")]
|
||||
start: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ServerNameParams {
|
||||
name: String,
|
||||
}
|
||||
|
||||
struct StdioMcpState {
|
||||
manager: McpManager,
|
||||
definitions: HashMap<String, McpServerDefinition>,
|
||||
running: HashMap<String, bool>,
|
||||
lifecycle_state: String,
|
||||
}
|
||||
|
||||
pub fn run_stdio_server(
|
||||
initial_definitions: Vec<McpServerDefinition>,
|
||||
) -> Result<Vec<McpServerDefinition>> {
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
||||
let stdin = io::stdin();
|
||||
let mut stdout = io::stdout();
|
||||
let mut stderr = io::stderr();
|
||||
let mut state = build_stdio_state(initial_definitions);
|
||||
|
||||
for line in stdin.lock().lines() {
|
||||
let line = line.context("failed to read stdio line")?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let request: JsonRpcRequest = match serde_json::from_str(&line) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
let msg = jsonrpc_error(
|
||||
None,
|
||||
JsonRpcError::parse_error(format!("invalid json: {err}")),
|
||||
);
|
||||
writeln!(stdout, "{msg}")?;
|
||||
stdout.flush()?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if request
|
||||
.jsonrpc
|
||||
.as_deref()
|
||||
.is_some_and(|version| version != "2.0")
|
||||
{
|
||||
let response = jsonrpc_error(
|
||||
request.id,
|
||||
JsonRpcError::invalid_request("jsonrpc version must be 2.0"),
|
||||
);
|
||||
writeln!(stdout, "{response}")?;
|
||||
stdout.flush()?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let response = match dispatch_stdio_request(&mut state, &request.method, request.params) {
|
||||
Ok((result, should_exit)) => {
|
||||
let payload = jsonrpc_result(request.id, result);
|
||||
writeln!(stdout, "{payload}")?;
|
||||
stdout.flush()?;
|
||||
if should_exit {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(err) => jsonrpc_error(request.id, err),
|
||||
};
|
||||
|
||||
writeln!(stdout, "{response}")?;
|
||||
stdout.flush()?;
|
||||
}
|
||||
|
||||
state.lifecycle_state = "stopped".to_string();
|
||||
let _ = writeln!(stderr, "deepseek-mcp stdio server exited");
|
||||
let mut definitions: Vec<McpServerDefinition> = state.definitions.into_values().collect();
|
||||
definitions.sort_by(|a, b| a.config.name.cmp(&b.config.name));
|
||||
Ok(definitions)
|
||||
}
|
||||
|
||||
fn build_stdio_state(initial_definitions: Vec<McpServerDefinition>) -> StdioMcpState {
|
||||
let mut manager = McpManager::default();
|
||||
let mut definitions = HashMap::new();
|
||||
let mut running = HashMap::new();
|
||||
|
||||
for definition in initial_definitions {
|
||||
let name = definition.config.name.clone();
|
||||
let should_start = definition.config.enabled;
|
||||
definitions.insert(name.clone(), definition.clone());
|
||||
if should_start {
|
||||
manager.register_server(
|
||||
definition.config.clone(),
|
||||
definition.filter.clone(),
|
||||
default_stdio_client(&name),
|
||||
);
|
||||
running.insert(name, true);
|
||||
} else {
|
||||
running.insert(name, false);
|
||||
}
|
||||
}
|
||||
|
||||
StdioMcpState {
|
||||
manager,
|
||||
definitions,
|
||||
running,
|
||||
lifecycle_state: "running".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_stdio_client(server_name: &str) -> Box<dyn McpManagedClient> {
|
||||
let health_uri = format!("mcp://{server_name}/health");
|
||||
let capabilities_uri = format!("mcp://{server_name}/capabilities");
|
||||
Box::new(
|
||||
InMemoryMcpClient::default()
|
||||
.with_tool(
|
||||
"health",
|
||||
json!({
|
||||
"status": "ok",
|
||||
"server_name": server_name
|
||||
}),
|
||||
)
|
||||
.with_tool(
|
||||
"capabilities",
|
||||
json!({
|
||||
"tools": ["health", "capabilities"],
|
||||
"resources": [health_uri.clone(), capabilities_uri.clone()]
|
||||
}),
|
||||
)
|
||||
.with_resource(
|
||||
&health_uri,
|
||||
json!({
|
||||
"status": "ok",
|
||||
"server_name": server_name
|
||||
}),
|
||||
)
|
||||
.with_resource(
|
||||
&capabilities_uri,
|
||||
json!({
|
||||
"server_name": server_name,
|
||||
"methods": [
|
||||
"tools/list",
|
||||
"tools/call",
|
||||
"resources/list",
|
||||
"resources/read",
|
||||
"server/list",
|
||||
"server/register",
|
||||
"server/start",
|
||||
"server/stop",
|
||||
"server/unregister"
|
||||
]
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn default_rpc_methods() -> Vec<&'static str> {
|
||||
vec![
|
||||
"initialize",
|
||||
"healthz",
|
||||
"capabilities",
|
||||
"tools/list",
|
||||
"tools/call",
|
||||
"resources/list",
|
||||
"resources/read",
|
||||
"server/list",
|
||||
"server/register",
|
||||
"server/start",
|
||||
"server/stop",
|
||||
"server/unregister",
|
||||
"shutdown",
|
||||
]
|
||||
}
|
||||
|
||||
fn lifecycle_snapshot(state: &StdioMcpState) -> Value {
|
||||
let mut servers: Vec<Value> = state
|
||||
.definitions
|
||||
.iter()
|
||||
.map(|(name, definition)| {
|
||||
let is_running = state.running.get(name).copied().unwrap_or(false);
|
||||
json!({
|
||||
"name": name,
|
||||
"enabled": definition.config.enabled,
|
||||
"running": is_running,
|
||||
"command": definition.config.command.clone(),
|
||||
"args": definition.config.args.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
servers.sort_by(|a, b| {
|
||||
let a_name = a.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||
let b_name = b.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||
a_name.cmp(b_name)
|
||||
});
|
||||
|
||||
let running_count = state.running.values().filter(|running| **running).count();
|
||||
json!({
|
||||
"status": state.lifecycle_state,
|
||||
"servers": servers,
|
||||
"counts": {
|
||||
"defined": state.definitions.len(),
|
||||
"running": running_count
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn params_or_object(params: Value) -> Value {
|
||||
if params.is_null() { json!({}) } else { params }
|
||||
}
|
||||
|
||||
fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
|
||||
serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
|
||||
}
|
||||
|
||||
fn parse_server_from_uri(uri: &str) -> Option<String> {
|
||||
let stripped = uri.strip_prefix("mcp://")?;
|
||||
let server = stripped.split('/').next()?;
|
||||
if server.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(server.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_stdio_request(
|
||||
state: &mut StdioMcpState,
|
||||
method: &str,
|
||||
params: Value,
|
||||
) -> std::result::Result<(Value, bool), JsonRpcError> {
|
||||
match method {
|
||||
"initialize" | "capabilities" => Ok((
|
||||
json!({
|
||||
"server": "deepseek-mcp",
|
||||
"transport": "stdio",
|
||||
"methods": default_rpc_methods(),
|
||||
"lifecycle": lifecycle_snapshot(state)
|
||||
}),
|
||||
false,
|
||||
)),
|
||||
"healthz" => Ok((
|
||||
json!({
|
||||
"status": "ok",
|
||||
"service": "deepseek-mcp",
|
||||
"transport": "stdio",
|
||||
"lifecycle": lifecycle_snapshot(state)
|
||||
}),
|
||||
false,
|
||||
)),
|
||||
"tools/list" => {
|
||||
let parsed: ToolsListParams = parse_params(params_or_object(params))?;
|
||||
let mut tools = state
|
||||
.manager
|
||||
.list_tools()
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?;
|
||||
if let Some(server) = parsed.server {
|
||||
tools.retain(|tool| tool.server_name == server);
|
||||
}
|
||||
Ok((json!({ "tools": tools }), false))
|
||||
}
|
||||
"tools/call" => {
|
||||
let parsed: ToolsCallParams = parse_params(params_or_object(params))?;
|
||||
let ToolsCallParams {
|
||||
name,
|
||||
tool,
|
||||
server,
|
||||
arguments,
|
||||
} = parsed;
|
||||
let tool_name = name
|
||||
.or(tool)
|
||||
.context("missing tool name")
|
||||
.map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
|
||||
let arguments = if arguments.is_null() {
|
||||
json!({})
|
||||
} else {
|
||||
arguments
|
||||
};
|
||||
let result = if tool_name.starts_with("mcp__") {
|
||||
state
|
||||
.manager
|
||||
.call_qualified_tool(&tool_name, arguments)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?
|
||||
} else {
|
||||
let server = server
|
||||
.context("missing server for unqualified tool")
|
||||
.map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
|
||||
state
|
||||
.manager
|
||||
.call_tool(&server, &tool_name, arguments)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?
|
||||
};
|
||||
Ok((json!({ "result": result }), false))
|
||||
}
|
||||
"resources/list" => {
|
||||
let parsed: ResourcesListParams = parse_params(params_or_object(params))?;
|
||||
let mut resources = state
|
||||
.manager
|
||||
.list_resources()
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?;
|
||||
if let Some(server) = parsed.server {
|
||||
resources.retain(|resource| resource.server_name == server);
|
||||
}
|
||||
Ok((json!({ "resources": resources }), false))
|
||||
}
|
||||
"resources/read" => {
|
||||
let parsed: ResourcesReadParams = parse_params(params_or_object(params))?;
|
||||
let ResourcesReadParams { server, uri } = parsed;
|
||||
let server_name = server
|
||||
.or_else(|| parse_server_from_uri(&uri))
|
||||
.context("missing server for resource read")
|
||||
.map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
|
||||
let value = state
|
||||
.manager
|
||||
.read_resource(&server_name, &uri)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?;
|
||||
Ok((json!({ "resource": value }), false))
|
||||
}
|
||||
"server/list" | "servers/list" => {
|
||||
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
|
||||
}
|
||||
"server/register" | "servers/register" => {
|
||||
let parsed: ServerRegisterParams = parse_params(params_or_object(params))?;
|
||||
let name = parsed.server.name.clone();
|
||||
if name.trim().is_empty() {
|
||||
return Err(JsonRpcError::invalid_params(
|
||||
"server.name must not be empty",
|
||||
));
|
||||
}
|
||||
|
||||
if state.definitions.contains_key(&name) {
|
||||
let _ = state.manager.unregister_server(&name);
|
||||
}
|
||||
state.definitions.insert(
|
||||
name.clone(),
|
||||
McpServerDefinition {
|
||||
config: parsed.server.clone(),
|
||||
filter: parsed.filter.clone(),
|
||||
},
|
||||
);
|
||||
let should_run = parsed.start && parsed.server.enabled;
|
||||
if should_run {
|
||||
state.manager.register_server(
|
||||
parsed.server.clone(),
|
||||
parsed.filter.clone(),
|
||||
default_stdio_client(&name),
|
||||
);
|
||||
}
|
||||
state.running.insert(name, should_run);
|
||||
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
|
||||
}
|
||||
"server/start" | "servers/start" => {
|
||||
let parsed: ServerNameParams = parse_params(params_or_object(params))?;
|
||||
let definition = state
|
||||
.definitions
|
||||
.get(&parsed.name)
|
||||
.cloned()
|
||||
.with_context(|| format!("server '{}' is not defined", parsed.name))
|
||||
.map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
|
||||
if !definition.config.enabled {
|
||||
return Err(JsonRpcError::invalid_params(format!(
|
||||
"server '{}' is disabled",
|
||||
parsed.name
|
||||
)));
|
||||
}
|
||||
if !state.running.get(&parsed.name).copied().unwrap_or(false) {
|
||||
state.manager.register_server(
|
||||
definition.config.clone(),
|
||||
definition.filter.clone(),
|
||||
default_stdio_client(&parsed.name),
|
||||
);
|
||||
state.running.insert(parsed.name, true);
|
||||
}
|
||||
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
|
||||
}
|
||||
"server/stop" | "servers/stop" => {
|
||||
let parsed: ServerNameParams = parse_params(params_or_object(params))?;
|
||||
if state.running.get(&parsed.name).copied().unwrap_or(false) {
|
||||
state
|
||||
.manager
|
||||
.stop_server(&parsed.name)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?;
|
||||
}
|
||||
state.running.insert(parsed.name, false);
|
||||
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
|
||||
}
|
||||
"server/unregister" | "servers/unregister" => {
|
||||
let parsed: ServerNameParams = parse_params(params_or_object(params))?;
|
||||
if state.definitions.remove(&parsed.name).is_none() {
|
||||
return Err(JsonRpcError::invalid_params(format!(
|
||||
"server '{}' is not defined",
|
||||
parsed.name
|
||||
)));
|
||||
}
|
||||
let _ = state.manager.unregister_server(&parsed.name);
|
||||
state.running.remove(&parsed.name);
|
||||
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
|
||||
}
|
||||
"shutdown" => {
|
||||
state.lifecycle_state = "shutting_down".to_string();
|
||||
Ok((
|
||||
json!({
|
||||
"ok": true,
|
||||
"lifecycle": lifecycle_snapshot(state)
|
||||
}),
|
||||
true,
|
||||
))
|
||||
}
|
||||
_ => Err(JsonRpcError::method_not_found(method)),
|
||||
}
|
||||
}
|
||||
|
||||
fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id.unwrap_or(Value::Null),
|
||||
"result": result
|
||||
})
|
||||
}
|
||||
|
||||
fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id.unwrap_or(Value::Null),
|
||||
"error": {
|
||||
"code": err.code,
|
||||
"message": err.message,
|
||||
"data": err.data
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl JsonRpcError {
|
||||
fn parse_error(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32700,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_request(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32600,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn method_not_found(method: &str) -> Self {
|
||||
Self {
|
||||
code: -32601,
|
||||
message: format!("unsupported method: {method}"),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_params(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32602,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn internal(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32603,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "deepseek-protocol"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Codex-style app-server protocol frames for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,451 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Envelope<T> {
|
||||
pub request_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thread_id: Option<String>,
|
||||
pub body: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ThreadStatus {
|
||||
Running,
|
||||
Idle,
|
||||
Completed,
|
||||
Failed,
|
||||
Paused,
|
||||
Archived,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionSource {
|
||||
Interactive,
|
||||
Resume,
|
||||
Fork,
|
||||
Api,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Thread {
|
||||
pub id: String,
|
||||
pub preview: String,
|
||||
pub ephemeral: bool,
|
||||
pub model_provider: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub status: ThreadStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<PathBuf>,
|
||||
pub cwd: PathBuf,
|
||||
pub cli_version: String,
|
||||
pub source: SessionSource,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadStartParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub persist_extended_history: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadResumeParams {
|
||||
pub thread_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub history: Option<Vec<Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<PathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub approval_policy: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub config: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub developer_instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub personality: Option<String>,
|
||||
#[serde(default)]
|
||||
pub persist_extended_history: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadForkParams {
|
||||
pub thread_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<PathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub approval_policy: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub config: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub developer_instructions: Option<String>,
|
||||
#[serde(default)]
|
||||
pub persist_extended_history: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadListParams {
|
||||
#[serde(default)]
|
||||
pub include_archived: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadReadParams {
|
||||
pub thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadSetNameParams {
|
||||
pub thread_id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ThreadRequest {
|
||||
Create {
|
||||
#[serde(default)]
|
||||
metadata: Value,
|
||||
},
|
||||
Start(ThreadStartParams),
|
||||
Resume(ThreadResumeParams),
|
||||
Fork(ThreadForkParams),
|
||||
List(ThreadListParams),
|
||||
Read(ThreadReadParams),
|
||||
SetName(ThreadSetNameParams),
|
||||
Archive {
|
||||
thread_id: String,
|
||||
},
|
||||
Unarchive {
|
||||
thread_id: String,
|
||||
},
|
||||
Message {
|
||||
thread_id: String,
|
||||
input: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadResponse {
|
||||
pub thread_id: String,
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thread: Option<Thread>,
|
||||
#[serde(default)]
|
||||
pub threads: Vec<Thread>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub approval_policy: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<String>,
|
||||
#[serde(default)]
|
||||
pub events: Vec<EventFrame>,
|
||||
#[serde(default)]
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AppRequest {
|
||||
Capabilities,
|
||||
ConfigGet { key: String },
|
||||
ConfigSet { key: String, value: String },
|
||||
ConfigUnset { key: String },
|
||||
ConfigList,
|
||||
Models,
|
||||
ThreadLoadedList,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppResponse {
|
||||
pub ok: bool,
|
||||
pub data: Value,
|
||||
#[serde(default)]
|
||||
pub events: Vec<EventFrame>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PromptRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thread_id: Option<String>,
|
||||
pub prompt: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PromptResponse {
|
||||
pub output: String,
|
||||
pub model: String,
|
||||
#[serde(default)]
|
||||
pub events: Vec<EventFrame>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AskForApproval {
|
||||
UnlessTrusted,
|
||||
OnFailure,
|
||||
OnRequest,
|
||||
Reject {
|
||||
sandbox_approval: bool,
|
||||
rules: bool,
|
||||
mcp_elicitations: bool,
|
||||
},
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolKind {
|
||||
Function,
|
||||
Mcp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LocalShellParams {
|
||||
pub command: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ToolPayload {
|
||||
Function {
|
||||
arguments: String,
|
||||
},
|
||||
Custom {
|
||||
input: String,
|
||||
},
|
||||
LocalShell {
|
||||
params: LocalShellParams,
|
||||
},
|
||||
Mcp {
|
||||
server: String,
|
||||
tool: String,
|
||||
raw_arguments: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
raw_tool_call_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ToolOutput {
|
||||
Function {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
body: Option<Value>,
|
||||
success: bool,
|
||||
},
|
||||
Mcp {
|
||||
result: Value,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NetworkPolicyRuleAction {
|
||||
Allow,
|
||||
Deny,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct NetworkPolicyAmendment {
|
||||
pub host: String,
|
||||
pub action: NetworkPolicyRuleAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ReviewDecision {
|
||||
Approved,
|
||||
ApprovedExecpolicyAmendment,
|
||||
ApprovedForSession,
|
||||
NetworkPolicyAmendment {
|
||||
host: String,
|
||||
action: NetworkPolicyRuleAction,
|
||||
},
|
||||
Denied,
|
||||
Abort,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum McpStartupStatus {
|
||||
Starting,
|
||||
Ready,
|
||||
Failed { error: String },
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpStartupUpdateEvent {
|
||||
pub server_name: String,
|
||||
pub status: McpStartupStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpStartupFailure {
|
||||
pub server_name: String,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpStartupCompleteEvent {
|
||||
pub ready: Vec<String>,
|
||||
pub failed: Vec<McpStartupFailure>,
|
||||
pub cancelled: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NetworkApprovalContext {
|
||||
pub host: String,
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExecApprovalRequestEvent {
|
||||
pub call_id: String,
|
||||
pub approval_id: String,
|
||||
pub turn_id: String,
|
||||
pub command: String,
|
||||
pub cwd: String,
|
||||
pub reason: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub network_approval_context: Option<NetworkApprovalContext>,
|
||||
#[serde(default)]
|
||||
pub proposed_execpolicy_amendment: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub proposed_network_policy_amendments: Vec<NetworkPolicyAmendment>,
|
||||
#[serde(default)]
|
||||
pub additional_permissions: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub available_decisions: Vec<ReviewDecision>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "event", rename_all = "snake_case")]
|
||||
pub enum EventFrame {
|
||||
ResponseStart {
|
||||
response_id: String,
|
||||
},
|
||||
ResponseDelta {
|
||||
response_id: String,
|
||||
delta: String,
|
||||
},
|
||||
ResponseEnd {
|
||||
response_id: String,
|
||||
},
|
||||
ToolCallStart {
|
||||
response_id: String,
|
||||
tool_name: String,
|
||||
arguments: Value,
|
||||
},
|
||||
ToolCallResult {
|
||||
response_id: String,
|
||||
tool_name: String,
|
||||
output: Value,
|
||||
},
|
||||
McpStartupUpdate {
|
||||
update: McpStartupUpdateEvent,
|
||||
},
|
||||
McpStartupComplete {
|
||||
summary: McpStartupCompleteEvent,
|
||||
},
|
||||
McpToolCallBegin {
|
||||
server_name: String,
|
||||
tool_name: String,
|
||||
},
|
||||
McpToolCallEnd {
|
||||
server_name: String,
|
||||
tool_name: String,
|
||||
ok: bool,
|
||||
},
|
||||
ExecApprovalRequest {
|
||||
request: ExecApprovalRequestEvent,
|
||||
},
|
||||
ApplyPatchApprovalRequest {
|
||||
request: ExecApprovalRequestEvent,
|
||||
},
|
||||
ElicitationRequest {
|
||||
server_name: String,
|
||||
request_id: String,
|
||||
prompt: String,
|
||||
},
|
||||
ExecCommandBegin {
|
||||
command: String,
|
||||
cwd: String,
|
||||
},
|
||||
ExecCommandOutputDelta {
|
||||
command: String,
|
||||
delta: String,
|
||||
},
|
||||
ExecCommandEnd {
|
||||
command: String,
|
||||
exit_code: i32,
|
||||
},
|
||||
PatchApplyBegin {
|
||||
path: String,
|
||||
},
|
||||
PatchApplyEnd {
|
||||
path: String,
|
||||
ok: bool,
|
||||
},
|
||||
TurnStarted {
|
||||
turn_id: String,
|
||||
},
|
||||
TurnComplete {
|
||||
turn_id: String,
|
||||
},
|
||||
TurnAborted {
|
||||
turn_id: String,
|
||||
reason: String,
|
||||
},
|
||||
Error {
|
||||
response_id: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use deepseek_protocol::{EventFrame, ThreadListParams, ThreadRequest, ThreadResumeParams};
|
||||
|
||||
#[test]
|
||||
fn thread_resume_params_round_trip() {
|
||||
let request = ThreadRequest::Resume(ThreadResumeParams {
|
||||
thread_id: "thread-123".to_string(),
|
||||
history: None,
|
||||
path: None,
|
||||
model: Some("deepseek-v4-pro".to_string()),
|
||||
model_provider: Some("deepseek".to_string()),
|
||||
cwd: None,
|
||||
approval_policy: Some("on-request".to_string()),
|
||||
sandbox: Some("workspace-write".to_string()),
|
||||
config: None,
|
||||
base_instructions: Some("base".to_string()),
|
||||
developer_instructions: Some("dev".to_string()),
|
||||
personality: Some("default".to_string()),
|
||||
persist_extended_history: true,
|
||||
});
|
||||
|
||||
let encoded = serde_json::to_string(&request).expect("serialize request");
|
||||
let decoded: ThreadRequest = serde_json::from_str(&encoded).expect("deserialize request");
|
||||
match decoded {
|
||||
ThreadRequest::Resume(params) => {
|
||||
assert_eq!(params.thread_id, "thread-123");
|
||||
assert_eq!(params.model.as_deref(), Some("deepseek-v4-pro"));
|
||||
assert!(params.persist_extended_history);
|
||||
}
|
||||
other => panic!("unexpected request: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_list_params_defaults_are_serializable() {
|
||||
let request = ThreadRequest::List(ThreadListParams {
|
||||
include_archived: false,
|
||||
limit: Some(20),
|
||||
});
|
||||
let encoded = serde_json::to_string_pretty(&request).expect("serialize list request");
|
||||
assert!(encoded.contains("include_archived"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_frame_serialization_contains_expected_tag() {
|
||||
let frame = EventFrame::TurnComplete {
|
||||
turn_id: "turn-1".to_string(),
|
||||
};
|
||||
let encoded = serde_json::to_string(&frame).expect("serialize frame");
|
||||
assert!(encoded.contains("turn_complete"));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "deepseek-state"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Session/thread persistence and recovery model for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
dirs.workspace = true
|
||||
rusqlite.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,950 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ThreadStatus {
|
||||
Running,
|
||||
Idle,
|
||||
Completed,
|
||||
Failed,
|
||||
Paused,
|
||||
Archived,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionSource {
|
||||
Interactive,
|
||||
Resume,
|
||||
Fork,
|
||||
Api,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadMetadata {
|
||||
pub id: String,
|
||||
pub rollout_path: Option<PathBuf>,
|
||||
pub preview: String,
|
||||
pub ephemeral: bool,
|
||||
pub model_provider: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub status: ThreadStatus,
|
||||
pub path: Option<PathBuf>,
|
||||
pub cwd: PathBuf,
|
||||
pub cli_version: String,
|
||||
pub source: SessionSource,
|
||||
pub name: Option<String>,
|
||||
pub sandbox_policy: Option<String>,
|
||||
pub approval_mode: Option<String>,
|
||||
pub archived: bool,
|
||||
pub archived_at: Option<i64>,
|
||||
pub git_sha: Option<String>,
|
||||
pub git_branch: Option<String>,
|
||||
pub git_origin_url: Option<String>,
|
||||
pub memory_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DynamicToolRecord {
|
||||
pub position: i64,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub input_schema: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageRecord {
|
||||
pub id: i64,
|
||||
pub thread_id: String,
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
pub item: Option<Value>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CheckpointRecord {
|
||||
pub thread_id: String,
|
||||
pub checkpoint_id: String,
|
||||
pub state: Value,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum JobStateStatus {
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JobStateRecord {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub status: JobStateStatus,
|
||||
pub progress: Option<u8>,
|
||||
pub detail: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThreadListFilters {
|
||||
pub include_archived: bool,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for ThreadListFilters {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
include_archived: false,
|
||||
limit: Some(50),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SessionIndexEntry {
|
||||
thread_id: String,
|
||||
thread_name: Option<String>,
|
||||
updated_at: i64,
|
||||
rollout_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StateStore {
|
||||
db_path: PathBuf,
|
||||
session_index_path: PathBuf,
|
||||
}
|
||||
|
||||
impl StateStore {
|
||||
pub fn open(path: Option<PathBuf>) -> Result<Self> {
|
||||
let db_path = path.unwrap_or_else(default_state_db_path);
|
||||
let session_index_path = db_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join("session_index.jsonl");
|
||||
if let Some(parent) = db_path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!("failed to create state directory {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
let store = Self {
|
||||
db_path,
|
||||
session_index_path,
|
||||
};
|
||||
store.init_schema()?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
pub fn db_path(&self) -> &Path {
|
||||
&self.db_path
|
||||
}
|
||||
|
||||
fn conn(&self) -> Result<Connection> {
|
||||
Connection::open(&self.db_path)
|
||||
.with_context(|| format!("failed to open state db {}", self.db_path.display()))
|
||||
}
|
||||
|
||||
fn init_schema(&self) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
rollout_path TEXT,
|
||||
preview TEXT NOT NULL,
|
||||
ephemeral INTEGER NOT NULL,
|
||||
model_provider TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
path TEXT,
|
||||
cwd TEXT NOT NULL,
|
||||
cli_version TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
title TEXT,
|
||||
sandbox_policy TEXT,
|
||||
approval_mode TEXT,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
archived_at INTEGER,
|
||||
git_sha TEXT,
|
||||
git_branch TEXT,
|
||||
git_origin_url TEXT,
|
||||
memory_mode TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_updated_at ON threads(updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_archived_at ON threads(archived_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_archived_updated ON threads(archived, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS thread_dynamic_tools (
|
||||
thread_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
input_schema TEXT NOT NULL,
|
||||
PRIMARY KEY (thread_id, position),
|
||||
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
thread_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
item_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_thread_created_at ON messages(thread_id, created_at ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
state_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(thread_id, checkpoint_id),
|
||||
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_checkpoints_thread_created_at ON checkpoints(thread_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
progress INTEGER,
|
||||
detail TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_updated_at ON jobs(updated_at DESC);
|
||||
"#,
|
||||
)
|
||||
.context("failed to initialize thread schema")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_thread(&self, thread: &ThreadMetadata) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO threads (
|
||||
id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd,
|
||||
cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at,
|
||||
git_sha, git_branch, git_origin_url, memory_mode
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
|
||||
?11, ?12, ?13, ?14, ?15, ?16, ?17,
|
||||
?18, ?19, ?20, ?21
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
rollout_path=excluded.rollout_path,
|
||||
preview=excluded.preview,
|
||||
ephemeral=excluded.ephemeral,
|
||||
model_provider=excluded.model_provider,
|
||||
created_at=excluded.created_at,
|
||||
updated_at=excluded.updated_at,
|
||||
status=excluded.status,
|
||||
path=excluded.path,
|
||||
cwd=excluded.cwd,
|
||||
cli_version=excluded.cli_version,
|
||||
source=excluded.source,
|
||||
title=excluded.title,
|
||||
sandbox_policy=excluded.sandbox_policy,
|
||||
approval_mode=excluded.approval_mode,
|
||||
archived=excluded.archived,
|
||||
archived_at=excluded.archived_at,
|
||||
git_sha=excluded.git_sha,
|
||||
git_branch=excluded.git_branch,
|
||||
git_origin_url=excluded.git_origin_url,
|
||||
memory_mode=excluded.memory_mode
|
||||
"#,
|
||||
params![
|
||||
thread.id,
|
||||
path_to_opt_string(thread.rollout_path.as_deref()),
|
||||
thread.preview,
|
||||
bool_to_i64(thread.ephemeral),
|
||||
thread.model_provider,
|
||||
thread.created_at,
|
||||
thread.updated_at,
|
||||
thread_status_to_str(&thread.status),
|
||||
path_to_opt_string(thread.path.as_deref()),
|
||||
thread.cwd.display().to_string(),
|
||||
thread.cli_version,
|
||||
session_source_to_str(&thread.source),
|
||||
thread.name,
|
||||
thread.sandbox_policy,
|
||||
thread.approval_mode,
|
||||
bool_to_i64(thread.archived),
|
||||
thread.archived_at,
|
||||
thread.git_sha,
|
||||
thread.git_branch,
|
||||
thread.git_origin_url,
|
||||
thread.memory_mode,
|
||||
],
|
||||
)
|
||||
.context("failed to upsert thread metadata")?;
|
||||
|
||||
self.append_thread_name(
|
||||
&thread.id,
|
||||
thread.name.clone(),
|
||||
thread.updated_at,
|
||||
thread.rollout_path.clone(),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_thread(&self, id: &str) -> Result<Option<ThreadMetadata>> {
|
||||
let conn = self.conn()?;
|
||||
conn.query_row(
|
||||
r#"
|
||||
SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd,
|
||||
cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at,
|
||||
git_sha, git_branch, git_origin_url, memory_mode
|
||||
FROM threads
|
||||
WHERE id = ?1
|
||||
"#,
|
||||
params![id],
|
||||
row_to_thread,
|
||||
)
|
||||
.optional()
|
||||
.context("failed to read thread")
|
||||
}
|
||||
|
||||
pub fn list_threads(&self, filters: ThreadListFilters) -> Result<Vec<ThreadMetadata>> {
|
||||
let conn = self.conn()?;
|
||||
let sql = if filters.include_archived {
|
||||
"SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode FROM threads ORDER BY updated_at DESC LIMIT ?1"
|
||||
} else {
|
||||
"SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode FROM threads WHERE archived = 0 ORDER BY updated_at DESC LIMIT ?1"
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(sql).context("failed to prepare list query")?;
|
||||
let limit = i64::try_from(filters.limit.unwrap_or(50)).unwrap_or(50);
|
||||
let mut rows = stmt
|
||||
.query(params![limit])
|
||||
.context("failed to query threads")?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(row) = rows.next().context("failed to iterate thread rows")? {
|
||||
out.push(row_to_thread(row)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn mark_archived(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
"UPDATE threads SET archived = 1, archived_at = ?2, status = ?3 WHERE id = ?1",
|
||||
params![
|
||||
id,
|
||||
Utc::now().timestamp(),
|
||||
thread_status_to_str(&ThreadStatus::Archived)
|
||||
],
|
||||
)
|
||||
.context("failed to archive thread")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_unarchived(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
"UPDATE threads SET archived = 0, archived_at = NULL WHERE id = ?1",
|
||||
params![id],
|
||||
)
|
||||
.context("failed to unarchive thread")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_thread(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute("DELETE FROM threads WHERE id = ?1", params![id])
|
||||
.context("failed to delete thread")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_thread_memory_mode(&self, id: &str, mode: Option<&str>) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
"UPDATE threads SET memory_mode = ?2 WHERE id = ?1",
|
||||
params![id, mode],
|
||||
)
|
||||
.context("failed to update thread memory mode")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_thread_memory_mode(&self, id: &str) -> Result<Option<String>> {
|
||||
let conn = self.conn()?;
|
||||
conn.query_row(
|
||||
"SELECT memory_mode FROM threads WHERE id = ?1",
|
||||
params![id],
|
||||
|row| row.get::<_, Option<String>>(0),
|
||||
)
|
||||
.optional()
|
||||
.context("failed to read thread memory mode")
|
||||
.map(Option::flatten)
|
||||
}
|
||||
|
||||
pub fn persist_dynamic_tools(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
tools: &[DynamicToolRecord],
|
||||
) -> Result<()> {
|
||||
let mut conn = self.conn()?;
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.context("failed to begin dynamic tools transaction")?;
|
||||
tx.execute(
|
||||
"DELETE FROM thread_dynamic_tools WHERE thread_id = ?1",
|
||||
params![thread_id],
|
||||
)
|
||||
.context("failed to clear dynamic tools")?;
|
||||
for tool in tools {
|
||||
tx.execute(
|
||||
"INSERT INTO thread_dynamic_tools(thread_id, position, name, description, input_schema) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
thread_id,
|
||||
tool.position,
|
||||
tool.name,
|
||||
tool.description,
|
||||
tool.input_schema.to_string()
|
||||
],
|
||||
)
|
||||
.with_context(|| format!("failed to persist dynamic tool {}", tool.name))?;
|
||||
}
|
||||
tx.commit().context("failed to commit dynamic tools")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_dynamic_tools(&self, thread_id: &str) -> Result<Vec<DynamicToolRecord>> {
|
||||
let conn = self.conn()?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT position, name, description, input_schema FROM thread_dynamic_tools WHERE thread_id = ?1 ORDER BY position ASC",
|
||||
)
|
||||
.context("failed to prepare get dynamic tools query")?;
|
||||
let mut rows = stmt
|
||||
.query(params![thread_id])
|
||||
.context("failed to query dynamic tools")?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(row) = rows.next().context("failed to iterate dynamic tools")? {
|
||||
let input_schema_raw: String =
|
||||
row.get(3).context("failed to read tool input schema")?;
|
||||
let input_schema: Value =
|
||||
serde_json::from_str(&input_schema_raw).with_context(|| {
|
||||
format!("failed to parse input schema for dynamic tool in thread {thread_id}")
|
||||
})?;
|
||||
out.push(DynamicToolRecord {
|
||||
position: row.get(0).context("failed to read tool position")?,
|
||||
name: row.get(1).context("failed to read tool name")?,
|
||||
description: row.get(2).context("failed to read tool description")?,
|
||||
input_schema,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn append_message(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
role: &str,
|
||||
content: &str,
|
||||
item: Option<Value>,
|
||||
) -> Result<i64> {
|
||||
let conn = self.conn()?;
|
||||
let created_at = Utc::now().timestamp();
|
||||
let item_json = item
|
||||
.as_ref()
|
||||
.map(serde_json::to_string)
|
||||
.transpose()
|
||||
.context("failed to serialize message item payload")?;
|
||||
conn.execute(
|
||||
"INSERT INTO messages(thread_id, role, content, item_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![thread_id, role, content, item_json, created_at],
|
||||
)
|
||||
.with_context(|| format!("failed to append message for thread {thread_id}"))?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn list_messages(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MessageRecord>> {
|
||||
let conn = self.conn()?;
|
||||
let limit = i64::try_from(limit.unwrap_or(500)).unwrap_or(500);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, thread_id, role, content, item_json, created_at FROM messages WHERE thread_id = ?1 ORDER BY created_at ASC LIMIT ?2",
|
||||
)
|
||||
.context("failed to prepare message listing query")?;
|
||||
let mut rows = stmt
|
||||
.query(params![thread_id, limit])
|
||||
.with_context(|| format!("failed to list messages for thread {thread_id}"))?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(row) = rows.next().context("failed to iterate message rows")? {
|
||||
let item_json: Option<String> = row.get(4).context("failed to read item json")?;
|
||||
let item = item_json
|
||||
.as_deref()
|
||||
.map(serde_json::from_str)
|
||||
.transpose()
|
||||
.with_context(|| {
|
||||
format!("failed to parse message item json in thread {thread_id}")
|
||||
})?;
|
||||
out.push(MessageRecord {
|
||||
id: row.get(0).context("failed to read message id")?,
|
||||
thread_id: row.get(1).context("failed to read message thread id")?,
|
||||
role: row.get(2).context("failed to read message role")?,
|
||||
content: row.get(3).context("failed to read message content")?,
|
||||
item,
|
||||
created_at: row.get(5).context("failed to read message timestamp")?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn clear_messages(&self, thread_id: &str) -> Result<usize> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
"DELETE FROM messages WHERE thread_id = ?1",
|
||||
params![thread_id],
|
||||
)
|
||||
.with_context(|| format!("failed to clear messages for thread {thread_id}"))
|
||||
}
|
||||
|
||||
pub fn save_checkpoint(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
checkpoint_id: &str,
|
||||
state: &Value,
|
||||
) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
let state_json =
|
||||
serde_json::to_string(state).context("failed to encode checkpoint state")?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO checkpoints(thread_id, checkpoint_id, state_json, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(thread_id, checkpoint_id) DO UPDATE SET
|
||||
state_json = excluded.state_json,
|
||||
created_at = excluded.created_at
|
||||
"#,
|
||||
params![thread_id, checkpoint_id, state_json, Utc::now().timestamp()],
|
||||
)
|
||||
.with_context(|| {
|
||||
format!("failed to save checkpoint {checkpoint_id} for thread {thread_id}")
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_checkpoint(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
checkpoint_id: Option<&str>,
|
||||
) -> Result<Option<CheckpointRecord>> {
|
||||
let conn = self.conn()?;
|
||||
if let Some(checkpoint_id) = checkpoint_id {
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT thread_id, checkpoint_id, state_json, created_at FROM checkpoints WHERE thread_id = ?1 AND checkpoint_id = ?2",
|
||||
params![thread_id, checkpoint_id],
|
||||
|row| {
|
||||
let state_json: String = row.get(2)?;
|
||||
let state = serde_json::from_str(&state_json).unwrap_or(Value::Null);
|
||||
Ok(CheckpointRecord {
|
||||
thread_id: row.get(0)?,
|
||||
checkpoint_id: row.get(1)?,
|
||||
state,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.with_context(|| {
|
||||
format!("failed to load checkpoint {checkpoint_id} for thread {thread_id}")
|
||||
})?;
|
||||
return Ok(row);
|
||||
}
|
||||
|
||||
conn.query_row(
|
||||
"SELECT thread_id, checkpoint_id, state_json, created_at FROM checkpoints WHERE thread_id = ?1 ORDER BY created_at DESC LIMIT 1",
|
||||
params![thread_id],
|
||||
|row| {
|
||||
let state_json: String = row.get(2)?;
|
||||
let state = serde_json::from_str(&state_json).unwrap_or(Value::Null);
|
||||
Ok(CheckpointRecord {
|
||||
thread_id: row.get(0)?,
|
||||
checkpoint_id: row.get(1)?,
|
||||
state,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.with_context(|| format!("failed to load latest checkpoint for thread {thread_id}"))
|
||||
}
|
||||
|
||||
pub fn list_checkpoints(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<CheckpointRecord>> {
|
||||
let conn = self.conn()?;
|
||||
let limit = i64::try_from(limit.unwrap_or(100)).unwrap_or(100);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT thread_id, checkpoint_id, state_json, created_at FROM checkpoints WHERE thread_id = ?1 ORDER BY created_at DESC LIMIT ?2",
|
||||
)
|
||||
.context("failed to prepare checkpoint list query")?;
|
||||
let mut rows = stmt
|
||||
.query(params![thread_id, limit])
|
||||
.with_context(|| format!("failed to list checkpoints for thread {thread_id}"))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
while let Some(row) = rows.next().context("failed to iterate checkpoint rows")? {
|
||||
let state_json: String = row.get(2).context("failed to read checkpoint state json")?;
|
||||
let state = serde_json::from_str(&state_json).unwrap_or(Value::Null);
|
||||
out.push(CheckpointRecord {
|
||||
thread_id: row.get(0).context("failed to read checkpoint thread id")?,
|
||||
checkpoint_id: row.get(1).context("failed to read checkpoint id")?,
|
||||
state,
|
||||
created_at: row.get(3).context("failed to read checkpoint timestamp")?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn delete_checkpoint(&self, thread_id: &str, checkpoint_id: &str) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
"DELETE FROM checkpoints WHERE thread_id = ?1 AND checkpoint_id = ?2",
|
||||
params![thread_id, checkpoint_id],
|
||||
)
|
||||
.with_context(|| {
|
||||
format!("failed to delete checkpoint {checkpoint_id} for thread {thread_id}")
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_job(&self, job: &JobStateRecord) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO jobs(id, name, status, progress, detail, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
status = excluded.status,
|
||||
progress = excluded.progress,
|
||||
detail = excluded.detail,
|
||||
created_at = excluded.created_at,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
params![
|
||||
job.id,
|
||||
job.name,
|
||||
job_state_status_to_str(&job.status),
|
||||
job.progress.map(i64::from),
|
||||
job.detail,
|
||||
job.created_at,
|
||||
job.updated_at
|
||||
],
|
||||
)
|
||||
.with_context(|| format!("failed to upsert job {}", job.id))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_job(&self, id: &str) -> Result<Option<JobStateRecord>> {
|
||||
let conn = self.conn()?;
|
||||
conn.query_row(
|
||||
"SELECT id, name, status, progress, detail, created_at, updated_at FROM jobs WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
let status_raw: String = row.get(2)?;
|
||||
let progress: Option<i64> = row.get(3)?;
|
||||
Ok(JobStateRecord {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
status: job_state_status_from_str(&status_raw),
|
||||
progress: progress.and_then(|v| u8::try_from(v).ok()),
|
||||
detail: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.with_context(|| format!("failed to read job {id}"))
|
||||
}
|
||||
|
||||
pub fn list_jobs(&self, limit: Option<usize>) -> Result<Vec<JobStateRecord>> {
|
||||
let conn = self.conn()?;
|
||||
let limit = i64::try_from(limit.unwrap_or(100)).unwrap_or(100);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, status, progress, detail, created_at, updated_at FROM jobs ORDER BY updated_at DESC LIMIT ?1",
|
||||
)
|
||||
.context("failed to prepare job list query")?;
|
||||
let mut rows = stmt
|
||||
.query(params![limit])
|
||||
.context("failed to query persisted jobs")?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(row) = rows.next().context("failed to iterate persisted jobs")? {
|
||||
let status_raw: String = row.get(2).context("failed to read job status")?;
|
||||
let progress: Option<i64> = row.get(3).context("failed to read job progress")?;
|
||||
out.push(JobStateRecord {
|
||||
id: row.get(0).context("failed to read job id")?,
|
||||
name: row.get(1).context("failed to read job name")?,
|
||||
status: job_state_status_from_str(&status_raw),
|
||||
progress: progress.and_then(|v| u8::try_from(v).ok()),
|
||||
detail: row.get(4).context("failed to read job detail")?,
|
||||
created_at: row.get(5).context("failed to read job created_at")?,
|
||||
updated_at: row.get(6).context("failed to read job updated_at")?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn delete_job(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?1", params![id])
|
||||
.with_context(|| format!("failed to delete job {id}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find_rollout_path_by_id(&self, id: &str) -> Result<Option<PathBuf>> {
|
||||
let conn = self.conn()?;
|
||||
conn.query_row(
|
||||
"SELECT rollout_path FROM threads WHERE id = ?1",
|
||||
params![id],
|
||||
|row| row.get::<_, Option<String>>(0),
|
||||
)
|
||||
.optional()
|
||||
.context("failed to lookup rollout path")
|
||||
.map(|opt| opt.flatten().map(PathBuf::from))
|
||||
}
|
||||
|
||||
pub fn append_thread_name(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
thread_name: Option<String>,
|
||||
updated_at: i64,
|
||||
rollout_path: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
if let Some(parent) = self.session_index_path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!(
|
||||
"failed to create session index directory {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let entry = SessionIndexEntry {
|
||||
thread_id: thread_id.to_string(),
|
||||
thread_name,
|
||||
updated_at,
|
||||
rollout_path,
|
||||
};
|
||||
let encoded =
|
||||
serde_json::to_string(&entry).context("failed to serialize session index entry")?;
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.session_index_path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to open session index {}",
|
||||
self.session_index_path.display()
|
||||
)
|
||||
})?;
|
||||
writeln!(file, "{encoded}").context("failed to append session index entry")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find_thread_name_by_id(&self, thread_id: &str) -> Result<Option<String>> {
|
||||
let map = self.session_index_map()?;
|
||||
Ok(map
|
||||
.get(thread_id)
|
||||
.and_then(|entry| entry.thread_name.clone()))
|
||||
}
|
||||
|
||||
pub fn find_thread_names_by_ids(
|
||||
&self,
|
||||
ids: &[String],
|
||||
) -> Result<HashMap<String, Option<String>>> {
|
||||
let map = self.session_index_map()?;
|
||||
let mut out = HashMap::new();
|
||||
for id in ids {
|
||||
let name = map.get(id).and_then(|entry| entry.thread_name.clone());
|
||||
out.insert(id.clone(), name);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn find_thread_path_by_name_str(&self, name: &str) -> Result<Option<PathBuf>> {
|
||||
let map = self.session_index_map()?;
|
||||
let matched = map
|
||||
.values()
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.thread_name
|
||||
.as_deref()
|
||||
.is_some_and(|n| n.eq_ignore_ascii_case(name))
|
||||
})
|
||||
.max_by_key(|entry| entry.updated_at);
|
||||
Ok(matched.and_then(|entry| entry.rollout_path.clone()))
|
||||
}
|
||||
|
||||
fn session_index_map(&self) -> Result<HashMap<String, SessionIndexEntry>> {
|
||||
if !self.session_index_path.exists() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.open(&self.session_index_path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to read session index {}",
|
||||
self.session_index_path.display()
|
||||
)
|
||||
})?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut latest = HashMap::<String, SessionIndexEntry>::new();
|
||||
for line in reader.lines() {
|
||||
let line = line.context("failed to read session index line")?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed: SessionIndexEntry =
|
||||
serde_json::from_str(&line).context("failed to parse session index entry")?;
|
||||
latest.insert(parsed.thread_id.clone(), parsed);
|
||||
}
|
||||
Ok(latest)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_state_db_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".deepseek")
|
||||
.join("state.db")
|
||||
}
|
||||
|
||||
fn bool_to_i64(value: bool) -> i64 {
|
||||
if value { 1 } else { 0 }
|
||||
}
|
||||
|
||||
fn i64_to_bool(value: i64) -> bool {
|
||||
value != 0
|
||||
}
|
||||
|
||||
fn thread_status_to_str(status: &ThreadStatus) -> &'static str {
|
||||
match status {
|
||||
ThreadStatus::Running => "running",
|
||||
ThreadStatus::Idle => "idle",
|
||||
ThreadStatus::Completed => "completed",
|
||||
ThreadStatus::Failed => "failed",
|
||||
ThreadStatus::Paused => "paused",
|
||||
ThreadStatus::Archived => "archived",
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_status_from_str(value: &str) -> ThreadStatus {
|
||||
match value {
|
||||
"running" => ThreadStatus::Running,
|
||||
"idle" => ThreadStatus::Idle,
|
||||
"completed" => ThreadStatus::Completed,
|
||||
"failed" => ThreadStatus::Failed,
|
||||
"paused" => ThreadStatus::Paused,
|
||||
"archived" => ThreadStatus::Archived,
|
||||
_ => ThreadStatus::Idle,
|
||||
}
|
||||
}
|
||||
|
||||
fn session_source_to_str(source: &SessionSource) -> &'static str {
|
||||
match source {
|
||||
SessionSource::Interactive => "interactive",
|
||||
SessionSource::Resume => "resume",
|
||||
SessionSource::Fork => "fork",
|
||||
SessionSource::Api => "api",
|
||||
SessionSource::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn session_source_from_str(value: &str) -> SessionSource {
|
||||
match value {
|
||||
"interactive" => SessionSource::Interactive,
|
||||
"resume" => SessionSource::Resume,
|
||||
"fork" => SessionSource::Fork,
|
||||
"api" => SessionSource::Api,
|
||||
_ => SessionSource::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn path_to_opt_string(path: Option<&Path>) -> Option<String> {
|
||||
path.map(|p| p.display().to_string())
|
||||
}
|
||||
|
||||
fn job_state_status_to_str(status: &JobStateStatus) -> &'static str {
|
||||
match status {
|
||||
JobStateStatus::Queued => "queued",
|
||||
JobStateStatus::Running => "running",
|
||||
JobStateStatus::Completed => "completed",
|
||||
JobStateStatus::Failed => "failed",
|
||||
JobStateStatus::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
fn job_state_status_from_str(value: &str) -> JobStateStatus {
|
||||
match value {
|
||||
"queued" => JobStateStatus::Queued,
|
||||
"running" => JobStateStatus::Running,
|
||||
"completed" => JobStateStatus::Completed,
|
||||
"failed" => JobStateStatus::Failed,
|
||||
"cancelled" => JobStateStatus::Cancelled,
|
||||
_ => JobStateStatus::Queued,
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_thread(row: &rusqlite::Row<'_>) -> rusqlite::Result<ThreadMetadata> {
|
||||
let status_raw: String = row.get(7)?;
|
||||
let source_raw: String = row.get(11)?;
|
||||
let rollout_path: Option<String> = row.get(1)?;
|
||||
let path: Option<String> = row.get(8)?;
|
||||
Ok(ThreadMetadata {
|
||||
id: row.get(0)?,
|
||||
rollout_path: rollout_path.map(PathBuf::from),
|
||||
preview: row.get(2)?,
|
||||
ephemeral: i64_to_bool(row.get(3)?),
|
||||
model_provider: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
status: thread_status_from_str(&status_raw),
|
||||
path: path.map(PathBuf::from),
|
||||
cwd: PathBuf::from(row.get::<_, String>(9)?),
|
||||
cli_version: row.get(10)?,
|
||||
source: session_source_from_str(&source_raw),
|
||||
name: row.get(12)?,
|
||||
sandbox_policy: row.get(13)?,
|
||||
approval_mode: row.get(14)?,
|
||||
archived: i64_to_bool(row.get(15)?),
|
||||
archived_at: row.get(16)?,
|
||||
git_sha: row.get(17)?,
|
||||
git_branch: row.get(18)?,
|
||||
git_origin_url: row.get(19)?,
|
||||
memory_mode: row.get(20)?,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use deepseek_state::{SessionSource, StateStore, ThreadListFilters, ThreadMetadata, ThreadStatus};
|
||||
|
||||
fn temp_state_path(label: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"deepseek_state_test_{}_{}_{}.db",
|
||||
label,
|
||||
std::process::id(),
|
||||
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_and_resume_thread_metadata() {
|
||||
let path = temp_state_path("upsert_resume");
|
||||
let store = StateStore::open(Some(path.clone())).expect("open state store");
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let thread = ThreadMetadata {
|
||||
id: "thread-test-1".to_string(),
|
||||
rollout_path: Some(PathBuf::from("/tmp/rollout.jsonl")),
|
||||
preview: "hello".to_string(),
|
||||
ephemeral: false,
|
||||
model_provider: "deepseek".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
status: ThreadStatus::Running,
|
||||
path: Some(PathBuf::from("/tmp/project")),
|
||||
cwd: PathBuf::from("/tmp/project"),
|
||||
cli_version: "0.0.0-test".to_string(),
|
||||
source: SessionSource::Interactive,
|
||||
name: Some("Test Thread".to_string()),
|
||||
sandbox_policy: Some("workspace-write".to_string()),
|
||||
approval_mode: Some("on-request".to_string()),
|
||||
archived: false,
|
||||
archived_at: None,
|
||||
git_sha: None,
|
||||
git_branch: None,
|
||||
git_origin_url: None,
|
||||
memory_mode: Some("extended".to_string()),
|
||||
};
|
||||
store.upsert_thread(&thread).expect("upsert thread");
|
||||
|
||||
let loaded = store
|
||||
.get_thread("thread-test-1")
|
||||
.expect("read thread")
|
||||
.expect("thread must exist");
|
||||
assert_eq!(loaded.id, "thread-test-1");
|
||||
assert_eq!(loaded.name.as_deref(), Some("Test Thread"));
|
||||
assert_eq!(loaded.memory_mode.as_deref(), Some("extended"));
|
||||
assert_eq!(
|
||||
loaded.rollout_path,
|
||||
Some(PathBuf::from("/tmp/rollout.jsonl"))
|
||||
);
|
||||
|
||||
store
|
||||
.mark_archived("thread-test-1")
|
||||
.expect("archive thread");
|
||||
let archived = store
|
||||
.get_thread("thread-test-1")
|
||||
.expect("read archived thread")
|
||||
.expect("thread exists after archive");
|
||||
assert!(archived.archived);
|
||||
|
||||
let listed = store
|
||||
.list_threads(ThreadListFilters {
|
||||
include_archived: true,
|
||||
limit: Some(10),
|
||||
})
|
||||
.expect("list threads");
|
||||
assert!(!listed.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "deepseek-tools"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Tool invocation lifecycle, schema validation, and scheduler parallelism for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
uuid.workspace = true
|
||||
@@ -0,0 +1,202 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use deepseek_protocol::{ToolKind, ToolOutput, ToolPayload};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolSpec {
|
||||
pub name: String,
|
||||
pub input_schema: Value,
|
||||
pub output_schema: Value,
|
||||
pub supports_parallel_tool_calls: bool,
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfiguredToolSpec {
|
||||
pub spec: ToolSpec,
|
||||
pub supports_parallel_tool_calls: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCallSource {
|
||||
Direct,
|
||||
JsRepl,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub name: String,
|
||||
pub payload: ToolPayload,
|
||||
pub source: ToolCallSource,
|
||||
pub raw_tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ToolCall {
|
||||
pub fn execution_subject(&self, fallback_cwd: &str) -> (String, String, &'static str) {
|
||||
match &self.payload {
|
||||
ToolPayload::LocalShell { params } => (
|
||||
params.command.clone(),
|
||||
params
|
||||
.cwd
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_cwd.to_string()),
|
||||
"shell",
|
||||
),
|
||||
_ => (self.name.clone(), fallback_cwd.to_string(), "tool"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolInvocation {
|
||||
pub call_id: String,
|
||||
pub tool_name: String,
|
||||
pub payload: ToolPayload,
|
||||
pub source: ToolCallSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum FunctionCallError {
|
||||
ToolNotFound { name: String },
|
||||
KindMismatch { expected: ToolKind, got: ToolKind },
|
||||
MutatingToolRejected { name: String },
|
||||
TimedOut { name: String, timeout_ms: u64 },
|
||||
Cancelled { name: String },
|
||||
ExecutionFailed { name: String, error: String },
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ToolHandler: Send + Sync {
|
||||
fn kind(&self) -> ToolKind;
|
||||
fn matches_kind(&self, kind: ToolKind) -> bool {
|
||||
self.kind() == kind
|
||||
}
|
||||
fn is_mutating(&self) -> bool {
|
||||
false
|
||||
}
|
||||
async fn handle(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> std::result::Result<ToolOutput, FunctionCallError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ToolCallRuntime {
|
||||
pub parallel_execution: Arc<RwLock<()>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ToolRegistry {
|
||||
handlers: HashMap<String, Arc<dyn ToolHandler>>,
|
||||
specs: HashMap<String, ConfiguredToolSpec>,
|
||||
runtime: ToolCallRuntime,
|
||||
}
|
||||
|
||||
impl ToolRegistry {
|
||||
pub fn register(&mut self, spec: ToolSpec, handler: Arc<dyn ToolHandler>) -> Result<()> {
|
||||
let name = spec.name.clone();
|
||||
self.specs.insert(
|
||||
name.clone(),
|
||||
ConfiguredToolSpec {
|
||||
supports_parallel_tool_calls: spec.supports_parallel_tool_calls,
|
||||
spec,
|
||||
},
|
||||
);
|
||||
self.handlers.insert(name, handler);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_specs(&self) -> Vec<ConfiguredToolSpec> {
|
||||
self.specs.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn dispatch(
|
||||
&self,
|
||||
call: ToolCall,
|
||||
allow_mutating: bool,
|
||||
) -> std::result::Result<ToolOutput, FunctionCallError> {
|
||||
let handler = self.handlers.get(&call.name).cloned().ok_or_else(|| {
|
||||
FunctionCallError::ToolNotFound {
|
||||
name: call.name.clone(),
|
||||
}
|
||||
})?;
|
||||
let configured =
|
||||
self.specs
|
||||
.get(&call.name)
|
||||
.cloned()
|
||||
.ok_or_else(|| FunctionCallError::ToolNotFound {
|
||||
name: call.name.clone(),
|
||||
})?;
|
||||
|
||||
let payload_kind = tool_payload_kind(&call.payload);
|
||||
let expected = handler.kind();
|
||||
if !handler.matches_kind(payload_kind) {
|
||||
return Err(FunctionCallError::KindMismatch {
|
||||
expected,
|
||||
got: payload_kind,
|
||||
});
|
||||
}
|
||||
if handler.is_mutating() && !allow_mutating {
|
||||
return Err(FunctionCallError::MutatingToolRejected { name: call.name });
|
||||
}
|
||||
|
||||
let invocation = ToolInvocation {
|
||||
call_id: call
|
||||
.raw_tool_call_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("tool-call-{}", uuid::Uuid::new_v4())),
|
||||
tool_name: call.name.clone(),
|
||||
payload: call.payload,
|
||||
source: call.source,
|
||||
};
|
||||
|
||||
if configured.supports_parallel_tool_calls {
|
||||
let _guard = self.runtime.parallel_execution.read().await;
|
||||
self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation)
|
||||
.await
|
||||
} else {
|
||||
let _guard = self.runtime.parallel_execution.write().await;
|
||||
self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_with_timeout(
|
||||
&self,
|
||||
handler: Arc<dyn ToolHandler>,
|
||||
timeout_ms: Option<u64>,
|
||||
invocation: ToolInvocation,
|
||||
) -> std::result::Result<ToolOutput, FunctionCallError> {
|
||||
if let Some(timeout_ms) = timeout_ms {
|
||||
let name = invocation.tool_name.clone();
|
||||
match tokio::time::timeout(
|
||||
Duration::from_millis(timeout_ms),
|
||||
handler.handle(invocation),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(FunctionCallError::TimedOut { name, timeout_ms }),
|
||||
}
|
||||
} else {
|
||||
handler.handle(invocation).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_payload_kind(payload: &ToolPayload) -> ToolKind {
|
||||
match payload {
|
||||
ToolPayload::Mcp { .. } => ToolKind::Mcp,
|
||||
ToolPayload::Function { .. }
|
||||
| ToolPayload::Custom { .. }
|
||||
| ToolPayload::LocalShell { .. } => ToolKind::Function,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use deepseek_protocol::{ToolKind, ToolOutput, ToolPayload};
|
||||
use deepseek_tools::{
|
||||
ToolCall, ToolCallSource, ToolHandler, ToolInvocation, ToolRegistry, ToolSpec,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
struct EchoHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for EchoHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
fn is_mutating(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> std::result::Result<ToolOutput, deepseek_tools::FunctionCallError> {
|
||||
Ok(ToolOutput::Function {
|
||||
body: Some(json!({
|
||||
"tool": invocation.tool_name,
|
||||
"call_id": invocation.call_id
|
||||
})),
|
||||
success: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatches_function_tool_with_parallel_flag() {
|
||||
let mut registry = ToolRegistry::default();
|
||||
registry
|
||||
.register(
|
||||
ToolSpec {
|
||||
name: "echo".to_string(),
|
||||
input_schema: json!({"type":"object"}),
|
||||
output_schema: json!({"type":"object"}),
|
||||
supports_parallel_tool_calls: true,
|
||||
timeout_ms: Some(1000),
|
||||
},
|
||||
Arc::new(EchoHandler),
|
||||
)
|
||||
.expect("register tool");
|
||||
|
||||
let output = registry
|
||||
.dispatch(
|
||||
ToolCall {
|
||||
name: "echo".to_string(),
|
||||
payload: ToolPayload::Function {
|
||||
arguments: "{\"message\":\"hi\"}".to_string(),
|
||||
},
|
||||
source: ToolCallSource::Direct,
|
||||
raw_tool_call_id: Some("call-1".to_string()),
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("dispatch tool");
|
||||
match output {
|
||||
ToolOutput::Function { success, .. } => assert!(success),
|
||||
other => panic!("unexpected output: {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "deepseek-tui-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Event-driven TUI state machine scaffold for DeepSeek workspace architecture"
|
||||
@@ -0,0 +1,192 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Pane {
|
||||
Chat,
|
||||
Diff,
|
||||
Tasks,
|
||||
Agents,
|
||||
Status,
|
||||
Jobs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UiEvent {
|
||||
KeyPressed(char),
|
||||
PromptSubmitted(String),
|
||||
ResponseDelta(String),
|
||||
ToolStarted(String),
|
||||
ToolFinished(String),
|
||||
JobQueued(String),
|
||||
JobProgress { job_id: String, progress: u8 },
|
||||
JobCompleted(String),
|
||||
ApprovalRequested(String),
|
||||
ApprovalResolved(String),
|
||||
PauseRequested,
|
||||
ResumeRequested,
|
||||
Tick,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UiEffect {
|
||||
Render,
|
||||
PersistCheckpoint,
|
||||
ScheduleBackgroundRefresh,
|
||||
EmitStatusLine(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UiState {
|
||||
pub active_pane: Pane,
|
||||
pub paused: bool,
|
||||
pub last_response_delta: Option<String>,
|
||||
pub active_tool: Option<String>,
|
||||
pub pending_tasks: usize,
|
||||
pub active_jobs: usize,
|
||||
pub pending_approvals: usize,
|
||||
pub status_line: String,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active_pane: Pane::Chat,
|
||||
paused: false,
|
||||
last_response_delta: None,
|
||||
active_tool: None,
|
||||
pending_tasks: 0,
|
||||
active_jobs: 0,
|
||||
pending_approvals: 0,
|
||||
status_line: "ready".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
pub fn reduce(&mut self, event: UiEvent) -> Vec<UiEffect> {
|
||||
match event {
|
||||
UiEvent::KeyPressed('1') => {
|
||||
self.active_pane = Pane::Chat;
|
||||
vec![UiEffect::Render]
|
||||
}
|
||||
UiEvent::KeyPressed('2') => {
|
||||
self.active_pane = Pane::Diff;
|
||||
vec![UiEffect::Render]
|
||||
}
|
||||
UiEvent::KeyPressed('3') => {
|
||||
self.active_pane = Pane::Tasks;
|
||||
vec![UiEffect::Render]
|
||||
}
|
||||
UiEvent::KeyPressed('4') => {
|
||||
self.active_pane = Pane::Agents;
|
||||
vec![UiEffect::Render]
|
||||
}
|
||||
UiEvent::KeyPressed('5') => {
|
||||
self.active_pane = Pane::Jobs;
|
||||
vec![UiEffect::Render]
|
||||
}
|
||||
UiEvent::PromptSubmitted(_) => {
|
||||
self.pending_tasks = self.pending_tasks.saturating_add(1);
|
||||
self.status_line = "prompt submitted".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::PersistCheckpoint,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::ResponseDelta(delta) => {
|
||||
self.last_response_delta = Some(delta);
|
||||
self.status_line = "streaming response".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::ToolStarted(name) => {
|
||||
self.active_tool = Some(name.clone());
|
||||
self.status_line = format!("tool running: {name}");
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::ToolFinished(name) => {
|
||||
self.active_tool = None;
|
||||
self.pending_tasks = self.pending_tasks.saturating_sub(1);
|
||||
self.status_line = format!("tool finished: {name}");
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::PersistCheckpoint,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::JobQueued(_) => {
|
||||
self.active_jobs = self.active_jobs.saturating_add(1);
|
||||
self.status_line = "job queued".to_string();
|
||||
vec![UiEffect::Render, UiEffect::PersistCheckpoint]
|
||||
}
|
||||
UiEvent::JobProgress { progress, .. } => {
|
||||
self.status_line = format!("job progress: {}%", progress.min(100));
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::JobCompleted(_) => {
|
||||
self.active_jobs = self.active_jobs.saturating_sub(1);
|
||||
self.status_line = "job completed".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::PersistCheckpoint,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::ApprovalRequested(_) => {
|
||||
self.pending_approvals = self.pending_approvals.saturating_add(1);
|
||||
self.status_line = "approval requested".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::ApprovalResolved(_) => {
|
||||
self.pending_approvals = self.pending_approvals.saturating_sub(1);
|
||||
self.status_line = "approval resolved".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::PersistCheckpoint,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::PauseRequested => {
|
||||
self.paused = true;
|
||||
self.status_line = "paused".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::ResumeRequested => {
|
||||
self.paused = false;
|
||||
self.status_line = "resumed".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::Tick => vec![UiEffect::ScheduleBackgroundRefresh],
|
||||
UiEvent::KeyPressed(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> String {
|
||||
format!(
|
||||
"pane={:?};paused={};pending_tasks={};active_jobs={};pending_approvals={};active_tool={};status={}",
|
||||
self.active_pane,
|
||||
self.paused,
|
||||
self.pending_tasks,
|
||||
self.active_jobs,
|
||||
self.pending_approvals,
|
||||
self.active_tool.clone().unwrap_or_default(),
|
||||
self.status_line
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use deepseek_tui_core::{Pane, UiEvent, UiState};
|
||||
|
||||
#[test]
|
||||
fn reducer_produces_stable_snapshot_for_core_workflow() {
|
||||
let mut state = UiState::default();
|
||||
state.reduce(UiEvent::PromptSubmitted("hello".to_string()));
|
||||
state.reduce(UiEvent::ToolStarted("web.search".to_string()));
|
||||
state.reduce(UiEvent::ResponseDelta("partial".to_string()));
|
||||
state.reduce(UiEvent::ToolFinished("web.search".to_string()));
|
||||
state.reduce(UiEvent::ApprovalRequested("approval-1".to_string()));
|
||||
state.reduce(UiEvent::ApprovalResolved("approval-1".to_string()));
|
||||
state.reduce(UiEvent::JobQueued("job-1".to_string()));
|
||||
state.reduce(UiEvent::JobProgress {
|
||||
job_id: "job-1".to_string(),
|
||||
progress: 60,
|
||||
});
|
||||
state.reduce(UiEvent::JobCompleted("job-1".to_string()));
|
||||
state.reduce(UiEvent::KeyPressed('5'));
|
||||
|
||||
assert_eq!(state.active_pane, Pane::Jobs);
|
||||
assert_eq!(
|
||||
state.snapshot(),
|
||||
"pane=Jobs;paused=false;pending_tasks=0;active_jobs=0;pending_approvals=0;active_tool=;status=job completed"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
[package]
|
||||
name = "deepseek-tui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Terminal UI for DeepSeek"
|
||||
|
||||
[[bin]]
|
||||
name = "deepseek-tui"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
arboard = "3.4"
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1"
|
||||
bytes = "1.11.0"
|
||||
base64 = "0.22.1"
|
||||
axum = { version = "0.8.4", features = ["json"] }
|
||||
clap = { version = "4.5.54", features = ["derive"] }
|
||||
clap_complete = "4.5"
|
||||
colored = "3.0.0"
|
||||
crossterm = "0.28"
|
||||
csv = "1.4"
|
||||
dotenvy = "0.15.7"
|
||||
dirs = "6.0.0"
|
||||
futures-util = "0.3.31"
|
||||
indicatif = "0.18.0"
|
||||
ratatui = "0.29"
|
||||
regex = "1.11"
|
||||
reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "native-tls", "http2"] }
|
||||
rustyline = "15.0.0"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
shellexpand = "3"
|
||||
toml = "0.9.7"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
tokio-util = { version = "0.7.16", features = ["io"] }
|
||||
unicode-width = "0.2"
|
||||
unicode-segmentation = "1.12"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
tokio-stream = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tempfile = "3.16"
|
||||
thiserror = "2.0"
|
||||
tracing = "0.1"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
wait-timeout = "0.2"
|
||||
multimap = "0.10.0"
|
||||
shlex = "1.3.0"
|
||||
starlark = "0.13.0"
|
||||
tiny_http = "0.12"
|
||||
portable-pty = "0.8"
|
||||
zeroize = "1.8.2"
|
||||
ignore = "0.4"
|
||||
pdf-extract = "0.7"
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = "0.6"
|
||||
pretty_assertions = "1.4"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.60", features = ["Win32_Foundation"] }
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Lightweight audit logging for sensitive operations.
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::Utc;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Append an audit event to `~/.deepseek/audit.log`.
|
||||
///
|
||||
/// This helper is best-effort by design: callers should not fail critical flows
|
||||
/// if audit persistence fails.
|
||||
pub fn log_sensitive_event(event: &str, details: Value) {
|
||||
if let Err(err) = append_event(event, details) {
|
||||
crate::logging::warn(format!("audit log write failed: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn append_event(event: &str, details: Value) -> anyhow::Result<()> {
|
||||
let path = default_audit_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
|
||||
let record = json!({
|
||||
"ts": Utc::now().to_rfc3339(),
|
||||
"event": event,
|
||||
"details": details,
|
||||
});
|
||||
writeln!(file, "{}", serde_json::to_string(&record)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_audit_path() -> anyhow::Result<PathBuf> {
|
||||
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?;
|
||||
Ok(home.join(".deepseek").join("audit.log"))
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
//! Durable automation records and scheduler support.
|
||||
//!
|
||||
//! Automations are local-first recurring jobs that enqueue standard background
|
||||
//! tasks. This module stores automation definitions and run history under
|
||||
//! `~/.deepseek/automations` (or `DEEPSEEK_AUTOMATIONS_DIR` override).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use chrono::{DateTime, Datelike, Duration, Local, TimeZone, Timelike, Utc, Weekday};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::sleep;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::task_manager::{NewTaskRequest, SharedTaskManager, TaskStatus};
|
||||
|
||||
const CURRENT_AUTOMATION_SCHEMA_VERSION: u32 = 1;
|
||||
const CURRENT_RUN_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
const fn default_automation_schema_version() -> u32 {
|
||||
CURRENT_AUTOMATION_SCHEMA_VERSION
|
||||
}
|
||||
|
||||
const fn default_run_schema_version() -> u32 {
|
||||
CURRENT_RUN_SCHEMA_VERSION
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AutomationStatus {
|
||||
Active,
|
||||
Paused,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AutomationRunStatus {
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Canceled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AutomationRecord {
|
||||
#[serde(default = "default_automation_schema_version")]
|
||||
pub schema_version: u32,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub prompt: String,
|
||||
pub rrule: String,
|
||||
#[serde(default)]
|
||||
pub cwds: Vec<PathBuf>,
|
||||
pub status: AutomationStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub next_run_at: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_run_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AutomationRunRecord {
|
||||
#[serde(default = "default_run_schema_version")]
|
||||
pub schema_version: u32,
|
||||
pub id: String,
|
||||
pub automation_id: String,
|
||||
pub scheduled_for: DateTime<Utc>,
|
||||
pub status: AutomationRunStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ended_at: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub task_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thread_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub turn_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateAutomationRequest {
|
||||
pub name: String,
|
||||
pub prompt: String,
|
||||
pub rrule: String,
|
||||
#[serde(default)]
|
||||
pub cwds: Vec<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub status: Option<AutomationStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct UpdateAutomationRequest {
|
||||
pub name: Option<String>,
|
||||
pub prompt: Option<String>,
|
||||
pub rrule: Option<String>,
|
||||
pub cwds: Option<Vec<PathBuf>>,
|
||||
pub status: Option<AutomationStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum AutomationFrequency {
|
||||
Hourly,
|
||||
Weekly,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AutomationSchedule {
|
||||
Hourly {
|
||||
interval_hours: u32,
|
||||
byday: Option<Vec<Weekday>>,
|
||||
},
|
||||
Weekly {
|
||||
byday: Vec<Weekday>,
|
||||
byhour: u32,
|
||||
byminute: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl AutomationSchedule {
|
||||
pub fn parse_rrule(rrule: &str) -> Result<Self> {
|
||||
let mut parts: BTreeMap<String, String> = BTreeMap::new();
|
||||
for raw in rrule.split(';') {
|
||||
let item = raw.trim();
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((k, v)) = item.split_once('=') else {
|
||||
bail!("Invalid RRULE segment '{item}'");
|
||||
};
|
||||
parts.insert(k.trim().to_ascii_uppercase(), v.trim().to_ascii_uppercase());
|
||||
}
|
||||
|
||||
let freq = match parts.get("FREQ").map(String::as_str) {
|
||||
Some("HOURLY") => AutomationFrequency::Hourly,
|
||||
Some("WEEKLY") => AutomationFrequency::Weekly,
|
||||
Some(other) => bail!("Unsupported RRULE FREQ '{other}'. Supported: HOURLY and WEEKLY"),
|
||||
None => bail!("RRULE must include FREQ"),
|
||||
};
|
||||
|
||||
match freq {
|
||||
AutomationFrequency::Hourly => {
|
||||
for key in parts.keys() {
|
||||
if key != "FREQ" && key != "INTERVAL" && key != "BYDAY" {
|
||||
bail!(
|
||||
"Unsupported RRULE field '{key}' for HOURLY. Allowed: FREQ,INTERVAL,BYDAY"
|
||||
);
|
||||
}
|
||||
}
|
||||
let interval_hours = parts
|
||||
.get("INTERVAL")
|
||||
.map(|v| v.parse::<u32>())
|
||||
.transpose()
|
||||
.context("Failed to parse INTERVAL")?
|
||||
.unwrap_or(1);
|
||||
if interval_hours == 0 {
|
||||
bail!("INTERVAL must be >= 1 for HOURLY schedules");
|
||||
}
|
||||
let byday = parts
|
||||
.get("BYDAY")
|
||||
.map(|value| parse_byday(value))
|
||||
.transpose()?;
|
||||
Ok(Self::Hourly {
|
||||
interval_hours,
|
||||
byday,
|
||||
})
|
||||
}
|
||||
AutomationFrequency::Weekly => {
|
||||
for key in parts.keys() {
|
||||
if key != "FREQ" && key != "BYDAY" && key != "BYHOUR" && key != "BYMINUTE" {
|
||||
bail!(
|
||||
"Unsupported RRULE field '{key}' for WEEKLY. Allowed: FREQ,BYDAY,BYHOUR,BYMINUTE"
|
||||
);
|
||||
}
|
||||
}
|
||||
let byday_raw = parts
|
||||
.get("BYDAY")
|
||||
.ok_or_else(|| anyhow::anyhow!("WEEKLY schedules require BYDAY"))?;
|
||||
let byday = parse_byday(byday_raw)?;
|
||||
if byday.is_empty() {
|
||||
bail!("BYDAY cannot be empty for WEEKLY schedules");
|
||||
}
|
||||
let byhour = parts
|
||||
.get("BYHOUR")
|
||||
.ok_or_else(|| anyhow::anyhow!("WEEKLY schedules require BYHOUR"))?
|
||||
.parse::<u32>()
|
||||
.context("Failed to parse BYHOUR")?;
|
||||
let byminute = parts
|
||||
.get("BYMINUTE")
|
||||
.ok_or_else(|| anyhow::anyhow!("WEEKLY schedules require BYMINUTE"))?
|
||||
.parse::<u32>()
|
||||
.context("Failed to parse BYMINUTE")?;
|
||||
|
||||
if byhour > 23 {
|
||||
bail!("BYHOUR must be between 0 and 23");
|
||||
}
|
||||
if byminute > 59 {
|
||||
bail!("BYMINUTE must be between 0 and 59");
|
||||
}
|
||||
|
||||
Ok(Self::Weekly {
|
||||
byday,
|
||||
byhour,
|
||||
byminute,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_after(&self, after: DateTime<Utc>) -> Result<DateTime<Utc>> {
|
||||
let local_after = after.with_timezone(&Local);
|
||||
match self {
|
||||
Self::Hourly {
|
||||
interval_hours,
|
||||
byday,
|
||||
} => {
|
||||
let mut candidate = local_after + Duration::hours(i64::from(*interval_hours))
|
||||
- Duration::seconds(i64::from(local_after.second()))
|
||||
- Duration::nanoseconds(i64::from(local_after.nanosecond()));
|
||||
|
||||
if let Some(days) = byday {
|
||||
for _ in 0..(24 * 21) {
|
||||
if days.contains(&candidate.weekday()) {
|
||||
return Ok(candidate.with_timezone(&Utc));
|
||||
}
|
||||
candidate += Duration::hours(i64::from(*interval_hours));
|
||||
}
|
||||
bail!("Unable to compute next HOURLY run for BYDAY filter");
|
||||
}
|
||||
|
||||
Ok(candidate.with_timezone(&Utc))
|
||||
}
|
||||
Self::Weekly {
|
||||
byday,
|
||||
byhour,
|
||||
byminute,
|
||||
} => {
|
||||
for day_offset in 0..15 {
|
||||
let date = local_after.date_naive() + Duration::days(i64::from(day_offset));
|
||||
if !byday.contains(&date.weekday()) {
|
||||
continue;
|
||||
}
|
||||
let Some(candidate_naive) = date.and_hms_opt(*byhour, *byminute, 0) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(candidate) = resolve_local_datetime(candidate_naive)
|
||||
&& candidate > local_after
|
||||
{
|
||||
return Ok(candidate.with_timezone(&Utc));
|
||||
}
|
||||
}
|
||||
bail!("Unable to compute next WEEKLY run");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_local_datetime(naive: chrono::NaiveDateTime) -> Option<DateTime<Local>> {
|
||||
Local
|
||||
.from_local_datetime(&naive)
|
||||
.single()
|
||||
.or_else(|| Local.from_local_datetime(&naive).earliest())
|
||||
.or_else(|| Local.from_local_datetime(&naive).latest())
|
||||
}
|
||||
|
||||
fn parse_byday(value: &str) -> Result<Vec<Weekday>> {
|
||||
let mut days = Vec::new();
|
||||
for token in value.split(',') {
|
||||
let day = match token.trim().to_ascii_uppercase().as_str() {
|
||||
"MO" => Weekday::Mon,
|
||||
"TU" => Weekday::Tue,
|
||||
"WE" => Weekday::Wed,
|
||||
"TH" => Weekday::Thu,
|
||||
"FR" => Weekday::Fri,
|
||||
"SA" => Weekday::Sat,
|
||||
"SU" => Weekday::Sun,
|
||||
other => bail!("Invalid BYDAY value '{other}'"),
|
||||
};
|
||||
if !days.contains(&day) {
|
||||
days.push(day);
|
||||
}
|
||||
}
|
||||
Ok(days)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutomationManager {
|
||||
automations_dir: PathBuf,
|
||||
runs_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl AutomationManager {
|
||||
pub fn open(root: PathBuf) -> Result<Self> {
|
||||
let automations_dir = root.join("automations");
|
||||
let runs_dir = root.join("runs");
|
||||
fs::create_dir_all(&automations_dir)
|
||||
.with_context(|| format!("Failed to create {}", automations_dir.display()))?;
|
||||
fs::create_dir_all(&runs_dir)
|
||||
.with_context(|| format!("Failed to create {}", runs_dir.display()))?;
|
||||
Ok(Self {
|
||||
automations_dir,
|
||||
runs_dir,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn default_location() -> Result<Self> {
|
||||
Self::open(default_automations_dir())
|
||||
}
|
||||
|
||||
fn automation_path(&self, id: &str) -> PathBuf {
|
||||
self.automations_dir.join(format!("{id}.json"))
|
||||
}
|
||||
|
||||
fn runs_dir_for(&self, automation_id: &str) -> PathBuf {
|
||||
self.runs_dir.join(automation_id)
|
||||
}
|
||||
|
||||
fn run_path(&self, automation_id: &str, run_id: &str) -> PathBuf {
|
||||
self.runs_dir_for(automation_id)
|
||||
.join(format!("{run_id}.json"))
|
||||
}
|
||||
|
||||
pub fn create_automation(&self, req: CreateAutomationRequest) -> Result<AutomationRecord> {
|
||||
validate_name_and_prompt(&req.name, &req.prompt)?;
|
||||
let schedule = AutomationSchedule::parse_rrule(&req.rrule)?;
|
||||
let now = Utc::now();
|
||||
let status = req.status.unwrap_or(AutomationStatus::Active);
|
||||
let next_run_at = if matches!(status, AutomationStatus::Active) {
|
||||
Some(schedule.next_after(now)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let record = AutomationRecord {
|
||||
schema_version: CURRENT_AUTOMATION_SCHEMA_VERSION,
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name: req.name.trim().to_string(),
|
||||
prompt: req.prompt.trim().to_string(),
|
||||
rrule: req.rrule.trim().to_ascii_uppercase(),
|
||||
cwds: req.cwds,
|
||||
status,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
next_run_at,
|
||||
last_run_at: None,
|
||||
};
|
||||
|
||||
self.save_automation(&record)?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub fn get_automation(&self, id: &str) -> Result<AutomationRecord> {
|
||||
let path = self.automation_path(id);
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read automation {}", path.display()))?;
|
||||
let record: AutomationRecord = serde_json::from_str(&raw)
|
||||
.with_context(|| format!("Failed to parse automation {}", path.display()))?;
|
||||
if record.schema_version > CURRENT_AUTOMATION_SCHEMA_VERSION {
|
||||
bail!(
|
||||
"Automation schema v{} is newer than supported v{}",
|
||||
record.schema_version,
|
||||
CURRENT_AUTOMATION_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub fn save_automation(&self, record: &AutomationRecord) -> Result<()> {
|
||||
write_json_atomic(&self.automation_path(&record.id), record)
|
||||
}
|
||||
|
||||
pub fn list_automations(&self) -> Result<Vec<AutomationRecord>> {
|
||||
let mut out = Vec::new();
|
||||
for entry in fs::read_dir(&self.automations_dir)
|
||||
.with_context(|| format!("Failed to read {}", self.automations_dir.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().is_none_or(|ext| ext != "json") {
|
||||
continue;
|
||||
}
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
let record: AutomationRecord = serde_json::from_str(&raw)
|
||||
.with_context(|| format!("Failed to parse {}", path.display()))?;
|
||||
if record.schema_version > CURRENT_AUTOMATION_SCHEMA_VERSION {
|
||||
bail!(
|
||||
"Automation schema v{} is newer than supported v{}",
|
||||
record.schema_version,
|
||||
CURRENT_AUTOMATION_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
out.push(record);
|
||||
}
|
||||
out.sort_by_key(|r| std::cmp::Reverse(r.updated_at));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn update_automation(
|
||||
&self,
|
||||
id: &str,
|
||||
req: UpdateAutomationRequest,
|
||||
) -> Result<AutomationRecord> {
|
||||
let mut existing = self.get_automation(id)?;
|
||||
|
||||
if let Some(name) = req.name {
|
||||
if name.trim().is_empty() {
|
||||
bail!("Automation name cannot be empty");
|
||||
}
|
||||
existing.name = name.trim().to_string();
|
||||
}
|
||||
if let Some(prompt) = req.prompt {
|
||||
if prompt.trim().is_empty() {
|
||||
bail!("Automation prompt cannot be empty");
|
||||
}
|
||||
existing.prompt = prompt.trim().to_string();
|
||||
}
|
||||
if let Some(rrule) = req.rrule {
|
||||
let normalized = rrule.trim().to_ascii_uppercase();
|
||||
AutomationSchedule::parse_rrule(&normalized)?;
|
||||
existing.rrule = normalized;
|
||||
if matches!(existing.status, AutomationStatus::Active) {
|
||||
let schedule = AutomationSchedule::parse_rrule(&existing.rrule)?;
|
||||
existing.next_run_at = Some(schedule.next_after(Utc::now())?);
|
||||
}
|
||||
}
|
||||
if let Some(cwds) = req.cwds {
|
||||
existing.cwds = cwds;
|
||||
}
|
||||
if let Some(status) = req.status {
|
||||
existing.status = status;
|
||||
if matches!(status, AutomationStatus::Paused) {
|
||||
existing.next_run_at = None;
|
||||
} else {
|
||||
let schedule = AutomationSchedule::parse_rrule(&existing.rrule)?;
|
||||
existing.next_run_at = Some(schedule.next_after(Utc::now())?);
|
||||
}
|
||||
}
|
||||
|
||||
existing.updated_at = Utc::now();
|
||||
self.save_automation(&existing)?;
|
||||
Ok(existing)
|
||||
}
|
||||
|
||||
pub fn pause_automation(&self, id: &str) -> Result<AutomationRecord> {
|
||||
self.update_automation(
|
||||
id,
|
||||
UpdateAutomationRequest {
|
||||
status: Some(AutomationStatus::Paused),
|
||||
..UpdateAutomationRequest::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resume_automation(&self, id: &str) -> Result<AutomationRecord> {
|
||||
self.update_automation(
|
||||
id,
|
||||
UpdateAutomationRequest {
|
||||
status: Some(AutomationStatus::Active),
|
||||
..UpdateAutomationRequest::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn delete_automation(&self, id: &str) -> Result<AutomationRecord> {
|
||||
let existing = self.get_automation(id)?;
|
||||
let path = self.automation_path(id);
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("Failed to delete automation {}", path.display()))?;
|
||||
|
||||
let runs_dir = self.runs_dir_for(id);
|
||||
if runs_dir.exists() {
|
||||
fs::remove_dir_all(&runs_dir).with_context(|| {
|
||||
format!("Failed to delete automation runs {}", runs_dir.display())
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(existing)
|
||||
}
|
||||
|
||||
pub fn list_runs(
|
||||
&self,
|
||||
automation_id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<AutomationRunRecord>> {
|
||||
let dir = self.runs_dir_for(automation_id);
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
for entry in
|
||||
fs::read_dir(&dir).with_context(|| format!("Failed to read {}", dir.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().is_none_or(|ext| ext != "json") {
|
||||
continue;
|
||||
}
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
let run: AutomationRunRecord = serde_json::from_str(&raw)
|
||||
.with_context(|| format!("Failed to parse {}", path.display()))?;
|
||||
if run.schema_version > CURRENT_RUN_SCHEMA_VERSION {
|
||||
bail!(
|
||||
"Automation run schema v{} is newer than supported v{}",
|
||||
run.schema_version,
|
||||
CURRENT_RUN_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
out.push(run);
|
||||
}
|
||||
|
||||
out.sort_by_key(|r| std::cmp::Reverse(r.created_at));
|
||||
if let Some(limit) = limit {
|
||||
out.truncate(limit);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn save_run(&self, run: &AutomationRunRecord) -> Result<()> {
|
||||
let dir = self.runs_dir_for(&run.automation_id);
|
||||
fs::create_dir_all(&dir).with_context(|| format!("Failed to create {}", dir.display()))?;
|
||||
write_json_atomic(&self.run_path(&run.automation_id, &run.id), run)
|
||||
}
|
||||
|
||||
async fn enqueue_run_task(
|
||||
&self,
|
||||
automation: &AutomationRecord,
|
||||
run: &mut AutomationRunRecord,
|
||||
task_manager: &SharedTaskManager,
|
||||
) -> Result<()> {
|
||||
let workspace = automation.cwds.first().cloned();
|
||||
|
||||
let new_task = NewTaskRequest {
|
||||
prompt: automation.prompt.clone(),
|
||||
model: None,
|
||||
workspace,
|
||||
mode: Some("agent".to_string()),
|
||||
allow_shell: Some(false),
|
||||
trust_mode: Some(false),
|
||||
auto_approve: Some(true),
|
||||
};
|
||||
|
||||
match task_manager.add_task(new_task).await {
|
||||
Ok(task) => {
|
||||
run.status = AutomationRunStatus::Running;
|
||||
run.started_at = Some(Utc::now());
|
||||
run.task_id = Some(task.id.clone());
|
||||
run.thread_id = task.thread_id.clone();
|
||||
run.turn_id = task.turn_id.clone();
|
||||
run.error = None;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
run.status = AutomationRunStatus::Failed;
|
||||
run.ended_at = Some(Utc::now());
|
||||
run.error = Some(format!("Failed to enqueue task: {err}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_now(
|
||||
&self,
|
||||
automation_id: &str,
|
||||
task_manager: &SharedTaskManager,
|
||||
) -> Result<AutomationRunRecord> {
|
||||
let mut automation = self.get_automation(automation_id)?;
|
||||
let now = Utc::now();
|
||||
let mut run = AutomationRunRecord {
|
||||
schema_version: CURRENT_RUN_SCHEMA_VERSION,
|
||||
id: Uuid::new_v4().to_string(),
|
||||
automation_id: automation.id.clone(),
|
||||
scheduled_for: now,
|
||||
status: AutomationRunStatus::Queued,
|
||||
created_at: now,
|
||||
started_at: None,
|
||||
ended_at: None,
|
||||
task_id: None,
|
||||
thread_id: None,
|
||||
turn_id: None,
|
||||
error: None,
|
||||
};
|
||||
|
||||
self.enqueue_run_task(&automation, &mut run, task_manager)
|
||||
.await?;
|
||||
self.save_run(&run)?;
|
||||
|
||||
automation.updated_at = Utc::now();
|
||||
if matches!(
|
||||
run.status,
|
||||
AutomationRunStatus::Completed
|
||||
| AutomationRunStatus::Failed
|
||||
| AutomationRunStatus::Canceled
|
||||
) {
|
||||
automation.last_run_at = run.ended_at.or(Some(Utc::now()));
|
||||
}
|
||||
self.save_automation(&automation)?;
|
||||
|
||||
Ok(run)
|
||||
}
|
||||
|
||||
pub async fn scheduler_tick(&self, task_manager: &SharedTaskManager) -> Result<()> {
|
||||
let now = Utc::now();
|
||||
let mut automations = self.list_automations()?;
|
||||
|
||||
for automation in &mut automations {
|
||||
if !matches!(automation.status, AutomationStatus::Active) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let schedule = AutomationSchedule::parse_rrule(&automation.rrule)?;
|
||||
if automation.next_run_at.is_none() {
|
||||
automation.next_run_at = Some(schedule.next_after(now)?);
|
||||
automation.updated_at = now;
|
||||
self.save_automation(automation)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let due_at = automation.next_run_at.expect("checked above");
|
||||
if due_at > now {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Idempotency: if a run already exists for this schedule slot, skip enqueue and
|
||||
// advance next_run_at.
|
||||
let existing_for_slot = self
|
||||
.list_runs(&automation.id, Some(25))?
|
||||
.into_iter()
|
||||
.any(|run| run.scheduled_for == due_at);
|
||||
|
||||
if existing_for_slot {
|
||||
automation.next_run_at = Some(schedule.next_after(due_at)?);
|
||||
automation.updated_at = now;
|
||||
self.save_automation(automation)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut run = AutomationRunRecord {
|
||||
schema_version: CURRENT_RUN_SCHEMA_VERSION,
|
||||
id: Uuid::new_v4().to_string(),
|
||||
automation_id: automation.id.clone(),
|
||||
scheduled_for: due_at,
|
||||
status: AutomationRunStatus::Queued,
|
||||
created_at: now,
|
||||
started_at: None,
|
||||
ended_at: None,
|
||||
task_id: None,
|
||||
thread_id: None,
|
||||
turn_id: None,
|
||||
error: None,
|
||||
};
|
||||
|
||||
self.enqueue_run_task(automation, &mut run, task_manager)
|
||||
.await?;
|
||||
self.save_run(&run)?;
|
||||
|
||||
automation.updated_at = now;
|
||||
automation.next_run_at = Some(schedule.next_after(due_at)?);
|
||||
self.save_automation(automation)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reconcile_run_statuses(&self, task_manager: &SharedTaskManager) -> Result<()> {
|
||||
let automations = self.list_automations()?;
|
||||
for automation in automations {
|
||||
let runs = self.list_runs(&automation.id, Some(100))?;
|
||||
for mut run in runs {
|
||||
if !matches!(
|
||||
run.status,
|
||||
AutomationRunStatus::Queued | AutomationRunStatus::Running
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let Some(task_id) = run.task_id.clone() else {
|
||||
continue;
|
||||
};
|
||||
let task = match task_manager.get_task(&task_id).await {
|
||||
Ok(task) => task,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
run.thread_id = task.thread_id.clone();
|
||||
run.turn_id = task.turn_id.clone();
|
||||
|
||||
let mut changed = false;
|
||||
match task.status {
|
||||
TaskStatus::Queued => {
|
||||
if !matches!(run.status, AutomationRunStatus::Queued) {
|
||||
run.status = AutomationRunStatus::Queued;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
TaskStatus::Running => {
|
||||
if !matches!(run.status, AutomationRunStatus::Running) {
|
||||
run.status = AutomationRunStatus::Running;
|
||||
changed = true;
|
||||
}
|
||||
if run.started_at.is_none() {
|
||||
run.started_at = Some(task.started_at.unwrap_or_else(Utc::now));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
TaskStatus::Completed => {
|
||||
run.status = AutomationRunStatus::Completed;
|
||||
run.started_at = run.started_at.or(task.started_at);
|
||||
run.ended_at = task.ended_at.or(Some(Utc::now()));
|
||||
run.error = None;
|
||||
changed = true;
|
||||
}
|
||||
TaskStatus::Failed => {
|
||||
run.status = AutomationRunStatus::Failed;
|
||||
run.started_at = run.started_at.or(task.started_at);
|
||||
run.ended_at = task.ended_at.or(Some(Utc::now()));
|
||||
run.error = task.error.clone();
|
||||
changed = true;
|
||||
}
|
||||
TaskStatus::Canceled => {
|
||||
run.status = AutomationRunStatus::Canceled;
|
||||
run.started_at = run.started_at.or(task.started_at);
|
||||
run.ended_at = task.ended_at.or(Some(Utc::now()));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
self.save_run(&run)?;
|
||||
if matches!(
|
||||
run.status,
|
||||
AutomationRunStatus::Completed
|
||||
| AutomationRunStatus::Failed
|
||||
| AutomationRunStatus::Canceled
|
||||
) {
|
||||
let mut updated_automation = self.get_automation(&automation.id)?;
|
||||
updated_automation.last_run_at = run.ended_at.or(Some(Utc::now()));
|
||||
updated_automation.updated_at = Utc::now();
|
||||
self.save_automation(&updated_automation)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_name_and_prompt(name: &str, prompt: &str) -> Result<()> {
|
||||
if name.trim().is_empty() {
|
||||
bail!("Automation name is required");
|
||||
}
|
||||
if prompt.trim().is_empty() {
|
||||
bail!("Automation prompt is required");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
||||
}
|
||||
let content = serde_json::to_string_pretty(value)?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, content).with_context(|| format!("Failed to write {}", tmp.display()))?;
|
||||
fs::rename(&tmp, path).with_context(|| {
|
||||
format!(
|
||||
"Failed to move temporary file {} to {}",
|
||||
tmp.display(),
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn default_automations_dir() -> PathBuf {
|
||||
if let Ok(path) = std::env::var("DEEPSEEK_AUTOMATIONS_DIR") {
|
||||
let trimmed = path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return PathBuf::from(trimmed);
|
||||
}
|
||||
}
|
||||
dirs::home_dir()
|
||||
.map(|home| home.join(".deepseek").join("automations"))
|
||||
.unwrap_or_else(|| PathBuf::from(".deepseek").join("automations"))
|
||||
}
|
||||
|
||||
pub type SharedAutomationManager = Arc<Mutex<AutomationManager>>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutomationSchedulerConfig {
|
||||
pub tick_interval_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for AutomationSchedulerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tick_interval_secs: 15,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_scheduler(
|
||||
automations: SharedAutomationManager,
|
||||
task_manager: SharedTaskManager,
|
||||
cancel: CancellationToken,
|
||||
config: AutomationSchedulerConfig,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let interval = config.tick_interval_secs.max(5);
|
||||
loop {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
|
||||
{
|
||||
let manager = automations.lock().await;
|
||||
if let Err(err) = manager.scheduler_tick(&task_manager).await {
|
||||
tracing::warn!("automation scheduler tick failed: {err}");
|
||||
}
|
||||
if let Err(err) = manager.reconcile_run_statuses(&task_manager).await {
|
||||
tracing::warn!("automation reconcile failed: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
_ = sleep(std::time::Duration::from_secs(interval)) => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_hourly_rrule() {
|
||||
let parsed =
|
||||
AutomationSchedule::parse_rrule("FREQ=HOURLY;INTERVAL=2;BYDAY=MO,TU").expect("parse");
|
||||
match parsed {
|
||||
AutomationSchedule::Hourly {
|
||||
interval_hours,
|
||||
byday,
|
||||
} => {
|
||||
assert_eq!(interval_hours, 2);
|
||||
assert_eq!(byday.expect("byday").len(), 2);
|
||||
}
|
||||
_ => panic!("expected hourly"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_weekly_rrule() {
|
||||
let parsed =
|
||||
AutomationSchedule::parse_rrule("FREQ=WEEKLY;BYDAY=MO,WE;BYHOUR=9;BYMINUTE=30")
|
||||
.expect("parse");
|
||||
match parsed {
|
||||
AutomationSchedule::Weekly {
|
||||
byday,
|
||||
byhour,
|
||||
byminute,
|
||||
} => {
|
||||
assert_eq!(byday.len(), 2);
|
||||
assert_eq!(byhour, 9);
|
||||
assert_eq!(byminute, 30);
|
||||
}
|
||||
_ => panic!("expected weekly"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_rrule_fields() {
|
||||
let err =
|
||||
AutomationSchedule::parse_rrule("FREQ=WEEKLY;BYSECOND=5").expect_err("should fail");
|
||||
assert!(err.to_string().contains("Unsupported RRULE field"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletes_automation_and_runs() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let manager = AutomationManager::open(tempdir.path().to_path_buf()).expect("manager");
|
||||
|
||||
let created = manager
|
||||
.create_automation(CreateAutomationRequest {
|
||||
name: "Delete me".to_string(),
|
||||
prompt: "prompt".to_string(),
|
||||
rrule: "FREQ=HOURLY;INTERVAL=1".to_string(),
|
||||
cwds: Vec::new(),
|
||||
status: Some(AutomationStatus::Active),
|
||||
})
|
||||
.expect("create");
|
||||
|
||||
let run = AutomationRunRecord {
|
||||
schema_version: CURRENT_RUN_SCHEMA_VERSION,
|
||||
id: Uuid::new_v4().to_string(),
|
||||
automation_id: created.id.clone(),
|
||||
scheduled_for: Utc::now(),
|
||||
status: AutomationRunStatus::Queued,
|
||||
created_at: Utc::now(),
|
||||
started_at: None,
|
||||
ended_at: None,
|
||||
task_id: None,
|
||||
thread_id: None,
|
||||
turn_id: None,
|
||||
error: None,
|
||||
};
|
||||
manager.save_run(&run).expect("save run");
|
||||
assert!(manager.runs_dir_for(&created.id).exists());
|
||||
|
||||
manager
|
||||
.delete_automation(&created.id)
|
||||
.expect("delete automation");
|
||||
|
||||
assert!(manager.get_automation(&created.id).is_err());
|
||||
assert!(!manager.runs_dir_for(&created.id).exists());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,10 +1,11 @@
|
||||
// TODO(integrate): Wire command safety analysis into shell tool approval flow
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Command safety analysis for shell execution
|
||||
//!
|
||||
//! This module provides pre-execution analysis of shell commands to detect
|
||||
//! potentially dangerous patterns and prevent accidental damage.
|
||||
|
||||
#![allow(dead_code)] // Public API - utility functions may not be used yet
|
||||
|
||||
/// Safety classification of a command
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SafetyLevel {
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Local media attachment commands.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::CommandResult;
|
||||
use crate::tui::app::App;
|
||||
|
||||
pub fn attach(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
let Some(raw_path) = arg.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return CommandResult::error("Usage: /attach <image-or-video-path>");
|
||||
};
|
||||
|
||||
let path = resolve_attachment_path(raw_path, &app.workspace);
|
||||
let Ok(path) = path.canonicalize() else {
|
||||
return CommandResult::error(format!("Attachment not found: {}", path.display()));
|
||||
};
|
||||
if !path.is_file() {
|
||||
return CommandResult::error(format!("Attachment is not a file: {}", path.display()));
|
||||
}
|
||||
|
||||
let Some(kind) = media_kind(&path) else {
|
||||
return CommandResult::error(
|
||||
"Unsupported attachment type. /attach is for image/video paths; use @path for text files or directories.",
|
||||
);
|
||||
};
|
||||
|
||||
app.insert_media_attachment(kind, &path, None);
|
||||
CommandResult::message(format!("Attached {kind}: {}", path.display()))
|
||||
}
|
||||
|
||||
fn resolve_attachment_path(raw_path: &str, workspace: &Path) -> PathBuf {
|
||||
let unquoted = raw_path.trim().trim_matches('"').trim_matches('\'');
|
||||
let path = expand_home(unquoted);
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
workspace.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_home(path: &str) -> PathBuf {
|
||||
if path == "~" {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return PathBuf::from(home);
|
||||
}
|
||||
} else if let Some(rest) = path.strip_prefix("~/")
|
||||
&& let Some(home) = std::env::var_os("HOME")
|
||||
{
|
||||
return PathBuf::from(home).join(rest);
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
fn media_kind(path: &Path) -> Option<&'static str> {
|
||||
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "ppm" => Some("image"),
|
||||
"mp4" | "mov" | "m4v" | "webm" | "avi" | "mkv" => Some("video"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::TuiOptions;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn app_with_workspace(tmpdir: &TempDir) -> App {
|
||||
App::new(
|
||||
TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: false,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
notes_path: tmpdir.path().join("notes.txt"),
|
||||
mcp_config_path: tmpdir.path().join("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
},
|
||||
&Config::default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_inserts_image_reference() {
|
||||
let tmpdir = TempDir::new().expect("tempdir");
|
||||
let image_path = tmpdir.path().join("photo.png");
|
||||
std::fs::write(&image_path, b"not actually decoded").expect("write image fixture");
|
||||
let mut app = app_with_workspace(&tmpdir);
|
||||
|
||||
let result = attach(&mut app, Some("photo.png"));
|
||||
|
||||
assert!(result.message.expect("message").contains("Attached image"));
|
||||
assert!(app.input.contains("[Attached image:"));
|
||||
let canonical_path = image_path.canonicalize().expect("canonical image path");
|
||||
assert!(app.input.contains(&canonical_path.display().to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_rejects_unsupported_extension() {
|
||||
let tmpdir = TempDir::new().expect("tempdir");
|
||||
std::fs::write(tmpdir.path().join("notes.txt"), b"text").expect("write fixture");
|
||||
let mut app = app_with_workspace(&tmpdir);
|
||||
|
||||
let result = attach(&mut app, Some("notes.txt"));
|
||||
|
||||
assert!(
|
||||
result
|
||||
.message
|
||||
.expect("message")
|
||||
.contains("Unsupported attachment type")
|
||||
);
|
||||
assert!(app.input.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
//! Config commands: config, settings, mode switches, trust, logout
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::CommandResult;
|
||||
use crate::config::{COMMON_DEEPSEEK_MODELS, clear_api_key, normalize_model_name};
|
||||
use crate::palette;
|
||||
use crate::settings::Settings;
|
||||
use crate::tui::app::{App, AppAction, AppMode, OnboardingState, SidebarFocus};
|
||||
use crate::tui::approval::ApprovalMode;
|
||||
|
||||
/// Open the interactive config editor modal.
|
||||
pub fn show_config(_app: &mut App) -> CommandResult {
|
||||
CommandResult::action(AppAction::OpenConfigView)
|
||||
}
|
||||
|
||||
/// Show persistent settings
|
||||
pub fn show_settings(_app: &mut App) -> CommandResult {
|
||||
match Settings::load() {
|
||||
Ok(settings) => CommandResult::message(settings.display()),
|
||||
Err(e) => CommandResult::error(format!("Failed to load settings: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Modify a setting at runtime
|
||||
pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult {
|
||||
let key = key.to_lowercase();
|
||||
|
||||
match key.as_str() {
|
||||
"model" => {
|
||||
let Some(model) = normalize_model_name(value) else {
|
||||
return CommandResult::error(format!(
|
||||
"Invalid model '{value}'. Expected a DeepSeek model ID. Common models: {}",
|
||||
COMMON_DEEPSEEK_MODELS.join(", ")
|
||||
));
|
||||
};
|
||||
app.model = model.clone();
|
||||
app.update_model_compaction_budget();
|
||||
app.last_prompt_tokens = None;
|
||||
app.last_completion_tokens = None;
|
||||
return CommandResult::with_message_and_action(
|
||||
format!("model = {model}"),
|
||||
AppAction::UpdateCompaction(app.compaction_config()),
|
||||
);
|
||||
}
|
||||
"approval_mode" | "approval" => {
|
||||
let mode = match value.to_lowercase().as_str() {
|
||||
"auto" => Some(ApprovalMode::Auto),
|
||||
"suggest" | "suggested" | "on-request" | "untrusted" => Some(ApprovalMode::Suggest),
|
||||
"never" => Some(ApprovalMode::Never),
|
||||
_ => None,
|
||||
};
|
||||
return match mode {
|
||||
Some(m) => {
|
||||
app.approval_mode = m;
|
||||
CommandResult::message(format!("approval_mode = {}", m.label()))
|
||||
}
|
||||
None => CommandResult::error(
|
||||
"Invalid approval_mode. Use: auto, suggest/on-request/untrusted, never",
|
||||
),
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let mut settings = match Settings::load() {
|
||||
Ok(s) => s,
|
||||
Err(e) if !persist => {
|
||||
app.status_message = Some(format!(
|
||||
"Settings unavailable; applying session-only override ({e})"
|
||||
));
|
||||
Settings::default()
|
||||
}
|
||||
Err(e) => return CommandResult::error(format!("Failed to load settings: {e}")),
|
||||
};
|
||||
|
||||
if let Err(e) = settings.set(&key, value) {
|
||||
return CommandResult::error(format!("{e}"));
|
||||
}
|
||||
|
||||
let mut action = None;
|
||||
match key.as_str() {
|
||||
"auto_compact" | "compact" => {
|
||||
app.auto_compact = settings.auto_compact;
|
||||
action = Some(AppAction::UpdateCompaction(app.compaction_config()));
|
||||
}
|
||||
"calm_mode" | "calm" => {
|
||||
app.calm_mode = settings.calm_mode;
|
||||
app.mark_history_updated();
|
||||
}
|
||||
"low_motion" | "motion" => {
|
||||
app.low_motion = settings.low_motion;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
"show_thinking" | "thinking" => {
|
||||
app.show_thinking = settings.show_thinking;
|
||||
app.mark_history_updated();
|
||||
}
|
||||
"show_tool_details" | "tool_details" => {
|
||||
app.show_tool_details = settings.show_tool_details;
|
||||
app.mark_history_updated();
|
||||
}
|
||||
"composer_density" | "composer" => {
|
||||
app.composer_density =
|
||||
crate::tui::app::ComposerDensity::from_setting(&settings.composer_density);
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
"composer_border" | "border" => {
|
||||
app.composer_border = settings.composer_border;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
"transcript_spacing" | "spacing" => {
|
||||
app.transcript_spacing =
|
||||
crate::tui::app::TranscriptSpacing::from_setting(&settings.transcript_spacing);
|
||||
app.mark_history_updated();
|
||||
}
|
||||
"default_mode" | "mode" => {
|
||||
let mode = AppMode::from_setting(&settings.default_mode);
|
||||
app.set_mode(mode);
|
||||
}
|
||||
"max_history" | "history" => {
|
||||
app.max_input_history = settings.max_input_history;
|
||||
}
|
||||
"default_model" => {
|
||||
if let Some(ref model) = settings.default_model {
|
||||
app.model.clone_from(model);
|
||||
app.update_model_compaction_budget();
|
||||
app.last_prompt_tokens = None;
|
||||
app.last_completion_tokens = None;
|
||||
action = Some(AppAction::UpdateCompaction(app.compaction_config()));
|
||||
}
|
||||
}
|
||||
"theme" => {
|
||||
app.ui_theme = palette::ui_theme(&settings.theme);
|
||||
app.mark_history_updated();
|
||||
}
|
||||
"sidebar_width" | "sidebar" => {
|
||||
app.sidebar_width_percent = settings.sidebar_width_percent;
|
||||
app.mark_history_updated();
|
||||
}
|
||||
"sidebar_focus" | "focus" => {
|
||||
app.set_sidebar_focus(SidebarFocus::from_setting(&settings.sidebar_focus));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let display_value = match key.as_str() {
|
||||
"default_mode" | "mode" => settings.default_mode.clone(),
|
||||
_ => value.to_string(),
|
||||
};
|
||||
|
||||
let message = if persist {
|
||||
if let Err(e) = settings.save() {
|
||||
return CommandResult::error(format!("Failed to save: {e}"));
|
||||
}
|
||||
format!("{key} = {display_value} (saved)")
|
||||
} else {
|
||||
format!("{key} = {display_value} (session only, add --save to persist)")
|
||||
};
|
||||
|
||||
CommandResult {
|
||||
message: Some(message),
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
/// Modify a setting at runtime
|
||||
#[allow(dead_code)]
|
||||
pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
let Some(args) = args else {
|
||||
let available = Settings::available_settings()
|
||||
.iter()
|
||||
.map(|(k, d)| format!(" {k}: {d}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
return CommandResult::message(format!(
|
||||
"Usage: /set <key> <value>\n\n\
|
||||
Available settings:\n{available}\n\n\
|
||||
Session-only settings:\n \
|
||||
model: Current model\n \
|
||||
approval_mode: auto | suggest | never\n\n\
|
||||
Add --save to persist to settings file."
|
||||
));
|
||||
};
|
||||
|
||||
let parts: Vec<&str> = args.splitn(2, ' ').collect();
|
||||
if parts.len() < 2 {
|
||||
return CommandResult::error("Usage: /set <key> <value>");
|
||||
}
|
||||
|
||||
let key = parts[0].to_lowercase();
|
||||
let (value, should_save) = if parts[1].ends_with(" --save") {
|
||||
(parts[1].trim_end_matches(" --save").trim(), true)
|
||||
} else {
|
||||
(parts[1].trim(), false)
|
||||
};
|
||||
|
||||
set_config_value(app, &key, value, should_save)
|
||||
}
|
||||
|
||||
/// Enable YOLO mode (shell + trust + auto-approve)
|
||||
pub fn yolo(app: &mut App) -> CommandResult {
|
||||
app.set_mode(AppMode::Yolo);
|
||||
CommandResult::message("YOLO mode enabled - shell + trust + auto-approve!")
|
||||
}
|
||||
|
||||
/// Legacy alias for the removed normal mode.
|
||||
pub fn normal_mode(app: &mut App) -> CommandResult {
|
||||
app.set_mode(AppMode::Agent);
|
||||
CommandResult::message("Normal mode was removed. Switched to Agent mode.")
|
||||
}
|
||||
|
||||
/// Enable agent mode (autonomous tool use with approvals)
|
||||
pub fn agent_mode(app: &mut App) -> CommandResult {
|
||||
app.set_mode(AppMode::Agent);
|
||||
CommandResult::message("Agent mode enabled.")
|
||||
}
|
||||
|
||||
/// Enable plan mode (tool planning, then choose execution route)
|
||||
pub fn plan_mode(app: &mut App) -> CommandResult {
|
||||
app.set_mode(AppMode::Plan);
|
||||
CommandResult::message(
|
||||
"Plan mode enabled. Describe your goal and I will create a plan before execution.",
|
||||
)
|
||||
}
|
||||
|
||||
/// Manage workspace-level trust and the per-path allowlist.
|
||||
///
|
||||
/// Subcommands:
|
||||
/// - `/trust` – show current state and trusted external paths
|
||||
/// - `/trust on` – legacy: trust the entire workspace (turn off all path checks)
|
||||
/// - `/trust off` – disable workspace-level trust mode
|
||||
/// - `/trust add <path>` – add a directory to the allowlist (#29)
|
||||
/// - `/trust remove <path>` (alias `rm`) – remove a path from the allowlist
|
||||
/// - `/trust list` – list trusted external paths for this workspace
|
||||
pub fn trust(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
let raw = arg.map(str::trim).unwrap_or("");
|
||||
let mut parts = raw.splitn(2, char::is_whitespace);
|
||||
let sub = parts.next().unwrap_or("").to_lowercase();
|
||||
let rest = parts.next().map(str::trim).unwrap_or("");
|
||||
let workspace = app.workspace.clone();
|
||||
|
||||
match sub.as_str() {
|
||||
"" | "status" | "list" => trust_status(&workspace, app, sub == "list"),
|
||||
"on" | "enable" | "yes" | "y" => {
|
||||
app.trust_mode = true;
|
||||
CommandResult::message(
|
||||
"Workspace trust mode enabled — agent file tools can now read/write any path. \
|
||||
Use `/trust off` to revert; prefer `/trust add <path>` for a narrower opt-in.",
|
||||
)
|
||||
}
|
||||
"off" | "disable" | "no" | "n" => {
|
||||
app.trust_mode = false;
|
||||
CommandResult::message("Workspace trust mode disabled.")
|
||||
}
|
||||
"add" => trust_add(&workspace, rest),
|
||||
"remove" | "rm" | "del" | "delete" => trust_remove(&workspace, rest),
|
||||
other => CommandResult::error(format!(
|
||||
"Unknown /trust action `{other}`. Use `/trust`, `/trust on|off`, `/trust add <path>`, or `/trust remove <path>`."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn trust_status(workspace: &Path, app: &App, force_paths: bool) -> CommandResult {
|
||||
let trust = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
|
||||
let mut lines = Vec::new();
|
||||
lines.push(format!(
|
||||
"Workspace trust mode: {}",
|
||||
if app.trust_mode {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
}
|
||||
));
|
||||
if trust.paths().is_empty() {
|
||||
if force_paths {
|
||||
lines.push("No external paths trusted from this workspace.".to_string());
|
||||
} else {
|
||||
lines.push(
|
||||
"No external paths trusted yet. Use `/trust add <path>` to allow a directory."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
lines.push(format!("Trusted external paths ({}):", trust.paths().len()));
|
||||
for path in trust.paths() {
|
||||
lines.push(format!(" • {}", path.display()));
|
||||
}
|
||||
}
|
||||
CommandResult::message(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn trust_add(workspace: &Path, raw: &str) -> CommandResult {
|
||||
if raw.is_empty() {
|
||||
return CommandResult::error(
|
||||
"Usage: /trust add <path>. Supply an absolute path or a path relative to the workspace.",
|
||||
);
|
||||
}
|
||||
let path = PathBuf::from(expand_tilde(raw));
|
||||
if !path.exists() {
|
||||
return CommandResult::error(format!(
|
||||
"Path not found: {} — supply an existing directory or file.",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
match crate::workspace_trust::add(workspace, &path) {
|
||||
Ok(stored) => CommandResult::message(format!(
|
||||
"Added to trust list for this workspace: {}",
|
||||
stored.display()
|
||||
)),
|
||||
Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn trust_remove(workspace: &Path, raw: &str) -> CommandResult {
|
||||
if raw.is_empty() {
|
||||
return CommandResult::error("Usage: /trust remove <path>");
|
||||
}
|
||||
let path = PathBuf::from(expand_tilde(raw));
|
||||
match crate::workspace_trust::remove(workspace, &path) {
|
||||
Ok(true) => CommandResult::message(format!("Removed from trust list: {}", path.display())),
|
||||
Ok(false) => CommandResult::message(format!("Not in trust list: {}", path.display())),
|
||||
Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_tilde(raw: &str) -> String {
|
||||
if let Some(rest) = raw.strip_prefix("~/")
|
||||
&& let Some(home) = dirs::home_dir()
|
||||
{
|
||||
return home.join(rest).to_string_lossy().into_owned();
|
||||
} else if raw == "~"
|
||||
&& let Some(home) = dirs::home_dir()
|
||||
{
|
||||
return home.to_string_lossy().into_owned();
|
||||
}
|
||||
raw.to_string()
|
||||
}
|
||||
|
||||
/// Logout - clear API key and return to onboarding
|
||||
pub fn logout(app: &mut App) -> CommandResult {
|
||||
match clear_api_key() {
|
||||
Ok(()) => {
|
||||
app.onboarding = OnboardingState::ApiKey;
|
||||
app.onboarding_needs_api_key = true;
|
||||
app.api_key_input.clear();
|
||||
app.api_key_cursor = 0;
|
||||
CommandResult::message("Logged out. Enter a new API key to continue.")
|
||||
}
|
||||
Err(e) => CommandResult::error(format!("Failed to clear API key: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::test_support::lock_test_env;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use crate::tui::approval::ApprovalMode;
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct EnvGuard {
|
||||
home: Option<OsString>,
|
||||
userprofile: Option<OsString>,
|
||||
deepseek_config_path: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn new(home: &Path) -> Self {
|
||||
let home_str = OsString::from(home.as_os_str());
|
||||
let config_path = home.join(".deepseek").join("config.toml");
|
||||
let config_str = OsString::from(config_path.as_os_str());
|
||||
let home_prev = env::var_os("HOME");
|
||||
let userprofile_prev = env::var_os("USERPROFILE");
|
||||
let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH");
|
||||
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("HOME", &home_str);
|
||||
env::set_var("USERPROFILE", &home_str);
|
||||
env::set_var("DEEPSEEK_CONFIG_PATH", &config_str);
|
||||
}
|
||||
|
||||
Self {
|
||||
home: home_prev,
|
||||
userprofile: userprofile_prev,
|
||||
deepseek_config_path: deepseek_config_prev,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(value) = self.home.take() {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("HOME", value);
|
||||
}
|
||||
} else {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::remove_var("HOME");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = self.userprofile.take() {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("USERPROFILE", value);
|
||||
}
|
||||
} else {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::remove_var("USERPROFILE");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = self.deepseek_config_path.take() {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_CONFIG_PATH", value);
|
||||
}
|
||||
} else {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::remove_var("DEEPSEEK_CONFIG_PATH");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "test-model".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: false,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yolo_command_sets_all_flags() {
|
||||
let mut app = create_test_app();
|
||||
let _ = yolo(&mut app);
|
||||
assert!(app.allow_shell);
|
||||
assert!(app.trust_mode);
|
||||
assert!(app.yolo);
|
||||
assert_eq!(app.approval_mode, ApprovalMode::Auto);
|
||||
assert_eq!(app.mode, AppMode::Yolo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_switch_commands() {
|
||||
let mut app = create_test_app();
|
||||
let _ = normal_mode(&mut app);
|
||||
assert_eq!(app.mode, AppMode::Agent);
|
||||
let _ = agent_mode(&mut app);
|
||||
assert_eq!(app.mode, AppMode::Agent);
|
||||
let _ = plan_mode(&mut app);
|
||||
assert_eq!(app.mode, AppMode::Plan);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_show_config_opens_config_editor() {
|
||||
let mut app = create_test_app();
|
||||
app.total_tokens = 1234;
|
||||
let result = show_config(&mut app);
|
||||
assert!(result.message.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_show_settings_loads_from_file() {
|
||||
let _lock = lock_test_env();
|
||||
let mut app = create_test_app();
|
||||
let result = show_settings(&mut app);
|
||||
// Settings should load (may use defaults if file doesn't exist)
|
||||
assert!(result.message.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_without_args_shows_usage() {
|
||||
let mut app = create_test_app();
|
||||
let result = set_config(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Usage: /set"));
|
||||
assert!(msg.contains("Available settings:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_model_updates_app_state() {
|
||||
let mut app = create_test_app();
|
||||
let _old_model = app.model.clone();
|
||||
let result = set_config(&mut app, Some("model deepseek-v4-flash"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("model = deepseek-v4-flash"));
|
||||
assert_eq!(app.model, "deepseek-v4-flash");
|
||||
assert!(matches!(
|
||||
result.action,
|
||||
Some(AppAction::UpdateCompaction(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_model_accepts_future_deepseek_model_id() {
|
||||
let mut app = create_test_app();
|
||||
let result = set_config(&mut app, Some("model deepseek-v4"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("model = deepseek-v4"));
|
||||
assert_eq!(app.model, "deepseek-v4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_model_with_save_flag() {
|
||||
let mut app = create_test_app();
|
||||
let _result = set_config(&mut app, Some("model deepseek-v4-flash --save"));
|
||||
// Note: This test may fail in environments where settings can't be saved
|
||||
// The important thing is that the model is updated
|
||||
assert_eq!(app.model, "deepseek-v4-flash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_default_mode_normal_save_reports_normalized_value() {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-tui-default-mode-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root).unwrap();
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
let mut app = create_test_app();
|
||||
let result = set_config(&mut app, Some("default_mode normal --save"));
|
||||
let msg = result.message.unwrap();
|
||||
assert_eq!(msg, "default_mode = agent (saved)");
|
||||
assert_eq!(app.mode, AppMode::Agent);
|
||||
|
||||
let settings_path = Settings::path().unwrap();
|
||||
let saved = fs::read_to_string(settings_path).unwrap();
|
||||
assert!(saved.contains("default_mode = \"agent\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_approval_mode_valid_values() {
|
||||
let mut app = create_test_app();
|
||||
// Test auto
|
||||
let result = set_config(&mut app, Some("approval_mode auto"));
|
||||
assert!(result.message.is_some());
|
||||
assert_eq!(app.approval_mode, ApprovalMode::Auto);
|
||||
|
||||
// Test suggest
|
||||
let result = set_config(&mut app, Some("approval_mode suggest"));
|
||||
assert!(result.message.is_some());
|
||||
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
|
||||
|
||||
// Test never
|
||||
let result = set_config(&mut app, Some("approval_mode never"));
|
||||
assert!(result.message.is_some());
|
||||
assert_eq!(app.approval_mode, ApprovalMode::Never);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_approval_mode_invalid_value() {
|
||||
let mut app = create_test_app();
|
||||
let result = set_config(&mut app, Some("approval_mode invalid"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Invalid approval_mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_without_save_flag() {
|
||||
let _lock = lock_test_env();
|
||||
let mut app = create_test_app();
|
||||
let result = set_config(&mut app, Some("auto_compact true"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("(session only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_composer_border_updates_live_app() {
|
||||
let _lock = lock_test_env();
|
||||
let mut app = create_test_app();
|
||||
app.composer_border = true;
|
||||
|
||||
let result = set_config(&mut app, Some("composer_border false"));
|
||||
|
||||
assert!(result.message.is_some());
|
||||
assert!(!app.composer_border);
|
||||
assert!(app.needs_redraw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_on_enables_flag() {
|
||||
let mut app = create_test_app();
|
||||
assert!(!app.trust_mode);
|
||||
let result = trust(&mut app, Some("on"));
|
||||
let msg = result.message.expect("message");
|
||||
assert!(msg.contains("Workspace trust mode enabled"));
|
||||
assert!(app.trust_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_status_default_lists_state() {
|
||||
let mut app = create_test_app();
|
||||
let result = trust(&mut app, None);
|
||||
let msg = result.message.expect("status message");
|
||||
assert!(msg.contains("Workspace trust mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_add_requires_path() {
|
||||
let mut app = create_test_app();
|
||||
let result = trust(&mut app, Some("add"));
|
||||
let msg = result.message.expect("error message");
|
||||
assert!(msg.starts_with("Error:"), "got {msg:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logout_clears_api_key_state() {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-tui-logout-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root).unwrap();
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
let config_path = temp_root.join(".deepseek").join("config.toml");
|
||||
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
|
||||
fs::write(&config_path, "api_key = \"test-key\"\n").unwrap();
|
||||
|
||||
let mut app = create_test_app();
|
||||
let result = logout(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
assert_eq!(app.onboarding, OnboardingState::ApiKey);
|
||||
assert!(app.onboarding_needs_api_key);
|
||||
assert!(app.api_key_input.is_empty());
|
||||
assert_eq!(app.api_key_cursor, 0);
|
||||
|
||||
let updated = fs::read_to_string(config_path).unwrap();
|
||||
assert!(!updated.contains("api_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_invalid_setting() {
|
||||
let _lock = lock_test_env();
|
||||
let mut app = create_test_app();
|
||||
let _result = set_config(&mut app, Some("nonexistent value"));
|
||||
// Should either error or handle as session setting
|
||||
// The current implementation tries to set it in Settings
|
||||
// which may succeed or fail depending on Settings implementation
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_key_without_value() {
|
||||
let mut app = create_test_app();
|
||||
let result = set_config(&mut app, Some("model"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Usage: /set"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
//! Core commands: help, clear, exit, model
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::config::{COMMON_DEEPSEEK_MODELS, normalize_model_name};
|
||||
use crate::tui::app::{App, AppAction, AppMode};
|
||||
use crate::tui::views::{HelpView, ModalKind, SubAgentsView};
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
/// Show help information
|
||||
pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult {
|
||||
if let Some(topic) = topic {
|
||||
// Show help for specific command
|
||||
if let Some(cmd) = super::get_command_info(topic) {
|
||||
let mut help = format!(
|
||||
"{}\n\n {}\n\n Usage: {}",
|
||||
cmd.name, cmd.description, cmd.usage
|
||||
);
|
||||
if !cmd.aliases.is_empty() {
|
||||
let _ = write!(help, "\n Aliases: {}", cmd.aliases.join(", "));
|
||||
}
|
||||
return CommandResult::message(help);
|
||||
}
|
||||
return CommandResult::error(format!("Unknown command: {topic}"));
|
||||
}
|
||||
|
||||
// Show help overlay
|
||||
if app.view_stack.top_kind() != Some(ModalKind::Help) {
|
||||
app.view_stack.push(HelpView::new());
|
||||
}
|
||||
CommandResult::ok()
|
||||
}
|
||||
|
||||
/// Clear conversation history
|
||||
pub fn clear(app: &mut App) -> CommandResult {
|
||||
app.history.clear();
|
||||
app.mark_history_updated();
|
||||
app.api_messages.clear();
|
||||
app.system_prompt = None;
|
||||
app.transcript_selection.clear();
|
||||
app.queued_messages.clear();
|
||||
app.queued_draft = None;
|
||||
app.total_conversation_tokens = 0;
|
||||
let todos_cleared = app.clear_todos();
|
||||
app.tool_log.clear();
|
||||
app.tool_cells.clear();
|
||||
app.tool_details_by_cell.clear();
|
||||
app.exploring_entries.clear();
|
||||
app.ignored_tool_calls.clear();
|
||||
app.pending_tool_uses.clear();
|
||||
app.last_exec_wait_command = None;
|
||||
app.last_prompt_tokens = None;
|
||||
app.last_completion_tokens = None;
|
||||
app.current_session_id = None;
|
||||
let message = if todos_cleared {
|
||||
"Conversation cleared".to_string()
|
||||
} else {
|
||||
"Conversation cleared (plan state busy; run /clear again if needed)".to_string()
|
||||
};
|
||||
CommandResult::with_message_and_action(
|
||||
message,
|
||||
AppAction::SyncSession {
|
||||
messages: Vec::new(),
|
||||
system_prompt: None,
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Exit the application
|
||||
pub fn exit() -> CommandResult {
|
||||
CommandResult::action(AppAction::Quit)
|
||||
}
|
||||
|
||||
/// Switch or view current model. With no argument, open the two-pane
|
||||
/// picker (Pro/Flash + thinking effort) per #39 — gives users a discoverable
|
||||
/// way to flip both knobs without memorising the docs.
|
||||
pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
|
||||
if let Some(name) = model_name {
|
||||
let Some(model_id) = normalize_model_name(name) else {
|
||||
return CommandResult::error(format!(
|
||||
"Invalid model '{name}'. Expected a DeepSeek model ID. Common models: {}",
|
||||
COMMON_DEEPSEEK_MODELS.join(", ")
|
||||
));
|
||||
};
|
||||
let old_model = app.model.clone();
|
||||
app.model = model_id.clone();
|
||||
app.update_model_compaction_budget();
|
||||
app.last_prompt_tokens = None;
|
||||
app.last_completion_tokens = None;
|
||||
CommandResult::with_message_and_action(
|
||||
format!("Model changed: {old_model} → {model_id}"),
|
||||
AppAction::UpdateCompaction(app.compaction_config()),
|
||||
)
|
||||
} else {
|
||||
CommandResult::action(AppAction::OpenModelPicker)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch and list available models from the configured API endpoint.
|
||||
pub fn models(_app: &mut App) -> CommandResult {
|
||||
CommandResult::action(AppAction::FetchModels)
|
||||
}
|
||||
|
||||
/// List sub-agent status from the engine
|
||||
pub fn subagents(app: &mut App) -> CommandResult {
|
||||
if app.view_stack.top_kind() != Some(ModalKind::SubAgents) {
|
||||
app.view_stack
|
||||
.push(SubAgentsView::new(app.subagent_cache.clone()));
|
||||
}
|
||||
app.status_message = Some("Fetching sub-agent status...".to_string());
|
||||
CommandResult::action(AppAction::ListSubAgents)
|
||||
}
|
||||
|
||||
/// Show `DeepSeek` dashboard and docs links
|
||||
pub fn deepseek_links() -> CommandResult {
|
||||
CommandResult::message(
|
||||
"DeepSeek Links:\n\
|
||||
─────────────────────────────\n\
|
||||
Dashboard: https://platform.deepseek.com\n\
|
||||
Docs: https://platform.deepseek.com/docs\n\n\
|
||||
Tip: API keys are available in the dashboard console.",
|
||||
)
|
||||
}
|
||||
|
||||
/// Show home dashboard with stats and quick actions
|
||||
pub fn home_dashboard(app: &mut App) -> CommandResult {
|
||||
let mut stats = String::new();
|
||||
|
||||
// Basic info
|
||||
let _ = writeln!(stats, "DeepSeek TUI Home Dashboard");
|
||||
let _ = writeln!(stats, "============================================");
|
||||
|
||||
// Model & mode
|
||||
let _ = writeln!(stats, "Model: {}", app.model);
|
||||
let _ = writeln!(stats, "Mode: {}", app.mode.label());
|
||||
let _ = writeln!(stats, "Workspace: {}", app.workspace.display());
|
||||
|
||||
// Session stats
|
||||
let history_count = app.history.len();
|
||||
let total_tokens = app.total_conversation_tokens;
|
||||
let queued_messages = app.queued_messages.len();
|
||||
let _ = writeln!(stats, "History: {} messages", history_count);
|
||||
let _ = writeln!(stats, "Tokens: {} (session)", total_tokens);
|
||||
if queued_messages > 0 {
|
||||
let _ = writeln!(stats, "Queued: {} messages", queued_messages);
|
||||
}
|
||||
|
||||
// Sub-agents
|
||||
let subagent_count = app.subagent_cache.len();
|
||||
if subagent_count > 0 {
|
||||
let _ = writeln!(stats, "Sub-agents: {} active", subagent_count);
|
||||
}
|
||||
|
||||
// Active skill
|
||||
if let Some(skill) = &app.active_skill {
|
||||
let _ = writeln!(stats, "Skill: {} (active)", skill);
|
||||
}
|
||||
|
||||
// Quick actions section
|
||||
let _ = writeln!(stats, "\nQuick Actions");
|
||||
let _ = writeln!(stats, "--------------------------------------------");
|
||||
let _ = writeln!(stats, "/links - Dashboard & API links");
|
||||
let _ = writeln!(stats, "/skills - List available skills");
|
||||
let _ = writeln!(
|
||||
stats,
|
||||
"/config - Open interactive configuration editor"
|
||||
);
|
||||
let _ = writeln!(stats, "/settings - Show persistent settings");
|
||||
let _ = writeln!(stats, "/model - Switch or view model");
|
||||
let _ = writeln!(stats, "/subagents - List sub-agent status");
|
||||
let _ = writeln!(stats, "/task list - Show background task queue");
|
||||
let _ = writeln!(stats, "/help - Show help");
|
||||
|
||||
// Mode-specific tips
|
||||
let _ = writeln!(stats, "\nMode Tips");
|
||||
let _ = writeln!(stats, "--------------------------------------------");
|
||||
match app.mode {
|
||||
AppMode::Agent => {
|
||||
let _ = writeln!(stats, "Agent mode - Use tools for autonomous tasks");
|
||||
let _ = writeln!(
|
||||
stats,
|
||||
" Use Ctrl+X to review in Plan mode before executing"
|
||||
);
|
||||
let _ = writeln!(stats, " Type /yolo to enable full tool access");
|
||||
}
|
||||
AppMode::Yolo => {
|
||||
let _ = writeln!(stats, "YOLO mode - Full tool access, no approvals");
|
||||
let _ = writeln!(stats, " Be careful with destructive operations!");
|
||||
}
|
||||
AppMode::Plan => {
|
||||
let _ = writeln!(stats, "Plan mode - Design before implementing");
|
||||
let _ = writeln!(stats, " Use /plan to create structured checklists");
|
||||
}
|
||||
}
|
||||
|
||||
CommandResult::message(stats)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::models::Message;
|
||||
use crate::tui::app::{App, AppMode, TuiOptions};
|
||||
use crate::tui::history::HistoryCell;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: PathBuf::from("/tmp/test-workspace"),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("/tmp/test-skills"),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_unknown_command() {
|
||||
let mut app = create_test_app();
|
||||
let result = help(&mut app, Some("nonexistent"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Unknown command"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_known_command() {
|
||||
let mut app = create_test_app();
|
||||
let result = help(&mut app, Some("clear"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("clear"));
|
||||
assert!(msg.contains("Clear conversation history"));
|
||||
assert!(msg.contains("Usage: /clear"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_config_topic_uses_interactive_editor_text() {
|
||||
let mut app = create_test_app();
|
||||
let result = help(&mut app, Some("config"));
|
||||
let msg = result.message.expect("help topic should return message");
|
||||
assert!(msg.contains("config"));
|
||||
assert!(msg.contains("Open interactive configuration editor"));
|
||||
assert!(msg.contains("Usage: /config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_links_topic_shows_aliases() {
|
||||
let mut app = create_test_app();
|
||||
let result = help(&mut app, Some("links"));
|
||||
let msg = result.message.expect("help topic should return message");
|
||||
assert!(msg.contains("links"));
|
||||
assert!(msg.contains("Show DeepSeek dashboard and docs links"));
|
||||
assert!(msg.contains("Usage: /links"));
|
||||
assert!(msg.contains("Aliases: dashboard, api"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_pushes_overlay() {
|
||||
let mut app = create_test_app();
|
||||
assert_ne!(app.view_stack.top_kind(), Some(ModalKind::Help));
|
||||
let result = help(&mut app, None);
|
||||
assert_eq!(result.message, None);
|
||||
assert_eq!(result.action, None);
|
||||
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_does_not_duplicate_overlay() {
|
||||
let mut app = create_test_app();
|
||||
help(&mut app, None);
|
||||
let initial_kind = app.view_stack.top_kind();
|
||||
help(&mut app, None);
|
||||
assert_eq!(app.view_stack.top_kind(), initial_kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_resets_all_state() {
|
||||
let mut app = create_test_app();
|
||||
// Set up some state
|
||||
app.history.push(HistoryCell::User {
|
||||
content: "test".to_string(),
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![],
|
||||
});
|
||||
app.total_conversation_tokens = 100;
|
||||
app.tool_log.push("test".to_string());
|
||||
app.current_session_id = Some("existing-session".to_string());
|
||||
|
||||
let result = clear(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
assert!(app.history.is_empty());
|
||||
assert!(app.api_messages.is_empty());
|
||||
assert_eq!(app.total_conversation_tokens, 0);
|
||||
assert!(app.tool_log.is_empty());
|
||||
assert!(app.tool_cells.is_empty());
|
||||
assert!(app.tool_details_by_cell.is_empty());
|
||||
assert!(app.current_session_id.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exit_returns_quit_action() {
|
||||
let result = exit();
|
||||
assert!(result.message.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::Quit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_change_updates_state() {
|
||||
let mut app = create_test_app();
|
||||
let old_model = app.model.clone();
|
||||
let result = model(&mut app, Some("deepseek-v4-flash"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains(&old_model));
|
||||
assert!(msg.contains("deepseek-v4-flash"));
|
||||
assert!(matches!(
|
||||
result.action,
|
||||
Some(AppAction::UpdateCompaction(_))
|
||||
));
|
||||
assert_eq!(app.model, "deepseek-v4-flash");
|
||||
assert_eq!(app.last_prompt_tokens, None);
|
||||
assert_eq!(app.last_completion_tokens, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_change_accepts_future_deepseek_model() {
|
||||
let mut app = create_test_app();
|
||||
let result = model(&mut app, Some("deepseek-v4"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("deepseek-v4"));
|
||||
assert_eq!(app.model, "deepseek-v4");
|
||||
assert!(matches!(
|
||||
result.action,
|
||||
Some(AppAction::UpdateCompaction(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_change_rejects_invalid_model() {
|
||||
let mut app = create_test_app();
|
||||
let result = model(&mut app, Some("gpt-4"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Invalid model"));
|
||||
assert!(msg.contains("DeepSeek model ID"));
|
||||
assert!(msg.contains("deepseek-v4-pro"));
|
||||
assert!(msg.contains("deepseek-v4-flash"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_without_args_opens_picker() {
|
||||
let mut app = create_test_app();
|
||||
let result = model(&mut app, None);
|
||||
assert_eq!(result.message, None);
|
||||
assert_eq!(result.action, Some(AppAction::OpenModelPicker));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_models_triggers_fetch_action() {
|
||||
let mut app = create_test_app();
|
||||
let result = models(&mut app);
|
||||
assert!(result.message.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::FetchModels)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subagents_pushes_view_and_sets_status() {
|
||||
let mut app = create_test_app();
|
||||
let result = subagents(&mut app);
|
||||
assert!(result.message.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::ListSubAgents)));
|
||||
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::SubAgents));
|
||||
assert_eq!(
|
||||
app.status_message,
|
||||
Some("Fetching sub-agent status...".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_links() {
|
||||
let result = deepseek_links();
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("DeepSeek Links"));
|
||||
assert!(msg.contains("https://platform.deepseek.com"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_home_dashboard_includes_all_sections() {
|
||||
let mut app = create_test_app();
|
||||
app.total_conversation_tokens = 1234;
|
||||
let result = home_dashboard(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("DeepSeek TUI Home Dashboard"));
|
||||
assert!(msg.contains("Model:"));
|
||||
assert!(msg.contains("Mode:"));
|
||||
assert!(msg.contains("Workspace:"));
|
||||
assert!(msg.contains("History:"));
|
||||
assert!(msg.contains("Tokens:"));
|
||||
assert!(msg.contains("Quick Actions"));
|
||||
assert!(msg.contains("Mode Tips"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_home_dashboard_shows_queued_when_present() {
|
||||
let mut app = create_test_app();
|
||||
app.queued_messages
|
||||
.push_back(crate::tui::app::QueuedMessage::new(
|
||||
"test".to_string(),
|
||||
None,
|
||||
));
|
||||
let result = home_dashboard(&mut app);
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Queued:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_home_dashboard_mode_tips_for_each_mode() {
|
||||
let modes = [AppMode::Agent, AppMode::Yolo, AppMode::Plan];
|
||||
for mode in modes {
|
||||
let mut app = create_test_app();
|
||||
app.mode = mode;
|
||||
let result = home_dashboard(&mut app);
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Mode Tips"), "Missing tips for mode {mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_home_dashboard_quick_actions_reflect_links_and_config_and_hide_removed_commands() {
|
||||
let mut app = create_test_app();
|
||||
let result = home_dashboard(&mut app);
|
||||
let msg = result
|
||||
.message
|
||||
.expect("home dashboard should return message");
|
||||
assert!(msg.contains("/links - Dashboard & API links"));
|
||||
assert!(msg.contains("/config - Open interactive configuration editor"));
|
||||
assert!(
|
||||
!msg.lines()
|
||||
.any(|line| line.trim_start().starts_with("/set "))
|
||||
);
|
||||
assert!(!msg.contains("/deepseek"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
#![allow(clippy::items_after_test_module)]
|
||||
|
||||
//! Debug commands: tokens, cost, system, context, undo, retry
|
||||
|
||||
use super::CommandResult;
|
||||
use crate::compaction::estimate_input_tokens_conservative;
|
||||
use crate::models::{DEFAULT_CONTEXT_WINDOW_TOKENS, SystemPrompt, context_window_for_model};
|
||||
use crate::tui::app::{App, AppAction};
|
||||
use crate::tui::history::HistoryCell;
|
||||
use crate::utils::estimate_message_chars;
|
||||
|
||||
/// Show token usage for session
|
||||
pub fn tokens(app: &mut App) -> CommandResult {
|
||||
let message_count = app.api_messages.len();
|
||||
let chat_count = app.history.len();
|
||||
|
||||
CommandResult::message(format!(
|
||||
"Token Usage:\n\
|
||||
─────────────────────────────\n\
|
||||
Total tokens: {}\n\
|
||||
Session cost: ${:.4}\n\
|
||||
API messages: {}\n\
|
||||
Chat messages: {}\n\
|
||||
Model: {}",
|
||||
app.total_tokens, app.session_cost, message_count, chat_count, app.model,
|
||||
))
|
||||
}
|
||||
|
||||
/// Show session cost breakdown
|
||||
pub fn cost(app: &mut App) -> CommandResult {
|
||||
CommandResult::message(format!(
|
||||
"Session Cost:\n\
|
||||
─────────────────────────────\n\
|
||||
Total spent: ${:.4}\n\n\
|
||||
DeepSeek API Pricing:\n\
|
||||
─────────────────────────────\n\
|
||||
Pricing details are not configured in this CLI.",
|
||||
app.session_cost,
|
||||
))
|
||||
}
|
||||
|
||||
/// Show current system prompt
|
||||
pub fn system_prompt(app: &mut App) -> CommandResult {
|
||||
let prompt_text = match &app.system_prompt {
|
||||
Some(SystemPrompt::Text(text)) => text.clone(),
|
||||
Some(SystemPrompt::Blocks(blocks)) => blocks
|
||||
.iter()
|
||||
.map(|b| b.text.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n---\n\n"),
|
||||
None => "(no system prompt)".to_string(),
|
||||
};
|
||||
|
||||
// Truncate if too long
|
||||
let display = if prompt_text.len() > 500 {
|
||||
// Find a valid UTF-8 char boundary at or before byte 500
|
||||
let truncate_at = prompt_text
|
||||
.char_indices()
|
||||
.take_while(|(i, _)| *i <= 500)
|
||||
.last()
|
||||
.map_or(0, |(i, _)| i);
|
||||
format!(
|
||||
"{}...\n\n(truncated, {} chars total)",
|
||||
&prompt_text[..truncate_at],
|
||||
prompt_text.len()
|
||||
)
|
||||
} else {
|
||||
prompt_text
|
||||
};
|
||||
|
||||
CommandResult::message(format!(
|
||||
"System Prompt ({} mode):\n─────────────────────────────\n{}",
|
||||
app.mode.label(),
|
||||
display
|
||||
))
|
||||
}
|
||||
|
||||
/// Show context window usage
|
||||
pub fn context(app: &mut App) -> CommandResult {
|
||||
let mut total_chars = estimate_message_chars(&app.api_messages);
|
||||
let estimated_tokens =
|
||||
estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref());
|
||||
|
||||
// System prompt
|
||||
if let Some(SystemPrompt::Text(text)) = &app.system_prompt {
|
||||
total_chars += text.len();
|
||||
} else if let Some(SystemPrompt::Blocks(blocks)) = &app.system_prompt {
|
||||
for block in blocks {
|
||||
total_chars += block.text.len();
|
||||
}
|
||||
}
|
||||
|
||||
let context_size =
|
||||
context_window_for_model(&app.model).unwrap_or(DEFAULT_CONTEXT_WINDOW_TOKENS);
|
||||
let estimated_tokens_u32 = u32::try_from(estimated_tokens).unwrap_or(u32::MAX);
|
||||
let usage_pct = (f64::from(estimated_tokens_u32) / f64::from(context_size) * 100.0).min(100.0);
|
||||
|
||||
CommandResult::message(format!(
|
||||
"Context Usage:\n\
|
||||
─────────────────────────────\n\
|
||||
Characters: {}\n\
|
||||
Estimated tokens: ~{}\n\
|
||||
Context window: {}\n\
|
||||
Usage: {:.1}%\n\n\
|
||||
Messages: {}\n\
|
||||
API messages: {}",
|
||||
total_chars,
|
||||
estimated_tokens,
|
||||
context_size,
|
||||
usage_pct,
|
||||
app.history.len(),
|
||||
app.api_messages.len(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::models::{ContentBlock, Message, SystemBlock};
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: PathBuf::from("/tmp/test-workspace"),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("/tmp/test-skills"),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokens_shows_usage_info() {
|
||||
let mut app = create_test_app();
|
||||
app.total_tokens = 1234;
|
||||
app.session_cost = 0.05;
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![],
|
||||
});
|
||||
app.history.push(HistoryCell::User {
|
||||
content: "test".to_string(),
|
||||
});
|
||||
|
||||
let result = tokens(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Token Usage"));
|
||||
assert!(msg.contains("Total tokens:"));
|
||||
assert!(msg.contains("Session cost:"));
|
||||
assert!(msg.contains("API messages:"));
|
||||
assert!(msg.contains("Chat messages:"));
|
||||
assert!(msg.contains("Model:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_shows_spending_info() {
|
||||
let mut app = create_test_app();
|
||||
app.session_cost = 0.1234;
|
||||
let result = cost(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Session Cost"));
|
||||
assert!(msg.contains("Total spent:"));
|
||||
assert!(msg.contains("$0.1234"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_displays_text() {
|
||||
let mut app = create_test_app();
|
||||
app.system_prompt = Some(SystemPrompt::Text("Test system prompt".to_string()));
|
||||
let result = system_prompt(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("System Prompt"));
|
||||
assert!(msg.contains("Test system prompt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_displays_blocks() {
|
||||
let mut app = create_test_app();
|
||||
app.system_prompt = Some(SystemPrompt::Blocks(vec![
|
||||
SystemBlock {
|
||||
block_type: "text".to_string(),
|
||||
text: "Block 1".to_string(),
|
||||
cache_control: None,
|
||||
},
|
||||
SystemBlock {
|
||||
block_type: "text".to_string(),
|
||||
text: "Block 2".to_string(),
|
||||
cache_control: None,
|
||||
},
|
||||
]));
|
||||
let result = system_prompt(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("System Prompt"));
|
||||
assert!(msg.contains("Block 1"));
|
||||
assert!(msg.contains("Block 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_none() {
|
||||
let mut app = create_test_app();
|
||||
app.system_prompt = None;
|
||||
let result = system_prompt(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("(no system prompt)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_truncates_long_text() {
|
||||
let mut app = create_test_app();
|
||||
let long_text = "x".repeat(600);
|
||||
app.system_prompt = Some(SystemPrompt::Text(long_text));
|
||||
let result = system_prompt(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("..."));
|
||||
assert!(msg.contains("chars total"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_shows_usage_stats() {
|
||||
let mut app = create_test_app();
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
app.history.push(HistoryCell::User {
|
||||
content: "Hello".to_string(),
|
||||
});
|
||||
|
||||
let result = context(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Context Usage"));
|
||||
assert!(msg.contains("Characters:"));
|
||||
assert!(msg.contains("Estimated tokens:"));
|
||||
assert!(msg.contains("Context window:"));
|
||||
assert!(msg.contains("Usage:"));
|
||||
assert!(msg.contains("Messages:"));
|
||||
assert!(msg.contains("API messages:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_removes_last_exchange() {
|
||||
let mut app = create_test_app();
|
||||
app.history.push(HistoryCell::User {
|
||||
content: "Hello".to_string(),
|
||||
});
|
||||
app.history.push(HistoryCell::Assistant {
|
||||
content: "Hi".to_string(),
|
||||
streaming: false,
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![],
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![],
|
||||
});
|
||||
|
||||
let initial_history_len = app.history.len();
|
||||
let initial_api_len = app.api_messages.len();
|
||||
let result = undo(&mut app);
|
||||
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Removed"));
|
||||
assert!(app.history.len() < initial_history_len);
|
||||
assert!(app.api_messages.len() < initial_api_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_nothing_to_undo() {
|
||||
let mut app = create_test_app();
|
||||
// Clear any default history
|
||||
app.history.clear();
|
||||
app.api_messages.clear();
|
||||
let result = undo(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Nothing to undo") || msg.contains("Removed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_with_previous_message() {
|
||||
let mut app = create_test_app();
|
||||
app.history.push(HistoryCell::User {
|
||||
content: "Test message".to_string(),
|
||||
});
|
||||
app.history.push(HistoryCell::Assistant {
|
||||
content: "Response".to_string(),
|
||||
streaming: false,
|
||||
});
|
||||
|
||||
let result = retry(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Retrying"));
|
||||
assert!(msg.contains("Test message"));
|
||||
assert!(matches!(result.action, Some(AppAction::SendMessage(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_no_previous_message() {
|
||||
let mut app = create_test_app();
|
||||
let result = retry(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("No previous request to retry"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_truncates_long_input() {
|
||||
let mut app = create_test_app();
|
||||
let long_input = "x".repeat(100);
|
||||
app.history.push(HistoryCell::User {
|
||||
content: long_input.clone(),
|
||||
});
|
||||
app.history.push(HistoryCell::Assistant {
|
||||
content: "Response".to_string(),
|
||||
streaming: false,
|
||||
});
|
||||
|
||||
let result = retry(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Retrying"));
|
||||
assert!(msg.contains("..."));
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove last message pair (user + assistant)
|
||||
pub fn undo(app: &mut App) -> CommandResult {
|
||||
// Remove from display history (up to the last user message)
|
||||
let mut removed_count = 0;
|
||||
while !app.history.is_empty() {
|
||||
let last_is_user = matches!(app.history.last(), Some(HistoryCell::User { .. }));
|
||||
app.history.pop();
|
||||
removed_count += 1;
|
||||
if last_is_user {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from API messages
|
||||
while let Some(last) = app.api_messages.last() {
|
||||
if last.role == "user" {
|
||||
app.api_messages.pop();
|
||||
break;
|
||||
}
|
||||
app.api_messages.pop();
|
||||
}
|
||||
|
||||
if removed_count > 0 {
|
||||
// Keep tool/index mappings consistent after truncation.
|
||||
app.tool_cells.clear();
|
||||
app.tool_details_by_cell.clear();
|
||||
app.exploring_entries.clear();
|
||||
app.ignored_tool_calls.clear();
|
||||
app.mark_history_updated();
|
||||
CommandResult::message(format!("Removed {removed_count} message(s)"))
|
||||
} else {
|
||||
CommandResult::message("Nothing to undo")
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry last request - remove last exchange and re-send the user's message
|
||||
pub fn retry(app: &mut App) -> CommandResult {
|
||||
let last_user_input = app.history.iter().rev().find_map(|cell| match cell {
|
||||
HistoryCell::User { content } => Some(content.clone()),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
match last_user_input {
|
||||
Some(input) => {
|
||||
undo(app);
|
||||
let display_input = if input.len() > 50 {
|
||||
let truncate_at = input
|
||||
.char_indices()
|
||||
.take_while(|(i, _)| *i <= 50)
|
||||
.last()
|
||||
.map_or(0, |(i, _)| i);
|
||||
format!("{}...", &input[..truncate_at])
|
||||
} else {
|
||||
input.clone()
|
||||
};
|
||||
CommandResult::with_message_and_action(
|
||||
format!("Retrying: {display_input}"),
|
||||
AppAction::SendMessage(input),
|
||||
)
|
||||
}
|
||||
None => CommandResult::error("No previous request to retry"),
|
||||
}
|
||||
}
|
||||
@@ -151,3 +151,123 @@ fn extract_cargo_name(content: &str) -> Option<String> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
notes_path: tmpdir.path().join("notes.txt"),
|
||||
mcp_config_path: tmpdir.path().join("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_creates_agents_md() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = init(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Created AGENTS.md"));
|
||||
let agents_path = tmpdir.path().join("AGENTS.md");
|
||||
assert!(agents_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_fails_if_exists() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
// Create file first
|
||||
std::fs::write(tmpdir.path().join("AGENTS.md"), "existing").unwrap();
|
||||
let result = init(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("already exists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_project_type_rust() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
std::fs::write(
|
||||
tmpdir.path().join("Cargo.toml"),
|
||||
"[package]\nname = \"test\"",
|
||||
)
|
||||
.unwrap();
|
||||
let info = detect_project_type(tmpdir.path());
|
||||
assert!(info.contains("Project Type: Rust"));
|
||||
assert!(info.contains("cargo build"));
|
||||
assert!(info.contains("cargo test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_project_type_node() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
std::fs::write(tmpdir.path().join("package.json"), "{}").unwrap();
|
||||
let info = detect_project_type(tmpdir.path());
|
||||
assert!(info.contains("Project Type: Node.js"));
|
||||
assert!(info.contains("npm install"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_project_type_python() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
std::fs::write(tmpdir.path().join("pyproject.toml"), "[project]").unwrap();
|
||||
let info = detect_project_type(tmpdir.path());
|
||||
assert!(info.contains("Project Type: Python"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_project_type_go() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
std::fs::write(tmpdir.path().join("go.mod"), "module test").unwrap();
|
||||
let info = detect_project_type(tmpdir.path());
|
||||
assert!(info.contains("Project Type: Go"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_project_type_unknown() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let info = detect_project_type(tmpdir.path());
|
||||
assert!(info.contains("Project Type: Unknown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_cargo_name() {
|
||||
let cargo = r#"
|
||||
[package]
|
||||
name = "my-project"
|
||||
version = "1.0.0"
|
||||
"#;
|
||||
assert_eq!(extract_cargo_name(cargo), Some("my-project".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_cargo_name_single_quotes() {
|
||||
let cargo = r#"name = 'single-quoted'"#;
|
||||
assert_eq!(extract_cargo_name(cargo), Some("single-quoted".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_cargo_name_not_found() {
|
||||
let cargo = "[package]\nversion = \"1.0.0\"";
|
||||
assert_eq!(extract_cargo_name(cargo), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
//! Slash command registry and dispatch system
|
||||
//!
|
||||
//! This module provides a modular command system inspired by Codex-rs.
|
||||
//! Commands are organized by category and dispatched through a central registry.
|
||||
|
||||
mod attachment;
|
||||
mod config;
|
||||
mod core;
|
||||
mod debug;
|
||||
mod init;
|
||||
mod note;
|
||||
mod provider;
|
||||
mod queue;
|
||||
mod review;
|
||||
mod session;
|
||||
mod skills;
|
||||
mod task;
|
||||
|
||||
use crate::tui::app::{App, AppAction};
|
||||
|
||||
/// Result of executing a command
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandResult {
|
||||
/// Optional message to display to the user
|
||||
pub message: Option<String>,
|
||||
/// Optional action for the app to take
|
||||
pub action: Option<AppAction>,
|
||||
}
|
||||
|
||||
impl CommandResult {
|
||||
/// Create an empty result (command succeeded with no output)
|
||||
pub fn ok() -> Self {
|
||||
Self {
|
||||
message: None,
|
||||
action: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a result with just a message
|
||||
pub fn message(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: Some(msg.into()),
|
||||
action: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a result with an action
|
||||
pub fn action(action: AppAction) -> Self {
|
||||
Self {
|
||||
message: None,
|
||||
action: Some(action),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a result with both message and action
|
||||
#[allow(dead_code)]
|
||||
pub fn with_message_and_action(msg: impl Into<String>, action: AppAction) -> Self {
|
||||
Self {
|
||||
message: Some(msg.into()),
|
||||
action: Some(action),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an error message result
|
||||
pub fn error(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: Some(format!("Error: {}", msg.into())),
|
||||
action: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command metadata for help and autocomplete
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CommandInfo {
|
||||
pub name: &'static str,
|
||||
pub aliases: &'static [&'static str],
|
||||
pub description: &'static str,
|
||||
pub usage: &'static str,
|
||||
}
|
||||
|
||||
impl CommandInfo {
|
||||
pub fn requires_argument(&self) -> bool {
|
||||
self.usage.contains('<') || self.usage.contains('[')
|
||||
}
|
||||
|
||||
pub fn palette_command(&self) -> String {
|
||||
if self.requires_argument() {
|
||||
format!("/{} ", self.name)
|
||||
} else {
|
||||
format!("/{}", self.name)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn palette_description(&self) -> String {
|
||||
if self.aliases.is_empty() {
|
||||
self.description.to_string()
|
||||
} else {
|
||||
format!("{} aliases: {}", self.description, self.aliases.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All registered commands
|
||||
pub const COMMANDS: &[CommandInfo] = &[
|
||||
// Core commands
|
||||
CommandInfo {
|
||||
name: "help",
|
||||
aliases: &["?"],
|
||||
description: "Show help information",
|
||||
usage: "/help [command]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "clear",
|
||||
aliases: &[],
|
||||
description: "Clear conversation history",
|
||||
usage: "/clear",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "exit",
|
||||
aliases: &["quit", "q"],
|
||||
description: "Exit the application",
|
||||
usage: "/exit",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "model",
|
||||
aliases: &[],
|
||||
description: "Switch or view current model",
|
||||
usage: "/model [name]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "models",
|
||||
aliases: &[],
|
||||
description: "List available models from API",
|
||||
usage: "/models",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "provider",
|
||||
aliases: &[],
|
||||
description: "Switch or view the active LLM backend (deepseek | nvidia-nim)",
|
||||
usage: "/provider [name]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "queue",
|
||||
aliases: &["queued"],
|
||||
description: "View or edit queued messages",
|
||||
usage: "/queue [list|edit <n>|drop <n>|clear]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "subagents",
|
||||
aliases: &["agents"],
|
||||
description: "List sub-agent status",
|
||||
usage: "/subagents",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "links",
|
||||
aliases: &["dashboard", "api"],
|
||||
description: "Show DeepSeek dashboard and docs links",
|
||||
usage: "/links",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "home",
|
||||
aliases: &["stats", "overview"],
|
||||
description: "Show home dashboard with stats and quick actions",
|
||||
usage: "/home",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "note",
|
||||
aliases: &[],
|
||||
description: "Append note to persistent notes file (.deepseek/notes.md)",
|
||||
usage: "/note <text>",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "attach",
|
||||
aliases: &["image", "media"],
|
||||
description: "Attach image/video media; use @path for text files or directories",
|
||||
usage: "/attach <path>",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "task",
|
||||
aliases: &["tasks"],
|
||||
description: "Manage background tasks",
|
||||
usage: "/task [add <prompt>|list|show <id>|cancel <id>]",
|
||||
},
|
||||
// Session commands
|
||||
CommandInfo {
|
||||
name: "save",
|
||||
aliases: &[],
|
||||
description: "Save session to file",
|
||||
usage: "/save [path]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "sessions",
|
||||
aliases: &["resume"],
|
||||
description: "Open session picker",
|
||||
usage: "/sessions",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "load",
|
||||
aliases: &[],
|
||||
description: "Load session from file",
|
||||
usage: "/load [path]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "compact",
|
||||
aliases: &[],
|
||||
description: "Trigger context compaction to free up space",
|
||||
usage: "/compact",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "export",
|
||||
aliases: &[],
|
||||
description: "Export conversation to markdown",
|
||||
usage: "/export [path]",
|
||||
},
|
||||
// Config commands
|
||||
CommandInfo {
|
||||
name: "config",
|
||||
aliases: &[],
|
||||
description: "Open interactive configuration editor",
|
||||
usage: "/config",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "yolo",
|
||||
aliases: &[],
|
||||
description: "Enable YOLO mode (shell + trust + auto-approve)",
|
||||
usage: "/yolo",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "agent",
|
||||
aliases: &[],
|
||||
description: "Switch to agent mode",
|
||||
usage: "/agent",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "plan",
|
||||
aliases: &[],
|
||||
description: "Switch to plan mode and review suggested implementation steps",
|
||||
usage: "/plan",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "trust",
|
||||
aliases: &[],
|
||||
description: "Manage workspace trust and per-path allowlist (`/trust add <path>`, `/trust list`, `/trust on|off`)",
|
||||
usage: "/trust [on|off|add <path>|remove <path>|list]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "logout",
|
||||
aliases: &[],
|
||||
description: "Clear API key and return to setup",
|
||||
usage: "/logout",
|
||||
},
|
||||
// Debug commands
|
||||
CommandInfo {
|
||||
name: "tokens",
|
||||
aliases: &[],
|
||||
description: "Show token usage for session",
|
||||
usage: "/tokens",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "system",
|
||||
aliases: &[],
|
||||
description: "Show current system prompt",
|
||||
usage: "/system",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "context",
|
||||
aliases: &[],
|
||||
description: "Show context window usage",
|
||||
usage: "/context",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "undo",
|
||||
aliases: &[],
|
||||
description: "Remove last message pair",
|
||||
usage: "/undo",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "retry",
|
||||
aliases: &[],
|
||||
description: "Retry the last request",
|
||||
usage: "/retry",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "init",
|
||||
aliases: &[],
|
||||
description: "Generate AGENTS.md for project",
|
||||
usage: "/init",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "settings",
|
||||
aliases: &[],
|
||||
description: "Show persistent settings",
|
||||
usage: "/settings",
|
||||
},
|
||||
// Skills commands
|
||||
CommandInfo {
|
||||
name: "skills",
|
||||
aliases: &[],
|
||||
description: "List available skills",
|
||||
usage: "/skills",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "skill",
|
||||
aliases: &[],
|
||||
description: "Activate a skill for next message",
|
||||
usage: "/skill <name>",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "review",
|
||||
aliases: &[],
|
||||
description: "Run a structured code review on a file, diff, or PR",
|
||||
usage: "/review <target>",
|
||||
},
|
||||
// Debug/cost command
|
||||
CommandInfo {
|
||||
name: "cost",
|
||||
aliases: &[],
|
||||
description: "Show session cost breakdown",
|
||||
usage: "/cost",
|
||||
},
|
||||
];
|
||||
|
||||
/// Execute a slash command
|
||||
pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
let parts: Vec<&str> = cmd.trim().splitn(2, ' ').collect();
|
||||
let command = parts[0].to_lowercase();
|
||||
let command = command.strip_prefix('/').unwrap_or(&command);
|
||||
let arg = parts.get(1).map(|s| s.trim());
|
||||
|
||||
// Match command or alias
|
||||
match command {
|
||||
// Core commands
|
||||
"help" | "?" => core::help(app, arg),
|
||||
"clear" => core::clear(app),
|
||||
"exit" | "quit" | "q" => core::exit(),
|
||||
"model" => core::model(app, arg),
|
||||
"models" => core::models(app),
|
||||
"provider" => provider::provider(app, arg),
|
||||
"queue" | "queued" => queue::queue(app, arg),
|
||||
"subagents" | "agents" => core::subagents(app),
|
||||
"links" | "dashboard" | "api" => core::deepseek_links(),
|
||||
"home" | "stats" | "overview" => core::home_dashboard(app),
|
||||
"note" => note::note(app, arg),
|
||||
"attach" | "image" | "media" => attachment::attach(app, arg),
|
||||
"task" | "tasks" => task::task(app, arg),
|
||||
|
||||
// Session commands
|
||||
"save" => session::save(app, arg),
|
||||
"sessions" | "resume" => session::sessions(app),
|
||||
"load" => session::load(app, arg),
|
||||
"compact" => session::compact(app),
|
||||
"export" => session::export(app, arg),
|
||||
|
||||
// Config commands
|
||||
"config" => config::show_config(app),
|
||||
"settings" => config::show_settings(app),
|
||||
"yolo" => config::yolo(app),
|
||||
"agent" => config::agent_mode(app),
|
||||
"plan" => config::plan_mode(app),
|
||||
"trust" => config::trust(app, arg),
|
||||
"logout" => config::logout(app),
|
||||
|
||||
// Debug commands
|
||||
"tokens" => debug::tokens(app),
|
||||
"cost" => debug::cost(app),
|
||||
"system" => debug::system_prompt(app),
|
||||
"context" => debug::context(app),
|
||||
"undo" => debug::undo(app),
|
||||
"retry" => debug::retry(app),
|
||||
|
||||
// Project commands
|
||||
"init" => init::init(app),
|
||||
|
||||
// Skills commands
|
||||
"skills" => skills::list_skills(app),
|
||||
"skill" => skills::run_skill(app, arg),
|
||||
"review" => review::review(app, arg),
|
||||
|
||||
// Legacy command migrations (kept out of registry/autocomplete intentionally).
|
||||
"set" => CommandResult::error(
|
||||
"The /set command was retired. Use /config to edit settings and /settings to inspect current values.",
|
||||
),
|
||||
"normal" => config::normal_mode(app),
|
||||
"deepseek" => CommandResult::error(
|
||||
"The /deepseek command was renamed. Use /links (aliases: /dashboard, /api).",
|
||||
),
|
||||
|
||||
_ => {
|
||||
let suggestions = suggest_command_names(command, 3);
|
||||
if suggestions.is_empty() {
|
||||
CommandResult::error(format!(
|
||||
"Unknown command: /{command}. Type /help for available commands."
|
||||
))
|
||||
} else {
|
||||
let list = suggestions
|
||||
.into_iter()
|
||||
.map(|name| format!("/{name}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
CommandResult::error(format!(
|
||||
"Unknown command: /{command}. Did you mean: {list}? Type /help for available commands."
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a configuration value programmatically (used by interactive UI views).
|
||||
pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult {
|
||||
config::set_config_value(app, key, value, persist)
|
||||
}
|
||||
|
||||
/// Get command info by name or alias
|
||||
pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> {
|
||||
let name = name.strip_prefix('/').unwrap_or(name);
|
||||
COMMANDS
|
||||
.iter()
|
||||
.find(|cmd| cmd.name == name || cmd.aliases.contains(&name))
|
||||
}
|
||||
|
||||
/// Get all commands matching a prefix (for autocomplete)
|
||||
#[allow(dead_code)]
|
||||
pub fn commands_matching(prefix: &str) -> Vec<&'static CommandInfo> {
|
||||
let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase();
|
||||
COMMANDS
|
||||
.iter()
|
||||
.filter(|cmd| {
|
||||
cmd.name.starts_with(&prefix) || cmd.aliases.iter().any(|a| a.starts_with(&prefix))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn edit_distance(a: &str, b: &str) -> usize {
|
||||
if a == b {
|
||||
return 0;
|
||||
}
|
||||
if a.is_empty() {
|
||||
return b.chars().count();
|
||||
}
|
||||
if b.is_empty() {
|
||||
return a.chars().count();
|
||||
}
|
||||
|
||||
let b_chars: Vec<char> = b.chars().collect();
|
||||
let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
|
||||
let mut curr = vec![0usize; b_chars.len() + 1];
|
||||
|
||||
for (i, a_ch) in a.chars().enumerate() {
|
||||
curr[0] = i + 1;
|
||||
for (j, b_ch) in b_chars.iter().enumerate() {
|
||||
let cost = if a_ch == *b_ch { 0 } else { 1 };
|
||||
let delete = prev[j + 1] + 1;
|
||||
let insert = curr[j] + 1;
|
||||
let substitute = prev[j] + cost;
|
||||
curr[j + 1] = delete.min(insert).min(substitute);
|
||||
}
|
||||
std::mem::swap(&mut prev, &mut curr);
|
||||
}
|
||||
|
||||
prev[b_chars.len()]
|
||||
}
|
||||
|
||||
fn suggest_command_names(input: &str, limit: usize) -> Vec<String> {
|
||||
let query = input.trim().to_ascii_lowercase();
|
||||
if query.is_empty() || limit == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut scored: Vec<(u8, usize, String)> = Vec::new();
|
||||
for command in COMMANDS {
|
||||
let mut best: Option<(u8, usize)> = None;
|
||||
for candidate in std::iter::once(command.name).chain(command.aliases.iter().copied()) {
|
||||
let candidate = candidate.to_ascii_lowercase();
|
||||
let prefix_match = candidate.starts_with(&query) || query.starts_with(&candidate);
|
||||
let contains_match = candidate.contains(&query) || query.contains(&candidate);
|
||||
let distance = edit_distance(&candidate, &query);
|
||||
let close_typo = distance <= 2;
|
||||
if !(prefix_match || contains_match || close_typo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rank = if prefix_match {
|
||||
0
|
||||
} else if contains_match {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
|
||||
match best {
|
||||
Some((best_rank, best_distance))
|
||||
if rank > best_rank || (rank == best_rank && distance >= best_distance) => {}
|
||||
_ => best = Some((rank, distance)),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((rank, distance)) = best {
|
||||
scored.push((rank, distance, command.name.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
scored.sort_by(|a, b| {
|
||||
a.0.cmp(&b.0)
|
||||
.then_with(|| a.1.cmp(&b.1))
|
||||
.then_with(|| a.2.cmp(&b.2))
|
||||
});
|
||||
scored
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|(_, _, name)| name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, AppAction, TuiOptions};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_registry_contains_config_and_links_but_not_set_or_deepseek() {
|
||||
assert!(COMMANDS.iter().any(|cmd| cmd.name == "config"));
|
||||
assert!(COMMANDS.iter().any(|cmd| cmd.name == "links"));
|
||||
assert!(!COMMANDS.iter().any(|cmd| cmd.name == "set"));
|
||||
assert!(!COMMANDS.iter().any(|cmd| cmd.name == "deepseek"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_command_has_dashboard_and_api_aliases() {
|
||||
let links = COMMANDS
|
||||
.iter()
|
||||
.find(|cmd| cmd.name == "links")
|
||||
.expect("links command should exist");
|
||||
assert_eq!(links.aliases, &["dashboard", "api"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_config_opens_config_view_action() {
|
||||
let mut app = create_test_app();
|
||||
let result = execute("/config", &mut app);
|
||||
assert!(result.message.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_links_and_aliases_return_links_message() {
|
||||
let mut app = create_test_app();
|
||||
for cmd in ["/links", "/dashboard", "/api"] {
|
||||
let result = execute(cmd, &mut app);
|
||||
let msg = result.message.expect("links commands should return text");
|
||||
assert!(msg.contains("https://platform.deepseek.com"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_set_and_deepseek_commands_show_migration_hints() {
|
||||
let mut app = create_test_app();
|
||||
let set_result = execute("/set model deepseek-v4-pro", &mut app);
|
||||
let set_msg = set_result
|
||||
.message
|
||||
.expect("legacy command should return an error message");
|
||||
assert!(set_msg.contains("The /set command was retired"));
|
||||
assert!(set_msg.contains("/config"));
|
||||
assert!(set_msg.contains("/settings"));
|
||||
assert!(set_result.action.is_none());
|
||||
|
||||
let deepseek_result = execute("/deepseek", &mut app);
|
||||
let deepseek_msg = deepseek_result
|
||||
.message
|
||||
.expect("legacy command should return an error message");
|
||||
assert!(deepseek_msg.contains("The /deepseek command was renamed"));
|
||||
assert!(deepseek_msg.contains("/links"));
|
||||
assert!(deepseek_msg.contains("/dashboard"));
|
||||
assert!(deepseek_msg.contains("/api"));
|
||||
assert!(deepseek_result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_suggests_nearest_match() {
|
||||
let mut app = create_test_app();
|
||||
let result = execute("/modle", &mut app);
|
||||
let msg = result
|
||||
.message
|
||||
.expect("unknown command should return an error message");
|
||||
assert!(msg.contains("Unknown command: /modle"));
|
||||
assert!(msg.contains("Did you mean:"));
|
||||
assert!(msg.contains("/model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_without_close_match_keeps_help_guidance() {
|
||||
let mut app = create_test_app();
|
||||
let result = execute("/zzzzzz", &mut app);
|
||||
let msg = result
|
||||
.message
|
||||
.expect("unknown command should return an error message");
|
||||
assert!(msg.contains("Unknown command: /zzzzzz"));
|
||||
assert!(msg.contains("Type /help for available commands."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Note command: append to persistent notes file
|
||||
|
||||
use crate::tui::app::App;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
/// Append a note to the persistent notes file
|
||||
pub fn note(app: &mut App, content: Option<&str>) -> CommandResult {
|
||||
let note_content = match content {
|
||||
Some(c) => c.trim(),
|
||||
None => {
|
||||
return CommandResult::error("Usage: /note <text>");
|
||||
}
|
||||
};
|
||||
|
||||
if note_content.is_empty() {
|
||||
return CommandResult::error("Note content cannot be empty");
|
||||
}
|
||||
|
||||
// Determine notes path: workspace/.deepseek/notes.md
|
||||
let notes_path = app.workspace.join(".deepseek").join("notes.md");
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = notes_path.parent()
|
||||
&& let Err(e) = fs::create_dir_all(parent)
|
||||
{
|
||||
return CommandResult::error(format!("Failed to create notes directory: {e}"));
|
||||
}
|
||||
|
||||
// Append to notes file
|
||||
let mut file = match fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(¬es_path)
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
return CommandResult::error(format!("Failed to open notes file: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
// Write separator and note content
|
||||
if let Err(e) = writeln!(file, "\n---\n{}", note_content) {
|
||||
return CommandResult::error(format!("Failed to write note: {e}"));
|
||||
}
|
||||
|
||||
CommandResult::message(format!("Note appended to {}", notes_path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
notes_path: tmpdir.path().join("notes.txt"),
|
||||
mcp_config_path: tmpdir.path().join("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_without_content_returns_error() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = note(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Usage: /note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_with_empty_content_returns_error() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = note(&mut app, Some(" "));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("cannot be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_appends_to_file() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = note(&mut app, Some("Test note content"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Note appended to"));
|
||||
|
||||
let notes_path = tmpdir.path().join(".deepseek").join("notes.md");
|
||||
assert!(notes_path.exists());
|
||||
let content = std::fs::read_to_string(¬es_path).unwrap();
|
||||
assert!(content.contains("Test note content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_multiple_appends() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
note(&mut app, Some("First note"));
|
||||
note(&mut app, Some("Second note"));
|
||||
|
||||
let notes_path = tmpdir.path().join(".deepseek").join("notes.md");
|
||||
let content = std::fs::read_to_string(¬es_path).unwrap();
|
||||
assert!(content.contains("First note"));
|
||||
assert!(content.contains("Second note"));
|
||||
// Should have two separators
|
||||
assert_eq!(content.matches("---").count(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Provider switching: flip between DeepSeek and NVIDIA NIM at runtime.
|
||||
|
||||
use crate::config::{ApiProvider, normalize_model_name};
|
||||
use crate::tui::app::{App, AppAction};
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
/// Switch or view the current LLM backend.
|
||||
///
|
||||
/// Accepts `<provider> [model]` so you can flip backend and model in one
|
||||
/// shot, e.g. `/provider nim flash` lands you on
|
||||
/// `deepseek-ai/deepseek-v4-flash`. The optional model accepts shorthand
|
||||
/// (`flash`, `pro`, `v4-flash`, `v4-pro`) or any normal DeepSeek model ID.
|
||||
pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
let trimmed = args.map(str::trim).filter(|s| !s.is_empty());
|
||||
let Some(args) = trimmed else {
|
||||
return CommandResult::message(format!(
|
||||
"Current provider: {}\n\
|
||||
Active model: {}\n\
|
||||
Available: deepseek, nvidia-nim\n\
|
||||
Usage: /provider <name> [model]\n\
|
||||
Examples: /provider nim flash → NIM v4-flash (recommended)\n\
|
||||
/provider nim pro → NIM v4-pro (currently DEGRADED)\n\
|
||||
/provider deepseek → DeepSeek native, default model\n\
|
||||
Tip: NIM needs NVIDIA_API_KEY (or [providers.nvidia_nim].api_key in config.toml).",
|
||||
app.api_provider.as_str(),
|
||||
app.model
|
||||
));
|
||||
};
|
||||
|
||||
let mut parts = args.split_whitespace();
|
||||
let name = parts.next().unwrap_or("");
|
||||
let model_arg = parts.next();
|
||||
|
||||
let Some(target) = ApiProvider::parse(name) else {
|
||||
return CommandResult::error(format!(
|
||||
"Unknown provider '{name}'. Expected: deepseek, nvidia-nim."
|
||||
));
|
||||
};
|
||||
|
||||
let model = match model_arg {
|
||||
None => None,
|
||||
Some(raw) => match normalize_model_name(&expand_model_alias(raw)) {
|
||||
Some(normalized) => Some(normalized),
|
||||
None => {
|
||||
return CommandResult::error(format!(
|
||||
"Invalid model '{raw}'. Try: flash, pro, deepseek-v4-flash, deepseek-v4-pro."
|
||||
));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if target == app.api_provider && model.is_none() {
|
||||
return CommandResult::message(format!("Already on provider: {}", target.as_str()));
|
||||
}
|
||||
|
||||
CommandResult::action(AppAction::SwitchProvider {
|
||||
provider: target,
|
||||
model,
|
||||
})
|
||||
}
|
||||
|
||||
fn expand_model_alias(name: &str) -> String {
|
||||
match name.trim().to_ascii_lowercase().as_str() {
|
||||
"pro" | "v4-pro" => "deepseek-v4-pro".to_string(),
|
||||
"flash" | "v4-flash" => "deepseek-v4-flash".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::TuiOptions;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_args_shows_current_provider_and_usage() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, None);
|
||||
let msg = result.message.expect("expected info message");
|
||||
assert!(msg.contains("Current provider:"));
|
||||
assert!(msg.contains("deepseek"));
|
||||
assert!(msg.contains("Available:"));
|
||||
assert!(msg.contains("nvidia-nim"));
|
||||
assert!(msg.contains("/provider nim flash"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_provider_returns_error() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("openai"));
|
||||
let msg = result.message.expect("expected error message");
|
||||
assert!(msg.contains("Unknown provider"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_to_active_provider_without_model_is_a_noop() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("deepseek"));
|
||||
let msg = result.message.expect("expected message");
|
||||
assert!(msg.contains("Already on provider"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_to_nim_emits_action_without_model_override() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("nvidia-nim"));
|
||||
assert!(result.message.is_none());
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::NvidiaNim);
|
||||
assert_eq!(model, None);
|
||||
}
|
||||
other => panic!("expected SwitchProvider action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nim_flash_shorthand_emits_action_with_model_override() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("nim flash"));
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::NvidiaNim);
|
||||
assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
|
||||
}
|
||||
other => panic!("expected SwitchProvider action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nim_pro_shorthand_emits_action_with_model_override() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("nim pro"));
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::NvidiaNim);
|
||||
assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
|
||||
}
|
||||
other => panic!("expected SwitchProvider action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_to_active_provider_with_new_model_still_emits_action() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("deepseek flash"));
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::Deepseek);
|
||||
assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
|
||||
}
|
||||
other => panic!("expected SwitchProvider action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_model_returns_error() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("nim gpt-4"));
|
||||
let msg = result.message.expect("expected error message");
|
||||
assert!(msg.contains("Invalid model"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Queue commands: queue list/edit/drop/clear
|
||||
|
||||
use crate::tui::app::App;
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
const PREVIEW_LIMIT: usize = 120;
|
||||
|
||||
pub fn queue(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
let arg = args.unwrap_or("").trim();
|
||||
if arg.is_empty() || arg.eq_ignore_ascii_case("list") {
|
||||
return list_queue(app);
|
||||
}
|
||||
|
||||
let mut parts = arg.split_whitespace();
|
||||
let action = parts.next().unwrap_or("").to_lowercase();
|
||||
|
||||
match action.as_str() {
|
||||
"edit" => edit_queue(app, parts.next()),
|
||||
"drop" | "remove" | "rm" => drop_queue(app, parts.next()),
|
||||
"clear" => clear_queue(app),
|
||||
_ => CommandResult::error("Usage: /queue [list|edit <n>|drop <n>|clear]"),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_queue(app: &mut App) -> CommandResult {
|
||||
let mut lines = Vec::new();
|
||||
let queued = app.queued_message_count();
|
||||
|
||||
if let Some(draft) = app.queued_draft.as_ref() {
|
||||
lines.push("Editing queued message:".to_string());
|
||||
lines.push(format!("- {}", truncate_preview(&draft.display)));
|
||||
}
|
||||
|
||||
if queued == 0 {
|
||||
if lines.is_empty() {
|
||||
return CommandResult::message("No queued messages");
|
||||
}
|
||||
return CommandResult::message(lines.join("\n"));
|
||||
}
|
||||
|
||||
lines.push(format!("Queued messages ({queued}):"));
|
||||
for (idx, message) in app.queued_messages.iter().enumerate() {
|
||||
lines.push(format!(
|
||||
"{}. {}",
|
||||
idx + 1,
|
||||
truncate_preview(&message.display)
|
||||
));
|
||||
}
|
||||
|
||||
lines.push("Tip: /queue edit <n> to edit, /queue drop <n> to remove".to_string());
|
||||
|
||||
CommandResult::message(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn edit_queue(app: &mut App, index: Option<&str>) -> CommandResult {
|
||||
if app.queued_draft.is_some() {
|
||||
return CommandResult::error(
|
||||
"Already editing a queued message. Send it or /queue clear to discard.",
|
||||
);
|
||||
}
|
||||
let index = match parse_index(index) {
|
||||
Ok(index) => index,
|
||||
Err(err) => return CommandResult::error(err),
|
||||
};
|
||||
|
||||
let Some(message) = app.remove_queued_message(index) else {
|
||||
return CommandResult::error("Queued message not found");
|
||||
};
|
||||
|
||||
app.input = message.display.clone();
|
||||
app.cursor_position = app.input.len();
|
||||
app.queued_draft = Some(message);
|
||||
app.status_message = Some(format!("Editing queued message {}", index + 1));
|
||||
|
||||
CommandResult::message(format!(
|
||||
"Editing queued message {} (press Enter to re-queue/send)",
|
||||
index + 1
|
||||
))
|
||||
}
|
||||
|
||||
fn drop_queue(app: &mut App, index: Option<&str>) -> CommandResult {
|
||||
let index = match parse_index(index) {
|
||||
Ok(index) => index,
|
||||
Err(err) => return CommandResult::error(err),
|
||||
};
|
||||
|
||||
if app.remove_queued_message(index).is_none() {
|
||||
return CommandResult::error("Queued message not found");
|
||||
}
|
||||
|
||||
CommandResult::message(format!("Dropped queued message {}", index + 1))
|
||||
}
|
||||
|
||||
fn clear_queue(app: &mut App) -> CommandResult {
|
||||
let queued = app.queued_message_count();
|
||||
let had_draft = app.queued_draft.take().is_some();
|
||||
app.queued_messages.clear();
|
||||
if queued == 0 && !had_draft {
|
||||
return CommandResult::message("Queue already empty");
|
||||
}
|
||||
|
||||
CommandResult::message("Queue cleared")
|
||||
}
|
||||
|
||||
fn parse_index(input: Option<&str>) -> Result<usize, &'static str> {
|
||||
let Some(input) = input else {
|
||||
return Err("Missing index. Usage: /queue edit <n> or /queue drop <n>");
|
||||
};
|
||||
let raw = input
|
||||
.parse::<usize>()
|
||||
.map_err(|_| "Index must be a positive number")?;
|
||||
if raw == 0 {
|
||||
return Err("Index must be >= 1");
|
||||
}
|
||||
Ok(raw - 1)
|
||||
}
|
||||
|
||||
fn truncate_preview(text: &str) -> String {
|
||||
if text.chars().count() <= PREVIEW_LIMIT {
|
||||
return text.to_string();
|
||||
}
|
||||
let mut out = String::new();
|
||||
for ch in text.chars().take(PREVIEW_LIMIT.saturating_sub(3)) {
|
||||
out.push(ch);
|
||||
}
|
||||
out.push_str("...");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, QueuedMessage, TuiOptions};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
notes_path: tmpdir.path().join("notes.txt"),
|
||||
mcp_config_path: tmpdir.path().join("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_list_empty() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = queue(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("No queued messages"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_list_with_messages() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.queued_messages
|
||||
.push_back(QueuedMessage::new("First message".to_string(), None));
|
||||
app.queued_messages
|
||||
.push_back(QueuedMessage::new("Second message".to_string(), None));
|
||||
let result = queue(&mut app, Some("list"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Queued messages (2)"));
|
||||
assert!(msg.contains("1. First message"));
|
||||
assert!(msg.contains("2. Second message"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_edit_missing_index() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.queued_messages
|
||||
.push_back(QueuedMessage::new("Test".to_string(), None));
|
||||
let result = queue(&mut app, Some("edit"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Missing index"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_edit_invalid_index() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = queue(&mut app, Some("edit abc"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(
|
||||
result
|
||||
.message
|
||||
.unwrap()
|
||||
.contains("must be a positive number")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_edit_not_found() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = queue(&mut app, Some("edit 1"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_edit_already_editing() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.queued_messages
|
||||
.push_back(QueuedMessage::new("First".to_string(), None));
|
||||
app.queued_messages
|
||||
.push_back(QueuedMessage::new("Second".to_string(), None));
|
||||
// Start editing
|
||||
queue(&mut app, Some("edit 1"));
|
||||
// Try to edit another
|
||||
let result = queue(&mut app, Some("edit 2"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Already editing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_edit_success() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.queued_messages
|
||||
.push_back(QueuedMessage::new("Original message".to_string(), None));
|
||||
let result = queue(&mut app, Some("edit 1"));
|
||||
assert!(result.message.is_some());
|
||||
assert_eq!(app.input, "Original message");
|
||||
assert_eq!(app.cursor_position, app.input.len());
|
||||
assert!(app.queued_draft.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_drop_success() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.queued_messages
|
||||
.push_back(QueuedMessage::new("To drop".to_string(), None));
|
||||
let initial_count = app.queued_messages.len();
|
||||
let result = queue(&mut app, Some("drop 1"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Dropped queued message"));
|
||||
assert_eq!(app.queued_messages.len(), initial_count - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_clear() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.queued_messages
|
||||
.push_back(QueuedMessage::new("Message 1".to_string(), None));
|
||||
app.queued_messages
|
||||
.push_back(QueuedMessage::new("Message 2".to_string(), None));
|
||||
let result = queue(&mut app, Some("clear"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Queue cleared"));
|
||||
assert!(app.queued_messages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_clear_already_empty() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = queue(&mut app, Some("clear"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Queue already empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_short_text() {
|
||||
let result = truncate_preview("Short text");
|
||||
assert_eq!(result, "Short text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_long_text() {
|
||||
let long_text = "x".repeat(200);
|
||||
let result = truncate_preview(&long_text);
|
||||
assert!(result.len() <= PREVIEW_LIMIT + 3);
|
||||
assert!(result.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_unicode() {
|
||||
let text = "Hello 世界 🌍";
|
||||
let result = truncate_preview(text);
|
||||
assert_eq!(result, text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Review command: activate review skill and send a target immediately.
|
||||
|
||||
use crate::skills::{SkillRegistry, default_skills_dir};
|
||||
use crate::tui::app::{App, AppAction};
|
||||
use crate::tui::history::HistoryCell;
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
fn warnings_suffix(registry: &SkillRegistry) -> String {
|
||||
if registry.warnings().is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
format!("\n\nWarnings:\n- {}", registry.warnings().join("\n- "))
|
||||
}
|
||||
|
||||
pub fn review(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
let target = args.unwrap_or("").trim();
|
||||
if target.is_empty() {
|
||||
return CommandResult::error("Usage: /review <target>");
|
||||
}
|
||||
|
||||
let skills_dir = app.skills_dir.clone();
|
||||
let registry = SkillRegistry::discover(&skills_dir);
|
||||
let mut warnings = warnings_suffix(®istry);
|
||||
let mut skill = registry.get("review").cloned();
|
||||
|
||||
let global_dir = default_skills_dir();
|
||||
if skill.is_none() && global_dir != skills_dir {
|
||||
let registry = SkillRegistry::discover(&global_dir);
|
||||
if warnings.is_empty() {
|
||||
warnings = warnings_suffix(®istry);
|
||||
} else if !registry.warnings().is_empty() {
|
||||
warnings.push_str(&format!("\n- {}", registry.warnings().join("\n- ")));
|
||||
}
|
||||
skill = registry.get("review").cloned();
|
||||
}
|
||||
|
||||
let skill = match skill {
|
||||
Some(skill) => skill,
|
||||
None => {
|
||||
let global_display = global_dir.display();
|
||||
return CommandResult::error(format!(
|
||||
"Review skill not found in {} or {}. Create ~/.deepseek/skills/review/SKILL.md.{}",
|
||||
skills_dir.display(),
|
||||
global_display,
|
||||
warnings
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let instruction = format!(
|
||||
"You are now using a skill. Follow these instructions:\n\n# Skill: {}\n\n{}\n\n---\n\nNow respond to the user's request following the above skill instructions.",
|
||||
skill.name, skill.body
|
||||
);
|
||||
|
||||
app.add_message(HistoryCell::System {
|
||||
content: format!("Activated skill: {}\n\n{}", skill.name, skill.description),
|
||||
});
|
||||
app.active_skill = Some(instruction);
|
||||
|
||||
CommandResult::action(AppAction::SendMessage(target.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
notes_path: tmpdir.path().join("notes.txt"),
|
||||
mcp_config_path: tmpdir.path().join("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
fn create_review_skill_dir(tmpdir: &TempDir) {
|
||||
let skill_dir = tmpdir.path().join("skills").join("review");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: review\ndescription: Code review skill\n---\nReview the code",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_review_without_target() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = review(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Usage: /review"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_review_without_skill_installed() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
// Set skills dir to empty temp dir
|
||||
app.skills_dir = tmpdir.path().join("nonexistent_skills");
|
||||
let result = review(&mut app, Some("file.rs"));
|
||||
// The command should either error about missing skill or work if global skill exists
|
||||
assert!(result.message.is_some() || result.action.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_review_with_skill_activates_and_sends() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
create_review_skill_dir(&tmpdir);
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = review(&mut app, Some("file.rs"));
|
||||
assert!(result.message.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::SendMessage(_))));
|
||||
assert!(app.active_skill.is_some());
|
||||
assert!(!app.history.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
//! Session commands: save, load, compact, export
|
||||
|
||||
use std::fmt::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::session_manager::create_saved_session_with_mode;
|
||||
use crate::tui::app::{App, AppAction};
|
||||
use crate::tui::history::{HistoryCell, history_cells_from_message};
|
||||
use crate::tui::session_picker::SessionPickerView;
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
/// Save session to file
|
||||
pub fn save(app: &mut App, path: Option<&str>) -> CommandResult {
|
||||
let save_path = if let Some(p) = path {
|
||||
PathBuf::from(p)
|
||||
} else {
|
||||
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
||||
PathBuf::from(format!("session_{timestamp}.json"))
|
||||
};
|
||||
|
||||
let messages = app.api_messages.clone();
|
||||
let session = create_saved_session_with_mode(
|
||||
&messages,
|
||||
&app.model,
|
||||
&app.workspace,
|
||||
u64::from(app.total_tokens),
|
||||
app.system_prompt.as_ref(),
|
||||
Some(app.mode.label()),
|
||||
);
|
||||
|
||||
let sessions_dir = save_path
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.map_or_else(|| app.workspace.clone(), std::path::Path::to_path_buf);
|
||||
|
||||
match std::fs::create_dir_all(&sessions_dir) {
|
||||
Ok(()) => {
|
||||
let json = match serde_json::to_string_pretty(&session) {
|
||||
Ok(j) => j,
|
||||
Err(e) => return CommandResult::error(format!("Failed to serialize session: {e}")),
|
||||
};
|
||||
match std::fs::write(&save_path, json) {
|
||||
Ok(()) => {
|
||||
app.current_session_id = Some(session.metadata.id.clone());
|
||||
CommandResult::message(format!(
|
||||
"Session saved to {} (ID: {})",
|
||||
save_path.display(),
|
||||
&session.metadata.id[..8]
|
||||
))
|
||||
}
|
||||
Err(e) => CommandResult::error(format!("Failed to save session: {e}")),
|
||||
}
|
||||
}
|
||||
Err(e) => CommandResult::error(format!("Failed to create directory: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load session from file
|
||||
pub fn load(app: &mut App, path: Option<&str>) -> CommandResult {
|
||||
let load_path = if let Some(p) = path {
|
||||
if p.contains('/') || p.contains('\\') {
|
||||
PathBuf::from(p)
|
||||
} else {
|
||||
app.workspace.join(p)
|
||||
}
|
||||
} else {
|
||||
return CommandResult::error("Usage: /load <path>");
|
||||
};
|
||||
|
||||
let content = match std::fs::read_to_string(&load_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return CommandResult::error(format!("Failed to read session file: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
let session: crate::session_manager::SavedSession = match serde_json::from_str(&content) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return CommandResult::error(format!("Failed to parse session file: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
app.api_messages.clone_from(&session.messages);
|
||||
app.history.clear();
|
||||
for msg in &app.api_messages {
|
||||
app.history.extend(history_cells_from_message(msg));
|
||||
}
|
||||
app.mark_history_updated();
|
||||
app.transcript_selection.clear();
|
||||
app.model.clone_from(&session.metadata.model);
|
||||
app.update_model_compaction_budget();
|
||||
app.workspace.clone_from(&session.metadata.workspace);
|
||||
app.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX);
|
||||
app.total_conversation_tokens = app.total_tokens;
|
||||
app.last_prompt_tokens = None;
|
||||
app.last_completion_tokens = None;
|
||||
app.current_session_id = Some(session.metadata.id.clone());
|
||||
if let Some(sp) = session.system_prompt {
|
||||
app.system_prompt = Some(crate::models::SystemPrompt::Text(sp));
|
||||
}
|
||||
app.scroll_to_bottom();
|
||||
|
||||
CommandResult::with_message_and_action(
|
||||
format!(
|
||||
"Session loaded from {} (ID: {}, {} messages)",
|
||||
load_path.display(),
|
||||
&session.metadata.id[..8],
|
||||
session.metadata.message_count
|
||||
),
|
||||
crate::tui::app::AppAction::SyncSession {
|
||||
messages: app.api_messages.clone(),
|
||||
system_prompt: app.system_prompt.clone(),
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Trigger context compaction
|
||||
pub fn compact(_app: &mut App) -> CommandResult {
|
||||
// Trigger immediate compaction via engine
|
||||
CommandResult::with_message_and_action(
|
||||
"Context compaction triggered...".to_string(),
|
||||
AppAction::CompactContext,
|
||||
)
|
||||
}
|
||||
|
||||
/// Export conversation to markdown
|
||||
pub fn export(app: &mut App, path: Option<&str>) -> CommandResult {
|
||||
let export_path = path.map_or_else(
|
||||
|| {
|
||||
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
||||
PathBuf::from(format!("chat_export_{timestamp}.md"))
|
||||
},
|
||||
PathBuf::from,
|
||||
);
|
||||
|
||||
let mut content = String::new();
|
||||
content.push_str("# Chat Export\n\n");
|
||||
let _ = write!(
|
||||
content,
|
||||
"**Model:** {}\n**Workspace:** {}\n**Date:** {}\n\n---\n\n",
|
||||
app.model,
|
||||
app.workspace.display(),
|
||||
chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
|
||||
);
|
||||
|
||||
for cell in &app.history {
|
||||
let (role, body) = match cell {
|
||||
HistoryCell::User { content } => ("**You:**", content.clone()),
|
||||
HistoryCell::Assistant { content, .. } => ("**Assistant:**", content.clone()),
|
||||
HistoryCell::System { content } => ("*System:*", content.clone()),
|
||||
HistoryCell::Thinking { content, .. } => ("*Thinking:*", content.clone()),
|
||||
HistoryCell::Tool(tool) => ("**Tool:**", render_tool_cell(tool, 80)),
|
||||
};
|
||||
|
||||
let _ = write!(content, "{}\n\n{}\n\n---\n\n", role, body.trim());
|
||||
}
|
||||
|
||||
match std::fs::write(&export_path, content) {
|
||||
Ok(()) => CommandResult::message(format!("Exported to {}", export_path.display())),
|
||||
Err(e) => CommandResult::error(format!("Failed to export: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the session picker UI
|
||||
pub fn sessions(app: &mut App) -> CommandResult {
|
||||
app.view_stack.push(SessionPickerView::new());
|
||||
CommandResult::ok()
|
||||
}
|
||||
|
||||
fn render_tool_cell(tool: &crate::tui::history::ToolCell, width: u16) -> String {
|
||||
tool.lines(width)
|
||||
.into_iter()
|
||||
.map(line_to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn line_to_string(line: ratatui::text::Line<'static>) -> String {
|
||||
line.spans
|
||||
.into_iter()
|
||||
.map(|span| span.content.to_string())
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
notes_path: tmpdir.path().join("notes.txt"),
|
||||
mcp_config_path: tmpdir.path().join("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_creates_file_and_sets_session_id() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let save_path = tmpdir.path().join("test_session.json");
|
||||
|
||||
let result = save(&mut app, Some(save_path.to_str().unwrap()));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Session saved to"));
|
||||
assert!(msg.contains("ID:"));
|
||||
assert!(app.current_session_id.is_some());
|
||||
assert!(save_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_with_default_path_uses_workspace() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = save(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
// Should create file in workspace with timestamp name
|
||||
// Give it a moment to ensure file is written
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
let entries: Vec<_> = std::fs::read_dir(tmpdir.path())
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().starts_with("session_"))
|
||||
.collect();
|
||||
// Test passes if file was created or if save returned success message
|
||||
assert!(!entries.is_empty() || msg.contains("Session saved"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_serialization_error() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
// This should work normally since SavedSession is serializable
|
||||
// Testing error path would require mocking, which is complex
|
||||
let save_path = tmpdir.path().join("test.json");
|
||||
let result = save(&mut app, Some(save_path.to_str().unwrap()));
|
||||
assert!(result.message.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_without_path_returns_error() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = load(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Usage: /load"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_nonexistent_file_returns_error() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = load(&mut app, Some("nonexistent.json"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Failed to read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_invalid_json_returns_error() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let bad_file = tmpdir.path().join("bad.json");
|
||||
std::fs::write(&bad_file, "not valid json").unwrap();
|
||||
let result = load(&mut app, Some(bad_file.to_str().unwrap()));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Failed to parse"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_valid_session_restores_state() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app1 = create_test_app_with_tmpdir(&tmpdir);
|
||||
// Set up some state to save
|
||||
app1.api_messages.push(crate::models::Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![crate::models::ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
app1.total_tokens = 500;
|
||||
let save_path = tmpdir.path().join("test.json");
|
||||
save(&mut app1, Some(save_path.to_str().unwrap()));
|
||||
|
||||
// Create new app and load
|
||||
let mut app2 = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = load(&mut app2, Some(save_path.to_str().unwrap()));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Session loaded from"));
|
||||
assert!(msg.contains("ID:"));
|
||||
assert!(msg.contains("messages"));
|
||||
assert_eq!(app2.api_messages.len(), 1);
|
||||
assert_eq!(app2.total_tokens, 500);
|
||||
assert!(app2.current_session_id.is_some());
|
||||
assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_toggles_state() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
|
||||
let result = compact(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("compaction") || msg.contains("Compact"));
|
||||
assert!(matches!(result.action, Some(AppAction::CompactContext)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_crees_markdown_file() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.history.push(HistoryCell::User {
|
||||
content: "Hello".to_string(),
|
||||
});
|
||||
app.history.push(HistoryCell::Assistant {
|
||||
content: "Hi there".to_string(),
|
||||
streaming: false,
|
||||
});
|
||||
|
||||
let export_path = tmpdir.path().join("export.md");
|
||||
let result = export(&mut app, Some(export_path.to_str().unwrap()));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Exported to"));
|
||||
assert!(export_path.exists());
|
||||
|
||||
let content = std::fs::read_to_string(&export_path).unwrap();
|
||||
assert!(content.contains("# Chat Export"));
|
||||
assert!(content.contains("**Model:**"));
|
||||
assert!(content.contains("**You:**"));
|
||||
assert!(content.contains("**Assistant:**"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_with_default_path() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = export(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
// Should create file with timestamp name in current dir
|
||||
let entries: Vec<_> = std::fs::read_dir(".")
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().starts_with("chat_export_"))
|
||||
.collect();
|
||||
// Clean up
|
||||
for entry in &entries {
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
assert!(!entries.is_empty() || result.message.unwrap().contains("Exported to"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sessions_pushes_picker_view() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let initial_kind = app.view_stack.top_kind();
|
||||
|
||||
let result = sessions(&mut app);
|
||||
assert_eq!(result.message, None);
|
||||
assert!(result.action.is_none());
|
||||
// View should have changed (session picker should be on top)
|
||||
assert_ne!(app.view_stack.top_kind(), initial_kind);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Skills commands: skills, skill
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::skills::SkillRegistry;
|
||||
use crate::tui::app::App;
|
||||
use crate::tui::history::HistoryCell;
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
fn render_skill_warnings(registry: &SkillRegistry) -> String {
|
||||
if registry.warnings().is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(out, "\nWarnings ({}):", registry.warnings().len());
|
||||
for warning in registry.warnings() {
|
||||
let _ = writeln!(out, " - {warning}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// List all available skills
|
||||
pub fn list_skills(app: &mut App) -> CommandResult {
|
||||
let skills_dir = app.skills_dir.clone();
|
||||
let registry = SkillRegistry::discover(&skills_dir);
|
||||
let warnings = render_skill_warnings(®istry);
|
||||
|
||||
if registry.is_empty() {
|
||||
let msg = format!(
|
||||
"No skills found.\n\n\
|
||||
Skills location: {}\n\n\
|
||||
To add skills, create directories with SKILL.md files:\n \
|
||||
{}/my-skill/SKILL.md\n\n\
|
||||
Format:\n \
|
||||
---\n \
|
||||
name: my-skill\n \
|
||||
description: What this skill does\n \
|
||||
allowed-tools: read_file, list_dir\n \
|
||||
---\n\n \
|
||||
<instructions here>{warnings}",
|
||||
skills_dir.display(),
|
||||
skills_dir.display()
|
||||
);
|
||||
return CommandResult::message(msg);
|
||||
}
|
||||
|
||||
let mut output = format!("Available skills ({}):\n", registry.len());
|
||||
output.push_str("─────────────────────────────\n");
|
||||
for skill in registry.list() {
|
||||
let _ = writeln!(output, " /{} - {}", skill.name, skill.description);
|
||||
}
|
||||
let _ = write!(
|
||||
output,
|
||||
"\nUse /skill <name> to run a skill\nSkills location: {}{}",
|
||||
skills_dir.display(),
|
||||
warnings
|
||||
);
|
||||
|
||||
CommandResult::message(output)
|
||||
}
|
||||
|
||||
/// Run a specific skill - activates skill for next user message
|
||||
pub fn run_skill(app: &mut App, name: Option<&str>) -> CommandResult {
|
||||
let name = match name {
|
||||
Some(n) => n.trim(),
|
||||
None => {
|
||||
return CommandResult::error("Usage: /skill <name>");
|
||||
}
|
||||
};
|
||||
|
||||
let skills_dir = app.skills_dir.clone();
|
||||
let registry = SkillRegistry::discover(&skills_dir);
|
||||
|
||||
if let Some(skill) = registry.get(name) {
|
||||
let instruction = format!(
|
||||
"You are now using a skill. Follow these instructions:\n\n# Skill: {}\n\n{}\n\n---\n\nNow respond to the user's request following the above skill instructions.",
|
||||
skill.name, skill.body
|
||||
);
|
||||
|
||||
app.add_message(HistoryCell::System {
|
||||
content: format!("Activated skill: {}\n\n{}", skill.name, skill.description),
|
||||
});
|
||||
|
||||
app.active_skill = Some(instruction);
|
||||
|
||||
CommandResult::message(format!(
|
||||
"Skill '{}' activated.\n\nDescription: {}\n\nType your request and the skill instructions will be applied.",
|
||||
skill.name, skill.description
|
||||
))
|
||||
} else {
|
||||
let available: Vec<String> = registry.list().iter().map(|s| s.name.clone()).collect();
|
||||
let warnings = render_skill_warnings(®istry);
|
||||
|
||||
if available.is_empty() {
|
||||
CommandResult::error(format!(
|
||||
"Skill '{name}' not found. No skills installed.\n\nUse /skills to see how to add skills.{warnings}"
|
||||
))
|
||||
} else {
|
||||
CommandResult::error(format!(
|
||||
"Skill '{}' not found.\n\nAvailable skills: {}{}",
|
||||
name,
|
||||
available.join(", "),
|
||||
warnings
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
notes_path: tmpdir.path().join("notes.txt"),
|
||||
mcp_config_path: tmpdir.path().join("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
fn create_skill_dir(tmpdir: &TempDir, skill_name: &str, skill_content: &str) {
|
||||
let skill_dir = tmpdir.path().join("skills").join(skill_name);
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(skill_dir.join("SKILL.md"), skill_content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_skills_empty_directory() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = list_skills(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("No skills found"));
|
||||
assert!(msg.contains("Skills location:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_skills_with_skills() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
create_skill_dir(
|
||||
&tmpdir,
|
||||
"test-skill",
|
||||
"---\nname: test-skill\ndescription: A test skill\n---\nDo something",
|
||||
);
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = list_skills(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Available skills"));
|
||||
assert!(msg.contains("/test-skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_skill_without_name() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = run_skill(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Usage: /skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_skill_not_found() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = run_skill(&mut app, Some("nonexistent"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_skill_activates() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
create_skill_dir(
|
||||
&tmpdir,
|
||||
"test-skill",
|
||||
"---\nname: test-skill\ndescription: A test skill\n---\nDo something special",
|
||||
);
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = run_skill(&mut app, Some("test-skill"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Skill 'test-skill' activated"));
|
||||
assert!(msg.contains("A test skill"));
|
||||
assert!(app.active_skill.is_some());
|
||||
assert!(!app.history.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Task commands: add/list/show/cancel
|
||||
|
||||
use crate::tui::app::{App, AppAction};
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
pub fn task(_app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
let raw = args.unwrap_or("").trim();
|
||||
if raw.is_empty() || raw.eq_ignore_ascii_case("list") {
|
||||
return CommandResult::action(AppAction::TaskList);
|
||||
}
|
||||
|
||||
let mut parts = raw.splitn(2, char::is_whitespace);
|
||||
let action = parts.next().unwrap_or("").to_ascii_lowercase();
|
||||
let remainder = parts.next().map(str::trim).filter(|s| !s.is_empty());
|
||||
|
||||
match action.as_str() {
|
||||
"add" => {
|
||||
let Some(prompt) = remainder else {
|
||||
return CommandResult::error("Usage: /task add <prompt>");
|
||||
};
|
||||
CommandResult::action(AppAction::TaskAdd {
|
||||
prompt: prompt.to_string(),
|
||||
})
|
||||
}
|
||||
"list" => CommandResult::action(AppAction::TaskList),
|
||||
"show" => {
|
||||
let Some(id) = remainder else {
|
||||
return CommandResult::error("Usage: /task show <id>");
|
||||
};
|
||||
CommandResult::action(AppAction::TaskShow { id: id.to_string() })
|
||||
}
|
||||
"cancel" | "stop" => {
|
||||
let Some(id) = remainder else {
|
||||
return CommandResult::error("Usage: /task cancel <id>");
|
||||
};
|
||||
CommandResult::action(AppAction::TaskCancel { id: id.to_string() })
|
||||
}
|
||||
_ => CommandResult::error("Usage: /task [add <prompt>|list|show <id>|cancel <id>]"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::TuiOptions;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn app() -> App {
|
||||
App::new(
|
||||
TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
allow_shell: false,
|
||||
use_alt_screen: false,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 2,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
},
|
||||
&Config::default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_add_and_cancel() {
|
||||
let mut app = app();
|
||||
let add = task(&mut app, Some("add write tests"));
|
||||
assert!(matches!(
|
||||
add.action,
|
||||
Some(AppAction::TaskAdd { prompt }) if prompt == "write tests"
|
||||
));
|
||||
|
||||
let cancel = task(&mut app, Some("cancel task_1234"));
|
||||
assert!(matches!(
|
||||
cancel.action,
|
||||
Some(AppAction::TaskCancel { id }) if id == "task_1234"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_usage() {
|
||||
let mut app = app();
|
||||
let result = task(&mut app, Some("add"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,605 @@
|
||||
//! Capacity-aware guardrail controller for context pressure management.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
/// Controller settings.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CapacityControllerConfig {
|
||||
pub enabled: bool,
|
||||
pub low_risk_max: f64,
|
||||
pub medium_risk_max: f64,
|
||||
pub severe_min_slack: f64,
|
||||
pub severe_violation_ratio: f64,
|
||||
pub refresh_cooldown_turns: u64,
|
||||
pub replan_cooldown_turns: u64,
|
||||
pub max_replay_per_turn: usize,
|
||||
pub min_turns_before_guardrail: u64,
|
||||
pub profile_window: usize,
|
||||
pub model_priors: HashMap<String, f64>,
|
||||
pub fallback_default: f64,
|
||||
}
|
||||
|
||||
impl Default for CapacityControllerConfig {
|
||||
fn default() -> Self {
|
||||
let mut model_priors = HashMap::new();
|
||||
model_priors.insert("deepseek_v3_2_chat".to_string(), 3.9);
|
||||
model_priors.insert("deepseek_v3_2_reasoner".to_string(), 4.1);
|
||||
|
||||
Self {
|
||||
enabled: true,
|
||||
low_risk_max: 0.34,
|
||||
medium_risk_max: 0.62,
|
||||
severe_min_slack: -0.25,
|
||||
severe_violation_ratio: 0.40,
|
||||
refresh_cooldown_turns: 2,
|
||||
replan_cooldown_turns: 5,
|
||||
max_replay_per_turn: 1,
|
||||
min_turns_before_guardrail: 2,
|
||||
profile_window: 8,
|
||||
model_priors,
|
||||
fallback_default: 3.8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CapacityControllerConfig {
|
||||
/// Build effective capacity config from app config.
|
||||
#[must_use]
|
||||
pub fn from_app_config(config: &crate::config::Config) -> Self {
|
||||
let mut out = Self::default();
|
||||
let Some(capacity) = config.capacity.as_ref() else {
|
||||
return out;
|
||||
};
|
||||
|
||||
if let Some(v) = capacity.enabled {
|
||||
out.enabled = v;
|
||||
}
|
||||
if let Some(v) = capacity.low_risk_max {
|
||||
out.low_risk_max = v;
|
||||
}
|
||||
if let Some(v) = capacity.medium_risk_max {
|
||||
out.medium_risk_max = v;
|
||||
}
|
||||
if let Some(v) = capacity.severe_min_slack {
|
||||
out.severe_min_slack = v;
|
||||
}
|
||||
if let Some(v) = capacity.severe_violation_ratio {
|
||||
out.severe_violation_ratio = v;
|
||||
}
|
||||
if let Some(v) = capacity.refresh_cooldown_turns {
|
||||
out.refresh_cooldown_turns = v;
|
||||
}
|
||||
if let Some(v) = capacity.replan_cooldown_turns {
|
||||
out.replan_cooldown_turns = v;
|
||||
}
|
||||
if let Some(v) = capacity.max_replay_per_turn {
|
||||
out.max_replay_per_turn = v;
|
||||
}
|
||||
if let Some(v) = capacity.min_turns_before_guardrail {
|
||||
out.min_turns_before_guardrail = v;
|
||||
}
|
||||
if let Some(v) = capacity.profile_window {
|
||||
out.profile_window = v.max(2);
|
||||
}
|
||||
|
||||
if let Some(v) = capacity.deepseek_v3_2_chat_prior {
|
||||
out.model_priors.insert("deepseek_v3_2_chat".to_string(), v);
|
||||
}
|
||||
if let Some(v) = capacity.deepseek_v3_2_reasoner_prior {
|
||||
out.model_priors
|
||||
.insert("deepseek_v3_2_reasoner".to_string(), v);
|
||||
}
|
||||
if let Some(v) = capacity.fallback_default_prior {
|
||||
out.fallback_default = v;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Guardrail decision output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GuardrailAction {
|
||||
NoIntervention,
|
||||
TargetedContextRefresh,
|
||||
VerifyWithToolReplay,
|
||||
VerifyAndReplan,
|
||||
}
|
||||
|
||||
impl GuardrailAction {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
GuardrailAction::NoIntervention => "no_intervention",
|
||||
GuardrailAction::TargetedContextRefresh => "targeted_context_refresh",
|
||||
GuardrailAction::VerifyWithToolReplay => "verify_with_tool_replay",
|
||||
GuardrailAction::VerifyAndReplan => "verify_and_replan",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Coarse failure risk band.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RiskBand {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
impl RiskBand {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
RiskBand::Low => "low",
|
||||
RiskBand::Medium => "medium",
|
||||
RiskBand::High => "high",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Input used to observe current turn pressure.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CapacityObservationInput {
|
||||
pub turn_index: u64,
|
||||
pub model: String,
|
||||
pub action_count_this_turn: usize,
|
||||
pub tool_calls_recent_window: usize,
|
||||
pub unique_reference_ids_recent_window: usize,
|
||||
pub context_used_ratio: f64,
|
||||
}
|
||||
|
||||
/// Rolling slack profile.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct DynamicSlackProfile {
|
||||
pub final_slack: f64,
|
||||
pub min_slack: f64,
|
||||
pub violation_ratio: f64,
|
||||
pub slack_volatility: f64,
|
||||
pub slack_drop: f64,
|
||||
}
|
||||
|
||||
/// Per-checkpoint capacity snapshot.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CapacitySnapshot {
|
||||
pub turn_index: u64,
|
||||
pub h_hat: f64,
|
||||
pub c_hat: f64,
|
||||
pub slack: f64,
|
||||
pub profile: DynamicSlackProfile,
|
||||
pub p_fail: f64,
|
||||
pub risk_band: RiskBand,
|
||||
pub severe: bool,
|
||||
}
|
||||
|
||||
/// Full controller decision including reason and block flags.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CapacityDecision {
|
||||
pub action: GuardrailAction,
|
||||
pub reason: String,
|
||||
pub cooldown_blocked: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct GuardrailRuntimeState {
|
||||
last_refresh_turn: Option<u64>,
|
||||
last_replan_turn: Option<u64>,
|
||||
replay_count_this_turn: usize,
|
||||
replay_disabled_turn: Option<u64>,
|
||||
intervention_applied_turn: Option<u64>,
|
||||
}
|
||||
|
||||
/// Capacity controller.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CapacityController {
|
||||
config: CapacityControllerConfig,
|
||||
slack_window: VecDeque<f64>,
|
||||
recent_tool_counts: VecDeque<usize>,
|
||||
recent_ref_counts: VecDeque<usize>,
|
||||
state: GuardrailRuntimeState,
|
||||
last_snapshot: Option<CapacitySnapshot>,
|
||||
}
|
||||
|
||||
impl CapacityController {
|
||||
#[must_use]
|
||||
pub fn new(config: CapacityControllerConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
slack_window: VecDeque::new(),
|
||||
recent_tool_counts: VecDeque::new(),
|
||||
recent_ref_counts: VecDeque::new(),
|
||||
state: GuardrailRuntimeState::default(),
|
||||
last_snapshot: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn observe_pre_turn(
|
||||
&mut self,
|
||||
input: CapacityObservationInput,
|
||||
) -> Option<CapacitySnapshot> {
|
||||
self.observe(input)
|
||||
}
|
||||
|
||||
pub fn observe_post_tool(
|
||||
&mut self,
|
||||
input: CapacityObservationInput,
|
||||
) -> Option<CapacitySnapshot> {
|
||||
self.observe(input)
|
||||
}
|
||||
|
||||
/// Decide intervention from the latest snapshot, with cooldown and safety gates.
|
||||
#[must_use]
|
||||
pub fn decide(
|
||||
&mut self,
|
||||
turn_index: u64,
|
||||
snapshot: Option<&CapacitySnapshot>,
|
||||
) -> CapacityDecision {
|
||||
if !self.config.enabled {
|
||||
return CapacityDecision {
|
||||
action: GuardrailAction::NoIntervention,
|
||||
reason: "capacity_controller_disabled".to_string(),
|
||||
cooldown_blocked: false,
|
||||
};
|
||||
}
|
||||
|
||||
let Some(snapshot) = snapshot else {
|
||||
return CapacityDecision {
|
||||
action: GuardrailAction::NoIntervention,
|
||||
reason: "missing_capacity_data_fail_open".to_string(),
|
||||
cooldown_blocked: false,
|
||||
};
|
||||
};
|
||||
|
||||
if turn_index < self.config.min_turns_before_guardrail {
|
||||
return CapacityDecision {
|
||||
action: GuardrailAction::NoIntervention,
|
||||
reason: "min_turns_before_guardrail_not_reached".to_string(),
|
||||
cooldown_blocked: false,
|
||||
};
|
||||
}
|
||||
|
||||
let proposed = decide_policy(&self.config, snapshot);
|
||||
if proposed == GuardrailAction::NoIntervention {
|
||||
return CapacityDecision {
|
||||
action: proposed,
|
||||
reason: "low_risk_no_intervention".to_string(),
|
||||
cooldown_blocked: false,
|
||||
};
|
||||
}
|
||||
|
||||
if self
|
||||
.state
|
||||
.intervention_applied_turn
|
||||
.is_some_and(|t| t == turn_index)
|
||||
{
|
||||
return CapacityDecision {
|
||||
action: GuardrailAction::NoIntervention,
|
||||
reason: "intervention_already_applied_this_turn".to_string(),
|
||||
cooldown_blocked: true,
|
||||
};
|
||||
}
|
||||
|
||||
match proposed {
|
||||
GuardrailAction::TargetedContextRefresh => {
|
||||
if self
|
||||
.state
|
||||
.last_refresh_turn
|
||||
.is_some_and(|last| turn_index <= last + self.config.refresh_cooldown_turns)
|
||||
{
|
||||
return CapacityDecision {
|
||||
action: GuardrailAction::NoIntervention,
|
||||
reason: "refresh_cooldown_active".to_string(),
|
||||
cooldown_blocked: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
GuardrailAction::VerifyWithToolReplay => {
|
||||
if self
|
||||
.state
|
||||
.replay_disabled_turn
|
||||
.is_some_and(|t| t == turn_index)
|
||||
{
|
||||
return CapacityDecision {
|
||||
action: GuardrailAction::NoIntervention,
|
||||
reason: "replay_disabled_for_turn".to_string(),
|
||||
cooldown_blocked: true,
|
||||
};
|
||||
}
|
||||
if self.state.replay_count_this_turn >= self.config.max_replay_per_turn {
|
||||
return CapacityDecision {
|
||||
action: GuardrailAction::NoIntervention,
|
||||
reason: "max_replay_per_turn_reached".to_string(),
|
||||
cooldown_blocked: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
GuardrailAction::VerifyAndReplan => {
|
||||
if self
|
||||
.state
|
||||
.last_replan_turn
|
||||
.is_some_and(|last| turn_index <= last + self.config.replan_cooldown_turns)
|
||||
{
|
||||
return CapacityDecision {
|
||||
action: GuardrailAction::NoIntervention,
|
||||
reason: "replan_cooldown_active".to_string(),
|
||||
cooldown_blocked: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
GuardrailAction::NoIntervention => {}
|
||||
}
|
||||
|
||||
CapacityDecision {
|
||||
action: proposed,
|
||||
reason: "policy_selected_action".to_string(),
|
||||
cooldown_blocked: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_turn_start(&mut self, turn_index: u64) {
|
||||
let new_turn = match self.last_snapshot.as_ref() {
|
||||
None => true,
|
||||
Some(snapshot) => snapshot.turn_index != turn_index,
|
||||
};
|
||||
if new_turn {
|
||||
self.state.replay_count_this_turn = 0;
|
||||
self.state.replay_disabled_turn = None;
|
||||
self.state.intervention_applied_turn = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_intervention_applied(&mut self, turn_index: u64, action: GuardrailAction) {
|
||||
self.state.intervention_applied_turn = Some(turn_index);
|
||||
match action {
|
||||
GuardrailAction::TargetedContextRefresh => {
|
||||
self.state.last_refresh_turn = Some(turn_index);
|
||||
}
|
||||
GuardrailAction::VerifyWithToolReplay => {
|
||||
self.state.replay_count_this_turn =
|
||||
self.state.replay_count_this_turn.saturating_add(1);
|
||||
}
|
||||
GuardrailAction::VerifyAndReplan => {
|
||||
self.state.last_replan_turn = Some(turn_index);
|
||||
}
|
||||
GuardrailAction::NoIntervention => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_replay_failed(&mut self, turn_index: u64) {
|
||||
self.state.replay_disabled_turn = Some(turn_index);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn last_snapshot(&self) -> Option<&CapacitySnapshot> {
|
||||
self.last_snapshot.as_ref()
|
||||
}
|
||||
|
||||
fn observe(&mut self, input: CapacityObservationInput) -> Option<CapacitySnapshot> {
|
||||
if !self.config.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let context_used_ratio = input.context_used_ratio.clamp(0.0, 2.0);
|
||||
let action_complexity_bits = log2_1p(input.action_count_this_turn);
|
||||
let tool_complexity_bits = log2_1p(input.tool_calls_recent_window);
|
||||
let ref_complexity_bits = log2_1p(input.unique_reference_ids_recent_window);
|
||||
let context_pressure_bits = 6.0 * context_used_ratio;
|
||||
|
||||
let h_hat = (0.35 * action_complexity_bits)
|
||||
+ (0.30 * tool_complexity_bits)
|
||||
+ (0.20 * ref_complexity_bits)
|
||||
+ (0.15 * context_pressure_bits);
|
||||
let c_hat = self.model_prior(&input.model);
|
||||
let slack = c_hat - h_hat;
|
||||
|
||||
push_window(&mut self.slack_window, slack, self.config.profile_window);
|
||||
push_window(
|
||||
&mut self.recent_tool_counts,
|
||||
input.tool_calls_recent_window,
|
||||
self.config.profile_window,
|
||||
);
|
||||
push_window(
|
||||
&mut self.recent_ref_counts,
|
||||
input.unique_reference_ids_recent_window,
|
||||
self.config.profile_window,
|
||||
);
|
||||
|
||||
let profile = compute_profile(&self.slack_window);
|
||||
let z = (-1.65 * profile.final_slack)
|
||||
+ (-0.85 * profile.min_slack)
|
||||
+ (1.35 * profile.violation_ratio)
|
||||
+ (0.70 * profile.slack_volatility)
|
||||
+ (0.28 * profile.slack_drop)
|
||||
- 0.12;
|
||||
let p_fail = sigmoid(z).clamp(0.0, 1.0);
|
||||
let risk_band = if p_fail <= self.config.low_risk_max {
|
||||
RiskBand::Low
|
||||
} else if p_fail <= self.config.medium_risk_max {
|
||||
RiskBand::Medium
|
||||
} else {
|
||||
RiskBand::High
|
||||
};
|
||||
let severe = profile.min_slack <= self.config.severe_min_slack
|
||||
|| profile.violation_ratio >= self.config.severe_violation_ratio;
|
||||
|
||||
let snapshot = CapacitySnapshot {
|
||||
turn_index: input.turn_index,
|
||||
h_hat,
|
||||
c_hat,
|
||||
slack,
|
||||
profile,
|
||||
p_fail,
|
||||
risk_band,
|
||||
severe,
|
||||
};
|
||||
self.last_snapshot = Some(snapshot.clone());
|
||||
Some(snapshot)
|
||||
}
|
||||
|
||||
fn model_prior(&self, model: &str) -> f64 {
|
||||
let normalized = normalize_model_prior_key(model);
|
||||
self.config
|
||||
.model_priors
|
||||
.get(normalized)
|
||||
.copied()
|
||||
.unwrap_or(self.config.fallback_default)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure policy mapping for snapshot -> action.
|
||||
#[must_use]
|
||||
pub fn decide_policy(
|
||||
_config: &CapacityControllerConfig,
|
||||
snapshot: &CapacitySnapshot,
|
||||
) -> GuardrailAction {
|
||||
match snapshot.risk_band {
|
||||
RiskBand::Low => GuardrailAction::NoIntervention,
|
||||
RiskBand::Medium => GuardrailAction::TargetedContextRefresh,
|
||||
RiskBand::High if snapshot.severe => GuardrailAction::VerifyAndReplan,
|
||||
RiskBand::High => GuardrailAction::VerifyWithToolReplay,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_model_prior_key(model: &str) -> &str {
|
||||
let lower = model.to_ascii_lowercase();
|
||||
if lower.contains("reasoner") || lower.contains("r1") {
|
||||
"deepseek_v3_2_reasoner"
|
||||
} else if lower.contains("chat") || lower.contains("v3") {
|
||||
"deepseek_v3_2_chat"
|
||||
} else {
|
||||
"fallback_default"
|
||||
}
|
||||
}
|
||||
|
||||
fn log2_1p(v: usize) -> f64 {
|
||||
(1.0 + (v as f64)).log2()
|
||||
}
|
||||
|
||||
fn push_window<T>(window: &mut VecDeque<T>, value: T, max_len: usize) {
|
||||
window.push_back(value);
|
||||
while window.len() > max_len {
|
||||
window.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_profile(window: &VecDeque<f64>) -> DynamicSlackProfile {
|
||||
if window.is_empty() {
|
||||
return DynamicSlackProfile::default();
|
||||
}
|
||||
|
||||
let values: Vec<f64> = window.iter().copied().collect();
|
||||
let final_slack = *values.last().unwrap_or(&0.0);
|
||||
let min_slack = values.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let violations = values.iter().filter(|v| **v <= 0.0).count() as f64;
|
||||
let violation_ratio = violations / (values.len() as f64);
|
||||
|
||||
let deltas: Vec<f64> = values.windows(2).map(|w| w[1] - w[0]).collect();
|
||||
let slack_drop = if values.len() >= 2 {
|
||||
(values[values.len() - 2] - values[values.len() - 1]).max(0.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let slack_volatility = if deltas.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
let mean = deltas.iter().sum::<f64>() / (deltas.len() as f64);
|
||||
let var = deltas
|
||||
.iter()
|
||||
.map(|delta| {
|
||||
let centered = *delta - mean;
|
||||
centered * centered
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ (deltas.len() as f64);
|
||||
var.sqrt()
|
||||
};
|
||||
|
||||
DynamicSlackProfile {
|
||||
final_slack,
|
||||
min_slack,
|
||||
violation_ratio,
|
||||
slack_volatility,
|
||||
slack_drop,
|
||||
}
|
||||
}
|
||||
|
||||
fn sigmoid(z: f64) -> f64 {
|
||||
if z >= 0.0 {
|
||||
let ez = (-z).exp();
|
||||
1.0 / (1.0 + ez)
|
||||
} else {
|
||||
let ez = z.exp();
|
||||
ez / (1.0 + ez)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_snapshot(p_fail: f64, severe: bool, risk_band: RiskBand) -> CapacitySnapshot {
|
||||
CapacitySnapshot {
|
||||
turn_index: 3,
|
||||
h_hat: 1.0,
|
||||
c_hat: 3.8,
|
||||
slack: 2.8,
|
||||
profile: DynamicSlackProfile {
|
||||
final_slack: 2.8,
|
||||
min_slack: if severe { -0.5 } else { 0.2 },
|
||||
violation_ratio: if severe { 0.6 } else { 0.1 },
|
||||
slack_volatility: 0.2,
|
||||
slack_drop: 0.1,
|
||||
},
|
||||
p_fail,
|
||||
risk_band,
|
||||
severe,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_risk_maps_to_no_intervention() {
|
||||
let cfg = CapacityControllerConfig::default();
|
||||
let snap = make_snapshot(0.2, false, RiskBand::Low);
|
||||
assert_eq!(decide_policy(&cfg, &snap), GuardrailAction::NoIntervention);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medium_risk_maps_to_refresh() {
|
||||
let cfg = CapacityControllerConfig::default();
|
||||
let snap = make_snapshot(0.5, false, RiskBand::Medium);
|
||||
assert_eq!(
|
||||
decide_policy(&cfg, &snap),
|
||||
GuardrailAction::TargetedContextRefresh
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_non_severe_maps_to_replay() {
|
||||
let cfg = CapacityControllerConfig::default();
|
||||
let snap = make_snapshot(0.8, false, RiskBand::High);
|
||||
assert_eq!(
|
||||
decide_policy(&cfg, &snap),
|
||||
GuardrailAction::VerifyWithToolReplay
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_severe_maps_to_replan() {
|
||||
let cfg = CapacityControllerConfig::default();
|
||||
let snap = make_snapshot(0.9, true, RiskBand::High);
|
||||
assert_eq!(decide_policy(&cfg, &snap), GuardrailAction::VerifyAndReplan);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_blocks_repeated_action() {
|
||||
let mut controller = CapacityController::new(CapacityControllerConfig::default());
|
||||
let turn_index = 5;
|
||||
controller.mark_turn_start(turn_index);
|
||||
controller.mark_intervention_applied(turn_index, GuardrailAction::TargetedContextRefresh);
|
||||
|
||||
let snapshot = make_snapshot(0.5, false, RiskBand::Medium);
|
||||
let decision = controller.decide(turn_index + 1, Some(&snapshot));
|
||||
assert_eq!(decision.action, GuardrailAction::NoIntervention);
|
||||
assert!(decision.cooldown_blocked);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Persistent memory snapshots for capacity controller interventions.
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Canonical compact state persisted by interventions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CanonicalState {
|
||||
pub goal: String,
|
||||
pub constraints: Vec<String>,
|
||||
pub confirmed_facts: Vec<String>,
|
||||
pub open_loops: Vec<String>,
|
||||
pub pending_actions: Vec<String>,
|
||||
pub critical_refs: Vec<String>,
|
||||
}
|
||||
|
||||
/// Replay verification metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReplayInfo {
|
||||
pub tool_id: String,
|
||||
pub tool_name: String,
|
||||
pub pass: bool,
|
||||
pub diff_summary: String,
|
||||
}
|
||||
|
||||
/// JSONL record written for each intervention.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CapacityMemoryRecord {
|
||||
pub id: String,
|
||||
pub ts: String,
|
||||
pub turn_index: u64,
|
||||
pub action_trigger: String,
|
||||
pub h_hat: f64,
|
||||
pub c_hat: f64,
|
||||
pub slack: f64,
|
||||
pub risk_band: String,
|
||||
pub canonical_state: CanonicalState,
|
||||
pub source_message_ids: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub replay_info: Option<ReplayInfo>,
|
||||
}
|
||||
|
||||
fn capacity_memory_dirs() -> Vec<PathBuf> {
|
||||
if let Ok(raw) = std::env::var("DEEPSEEK_CAPACITY_MEMORY_DIR") {
|
||||
let trimmed = raw.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return vec![PathBuf::from(shellexpand::tilde(trimmed).as_ref())];
|
||||
}
|
||||
}
|
||||
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
dirs.push(home.join(".deepseek").join("memory"));
|
||||
}
|
||||
|
||||
let cwd = std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join(".deepseek")
|
||||
.join("memory");
|
||||
dirs.push(cwd);
|
||||
|
||||
dirs.dedup();
|
||||
dirs
|
||||
}
|
||||
|
||||
pub fn append_capacity_record(session_id: &str, record: &CapacityMemoryRecord) -> Result<PathBuf> {
|
||||
let candidates = candidate_session_memory_paths(session_id);
|
||||
append_capacity_record_to_candidates(&candidates, record)
|
||||
}
|
||||
|
||||
pub fn append_capacity_record_to_path(path: &Path, record: &CapacityMemoryRecord) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create memory directory {}", parent.display()))?;
|
||||
}
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.with_context(|| format!("Failed to open memory log {}", path.display()))?;
|
||||
let line =
|
||||
serde_json::to_string(record).context("Failed to serialize capacity memory record")?;
|
||||
writeln!(file, "{line}")
|
||||
.with_context(|| format!("Failed to write memory record {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_last_k_capacity_records(
|
||||
session_id: &str,
|
||||
k: usize,
|
||||
) -> Result<Vec<CapacityMemoryRecord>> {
|
||||
let candidates = candidate_session_memory_paths(session_id);
|
||||
load_last_k_capacity_records_from_candidates(&candidates, k)
|
||||
}
|
||||
|
||||
pub fn load_last_k_capacity_records_from_path(
|
||||
path: &Path,
|
||||
k: usize,
|
||||
) -> Result<Vec<CapacityMemoryRecord>> {
|
||||
if k == 0 || !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.open(path)
|
||||
.with_context(|| format!("Failed to open memory log {}", path.display()))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut records = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line.with_context(|| format!("Failed reading {}", path.display()))?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(record) = serde_json::from_str::<CapacityMemoryRecord>(&line) {
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
if records.len() > k {
|
||||
Ok(records.split_off(records.len() - k))
|
||||
} else {
|
||||
Ok(records)
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_session_memory_paths(session_id: &str) -> Vec<PathBuf> {
|
||||
capacity_memory_dirs()
|
||||
.into_iter()
|
||||
.map(|dir| dir.join(format!("{session_id}.jsonl")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn append_capacity_record_to_candidates(
|
||||
paths: &[PathBuf],
|
||||
record: &CapacityMemoryRecord,
|
||||
) -> Result<PathBuf> {
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for path in paths {
|
||||
match append_capacity_record_to_path(path, record) {
|
||||
Ok(()) => return Ok(path.clone()),
|
||||
Err(err) => last_err = Some(err),
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err.unwrap_or_else(|| anyhow!("No capacity memory path candidates available")))
|
||||
}
|
||||
|
||||
fn load_last_k_capacity_records_from_candidates(
|
||||
paths: &[PathBuf],
|
||||
k: usize,
|
||||
) -> Result<Vec<CapacityMemoryRecord>> {
|
||||
if k == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut newest: Option<(SystemTime, Vec<CapacityMemoryRecord>)> = None;
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
|
||||
for path in paths {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match load_last_k_capacity_records_from_path(path, k) {
|
||||
Ok(records) => {
|
||||
if records.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let modified = fs::metadata(path)
|
||||
.and_then(|meta| meta.modified())
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
let should_replace = newest
|
||||
.as_ref()
|
||||
.map(|(current, _)| modified >= *current)
|
||||
.unwrap_or(true);
|
||||
if should_replace {
|
||||
newest = Some((modified, records));
|
||||
}
|
||||
}
|
||||
Err(err) => last_err = Some(err),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((_, records)) = newest {
|
||||
return Ok(records);
|
||||
}
|
||||
if let Some(err) = last_err {
|
||||
return Err(err);
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_record_id() -> String {
|
||||
format!("cap_{}", &uuid::Uuid::new_v4().to_string()[..8])
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn now_rfc3339() -> String {
|
||||
Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn memory_jsonl_round_trip() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let path = tmp.path().join("session.jsonl");
|
||||
|
||||
let record = CapacityMemoryRecord {
|
||||
id: "cap_1".to_string(),
|
||||
ts: now_rfc3339(),
|
||||
turn_index: 2,
|
||||
action_trigger: "targeted_context_refresh".to_string(),
|
||||
h_hat: 1.2,
|
||||
c_hat: 3.8,
|
||||
slack: 2.6,
|
||||
risk_band: "medium".to_string(),
|
||||
canonical_state: CanonicalState {
|
||||
goal: "Ship feature".to_string(),
|
||||
..CanonicalState::default()
|
||||
},
|
||||
source_message_ids: vec!["m1".to_string()],
|
||||
replay_info: None,
|
||||
};
|
||||
|
||||
append_capacity_record_to_path(&path, &record).expect("append");
|
||||
let records = load_last_k_capacity_records_from_path(&path, 1).expect("load");
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].canonical_state.goal, "Ship feature");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_falls_back_to_next_candidate_path() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let blocked_root = tmp.path().join("blocked");
|
||||
fs::write(&blocked_root, "file").expect("create blocking file");
|
||||
let blocked_path = blocked_root.join("session.jsonl");
|
||||
let fallback_path = tmp.path().join("fallback").join("session.jsonl");
|
||||
|
||||
let record = CapacityMemoryRecord {
|
||||
id: "cap_fallback".to_string(),
|
||||
ts: now_rfc3339(),
|
||||
turn_index: 1,
|
||||
action_trigger: "targeted_context_refresh".to_string(),
|
||||
h_hat: 1.0,
|
||||
c_hat: 3.8,
|
||||
slack: 2.8,
|
||||
risk_band: "medium".to_string(),
|
||||
canonical_state: CanonicalState::default(),
|
||||
source_message_ids: vec!["m1".to_string()],
|
||||
replay_info: None,
|
||||
};
|
||||
|
||||
let chosen = append_capacity_record_to_candidates(
|
||||
&[blocked_path.clone(), fallback_path.clone()],
|
||||
&record,
|
||||
)
|
||||
.expect("append with fallback");
|
||||
assert_eq!(chosen, fallback_path);
|
||||
assert!(chosen.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_prefers_newest_candidate_records() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let older = tmp.path().join("older.jsonl");
|
||||
let newer = tmp.path().join("newer.jsonl");
|
||||
|
||||
let old_record = CapacityMemoryRecord {
|
||||
id: "cap_old".to_string(),
|
||||
ts: now_rfc3339(),
|
||||
turn_index: 1,
|
||||
action_trigger: "targeted_context_refresh".to_string(),
|
||||
h_hat: 1.0,
|
||||
c_hat: 3.8,
|
||||
slack: 2.8,
|
||||
risk_band: "medium".to_string(),
|
||||
canonical_state: CanonicalState {
|
||||
goal: "old".to_string(),
|
||||
..CanonicalState::default()
|
||||
},
|
||||
source_message_ids: vec!["m1".to_string()],
|
||||
replay_info: None,
|
||||
};
|
||||
let new_record = CapacityMemoryRecord {
|
||||
id: "cap_new".to_string(),
|
||||
ts: now_rfc3339(),
|
||||
turn_index: 2,
|
||||
action_trigger: "verify_and_replan".to_string(),
|
||||
h_hat: 1.4,
|
||||
c_hat: 3.8,
|
||||
slack: 2.4,
|
||||
risk_band: "high".to_string(),
|
||||
canonical_state: CanonicalState {
|
||||
goal: "new".to_string(),
|
||||
..CanonicalState::default()
|
||||
},
|
||||
source_message_ids: vec!["m2".to_string()],
|
||||
replay_info: None,
|
||||
};
|
||||
|
||||
append_capacity_record_to_path(&older, &old_record).expect("write older");
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
append_capacity_record_to_path(&newer, &new_record).expect("write newer");
|
||||
|
||||
let records = load_last_k_capacity_records_from_candidates(&[older, newer], 1)
|
||||
.expect("load newest records");
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].canonical_state.goal, "new");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Plain-language session coherence state derived from capacity events.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::capacity::{GuardrailAction, RiskBand};
|
||||
|
||||
/// User-facing coherence ladder for session health.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CoherenceState {
|
||||
#[default]
|
||||
Healthy,
|
||||
GettingCrowded,
|
||||
RefreshingContext,
|
||||
VerifyingRecentWork,
|
||||
ResettingPlan,
|
||||
}
|
||||
|
||||
impl CoherenceState {
|
||||
#[must_use]
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Healthy => "healthy",
|
||||
Self::GettingCrowded => "getting crowded",
|
||||
Self::RefreshingContext => "refreshing context",
|
||||
Self::VerifyingRecentWork => "verifying recent work",
|
||||
Self::ResettingPlan => "resetting plan",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
Self::Healthy => "The session is stable and focused.",
|
||||
Self::GettingCrowded => "The session is approaching context pressure.",
|
||||
Self::RefreshingContext => "The engine is refreshing context before continuing.",
|
||||
Self::VerifyingRecentWork => {
|
||||
"The engine is checking recent tool results before continuing."
|
||||
}
|
||||
Self::ResettingPlan => {
|
||||
"The engine is rebuilding from canonical context and replanning."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthetic input to the coherence reducer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CoherenceSignal {
|
||||
CapacityDecision {
|
||||
risk_band: RiskBand,
|
||||
action: GuardrailAction,
|
||||
cooldown_blocked: bool,
|
||||
},
|
||||
CapacityIntervention {
|
||||
action: GuardrailAction,
|
||||
},
|
||||
CompactionStarted,
|
||||
CompactionCompleted,
|
||||
CompactionFailed,
|
||||
}
|
||||
|
||||
/// Pure transition function for the plain-language coherence ladder.
|
||||
#[must_use]
|
||||
pub fn next_coherence_state(current: CoherenceState, signal: CoherenceSignal) -> CoherenceState {
|
||||
match signal {
|
||||
CoherenceSignal::CompactionStarted => CoherenceState::RefreshingContext,
|
||||
CoherenceSignal::CompactionCompleted => CoherenceState::Healthy,
|
||||
CoherenceSignal::CompactionFailed => CoherenceState::GettingCrowded,
|
||||
CoherenceSignal::CapacityIntervention { action }
|
||||
| CoherenceSignal::CapacityDecision { action, .. } => match action {
|
||||
GuardrailAction::NoIntervention => match signal {
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band,
|
||||
cooldown_blocked,
|
||||
..
|
||||
} => {
|
||||
if cooldown_blocked {
|
||||
return current;
|
||||
}
|
||||
match risk_band {
|
||||
RiskBand::Low => CoherenceState::Healthy,
|
||||
RiskBand::Medium | RiskBand::High => CoherenceState::GettingCrowded,
|
||||
}
|
||||
}
|
||||
_ => current,
|
||||
},
|
||||
GuardrailAction::TargetedContextRefresh => CoherenceState::RefreshingContext,
|
||||
GuardrailAction::VerifyWithToolReplay => CoherenceState::VerifyingRecentWork,
|
||||
GuardrailAction::VerifyAndReplan => CoherenceState::ResettingPlan,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn synthetic_capacity_event_log_drives_plain_language_ladder() {
|
||||
let log = [
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band: RiskBand::Low,
|
||||
action: GuardrailAction::NoIntervention,
|
||||
cooldown_blocked: false,
|
||||
},
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band: RiskBand::Medium,
|
||||
action: GuardrailAction::NoIntervention,
|
||||
cooldown_blocked: false,
|
||||
},
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band: RiskBand::Medium,
|
||||
action: GuardrailAction::TargetedContextRefresh,
|
||||
cooldown_blocked: false,
|
||||
},
|
||||
CoherenceSignal::CompactionCompleted,
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band: RiskBand::High,
|
||||
action: GuardrailAction::VerifyWithToolReplay,
|
||||
cooldown_blocked: false,
|
||||
},
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band: RiskBand::High,
|
||||
action: GuardrailAction::VerifyAndReplan,
|
||||
cooldown_blocked: false,
|
||||
},
|
||||
];
|
||||
|
||||
let mut state = CoherenceState::Healthy;
|
||||
let mut states = Vec::new();
|
||||
for signal in log {
|
||||
state = next_coherence_state(state, signal);
|
||||
states.push(state);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
states,
|
||||
vec![
|
||||
CoherenceState::Healthy,
|
||||
CoherenceState::GettingCrowded,
|
||||
CoherenceState::RefreshingContext,
|
||||
CoherenceState::Healthy,
|
||||
CoherenceState::VerifyingRecentWork,
|
||||
CoherenceState::ResettingPlan,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,885 @@
|
||||
use super::*;
|
||||
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn build_engine_with_capacity(capacity: CapacityControllerConfig) -> Engine {
|
||||
let engine_config = EngineConfig {
|
||||
capacity,
|
||||
..Default::default()
|
||||
};
|
||||
let (engine, _handle) = Engine::new(engine_config, &Config::default());
|
||||
engine
|
||||
}
|
||||
|
||||
fn make_plan(
|
||||
read_only: bool,
|
||||
supports_parallel: bool,
|
||||
approval_required: bool,
|
||||
interactive: bool,
|
||||
) -> ToolExecutionPlan {
|
||||
ToolExecutionPlan {
|
||||
index: 0,
|
||||
id: "tool-1".to_string(),
|
||||
name: "grep_files".to_string(),
|
||||
input: json!({"pattern": "test"}),
|
||||
caller: None,
|
||||
interactive,
|
||||
approval_required,
|
||||
approval_description: "desc".to_string(),
|
||||
supports_parallel,
|
||||
read_only,
|
||||
blocked_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_handle_cancel_tracks_latest_turn_token() {
|
||||
let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default());
|
||||
let stale_token = engine.cancel_token.clone();
|
||||
|
||||
engine.reset_cancel_token();
|
||||
handle.cancel();
|
||||
|
||||
assert!(engine.cancel_token.is_cancelled());
|
||||
assert!(handle.is_cancelled());
|
||||
assert!(!stale_token.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_batch_requires_read_only_parallel_tools() {
|
||||
let plans = vec![make_plan(true, true, false, false)];
|
||||
assert!(should_parallelize_tool_batch(&plans));
|
||||
|
||||
let plans = vec![
|
||||
make_plan(true, true, false, false),
|
||||
make_plan(true, true, false, false),
|
||||
];
|
||||
assert!(should_parallelize_tool_batch(&plans));
|
||||
|
||||
let plans = vec![make_plan(false, true, false, false)];
|
||||
assert!(!should_parallelize_tool_batch(&plans));
|
||||
|
||||
let plans = vec![make_plan(true, false, false, false)];
|
||||
assert!(!should_parallelize_tool_batch(&plans));
|
||||
|
||||
let plans = vec![make_plan(true, true, true, false)];
|
||||
assert!(!should_parallelize_tool_batch(&plans));
|
||||
|
||||
let plans = vec![make_plan(true, true, false, true)];
|
||||
assert!(!should_parallelize_tool_batch(&plans));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_update_plan_ends_plan_mode_turn_immediately() {
|
||||
assert!(should_stop_after_plan_tool(
|
||||
AppMode::Plan,
|
||||
"update_plan",
|
||||
&Ok(ToolResult::success("planned"))
|
||||
));
|
||||
assert!(!should_stop_after_plan_tool(
|
||||
AppMode::Agent,
|
||||
"update_plan",
|
||||
&Ok(ToolResult::success("planned"))
|
||||
));
|
||||
assert!(!should_stop_after_plan_tool(
|
||||
AppMode::Plan,
|
||||
"request_user_input",
|
||||
&Ok(ToolResult::success("input"))
|
||||
));
|
||||
assert!(!should_stop_after_plan_tool(
|
||||
AppMode::Plan,
|
||||
"update_plan",
|
||||
&Err(ToolError::execution_failed("failed".to_string()))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_plan_requests_force_update_plan_on_first_step() {
|
||||
assert!(should_force_update_plan_first(
|
||||
AppMode::Plan,
|
||||
"Give me a quick 3-step plan to verify the UI changes."
|
||||
));
|
||||
assert!(should_force_update_plan_first(
|
||||
AppMode::Plan,
|
||||
"Make a high-level plan for the footer work."
|
||||
));
|
||||
assert!(!should_force_update_plan_first(
|
||||
AppMode::Plan,
|
||||
"Inspect the repo and then give me a quick plan."
|
||||
));
|
||||
assert!(!should_force_update_plan_first(
|
||||
AppMode::Agent,
|
||||
"Give me a quick 3-step plan."
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_plan_turn_can_narrow_first_step_tools_to_update_plan() {
|
||||
let catalog = vec![
|
||||
Tool {
|
||||
tool_type: Some("function".to_string()),
|
||||
name: "read_file".to_string(),
|
||||
description: "Read a file".to_string(),
|
||||
input_schema: json!({"type": "object"}),
|
||||
allowed_callers: Some(vec!["direct".to_string()]),
|
||||
defer_loading: Some(false),
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
},
|
||||
Tool {
|
||||
tool_type: Some("function".to_string()),
|
||||
name: "update_plan".to_string(),
|
||||
description: "Publish a plan".to_string(),
|
||||
input_schema: json!({"type": "object"}),
|
||||
allowed_callers: Some(vec!["direct".to_string()]),
|
||||
defer_loading: Some(false),
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
},
|
||||
];
|
||||
let active = initial_active_tools(&catalog);
|
||||
|
||||
let forced = active_tools_for_step(&catalog, &active, true);
|
||||
assert_eq!(forced.len(), 1);
|
||||
assert_eq!(forced[0].name, "update_plan");
|
||||
|
||||
let default = active_tools_for_step(&catalog, &active, false);
|
||||
assert_eq!(default.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_error_messages_include_actionable_hints() {
|
||||
let path_error = ToolError::path_escape(PathBuf::from("../escape.txt"));
|
||||
let formatted = format_tool_error(&path_error, "read_file");
|
||||
assert!(formatted.contains("escapes workspace"));
|
||||
|
||||
let missing_field = ToolError::missing_field("path");
|
||||
let formatted = format_tool_error(&missing_field, "read_file");
|
||||
assert!(formatted.contains("missing required field"));
|
||||
|
||||
let timeout = ToolError::Timeout { seconds: 5 };
|
||||
let formatted = format_tool_error(&timeout, "exec_shell");
|
||||
assert!(formatted.contains("timed out"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_exec_outcome_tracks_duration() {
|
||||
let outcome = ToolExecOutcome {
|
||||
index: 0,
|
||||
id: "tool-1".to_string(),
|
||||
name: "grep_files".to_string(),
|
||||
input: json!({"pattern": "test"}),
|
||||
started_at: Instant::now(),
|
||||
result: Ok(ToolResult::success("ok")),
|
||||
};
|
||||
|
||||
assert!(outcome.started_at.elapsed().as_nanos() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yolo_mode_keeps_tools_preloaded() {
|
||||
assert!(!should_default_defer_tool("exec_shell", AppMode::Yolo));
|
||||
assert!(!should_default_defer_tool(
|
||||
"mcp_read_resource",
|
||||
AppMode::Yolo
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_yolo_mode_retains_default_defer_policy() {
|
||||
assert!(should_default_defer_tool("exec_shell", AppMode::Agent));
|
||||
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);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_update_preserves_reasoning_tool_only_turn() {
|
||||
let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default());
|
||||
let assistant = Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "Need a tool before answering.".to_string(),
|
||||
},
|
||||
ContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: json!({"path": "Cargo.toml"}),
|
||||
caller: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
engine.add_session_message(assistant.clone()).await;
|
||||
|
||||
let event = {
|
||||
let mut rx = handle.rx_event.write().await;
|
||||
rx.recv().await.expect("session update event")
|
||||
};
|
||||
let Event::SessionUpdated { messages, .. } = event else {
|
||||
panic!("expected session update event");
|
||||
};
|
||||
|
||||
assert_eq!(messages, vec![assistant]);
|
||||
}
|
||||
|
||||
#[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"}}"#;
|
||||
assert!(is_context_length_error_message(msg));
|
||||
assert!(!is_context_length_error_message(
|
||||
"SSE stream request failed: HTTP 400 Bad Request: model not found"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_budget_reserves_output_and_headroom() {
|
||||
let budget = context_input_budget("deepseek-v3.2-128k", TURN_MAX_OUTPUT_TOKENS)
|
||||
.expect("deepseek models should have known context window");
|
||||
let expected = 128_000usize - 4_096usize - 1_024usize;
|
||||
assert_eq!(budget, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_tool_outputs_keep_large_file_reads_in_context() {
|
||||
let content = "0123456789abcdef\n".repeat(2_000);
|
||||
let output = ToolResult::success(content.clone());
|
||||
|
||||
let v4_context = compact_tool_result_for_context("deepseek-v4-pro", "exec_shell", &output);
|
||||
assert_eq!(v4_context, content.trim());
|
||||
|
||||
let legacy_context =
|
||||
compact_tool_result_for_context("deepseek-v3.2-128k", "exec_shell", &output);
|
||||
assert!(legacy_context.contains("output compacted to protect context"));
|
||||
assert!(legacy_context.len() < v4_context.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_system_prompt_places_working_set_after_stable_prefix() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
|
||||
fs::write(tmp.path().join("src/lib.rs"), "pub fn sample() {}").expect("write");
|
||||
|
||||
let config = EngineConfig {
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
let (mut engine, _handle) = Engine::new(config, &Config::default());
|
||||
engine
|
||||
.session
|
||||
.working_set
|
||||
.observe_user_message("please inspect src/lib.rs", tmp.path());
|
||||
|
||||
engine.refresh_system_prompt(AppMode::Agent);
|
||||
|
||||
let Some(SystemPrompt::Blocks(blocks)) = &engine.session.system_prompt else {
|
||||
panic!("expected structured prompt blocks");
|
||||
};
|
||||
let last = blocks.last().expect("working-set block");
|
||||
assert!(last.text.contains(WORKING_SET_SUMMARY_MARKER));
|
||||
assert!(
|
||||
blocks[..blocks.len() - 1]
|
||||
.iter()
|
||||
.all(|block| !block.text.contains(WORKING_SET_SUMMARY_MARKER))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_summary_stays_before_volatile_working_set() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
|
||||
fs::write(tmp.path().join("src/main.rs"), "fn main() {}").expect("write");
|
||||
|
||||
let config = EngineConfig {
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
let (mut engine, _handle) = Engine::new(config, &Config::default());
|
||||
engine
|
||||
.session
|
||||
.working_set
|
||||
.observe_user_message("continue in src/main.rs", tmp.path());
|
||||
engine.refresh_system_prompt(AppMode::Agent);
|
||||
engine.merge_compaction_summary(Some(SystemPrompt::Blocks(vec![SystemBlock {
|
||||
block_type: "text".to_string(),
|
||||
text: format!("{COMPACTION_SUMMARY_MARKER}\nsummary"),
|
||||
cache_control: None,
|
||||
}])));
|
||||
|
||||
let Some(SystemPrompt::Blocks(blocks)) = &engine.session.system_prompt else {
|
||||
panic!("expected structured prompt blocks");
|
||||
};
|
||||
let summary_index = blocks
|
||||
.iter()
|
||||
.position(|block| block.text.contains(COMPACTION_SUMMARY_MARKER))
|
||||
.expect("summary block");
|
||||
let working_set_index = blocks
|
||||
.iter()
|
||||
.position(|block| block.text.contains(WORKING_SET_SUMMARY_MARKER))
|
||||
.expect("working-set block");
|
||||
|
||||
assert!(summary_index < working_set_index);
|
||||
assert_eq!(working_set_index, blocks.len() - 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_request_refresh_skips_compaction_below_normal_threshold() {
|
||||
let capacity = CapacityControllerConfig {
|
||||
enabled: true,
|
||||
low_risk_max: 0.0,
|
||||
medium_risk_max: 1.0,
|
||||
min_turns_before_guardrail: 0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut engine = build_engine_with_capacity(capacity.clone());
|
||||
engine.config.capacity = capacity.clone();
|
||||
engine.capacity_controller = CapacityController::new(capacity);
|
||||
engine.turn_counter = 5;
|
||||
engine
|
||||
.capacity_controller
|
||||
.mark_turn_start(engine.turn_counter);
|
||||
engine.session.model = "deepseek-v4-pro".to_string();
|
||||
engine.config.model = "deepseek-v4-pro".to_string();
|
||||
|
||||
for i in 0..20 {
|
||||
engine.session.messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: format!("small message {i}"),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
let before = engine.estimated_input_tokens();
|
||||
let before_len = engine.session.messages.len();
|
||||
let turn = TurnContext::new(10);
|
||||
let applied = engine
|
||||
.run_capacity_pre_request_checkpoint(&turn, None, AppMode::Agent)
|
||||
.await;
|
||||
let after = engine.estimated_input_tokens();
|
||||
|
||||
assert!(!applied);
|
||||
assert_eq!(after, before);
|
||||
assert_eq!(engine.session.messages.len(), before_len);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_request_refresh_invoked_when_medium_risk() {
|
||||
let capacity = CapacityControllerConfig {
|
||||
enabled: true,
|
||||
low_risk_max: 0.0,
|
||||
medium_risk_max: 1.0,
|
||||
min_turns_before_guardrail: 0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut engine = build_engine_with_capacity(capacity.clone());
|
||||
engine.config.capacity = capacity.clone();
|
||||
engine.capacity_controller = CapacityController::new(capacity);
|
||||
engine.turn_counter = 5;
|
||||
engine
|
||||
.capacity_controller
|
||||
.mark_turn_start(engine.turn_counter);
|
||||
|
||||
// Pin the model to an explicit 128k-context variant so the pressure ratio stays
|
||||
// stable regardless of changes to the workspace-wide default model.
|
||||
engine.session.model = "deepseek-v3.2-128k".to_string();
|
||||
engine.config.model = "deepseek-v3.2-128k".to_string();
|
||||
|
||||
let long = "x".repeat(5_000);
|
||||
for _ in 0..200 {
|
||||
engine.session.messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: long.clone(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
let before = engine.estimated_input_tokens();
|
||||
let turn = TurnContext::new(10);
|
||||
let applied = engine
|
||||
.run_capacity_pre_request_checkpoint(&turn, None, AppMode::Agent)
|
||||
.await;
|
||||
let after = engine.estimated_input_tokens();
|
||||
|
||||
assert!(applied);
|
||||
assert!(after < before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_tool_replay_invoked_when_high_non_severe_risk() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("sample.txt"), "hello replay").expect("write");
|
||||
|
||||
let capacity = CapacityControllerConfig {
|
||||
enabled: true,
|
||||
low_risk_max: 0.0,
|
||||
medium_risk_max: 0.0,
|
||||
severe_min_slack: -10.0,
|
||||
severe_violation_ratio: 2.0,
|
||||
min_turns_before_guardrail: 0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut engine = build_engine_with_capacity(capacity.clone());
|
||||
engine.session.workspace = tmp.path().to_path_buf();
|
||||
engine.config.workspace = tmp.path().to_path_buf();
|
||||
engine.config.capacity = capacity.clone();
|
||||
engine.capacity_controller = CapacityController::new(capacity);
|
||||
engine.turn_counter = 4;
|
||||
engine
|
||||
.capacity_controller
|
||||
.mark_turn_start(engine.turn_counter);
|
||||
|
||||
let mut turn = TurnContext::new(10);
|
||||
let mut tool_call = TurnToolCall::new(
|
||||
"tool_read_1".to_string(),
|
||||
"read_file".to_string(),
|
||||
json!({ "path": "sample.txt" }),
|
||||
);
|
||||
tool_call.set_result(
|
||||
"hello replay".to_string(),
|
||||
std::time::Duration::from_millis(1),
|
||||
);
|
||||
turn.record_tool_call(tool_call);
|
||||
|
||||
let registry = ToolRegistryBuilder::new()
|
||||
.with_read_only_file_tools()
|
||||
.build(engine.build_tool_context(AppMode::Agent, false));
|
||||
|
||||
let restarted = engine
|
||||
.run_capacity_post_tool_checkpoint(
|
||||
&turn,
|
||||
AppMode::Agent,
|
||||
Some(®istry),
|
||||
Arc::new(RwLock::new(())),
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!restarted);
|
||||
let has_verification_note = engine.session.messages.iter().any(|msg| {
|
||||
msg.content.iter().any(|block| match block {
|
||||
ContentBlock::ToolResult { content, .. } => content.contains("[verification replay]"),
|
||||
_ => false,
|
||||
})
|
||||
});
|
||||
assert!(has_verification_note);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_escalation_triggers_replan_when_severe_or_repeated_failures() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
// Safety: scoped to test process; reset at end.
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
"DEEPSEEK_CAPACITY_MEMORY_DIR",
|
||||
tmp.path().to_string_lossy().to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let capacity = CapacityControllerConfig {
|
||||
enabled: true,
|
||||
low_risk_max: 0.0,
|
||||
medium_risk_max: 0.0,
|
||||
min_turns_before_guardrail: 0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut engine = build_engine_with_capacity(capacity.clone());
|
||||
engine.config.capacity = capacity.clone();
|
||||
engine.capacity_controller = CapacityController::new(capacity);
|
||||
engine.turn_counter = 6;
|
||||
engine
|
||||
.capacity_controller
|
||||
.mark_turn_start(engine.turn_counter);
|
||||
|
||||
for i in 0..10 {
|
||||
engine.session.messages.push(Message {
|
||||
role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: format!("noise message {i}"),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
}
|
||||
engine.session.messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Please finish task".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let before_len = engine.session.messages.len();
|
||||
let turn = TurnContext::new(10);
|
||||
let restarted = engine
|
||||
.run_capacity_error_escalation_checkpoint(&turn, AppMode::Agent, 2, 2)
|
||||
.await;
|
||||
|
||||
assert!(restarted);
|
||||
assert!(engine.session.messages.len() < before_len);
|
||||
assert!(engine.session.messages.len() <= 2);
|
||||
|
||||
let records = load_last_k_capacity_records(&engine.session.id, 1).expect("load memory");
|
||||
assert!(!records.is_empty());
|
||||
assert!(!records[0].canonical_state.goal.is_empty());
|
||||
unsafe {
|
||||
std::env::remove_var("DEEPSEEK_CAPACITY_MEMORY_DIR");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn controller_disabled_keeps_behavior_unchanged() {
|
||||
let capacity = CapacityControllerConfig {
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut engine = build_engine_with_capacity(capacity.clone());
|
||||
engine.config.capacity = capacity.clone();
|
||||
engine.capacity_controller = CapacityController::new(capacity);
|
||||
engine.turn_counter = 3;
|
||||
engine
|
||||
.capacity_controller
|
||||
.mark_turn_start(engine.turn_counter);
|
||||
|
||||
let long = "y".repeat(5_000);
|
||||
for _ in 0..120 {
|
||||
engine.session.messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: long.clone(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
let before = engine.estimated_input_tokens();
|
||||
let before_len = engine.session.messages.len();
|
||||
let turn = TurnContext::new(10);
|
||||
let applied = engine
|
||||
.run_capacity_pre_request_checkpoint(&turn, None, AppMode::Agent)
|
||||
.await;
|
||||
let after = engine.estimated_input_tokens();
|
||||
let after_len = engine.session.messages.len();
|
||||
|
||||
assert!(!applied);
|
||||
assert_eq!(before, after);
|
||||
assert_eq!(before_len, after_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_policy_defaults_to_direct() {
|
||||
let tool = Tool {
|
||||
tool_type: None,
|
||||
name: "read_file".to_string(),
|
||||
description: "Read".to_string(),
|
||||
input_schema: json!({"type":"object"}),
|
||||
allowed_callers: Some(vec!["direct".to_string()]),
|
||||
defer_loading: Some(false),
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
};
|
||||
let direct = ToolCaller {
|
||||
caller_type: "direct".to_string(),
|
||||
tool_id: None,
|
||||
};
|
||||
let code = ToolCaller {
|
||||
caller_type: "code_execution_20250825".to_string(),
|
||||
tool_id: Some("srvtoolu_1".to_string()),
|
||||
};
|
||||
assert!(caller_allowed_for_tool(Some(&direct), Some(&tool)));
|
||||
assert!(!caller_allowed_for_tool(Some(&code), Some(&tool)));
|
||||
assert!(caller_allowed_for_tool(None, Some(&tool)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_search_activates_discovered_deferred_tools() {
|
||||
let mut catalog = vec![
|
||||
Tool {
|
||||
tool_type: None,
|
||||
name: "read_file".to_string(),
|
||||
description: "Read files".to_string(),
|
||||
input_schema: json!({"type":"object","properties":{"path":{"type":"string"}}}),
|
||||
allowed_callers: Some(vec!["direct".to_string()]),
|
||||
defer_loading: Some(true),
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
},
|
||||
Tool {
|
||||
tool_type: None,
|
||||
name: "grep_files".to_string(),
|
||||
description: "Search files".to_string(),
|
||||
input_schema: json!({"type":"object","properties":{"pattern":{"type":"string"}}}),
|
||||
allowed_callers: Some(vec!["direct".to_string()]),
|
||||
defer_loading: Some(true),
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
},
|
||||
];
|
||||
ensure_advanced_tooling(&mut catalog);
|
||||
let mut active = initial_active_tools(&catalog);
|
||||
let result = execute_tool_search(
|
||||
TOOL_SEARCH_BM25_NAME,
|
||||
&json!({"query":"read file"}),
|
||||
&catalog,
|
||||
&mut active,
|
||||
)
|
||||
.expect("search succeeds");
|
||||
assert!(result.success);
|
||||
assert!(active.contains("read_file"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn code_execution_runs_python_and_returns_result_payload() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let result =
|
||||
execute_code_execution_tool(&json!({"code":"print('hello from code exec')"}), tmp.path())
|
||||
.await
|
||||
.expect("code execution should run");
|
||||
assert!(result.content.contains("hello from code exec"));
|
||||
assert!(result.content.contains("return_code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_tool_requests_are_auto_activated() {
|
||||
use std::collections::HashSet;
|
||||
|
||||
let catalog = vec![Tool {
|
||||
tool_type: None,
|
||||
name: "exec_shell".to_string(),
|
||||
description: "Run shell commands".to_string(),
|
||||
input_schema: json!({"type":"object","properties":{"cmd":{"type":"string"}}}),
|
||||
allowed_callers: Some(vec!["direct".to_string()]),
|
||||
defer_loading: Some(true),
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
}];
|
||||
|
||||
let mut active = HashSet::new();
|
||||
assert!(!active.contains("exec_shell"));
|
||||
assert!(maybe_activate_requested_deferred_tool(
|
||||
"exec_shell",
|
||||
&catalog,
|
||||
&mut active
|
||||
));
|
||||
assert!(active.contains("exec_shell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_tool_error_message_offers_suggestions() {
|
||||
let catalog = vec![
|
||||
Tool {
|
||||
tool_type: None,
|
||||
name: "read_file".to_string(),
|
||||
description: "Read file contents".to_string(),
|
||||
input_schema: json!({"type":"object","properties":{"path":{"type":"string"}}}),
|
||||
allowed_callers: Some(vec!["direct".to_string()]),
|
||||
defer_loading: Some(false),
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
},
|
||||
Tool {
|
||||
tool_type: None,
|
||||
name: "grep_files".to_string(),
|
||||
description: "Search file contents".to_string(),
|
||||
input_schema: json!({"type":"object","properties":{"pattern":{"type":"string"}}}),
|
||||
allowed_callers: Some(vec!["direct".to_string()]),
|
||||
defer_loading: Some(false),
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
},
|
||||
];
|
||||
|
||||
let message = missing_tool_error_message("reed_file", &catalog);
|
||||
assert!(message.contains("Did you mean:"));
|
||||
assert!(message.contains("read_file"));
|
||||
assert!(message.contains(TOOL_SEARCH_BM25_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_tool_error_message_includes_discovery_guidance_when_no_match() {
|
||||
let catalog = vec![Tool {
|
||||
tool_type: None,
|
||||
name: "read_file".to_string(),
|
||||
description: "Read file contents".to_string(),
|
||||
input_schema: json!({"type":"object","properties":{"path":{"type":"string"}}}),
|
||||
allowed_callers: Some(vec!["direct".to_string()]),
|
||||
defer_loading: Some(false),
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
}];
|
||||
|
||||
let message = missing_tool_error_message("totally_unknown_tool", &catalog);
|
||||
assert!(message.contains("not available in the current tool catalog"));
|
||||
assert!(message.contains(TOOL_SEARCH_BM25_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_call_delta_strips_bracket_marker() {
|
||||
let mut in_block = false;
|
||||
let visible = filter_tool_call_delta(
|
||||
"intro [TOOL_CALL]\n{\"tool\":\"x\"}\n[/TOOL_CALL] outro",
|
||||
&mut in_block,
|
||||
);
|
||||
assert!(!in_block);
|
||||
assert!(!visible.contains("[TOOL_CALL]"));
|
||||
assert!(!visible.contains("[/TOOL_CALL]"));
|
||||
assert!(!visible.contains("\"tool\":\"x\""));
|
||||
assert!(visible.contains("intro"));
|
||||
assert!(visible.contains("outro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_call_delta_strips_deepseek_xml_marker() {
|
||||
let mut in_block = false;
|
||||
let visible = filter_tool_call_delta(
|
||||
"before <deepseek:tool_call name=\"x\">payload</deepseek:tool_call> after",
|
||||
&mut in_block,
|
||||
);
|
||||
assert!(!in_block);
|
||||
for marker in TOOL_CALL_START_MARKERS {
|
||||
assert!(
|
||||
!visible.contains(marker),
|
||||
"visible text leaked start marker `{marker}`: {visible:?}"
|
||||
);
|
||||
}
|
||||
assert!(visible.contains("before"));
|
||||
assert!(visible.contains("after"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_call_delta_strips_generic_tool_call_marker() {
|
||||
let mut in_block = false;
|
||||
let visible = filter_tool_call_delta(
|
||||
"lead <tool_call>\n{\"name\":\"do\"}\n</tool_call> tail",
|
||||
&mut in_block,
|
||||
);
|
||||
assert!(!in_block);
|
||||
assert!(!visible.contains("<tool_call"));
|
||||
assert!(!visible.contains("</tool_call>"));
|
||||
assert!(visible.contains("lead"));
|
||||
assert!(visible.contains("tail"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_call_delta_strips_invoke_marker() {
|
||||
let mut in_block = false;
|
||||
let visible = filter_tool_call_delta(
|
||||
"alpha <invoke name=\"x\"><parameter name=\"k\">v</parameter></invoke> beta",
|
||||
&mut in_block,
|
||||
);
|
||||
assert!(!in_block);
|
||||
assert!(!visible.contains("<invoke "));
|
||||
assert!(!visible.contains("</invoke>"));
|
||||
assert!(visible.contains("alpha"));
|
||||
assert!(visible.contains("beta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_call_delta_strips_function_calls_marker() {
|
||||
let mut in_block = false;
|
||||
let visible = filter_tool_call_delta(
|
||||
"head <function_calls>\n{\"name\":\"x\"}\n</function_calls> tail",
|
||||
&mut in_block,
|
||||
);
|
||||
assert!(!in_block);
|
||||
assert!(!visible.contains("<function_calls>"));
|
||||
assert!(!visible.contains("</function_calls>"));
|
||||
assert!(visible.contains("head"));
|
||||
assert!(visible.contains("tail"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_call_delta_handles_chunk_split_marker() {
|
||||
let mut in_block = false;
|
||||
// First chunk opens the wrapper but does not close it.
|
||||
let visible_a = filter_tool_call_delta("hello <tool_call>partial", &mut in_block);
|
||||
assert!(in_block, "filter must remember it is mid-wrapper");
|
||||
assert_eq!(visible_a, "hello ");
|
||||
|
||||
// Second chunk continues inside the wrapper, then closes it and adds tail.
|
||||
let visible_b = filter_tool_call_delta("payload</tool_call> tail", &mut in_block);
|
||||
assert!(!in_block);
|
||||
assert_eq!(visible_b, " tail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_call_delta_unmatched_open_suppresses_remainder() {
|
||||
let mut in_block = false;
|
||||
let visible = filter_tool_call_delta("ok [TOOL_CALL]rest of stream", &mut in_block);
|
||||
assert_eq!(visible, "ok ");
|
||||
assert!(
|
||||
in_block,
|
||||
"unmatched open must leave filter in tool-call mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_call_delta_passes_through_clean_text() {
|
||||
let mut in_block = false;
|
||||
let input = "no markers here, just prose with code `<not a tag>`.";
|
||||
let visible = filter_tool_call_delta(input, &mut in_block);
|
||||
assert!(!in_block);
|
||||
assert_eq!(visible, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_fake_tool_wrapper_detects_each_marker() {
|
||||
for marker in TOOL_CALL_START_MARKERS {
|
||||
let needle = format!("noise {marker} more noise");
|
||||
assert!(
|
||||
contains_fake_tool_wrapper(&needle),
|
||||
"marker `{marker}` should be detected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_fake_tool_wrapper_returns_false_on_clean_text() {
|
||||
assert!(!contains_fake_tool_wrapper(
|
||||
"plain assistant text without wrappers"
|
||||
));
|
||||
assert!(!contains_fake_tool_wrapper(
|
||||
"`<tool` lookalike but not a real start marker"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fake_wrapper_notice_is_compact_and_actionable() {
|
||||
// Keep this short so it fits cleanly in a single status line.
|
||||
assert!(FAKE_WRAPPER_NOTICE.len() < 120);
|
||||
assert!(FAKE_WRAPPER_NOTICE.contains("API tool channel"));
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Events emitted by the core engine to the UI.
|
||||
//!
|
||||
//! These events flow from the engine to the TUI via a channel,
|
||||
//! enabling non-blocking, real-time updates.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::core::coherence::CoherenceState;
|
||||
use crate::models::{Message, SystemPrompt, Usage};
|
||||
use crate::tools::spec::{ToolError, ToolResult};
|
||||
use crate::tools::subagent::SubAgentResult;
|
||||
use crate::tools::user_input::UserInputRequest;
|
||||
|
||||
/// Final status for a turn.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TurnOutcomeStatus {
|
||||
Completed,
|
||||
Interrupted,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Events emitted by the engine to update the UI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
// === Streaming Events ===
|
||||
/// A new message block has started
|
||||
MessageStarted {
|
||||
#[allow(dead_code)]
|
||||
index: usize,
|
||||
},
|
||||
|
||||
/// Incremental text content delta
|
||||
MessageDelta {
|
||||
#[allow(dead_code)]
|
||||
index: usize,
|
||||
content: String,
|
||||
},
|
||||
|
||||
/// Message block completed
|
||||
MessageComplete {
|
||||
#[allow(dead_code)]
|
||||
index: usize,
|
||||
},
|
||||
|
||||
/// Thinking block started
|
||||
ThinkingStarted {
|
||||
#[allow(dead_code)]
|
||||
index: usize,
|
||||
},
|
||||
|
||||
/// Incremental thinking content delta
|
||||
ThinkingDelta {
|
||||
#[allow(dead_code)]
|
||||
index: usize,
|
||||
content: String,
|
||||
},
|
||||
|
||||
/// Thinking block completed
|
||||
ThinkingComplete {
|
||||
#[allow(dead_code)]
|
||||
index: usize,
|
||||
},
|
||||
|
||||
// === Tool Events ===
|
||||
/// Tool call initiated
|
||||
ToolCallStarted {
|
||||
id: String,
|
||||
name: String,
|
||||
input: Value,
|
||||
},
|
||||
|
||||
/// Tool execution progress (for long-running tools)
|
||||
#[allow(dead_code)]
|
||||
ToolCallProgress { id: String, output: String },
|
||||
|
||||
/// Tool call completed
|
||||
ToolCallComplete {
|
||||
id: String,
|
||||
name: String,
|
||||
result: Result<ToolResult, ToolError>,
|
||||
},
|
||||
|
||||
// === Turn Lifecycle ===
|
||||
/// A new turn has started (user sent a message)
|
||||
TurnStarted { turn_id: String },
|
||||
|
||||
/// The turn is complete (no more tool calls)
|
||||
TurnComplete {
|
||||
usage: Usage,
|
||||
status: TurnOutcomeStatus,
|
||||
error: Option<String>,
|
||||
},
|
||||
|
||||
/// Context compaction started.
|
||||
CompactionStarted {
|
||||
id: String,
|
||||
auto: bool,
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Context compaction completed.
|
||||
CompactionCompleted {
|
||||
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.
|
||||
CompactionFailed {
|
||||
id: String,
|
||||
auto: bool,
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Capacity decision telemetry.
|
||||
#[allow(dead_code)]
|
||||
CapacityDecision {
|
||||
session_id: String,
|
||||
turn_id: String,
|
||||
h_hat: f64,
|
||||
c_hat: f64,
|
||||
slack: f64,
|
||||
min_slack: f64,
|
||||
violation_ratio: f64,
|
||||
p_fail: f64,
|
||||
risk_band: String,
|
||||
action: String,
|
||||
cooldown_blocked: bool,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
/// Capacity intervention telemetry.
|
||||
#[allow(dead_code)]
|
||||
CapacityIntervention {
|
||||
session_id: String,
|
||||
turn_id: String,
|
||||
action: String,
|
||||
before_prompt_tokens: usize,
|
||||
after_prompt_tokens: usize,
|
||||
compaction_size_reduction: usize,
|
||||
replay_outcome: Option<String>,
|
||||
replan_performed: bool,
|
||||
},
|
||||
|
||||
/// Capacity memory persistence failure telemetry.
|
||||
#[allow(dead_code)]
|
||||
CapacityMemoryPersistFailed {
|
||||
session_id: String,
|
||||
turn_id: String,
|
||||
action: String,
|
||||
error: String,
|
||||
},
|
||||
|
||||
/// Plain-language session coherence state.
|
||||
CoherenceState {
|
||||
state: CoherenceState,
|
||||
label: String,
|
||||
description: String,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
// === Sub-Agent Events ===
|
||||
/// A sub-agent has been spawned
|
||||
AgentSpawned { id: String, prompt: String },
|
||||
|
||||
/// Sub-agent progress update
|
||||
AgentProgress { id: String, status: String },
|
||||
|
||||
/// Sub-agent completed
|
||||
AgentComplete { id: String, result: String },
|
||||
|
||||
/// Sub-agent listing
|
||||
AgentList { agents: Vec<SubAgentResult> },
|
||||
|
||||
// === System Events ===
|
||||
/// An error occurred
|
||||
Error {
|
||||
message: String,
|
||||
#[allow(dead_code)]
|
||||
recoverable: bool,
|
||||
},
|
||||
|
||||
/// Status message for UI display
|
||||
Status { message: String },
|
||||
|
||||
/// Pause terminal input events (for interactive subprocesses)
|
||||
PauseEvents,
|
||||
|
||||
/// Resume terminal input events after subprocess completion
|
||||
ResumeEvents,
|
||||
|
||||
/// Request user approval for a tool call
|
||||
ApprovalRequired {
|
||||
id: String,
|
||||
tool_name: String,
|
||||
description: String,
|
||||
},
|
||||
|
||||
/// Request user input for a tool call
|
||||
UserInputRequired {
|
||||
id: String,
|
||||
request: UserInputRequest,
|
||||
},
|
||||
|
||||
/// Authoritative API conversation state from the engine session.
|
||||
///
|
||||
/// The UI receives granular display events, but those are not always a
|
||||
/// lossless representation of the API transcript. DeepSeek can emit
|
||||
/// reasoning directly followed by tool calls without a visible assistant
|
||||
/// text block, and that assistant message still has to be persisted for
|
||||
/// later `reasoning_content` replay.
|
||||
SessionUpdated {
|
||||
messages: Vec<Message>,
|
||||
system_prompt: Option<SystemPrompt>,
|
||||
model: String,
|
||||
workspace: PathBuf,
|
||||
},
|
||||
|
||||
/// Request user decision after sandbox denial
|
||||
#[allow(dead_code)]
|
||||
ElevationRequired {
|
||||
tool_id: String,
|
||||
tool_name: String,
|
||||
command: Option<String>,
|
||||
denial_reason: String,
|
||||
blocked_network: bool,
|
||||
blocked_write: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl Event {
|
||||
/// Create a new error event
|
||||
pub fn error(message: impl Into<String>, recoverable: bool) -> Self {
|
||||
Event::Error {
|
||||
message: message.into(),
|
||||
recoverable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new status event
|
||||
pub fn status(message: impl Into<String>) -> Self {
|
||||
Event::Status {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,9 @@
|
||||
//! - `session`: Session state management
|
||||
//! - `turn`: Turn context and tracking
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod capacity;
|
||||
pub mod capacity_memory;
|
||||
pub mod coherence;
|
||||
pub mod engine;
|
||||
pub mod events;
|
||||
pub mod ops;
|
||||
@@ -16,29 +16,39 @@ pub enum Op {
|
||||
content: String,
|
||||
mode: AppMode,
|
||||
model: String,
|
||||
/// Reasoning-effort tier: `"off" | "low" | "medium" | "high" | "max"`.
|
||||
/// `None` lets the provider apply its default.
|
||||
reasoning_effort: Option<String>,
|
||||
allow_shell: bool,
|
||||
trust_mode: bool,
|
||||
auto_approve: bool,
|
||||
},
|
||||
|
||||
/// Cancel the current request
|
||||
#[allow(dead_code)]
|
||||
CancelRequest,
|
||||
|
||||
/// Approve a tool call that requires permission
|
||||
#[allow(dead_code)]
|
||||
ApproveToolCall { id: String },
|
||||
|
||||
/// Deny a tool call that requires permission
|
||||
#[allow(dead_code)]
|
||||
DenyToolCall { id: String },
|
||||
|
||||
/// Spawn a sub-agent (for RLM mode)
|
||||
/// Spawn a sub-agent
|
||||
#[allow(dead_code)]
|
||||
SpawnSubAgent { prompt: String },
|
||||
|
||||
/// List current sub-agents and their status
|
||||
ListSubAgents,
|
||||
|
||||
/// Change the operating mode
|
||||
#[allow(dead_code)]
|
||||
ChangeMode { mode: AppMode },
|
||||
|
||||
/// Update the model being used
|
||||
#[allow(dead_code)]
|
||||
SetModel { model: String },
|
||||
|
||||
/// Update auto-compaction settings
|
||||
@@ -52,6 +62,9 @@ pub enum Op {
|
||||
workspace: PathBuf,
|
||||
},
|
||||
|
||||
/// Run context compaction immediately.
|
||||
CompactContext,
|
||||
|
||||
/// Shutdown the engine
|
||||
Shutdown,
|
||||
}
|
||||
@@ -62,20 +75,19 @@ impl Op {
|
||||
content: impl Into<String>,
|
||||
mode: AppMode,
|
||||
model: impl Into<String>,
|
||||
reasoning_effort: Option<String>,
|
||||
allow_shell: bool,
|
||||
trust_mode: bool,
|
||||
auto_approve: bool,
|
||||
) -> Self {
|
||||
Op::SendMessage {
|
||||
content: content.into(),
|
||||
mode,
|
||||
model: model.into(),
|
||||
reasoning_effort,
|
||||
allow_shell,
|
||||
trust_mode,
|
||||
auto_approve,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a cancel operation
|
||||
pub fn cancel() -> Self {
|
||||
Op::CancelRequest
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use crate::models::{Message, SystemPrompt, Usage};
|
||||
use crate::project_context::{ProjectContext, load_project_context_with_parents};
|
||||
use crate::working_set::WorkingSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Session state for the engine.
|
||||
@@ -12,11 +13,18 @@ pub struct Session {
|
||||
/// Model being used
|
||||
pub model: String,
|
||||
|
||||
/// Reasoning-effort tier for DeepSeek thinking mode:
|
||||
/// `"off" | "low" | "medium" | "high" | "max"`. `None` lets the provider
|
||||
/// apply its own defaults.
|
||||
pub reasoning_effort: Option<String>,
|
||||
|
||||
/// Workspace directory
|
||||
pub workspace: PathBuf,
|
||||
|
||||
/// System prompt (optional)
|
||||
pub system_prompt: Option<SystemPrompt>,
|
||||
/// Persisted summary blocks generated by context compaction.
|
||||
pub compaction_summary_prompt: Option<SystemPrompt>,
|
||||
|
||||
/// Conversation history (API format)
|
||||
pub messages: Vec<Message>,
|
||||
@@ -30,6 +38,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,
|
||||
|
||||
@@ -41,6 +52,9 @@ pub struct Session {
|
||||
|
||||
/// Project context loaded from AGENTS.md, etc.
|
||||
pub project_context: Option<ProjectContext>,
|
||||
|
||||
/// Repo-aware working set for context management.
|
||||
pub working_set: WorkingSet,
|
||||
}
|
||||
|
||||
/// Cumulative usage statistics for a session.
|
||||
@@ -49,7 +63,9 @@ pub struct Session {
|
||||
pub struct SessionUsage {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
#[allow(dead_code)]
|
||||
pub cache_creation_input_tokens: u64,
|
||||
#[allow(dead_code)]
|
||||
pub cache_read_input_tokens: u64,
|
||||
}
|
||||
|
||||
@@ -58,11 +74,12 @@ impl SessionUsage {
|
||||
pub fn add(&mut self, usage: &Usage) {
|
||||
self.input_tokens += u64::from(usage.input_tokens);
|
||||
self.output_tokens += u64::from(usage.output_tokens);
|
||||
}
|
||||
|
||||
/// Total tokens used
|
||||
pub fn total(&self) -> u64 {
|
||||
self.input_tokens + self.output_tokens
|
||||
if let Some(tokens) = usage.prompt_cache_miss_tokens {
|
||||
self.cache_creation_input_tokens += u64::from(tokens);
|
||||
}
|
||||
if let Some(tokens) = usage.prompt_cache_hit_tokens {
|
||||
self.cache_read_input_tokens += u64::from(tokens);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,12 +99,15 @@ impl Session {
|
||||
|
||||
Self {
|
||||
model,
|
||||
reasoning_effort: None,
|
||||
workspace,
|
||||
system_prompt: None,
|
||||
compaction_summary_prompt: None,
|
||||
messages: Vec::new(),
|
||||
total_usage: SessionUsage::default(),
|
||||
allow_shell,
|
||||
trust_mode,
|
||||
auto_approve: false,
|
||||
notes_path,
|
||||
mcp_config_path,
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
@@ -96,28 +116,18 @@ impl Session {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
working_set: WorkingSet::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get project instructions as a system prompt block (if available)
|
||||
pub fn get_project_instructions(&self) -> Option<String> {
|
||||
self.project_context
|
||||
.as_ref()
|
||||
.and_then(super::super::project_context::ProjectContext::as_system_block)
|
||||
}
|
||||
|
||||
/// Add a message to the conversation
|
||||
pub fn add_message(&mut self, message: Message) {
|
||||
self.messages.push(message);
|
||||
}
|
||||
|
||||
/// Clear the conversation history
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
}
|
||||
|
||||
/// Get the message count
|
||||
pub fn message_count(&self) -> usize {
|
||||
self.messages.len()
|
||||
/// Rebuild the working set from current messages (best effort).
|
||||
pub fn rebuild_working_set(&mut self) {
|
||||
self.working_set
|
||||
.rebuild_from_messages(&self.messages, &self.workspace);
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,8 @@ static THINKING_REGEX: OnceLock<Regex> = OnceLock::new();
|
||||
fn get_tool_call_regex() -> &'static Regex {
|
||||
TOOL_CALL_REGEX.get_or_init(|| {
|
||||
// Match [TOOL_CALL] ... [/TOOL_CALL] blocks
|
||||
Regex::new(r"(?s)\[TOOL_CALL\]\s*(.*?)\s*\[/TOOL_CALL\]").unwrap()
|
||||
Regex::new(r"(?s)\[TOOL_CALL\]\s*(.*?)\s*\[/TOOL_CALL\]")
|
||||
.expect("TOOL_CALL regex pattern is valid")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,21 +62,22 @@ fn get_xml_tool_call_regex() -> &'static Regex {
|
||||
XML_TOOL_CALL_REGEX.get_or_init(|| {
|
||||
// Match <deepseek:tool_call>...</deepseek:tool_call> or similar XML patterns
|
||||
Regex::new(r"(?s)<(?:deepseek:)?tool_call[^>]*>\s*(.*?)\s*</(?:deepseek:)?tool_call>")
|
||||
.unwrap()
|
||||
.expect("XML tool_call regex pattern is valid")
|
||||
})
|
||||
}
|
||||
|
||||
fn get_invoke_regex() -> &'static Regex {
|
||||
INVOKE_REGEX.get_or_init(|| {
|
||||
// Match <invoke name="tool_name">...</invoke> patterns
|
||||
Regex::new(r#"(?s)<invoke\s+name\s*=\s*"([^"]+)"[^>]*>(.*?)</invoke>"#).unwrap()
|
||||
Regex::new(r#"(?s)<invoke\s+name\s*=\s*"([^"]+)"[^>]*>(.*?)</invoke>"#)
|
||||
.expect("invoke regex pattern is valid")
|
||||
})
|
||||
}
|
||||
|
||||
fn get_thinking_regex() -> &'static Regex {
|
||||
THINKING_REGEX.get_or_init(|| {
|
||||
// Match thinking blocks including partial closing tags
|
||||
Regex::new(r"(?s)</?(?:think|thinking)[^>]*>").unwrap()
|
||||
Regex::new(r"(?s)</?(?:think|thinking)[^>]*>").expect("thinking regex pattern is valid")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -446,72 +448,6 @@ pub fn has_tool_call_markers(text: &str) -> bool {
|
||||
|| text.contains("<invoke ")
|
||||
}
|
||||
|
||||
/// Clean streaming text by removing partial tool call markers and thinking tags.
|
||||
/// This is used during streaming to prevent raw markers from appearing in the UI.
|
||||
pub fn clean_streaming_text(text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
|
||||
// Remove thinking tags
|
||||
let thinking_regex = get_thinking_regex();
|
||||
result = thinking_regex.replace_all(&result, "").to_string();
|
||||
|
||||
// Remove [TOOL_CALL] blocks entirely
|
||||
let tool_call_regex = get_tool_call_regex();
|
||||
result = tool_call_regex.replace_all(&result, "").to_string();
|
||||
|
||||
// Remove XML-style partial markers that might appear during streaming
|
||||
let patterns_to_remove = [
|
||||
r"\[TOOL_CALL\]",
|
||||
r"\[/TOOL_CALL\]",
|
||||
r"</?deepseek:tool_call[^>]*>",
|
||||
r"</?tool_call[^>]*>",
|
||||
r"<invoke\s+name\s*=\s*[^>]*>",
|
||||
r"</invoke>",
|
||||
r"</?parameter[^>]*>",
|
||||
r"</?param[^>]*>",
|
||||
r"</?function_calls>",
|
||||
r"</?antml:invoke[^>]*>",
|
||||
r"</?antml:function_calls>",
|
||||
// Also remove the tool call content patterns
|
||||
r"\{tool\s*=>\s*[^}]+\}",
|
||||
];
|
||||
|
||||
for pattern in patterns_to_remove {
|
||||
if let Ok(regex) = Regex::new(pattern) {
|
||||
result = regex.replace_all(&result, "").to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up extra whitespace and empty lines
|
||||
result = result
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
result.trim().to_string()
|
||||
}
|
||||
|
||||
/// Check if a streaming chunk contains the start of a tool call marker.
|
||||
/// This is used to suppress streaming output when we detect the start of a tool block.
|
||||
pub fn is_tool_call_start(text: &str) -> bool {
|
||||
text.contains("[TOOL_CALL]")
|
||||
|| text.contains("<deepseek:tool_call")
|
||||
|| text.contains("<tool_call")
|
||||
|| text.contains("<invoke ")
|
||||
|| text.contains("<function_calls>")
|
||||
|| text.contains("<function_calls>")
|
||||
}
|
||||
|
||||
/// Check if a streaming chunk contains the end of a tool call marker.
|
||||
pub fn is_tool_call_end(text: &str) -> bool {
|
||||
text.contains("[/TOOL_CALL]")
|
||||
|| text.contains("</deepseek:tool_call>")
|
||||
|| text.contains("</tool_call>")
|
||||
|| text.contains("</invoke>")
|
||||
|| text.contains("</function_calls>")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -13,6 +13,7 @@ pub struct TurnContext {
|
||||
pub id: String,
|
||||
|
||||
/// When the turn started
|
||||
#[allow(dead_code)]
|
||||
pub started_at: Instant,
|
||||
|
||||
/// Current step in the turn (tool call iteration)
|
||||
@@ -25,6 +26,7 @@ pub struct TurnContext {
|
||||
pub tool_calls: Vec<TurnToolCall>,
|
||||
|
||||
/// Whether the turn has been cancelled
|
||||
#[allow(dead_code)]
|
||||
pub cancelled: bool,
|
||||
|
||||
/// Usage for this turn
|
||||
@@ -55,6 +57,7 @@ impl TurnContext {
|
||||
usage: Usage {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
..Usage::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -76,11 +79,13 @@ impl TurnContext {
|
||||
}
|
||||
|
||||
/// Cancel the turn
|
||||
#[allow(dead_code)]
|
||||
pub fn cancel(&mut self) {
|
||||
self.cancelled = true;
|
||||
}
|
||||
|
||||
/// Get the elapsed time
|
||||
#[allow(dead_code)]
|
||||
pub fn elapsed(&self) -> Duration {
|
||||
self.started_at.elapsed()
|
||||
}
|
||||
@@ -89,6 +94,25 @@ impl TurnContext {
|
||||
pub fn add_usage(&mut self, usage: &Usage) {
|
||||
self.usage.input_tokens += usage.input_tokens;
|
||||
self.usage.output_tokens += usage.output_tokens;
|
||||
self.usage.prompt_cache_hit_tokens = add_optional_usage(
|
||||
self.usage.prompt_cache_hit_tokens,
|
||||
usage.prompt_cache_hit_tokens,
|
||||
);
|
||||
self.usage.prompt_cache_miss_tokens = add_optional_usage(
|
||||
self.usage.prompt_cache_miss_tokens,
|
||||
usage.prompt_cache_miss_tokens,
|
||||
);
|
||||
self.usage.reasoning_tokens =
|
||||
add_optional_usage(self.usage.reasoning_tokens, usage.reasoning_tokens);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_optional_usage(total: Option<u32>, delta: Option<u32>) -> Option<u32> {
|
||||
match (total, delta) {
|
||||
(Some(total), Some(delta)) => Some(total.saturating_add(delta)),
|
||||
(None, Some(delta)) => Some(delta),
|
||||
(Some(total), None) => Some(total),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Whale/DeepSeek terminal theme tokens.
|
||||
//!
|
||||
//! A small, deliberately flat module that names the color, border, and
|
||||
//! padding choices the TUI is already making. The dark variant matches the
|
||||
//! values previously hard-coded against [`crate::palette`]; the light variant
|
||||
//! is reserved for the future skin swap that issue #14 tracks. Visible output
|
||||
//! is not changed by introducing this module.
|
||||
//!
|
||||
//! The only consumers today are the plan and tool cell renderers in
|
||||
//! [`crate::tui::history`] and the sidebar section chrome in
|
||||
//! [`crate::tui::ui`]. All other call sites continue to use [`crate::palette`]
|
||||
//! directly until they are migrated in a later slice.
|
||||
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::widgets::{BorderType, Borders, Padding};
|
||||
|
||||
use crate::palette;
|
||||
use crate::tui::history::ToolStatus;
|
||||
|
||||
/// Visual variant exposed by the theme.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Variant {
|
||||
Dark,
|
||||
/// Reserved for the future skin swap (issue #14). Not wired up yet.
|
||||
#[allow(dead_code)]
|
||||
Light,
|
||||
}
|
||||
|
||||
/// Centralized visual tokens for sidebar, plan, and tool rendering.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Theme {
|
||||
pub variant: Variant,
|
||||
|
||||
// Sidebar / section chrome
|
||||
pub section_borders: Borders,
|
||||
pub section_border_type: BorderType,
|
||||
pub section_border_color: Color,
|
||||
pub section_bg: Color,
|
||||
pub section_title_color: Color,
|
||||
pub section_padding: Padding,
|
||||
|
||||
// Tool cell color tokens
|
||||
pub tool_title_color: Color,
|
||||
pub tool_value_color: Color,
|
||||
pub tool_label_color: Color,
|
||||
pub tool_running_accent: Color,
|
||||
pub tool_success_accent: Color,
|
||||
pub tool_failed_accent: Color,
|
||||
|
||||
// Plan cell color tokens
|
||||
pub plan_progress_color: Color,
|
||||
pub plan_summary_color: Color,
|
||||
pub plan_explanation_color: Color,
|
||||
pub plan_pending_color: Color,
|
||||
pub plan_in_progress_color: Color,
|
||||
pub plan_completed_color: Color,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// The current dark theme. Visible output today uses these values.
|
||||
#[must_use]
|
||||
pub const fn dark() -> Self {
|
||||
Self {
|
||||
variant: Variant::Dark,
|
||||
section_borders: Borders::ALL,
|
||||
section_border_type: BorderType::Plain,
|
||||
section_border_color: palette::BORDER_COLOR,
|
||||
section_bg: palette::DEEPSEEK_INK,
|
||||
section_title_color: palette::DEEPSEEK_BLUE,
|
||||
section_padding: Padding::uniform(1),
|
||||
tool_title_color: palette::TEXT_SOFT,
|
||||
tool_value_color: palette::TEXT_MUTED,
|
||||
tool_label_color: palette::TEXT_DIM,
|
||||
tool_running_accent: palette::ACCENT_TOOL_LIVE,
|
||||
tool_success_accent: palette::TEXT_DIM,
|
||||
tool_failed_accent: palette::ACCENT_TOOL_ISSUE,
|
||||
plan_progress_color: palette::STATUS_SUCCESS,
|
||||
plan_summary_color: palette::TEXT_MUTED,
|
||||
plan_explanation_color: palette::TEXT_DIM,
|
||||
plan_pending_color: palette::TEXT_MUTED,
|
||||
plan_in_progress_color: palette::STATUS_WARNING,
|
||||
plan_completed_color: palette::STATUS_SUCCESS,
|
||||
}
|
||||
}
|
||||
|
||||
/// The light variant. Same RGB values as `dark()` today so a future skin
|
||||
/// swap is a single-source-of-truth change in this file. Out of scope:
|
||||
/// actually picking a distinct light palette.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub const fn light() -> Self {
|
||||
Self {
|
||||
variant: Variant::Light,
|
||||
..Self::dark()
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the right tool accent for a given [`ToolStatus`].
|
||||
#[must_use]
|
||||
pub const fn tool_status_color(self, status: ToolStatus) -> Color {
|
||||
match status {
|
||||
ToolStatus::Running => self.tool_running_accent,
|
||||
ToolStatus::Success => self.tool_success_accent,
|
||||
ToolStatus::Failed => self.tool_failed_accent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bold tool title style (e.g. "Plan", "Shell").
|
||||
#[must_use]
|
||||
pub fn tool_title_style(self) -> Style {
|
||||
Style::default()
|
||||
.fg(self.tool_title_color)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
/// Right-side status text ("running", "done", "issue") style.
|
||||
#[must_use]
|
||||
pub fn tool_status_style(self, status: ToolStatus) -> Style {
|
||||
Style::default().fg(self.tool_status_color(status))
|
||||
}
|
||||
|
||||
/// Detail label style ("command:", "time:", step markers).
|
||||
#[must_use]
|
||||
pub fn tool_label_style(self) -> Style {
|
||||
Style::default().fg(self.tool_label_color)
|
||||
}
|
||||
|
||||
/// Default value style for tool detail rows.
|
||||
#[must_use]
|
||||
pub fn tool_value_style(self) -> Style {
|
||||
Style::default().fg(self.tool_value_color)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the active theme used by the TUI today.
|
||||
///
|
||||
/// Today this is always `Theme::dark()`. A future PR can wire this to an
|
||||
/// `App` field or a config setting in five lines.
|
||||
#[must_use]
|
||||
pub const fn active_theme() -> Theme {
|
||||
Theme::dark()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Theme, Variant, active_theme};
|
||||
use crate::palette;
|
||||
use crate::tui::history::ToolStatus;
|
||||
|
||||
#[test]
|
||||
fn active_theme_returns_dark() {
|
||||
assert_eq!(active_theme(), Theme::dark());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_theme_matches_existing_palette_choices() {
|
||||
let theme = Theme::dark();
|
||||
assert_eq!(theme.variant, Variant::Dark);
|
||||
assert_eq!(theme.section_border_color, palette::BORDER_COLOR);
|
||||
assert_eq!(theme.section_bg, palette::DEEPSEEK_INK);
|
||||
assert_eq!(theme.section_title_color, palette::DEEPSEEK_BLUE);
|
||||
assert_eq!(theme.tool_title_color, palette::TEXT_SOFT);
|
||||
assert_eq!(theme.tool_value_color, palette::TEXT_MUTED);
|
||||
assert_eq!(theme.tool_label_color, palette::TEXT_DIM);
|
||||
assert_eq!(theme.tool_running_accent, palette::ACCENT_TOOL_LIVE);
|
||||
assert_eq!(theme.tool_success_accent, palette::TEXT_DIM);
|
||||
assert_eq!(theme.tool_failed_accent, palette::ACCENT_TOOL_ISSUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_theme_keeps_dark_values_until_skin_swap() {
|
||||
// The light variant exists so the future skin swap is a single-file
|
||||
// change. It intentionally mirrors `dark()` today so introducing the
|
||||
// module does not move pixels.
|
||||
let dark = Theme::dark();
|
||||
let light = Theme::light();
|
||||
assert_eq!(light.variant, Variant::Light);
|
||||
assert_eq!(light.section_border_color, dark.section_border_color);
|
||||
assert_eq!(light.tool_running_accent, dark.tool_running_accent);
|
||||
assert_eq!(light.tool_failed_accent, dark.tool_failed_accent);
|
||||
assert_eq!(light.plan_in_progress_color, dark.plan_in_progress_color);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_status_color_maps_each_status() {
|
||||
let theme = Theme::dark();
|
||||
assert_eq!(
|
||||
theme.tool_status_color(ToolStatus::Running),
|
||||
theme.tool_running_accent
|
||||
);
|
||||
assert_eq!(
|
||||
theme.tool_status_color(ToolStatus::Success),
|
||||
theme.tool_success_accent
|
||||
);
|
||||
assert_eq!(
|
||||
theme.tool_status_color(ToolStatus::Failed),
|
||||
theme.tool_failed_accent
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// TODO(integrate): Wire into engine/UI — tracked as future work
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Shared error taxonomy across client, tools, runtime, and UI.
|
||||
//!
|
||||
//! Not yet wired into consumers; will be adopted incrementally.
|
||||
use std::fmt;
|
||||
|
||||
use crate::llm_client::LlmError;
|
||||
use crate::tools::spec::ToolError;
|
||||
|
||||
/// Broad category for typed error handling and policy decisions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ErrorCategory {
|
||||
Network,
|
||||
Authentication,
|
||||
Authorization,
|
||||
RateLimit,
|
||||
Timeout,
|
||||
InvalidInput,
|
||||
Parse,
|
||||
Tool,
|
||||
State,
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// Severity hint for UI and logs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ErrorSeverity {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
Critical,
|
||||
}
|
||||
|
||||
/// Unified envelope used when crossing subsystem boundaries.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ErrorEnvelope {
|
||||
pub category: ErrorCategory,
|
||||
pub severity: ErrorSeverity,
|
||||
pub recoverable: bool,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for ErrorCategory {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let label = match self {
|
||||
Self::Network => "network",
|
||||
Self::Authentication => "authentication",
|
||||
Self::Authorization => "authorization",
|
||||
Self::RateLimit => "rate_limit",
|
||||
Self::Timeout => "timeout",
|
||||
Self::InvalidInput => "invalid_input",
|
||||
Self::Parse => "parse",
|
||||
Self::Tool => "tool",
|
||||
Self::State => "state",
|
||||
Self::Internal => "internal",
|
||||
};
|
||||
f.write_str(label)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ErrorSeverity {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let label = match self {
|
||||
Self::Info => "info",
|
||||
Self::Warning => "warning",
|
||||
Self::Error => "error",
|
||||
Self::Critical => "critical",
|
||||
};
|
||||
f.write_str(label)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ErrorEnvelope {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "[{}] {}: {}", self.severity, self.code, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ErrorEnvelope {}
|
||||
|
||||
impl ErrorEnvelope {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
category: ErrorCategory,
|
||||
severity: ErrorSeverity,
|
||||
recoverable: bool,
|
||||
code: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
category,
|
||||
severity,
|
||||
recoverable,
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LlmError> for ErrorEnvelope {
|
||||
fn from(value: LlmError) -> Self {
|
||||
match value {
|
||||
LlmError::RateLimited { message, .. } => Self::new(
|
||||
ErrorCategory::RateLimit,
|
||||
ErrorSeverity::Warning,
|
||||
true,
|
||||
"llm_rate_limited",
|
||||
message,
|
||||
),
|
||||
LlmError::ServerError { status, message } => Self::new(
|
||||
ErrorCategory::Internal,
|
||||
ErrorSeverity::Error,
|
||||
true,
|
||||
format!("llm_server_{status}"),
|
||||
message,
|
||||
),
|
||||
LlmError::NetworkError(message) => Self::new(
|
||||
ErrorCategory::Network,
|
||||
ErrorSeverity::Error,
|
||||
true,
|
||||
"llm_network_error",
|
||||
message,
|
||||
),
|
||||
LlmError::Timeout(duration) => Self::new(
|
||||
ErrorCategory::Timeout,
|
||||
ErrorSeverity::Warning,
|
||||
true,
|
||||
"llm_timeout",
|
||||
format!("Request timed out after {duration:?}"),
|
||||
),
|
||||
LlmError::AuthenticationError(message) => Self::new(
|
||||
ErrorCategory::Authentication,
|
||||
ErrorSeverity::Critical,
|
||||
false,
|
||||
"llm_auth_error",
|
||||
message,
|
||||
),
|
||||
LlmError::InvalidRequest { message, .. } => Self::new(
|
||||
ErrorCategory::InvalidInput,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"llm_invalid_request",
|
||||
message,
|
||||
),
|
||||
LlmError::ModelError(message) => Self::new(
|
||||
ErrorCategory::InvalidInput,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"llm_model_error",
|
||||
message,
|
||||
),
|
||||
LlmError::ContentPolicyError(message) => Self::new(
|
||||
ErrorCategory::Authorization,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"llm_content_policy",
|
||||
message,
|
||||
),
|
||||
LlmError::ParseError(message) => Self::new(
|
||||
ErrorCategory::Parse,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"llm_parse_error",
|
||||
message,
|
||||
),
|
||||
LlmError::ContextLengthError(message) => Self::new(
|
||||
ErrorCategory::InvalidInput,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"llm_context_length",
|
||||
message,
|
||||
),
|
||||
LlmError::Other(message) => Self::new(
|
||||
ErrorCategory::Internal,
|
||||
ErrorSeverity::Error,
|
||||
true,
|
||||
"llm_other",
|
||||
message,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ToolError> for ErrorEnvelope {
|
||||
fn from(value: ToolError) -> Self {
|
||||
match value {
|
||||
ToolError::InvalidInput { message } => Self::new(
|
||||
ErrorCategory::InvalidInput,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"tool_invalid_input",
|
||||
message,
|
||||
),
|
||||
ToolError::MissingField { field } => Self::new(
|
||||
ErrorCategory::InvalidInput,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"tool_missing_field",
|
||||
format!("Missing required field: {field}"),
|
||||
),
|
||||
ToolError::PathEscape { path } => Self::new(
|
||||
ErrorCategory::Authorization,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"tool_path_escape",
|
||||
format!("Path escapes workspace: {}", path.display()),
|
||||
),
|
||||
ToolError::ExecutionFailed { message } => Self::new(
|
||||
ErrorCategory::Tool,
|
||||
ErrorSeverity::Error,
|
||||
true,
|
||||
"tool_execution_failed",
|
||||
message,
|
||||
),
|
||||
ToolError::Timeout { seconds } => Self::new(
|
||||
ErrorCategory::Timeout,
|
||||
ErrorSeverity::Warning,
|
||||
true,
|
||||
"tool_timeout",
|
||||
format!("Tool timed out after {seconds}s"),
|
||||
),
|
||||
ToolError::NotAvailable { message } => Self::new(
|
||||
ErrorCategory::State,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"tool_not_available",
|
||||
message,
|
||||
),
|
||||
ToolError::PermissionDenied { message } => Self::new(
|
||||
ErrorCategory::Authorization,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"tool_permission_denied",
|
||||
message,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
//! Offline evaluation harness for exercising representative tool loops.
|
||||
//!
|
||||
//! This module is intentionally self-contained so it can be wired into a CLI
|
||||
//! command later without calling the network or any LLM endpoints.
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use ignore::WalkBuilder;
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Representative tool steps covered by the evaluation harness.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
||||
pub enum ScenarioStepKind {
|
||||
List,
|
||||
Read,
|
||||
Search,
|
||||
Edit,
|
||||
ApplyPatch,
|
||||
ExecShell,
|
||||
}
|
||||
|
||||
impl ScenarioStepKind {
|
||||
/// Tool name associated with this step.
|
||||
pub fn tool_name(self) -> &'static str {
|
||||
match self {
|
||||
ScenarioStepKind::List => "list_dir",
|
||||
ScenarioStepKind::Read => "read_file",
|
||||
ScenarioStepKind::Search => "search",
|
||||
ScenarioStepKind::Edit => "edit_file",
|
||||
ScenarioStepKind::ApplyPatch => "apply_patch",
|
||||
ScenarioStepKind::ExecShell => "exec_shell",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a step kind from CLI-friendly strings.
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value.trim().to_lowercase().as_str() {
|
||||
"list" | "list_dir" => Some(Self::List),
|
||||
"read" | "read_file" => Some(Self::Read),
|
||||
"search" | "grep" | "grep_files" => Some(Self::Search),
|
||||
"edit" | "edit_file" => Some(Self::Edit),
|
||||
"patch" | "apply_patch" => Some(Self::ApplyPatch),
|
||||
"shell" | "exec_shell" | "exec" => Some(Self::ExecShell),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregate statistics for a single tool kind.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct ToolStats {
|
||||
pub invocations: usize,
|
||||
pub errors: usize,
|
||||
pub total_duration: Duration,
|
||||
}
|
||||
|
||||
/// Top-level metrics produced by an evaluation run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct EvalMetrics {
|
||||
pub success: bool,
|
||||
pub tool_errors: usize,
|
||||
pub steps: usize,
|
||||
pub duration: Duration,
|
||||
pub per_tool: BTreeMap<ScenarioStepKind, ToolStats>,
|
||||
}
|
||||
|
||||
/// One tool invocation recorded by the harness.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct EvalStep {
|
||||
pub kind: ScenarioStepKind,
|
||||
pub tool_name: &'static str,
|
||||
pub success: bool,
|
||||
pub duration: Duration,
|
||||
pub error: Option<String>,
|
||||
pub output: Option<String>,
|
||||
}
|
||||
|
||||
/// Summary of the generated temporary workspace.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct WorkspaceSummary {
|
||||
pub root: PathBuf,
|
||||
pub file_count: usize,
|
||||
pub files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Configuration for the offline evaluation harness.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EvalHarnessConfig {
|
||||
/// Human-readable scenario name for reporting.
|
||||
pub scenario_name: String,
|
||||
/// If set, the harness will intentionally fail this step to test metrics.
|
||||
pub fail_step: Option<ScenarioStepKind>,
|
||||
/// Shell command executed during the `exec_shell` step.
|
||||
pub shell_command: String,
|
||||
/// Token that must appear in shell output for validation.
|
||||
pub shell_expect_token: String,
|
||||
/// Maximum characters stored for step output summaries.
|
||||
pub max_output_chars: usize,
|
||||
}
|
||||
|
||||
impl Default for EvalHarnessConfig {
|
||||
fn default() -> Self {
|
||||
let shell_command = if cfg!(windows) {
|
||||
"echo eval-harness".to_string()
|
||||
} else {
|
||||
"printf eval-harness".to_string()
|
||||
};
|
||||
Self {
|
||||
scenario_name: "offline-tool-loop".to_string(),
|
||||
fail_step: None,
|
||||
shell_command,
|
||||
shell_expect_token: "eval-harness".to_string(),
|
||||
max_output_chars: 240,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Offline harness that exercises representative tool loops in a temp workspace.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EvalHarness {
|
||||
config: EvalHarnessConfig,
|
||||
}
|
||||
|
||||
impl EvalHarness {
|
||||
/// Create a new harness with the provided configuration.
|
||||
pub fn new(config: EvalHarnessConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Execute the offline evaluation scenario and return detailed results.
|
||||
pub fn run(&self) -> Result<EvalRun> {
|
||||
let started_at = Instant::now();
|
||||
let workspace = tempfile::Builder::new()
|
||||
.prefix("deepseek-eval-")
|
||||
.tempdir()
|
||||
.context("failed to create evaluation workspace")?;
|
||||
|
||||
let seed = seed_workspace(workspace.path())?;
|
||||
|
||||
let mut steps = Vec::new();
|
||||
let mut per_tool: BTreeMap<ScenarioStepKind, ToolStats> = BTreeMap::new();
|
||||
|
||||
let list_output = self.run_step(ScenarioStepKind::List, &mut steps, &mut per_tool, || {
|
||||
let entries = list_dir(workspace.path())?;
|
||||
Ok(entries.join(", "))
|
||||
});
|
||||
|
||||
let _read_output = self.run_step(ScenarioStepKind::Read, &mut steps, &mut per_tool, || {
|
||||
let path = if self.config.fail_step == Some(ScenarioStepKind::Read) {
|
||||
workspace.path().join("missing.txt")
|
||||
} else {
|
||||
seed.notes_path.clone()
|
||||
};
|
||||
read_file(&path)
|
||||
});
|
||||
|
||||
let search_output =
|
||||
self.run_step(ScenarioStepKind::Search, &mut steps, &mut per_tool, || {
|
||||
let root = if self.config.fail_step == Some(ScenarioStepKind::Search) {
|
||||
workspace.path().join("missing-dir")
|
||||
} else {
|
||||
workspace.path().to_path_buf()
|
||||
};
|
||||
let result = search_files(&root, "offline")?;
|
||||
Ok(format!("matches={}", result.matches.len()))
|
||||
});
|
||||
|
||||
let edit_output = self.run_step(ScenarioStepKind::Edit, &mut steps, &mut per_tool, || {
|
||||
let path = if self.config.fail_step == Some(ScenarioStepKind::Edit) {
|
||||
workspace.path().join("missing.txt")
|
||||
} else {
|
||||
seed.notes_path.clone()
|
||||
};
|
||||
edit_file_append(&path, "edited = true")?;
|
||||
Ok("appended line".to_string())
|
||||
});
|
||||
|
||||
let patch_output = self.run_step(
|
||||
ScenarioStepKind::ApplyPatch,
|
||||
&mut steps,
|
||||
&mut per_tool,
|
||||
|| {
|
||||
let patch = if self.config.fail_step == Some(ScenarioStepKind::ApplyPatch) {
|
||||
"*** Begin Patch\n*** Update File: notes.txt\n@@\n-THIS LINE DOES NOT EXIST\n+broken\n*** End Patch\n"
|
||||
.to_string()
|
||||
} else {
|
||||
"*** Begin Patch\n*** Update File: notes.txt\n@@\n status = \"draft\"\n-todo: offline metrics\n+todo: offline metrics (patched)\n*** End Patch\n"
|
||||
.to_string()
|
||||
};
|
||||
apply_patch(workspace.path(), &patch)?;
|
||||
Ok("patch applied".to_string())
|
||||
},
|
||||
);
|
||||
|
||||
let shell_output = self.run_step(
|
||||
ScenarioStepKind::ExecShell,
|
||||
&mut steps,
|
||||
&mut per_tool,
|
||||
|| {
|
||||
let command = if self.config.fail_step == Some(ScenarioStepKind::ExecShell) {
|
||||
"command_that_does_not_exist".to_string()
|
||||
} else {
|
||||
self.config.shell_command.clone()
|
||||
};
|
||||
exec_shell(workspace.path(), &command)
|
||||
},
|
||||
);
|
||||
|
||||
let duration = started_at.elapsed();
|
||||
|
||||
let workspace_summary = summarize_workspace(workspace.path(), list_output.as_deref())?;
|
||||
|
||||
let validation_success = validate_outputs(
|
||||
workspace.path(),
|
||||
&self.config.shell_expect_token,
|
||||
search_output.as_deref(),
|
||||
edit_output.as_deref(),
|
||||
patch_output.as_deref(),
|
||||
shell_output.as_deref(),
|
||||
);
|
||||
|
||||
let tool_errors = steps.iter().filter(|s| !s.success).count();
|
||||
let success = tool_errors == 0 && validation_success;
|
||||
|
||||
let metrics = EvalMetrics {
|
||||
success,
|
||||
tool_errors,
|
||||
steps: steps.len(),
|
||||
duration,
|
||||
per_tool,
|
||||
};
|
||||
|
||||
Ok(EvalRun {
|
||||
scenario_name: self.config.scenario_name.clone(),
|
||||
workspace,
|
||||
workspace_summary,
|
||||
metrics,
|
||||
steps,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_step<T, F>(
|
||||
&self,
|
||||
kind: ScenarioStepKind,
|
||||
steps: &mut Vec<EvalStep>,
|
||||
per_tool: &mut BTreeMap<ScenarioStepKind, ToolStats>,
|
||||
f: F,
|
||||
) -> Option<T>
|
||||
where
|
||||
F: FnOnce() -> Result<T>,
|
||||
T: ToString,
|
||||
{
|
||||
let started_at = Instant::now();
|
||||
let result = f();
|
||||
let duration = started_at.elapsed();
|
||||
|
||||
let stats = per_tool.entry(kind).or_default();
|
||||
stats.invocations += 1;
|
||||
stats.total_duration += duration;
|
||||
|
||||
match result {
|
||||
Ok(value) => {
|
||||
let output = truncate_output(&value.to_string(), self.config.max_output_chars);
|
||||
steps.push(EvalStep {
|
||||
kind,
|
||||
tool_name: kind.tool_name(),
|
||||
success: true,
|
||||
duration,
|
||||
error: None,
|
||||
output: Some(output),
|
||||
});
|
||||
Some(value)
|
||||
}
|
||||
Err(err) => {
|
||||
stats.errors += 1;
|
||||
steps.push(EvalStep {
|
||||
kind,
|
||||
tool_name: kind.tool_name(),
|
||||
success: false,
|
||||
duration,
|
||||
error: Some(err.to_string()),
|
||||
output: None,
|
||||
});
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EvalHarness {
|
||||
fn default() -> Self {
|
||||
Self::new(EvalHarnessConfig::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of running the evaluation harness.
|
||||
#[derive(Debug)]
|
||||
pub struct EvalRun {
|
||||
pub scenario_name: String,
|
||||
workspace: TempDir,
|
||||
pub workspace_summary: WorkspaceSummary,
|
||||
pub metrics: EvalMetrics,
|
||||
pub steps: Vec<EvalStep>,
|
||||
}
|
||||
|
||||
impl EvalRun {
|
||||
/// Get the root of the temporary workspace.
|
||||
pub fn workspace_root(&self) -> &Path {
|
||||
self.workspace.path()
|
||||
}
|
||||
|
||||
/// Convert the run into a serializable report for CLI output.
|
||||
pub fn to_report(&self) -> EvalReport {
|
||||
EvalReport {
|
||||
scenario_name: self.scenario_name.clone(),
|
||||
workspace_root: self.workspace_root().to_path_buf(),
|
||||
workspace_summary: self.workspace_summary.clone(),
|
||||
metrics: self.metrics.clone(),
|
||||
steps: self.steps.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializable report derived from an `EvalRun`.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct EvalReport {
|
||||
pub scenario_name: String,
|
||||
pub workspace_root: PathBuf,
|
||||
pub workspace_summary: WorkspaceSummary,
|
||||
pub metrics: EvalMetrics,
|
||||
pub steps: Vec<EvalStep>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SeedWorkspace {
|
||||
notes_path: PathBuf,
|
||||
}
|
||||
|
||||
fn seed_workspace(root: &Path) -> Result<SeedWorkspace> {
|
||||
let src_dir = root.join("src");
|
||||
fs::create_dir_all(&src_dir)
|
||||
.with_context(|| format!("failed to create seed directory: {}", src_dir.display()))?;
|
||||
|
||||
let readme_path = root.join("README.md");
|
||||
fs::write(
|
||||
&readme_path,
|
||||
"# Eval Harness Workspace\n\nThis workspace is offline.\n",
|
||||
)
|
||||
.with_context(|| format!("failed to write {}", readme_path.display()))?;
|
||||
|
||||
let notes_path = root.join("notes.txt");
|
||||
fs::write(
|
||||
¬es_path,
|
||||
"# Eval Harness\nstatus = \"draft\"\ntodo: offline metrics\n",
|
||||
)
|
||||
.with_context(|| format!("failed to write {}", notes_path.display()))?;
|
||||
|
||||
let lib_path = src_dir.join("lib.rs");
|
||||
fs::write(
|
||||
&lib_path,
|
||||
"pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n",
|
||||
)
|
||||
.with_context(|| format!("failed to write {}", lib_path.display()))?;
|
||||
|
||||
Ok(SeedWorkspace { notes_path })
|
||||
}
|
||||
|
||||
fn summarize_workspace(root: &Path, list_output: Option<&str>) -> Result<WorkspaceSummary> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
let walker = WalkBuilder::new(root)
|
||||
.hidden(false)
|
||||
.git_ignore(false)
|
||||
.git_global(false)
|
||||
.git_exclude(false)
|
||||
.build();
|
||||
|
||||
for entry in walker {
|
||||
let entry = entry.with_context(|| format!("failed to walk {}", root.display()))?;
|
||||
if entry.file_type().is_some_and(|t| t.is_file()) {
|
||||
files.push(entry.into_path());
|
||||
}
|
||||
}
|
||||
|
||||
if files.is_empty()
|
||||
&& let Some(output) = list_output
|
||||
&& !output.trim().is_empty()
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"workspace appears empty after list_dir: {}",
|
||||
output.trim()
|
||||
));
|
||||
}
|
||||
|
||||
files.sort();
|
||||
|
||||
Ok(WorkspaceSummary {
|
||||
root: root.to_path_buf(),
|
||||
file_count: files.len(),
|
||||
files,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_outputs(
|
||||
root: &Path,
|
||||
shell_expect_token: &str,
|
||||
search_output: Option<&str>,
|
||||
edit_output: Option<&str>,
|
||||
patch_output: Option<&str>,
|
||||
shell_output: Option<&str>,
|
||||
) -> bool {
|
||||
let notes_path = root.join("notes.txt");
|
||||
let notes = match fs::read_to_string(¬es_path) {
|
||||
Ok(content) => content,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let search_ok = search_output.is_some_and(|s| s.contains("matches="));
|
||||
let edit_ok = edit_output.is_some_and(|s| !s.is_empty()) && notes.contains("edited = true");
|
||||
let patch_ok = patch_output.is_some_and(|s| !s.is_empty())
|
||||
&& notes.contains("todo: offline metrics (patched)");
|
||||
let shell_ok = shell_output
|
||||
.map(str::trim)
|
||||
.is_some_and(|s| s.contains(shell_expect_token));
|
||||
|
||||
search_ok && edit_ok && patch_ok && shell_ok
|
||||
}
|
||||
|
||||
fn list_dir(path: &Path) -> Result<Vec<String>> {
|
||||
let mut entries = Vec::new();
|
||||
let dir = fs::read_dir(path)
|
||||
.with_context(|| format!("failed to read directory: {}", path.display()))?;
|
||||
|
||||
for entry in dir {
|
||||
let entry = entry.with_context(|| format!("failed to list {}", path.display()))?;
|
||||
entries.push(entry.file_name().to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
entries.sort();
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn read_file(path: &Path) -> Result<String> {
|
||||
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct SearchMatch {
|
||||
path: PathBuf,
|
||||
line: usize,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct SearchResult {
|
||||
matches: Vec<SearchMatch>,
|
||||
}
|
||||
|
||||
fn search_files(root: &Path, pattern: &str) -> Result<SearchResult> {
|
||||
if !root.exists() {
|
||||
return Err(anyhow!("search root does not exist: {}", root.display()));
|
||||
}
|
||||
|
||||
let regex = Regex::new(pattern).context("failed to compile search regex")?;
|
||||
let mut matches = Vec::new();
|
||||
|
||||
let walker = WalkBuilder::new(root)
|
||||
.hidden(false)
|
||||
.git_ignore(false)
|
||||
.git_global(false)
|
||||
.git_exclude(false)
|
||||
.build();
|
||||
|
||||
for entry in walker {
|
||||
let entry = entry.with_context(|| format!("failed to walk {}", root.display()))?;
|
||||
if !entry.file_type().is_some_and(|t| t.is_file()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = entry.path();
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for (idx, line) in content.lines().enumerate() {
|
||||
if regex.is_match(line) {
|
||||
matches.push(SearchMatch {
|
||||
path: path.to_path_buf(),
|
||||
line: idx + 1,
|
||||
content: line.to_string(),
|
||||
});
|
||||
}
|
||||
if matches.len() >= 64 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if matches.len() >= 64 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SearchResult { matches })
|
||||
}
|
||||
|
||||
fn edit_file_append(path: &Path, line: &str) -> Result<()> {
|
||||
let mut content = read_file(path)?;
|
||||
if !content.ends_with('\n') {
|
||||
content.push('\n');
|
||||
}
|
||||
content.push_str(line);
|
||||
content.push('\n');
|
||||
fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
fn apply_patch(root: &Path, patch: &str) -> Result<()> {
|
||||
let mut lines = patch.lines();
|
||||
|
||||
let begin = lines.next().unwrap_or_default();
|
||||
if begin != "*** Begin Patch" {
|
||||
return Err(anyhow!("patch missing *** Begin Patch header"));
|
||||
}
|
||||
|
||||
let header = lines.next().unwrap_or_default();
|
||||
let file_rel = header
|
||||
.strip_prefix("*** Update File: ")
|
||||
.ok_or_else(|| anyhow!("only *** Update File patches are supported"))?;
|
||||
if file_rel.contains("..") {
|
||||
return Err(anyhow!("patch path must be workspace-relative"));
|
||||
}
|
||||
|
||||
let file_path = root.join(file_rel);
|
||||
let original = read_file(&file_path)?;
|
||||
let had_trailing_newline = original.ends_with('\n');
|
||||
let mut file_lines: Vec<String> = original.lines().map(|l| l.to_string()).collect();
|
||||
|
||||
let mut cursor = 0usize;
|
||||
for raw_line in lines {
|
||||
if raw_line == "*** End Patch" {
|
||||
break;
|
||||
}
|
||||
if raw_line.starts_with("*** ") {
|
||||
return Err(anyhow!("unexpected patch directive: {raw_line}"));
|
||||
}
|
||||
if raw_line.starts_with("@@") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (kind, rest) = raw_line.split_at(1);
|
||||
let content = rest.to_string();
|
||||
|
||||
match kind {
|
||||
" " => {
|
||||
let Some(found) = file_lines[cursor..]
|
||||
.iter()
|
||||
.position(|line| line == &content)
|
||||
.map(|offset| cursor + offset)
|
||||
else {
|
||||
return Err(anyhow!(
|
||||
"patch context not found in {}: {}",
|
||||
file_path.display(),
|
||||
content
|
||||
));
|
||||
};
|
||||
cursor = found + 1;
|
||||
}
|
||||
"-" => {
|
||||
if cursor >= file_lines.len() || file_lines[cursor] != content {
|
||||
return Err(anyhow!(
|
||||
"patch removal mismatch in {}: expected '{}'",
|
||||
file_path.display(),
|
||||
content
|
||||
));
|
||||
}
|
||||
file_lines.remove(cursor);
|
||||
}
|
||||
"+" => {
|
||||
file_lines.insert(cursor, content);
|
||||
cursor += 1;
|
||||
}
|
||||
_ => return Err(anyhow!("unsupported patch line: {raw_line}")),
|
||||
}
|
||||
}
|
||||
|
||||
let mut updated = file_lines.join("\n");
|
||||
if had_trailing_newline {
|
||||
updated.push('\n');
|
||||
}
|
||||
|
||||
fs::write(&file_path, updated)
|
||||
.with_context(|| format!("failed to write patched file {}", file_path.display()))
|
||||
}
|
||||
|
||||
fn exec_shell(root: &Path, command: &str) -> Result<String> {
|
||||
#[cfg(windows)]
|
||||
let output = Command::new("cmd")
|
||||
.args(["/C", command])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.with_context(|| format!("failed to execute shell command: {command}"))?;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
let output = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(command)
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.with_context(|| format!("failed to execute shell command: {command}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!(
|
||||
"shell command failed (status={}): {}",
|
||||
output.status,
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
Ok(stdout.trim().to_string())
|
||||
}
|
||||
|
||||
fn truncate_output(value: &str, max_chars: usize) -> String {
|
||||
if value.chars().count() <= max_chars {
|
||||
return value.to_string();
|
||||
}
|
||||
|
||||
let truncated: String = value.chars().take(max_chars).collect();
|
||||
format!("{}...", truncated)
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Read;
|
||||
use std::io::Seek;
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,56 @@
|
||||
//! Command matching helpers for execpolicy rules.
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
/// Normalize a command string by shlex parsing and re-joining tokens.
|
||||
pub fn normalize_command(command: &str) -> String {
|
||||
if let Some(tokens) = shlex::split(command) {
|
||||
tokens.join(" ")
|
||||
} else {
|
||||
command
|
||||
.split_whitespace()
|
||||
.filter(|token| !token.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Return true if the pattern matches the command.
|
||||
///
|
||||
/// Patterns support `*` wildcards that match any substring.
|
||||
pub fn pattern_matches(pattern: &str, command: &str) -> bool {
|
||||
let pattern = normalize_command(pattern);
|
||||
let command = normalize_command(command);
|
||||
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
|
||||
let escaped = regex::escape(&pattern).replace("\\*", ".*");
|
||||
let Ok(re) = Regex::new(&format!("^{escaped}$")) else {
|
||||
return false;
|
||||
};
|
||||
re.is_match(&command)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_normalize_command() {
|
||||
assert_eq!(normalize_command("git status"), "git status");
|
||||
assert_eq!(
|
||||
normalize_command("git \"log --oneline\""),
|
||||
"git log --oneline"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pattern_matches() {
|
||||
assert!(pattern_matches("git status", "git status"));
|
||||
assert!(pattern_matches("git log *", "git log --oneline"));
|
||||
assert!(pattern_matches("cargo *", "cargo test --all"));
|
||||
assert!(!pattern_matches("git push --force", "git push origin main"));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
// TODO(integrate): Wire exec-policy into shell tool — tracked as future work
|
||||
#![allow(dead_code)]
|
||||
#![allow(unused_imports)]
|
||||
|
||||
pub mod amend;
|
||||
pub mod decision;
|
||||
pub mod error;
|
||||
pub mod execpolicycheck;
|
||||
pub mod matcher;
|
||||
pub mod parser;
|
||||
pub mod policy;
|
||||
pub mod rule;
|
||||
pub mod rules;
|
||||
|
||||
pub use amend::AmendError;
|
||||
pub use amend::blocking_append_allow_prefix_rule;
|
||||
@@ -20,3 +24,6 @@ pub use policy::Policy;
|
||||
pub use rule::Rule;
|
||||
pub use rule::RuleMatch;
|
||||
pub use rule::RuleRef;
|
||||
pub use rules::{
|
||||
ExecPolicyConfig, ExecPolicyDecision, default_execpolicy_path, load_default_policy,
|
||||
};
|
||||
File renamed without changes.
@@ -1,5 +1,3 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use super::decision::Decision;
|
||||
use super::error::Error;
|
||||
use super::error::Result;
|
||||
File renamed without changes.
@@ -0,0 +1,123 @@
|
||||
//! Execpolicy rules loaded from TOML configuration.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::matcher::pattern_matches;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ExecPolicyDecision {
|
||||
Allow,
|
||||
Deny(String),
|
||||
AskUser(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct ExecPolicyConfig {
|
||||
#[serde(default)]
|
||||
pub rules: BTreeMap<String, RuleSet>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct RuleSet {
|
||||
#[serde(default)]
|
||||
pub allow: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub deny: Vec<String>,
|
||||
}
|
||||
|
||||
impl ExecPolicyConfig {
|
||||
pub fn from_str(contents: &str) -> Result<Self> {
|
||||
toml::from_str(contents).context("failed to parse execpolicy.toml")
|
||||
}
|
||||
|
||||
pub fn from_path(path: &Path) -> Result<Self> {
|
||||
let contents = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read execpolicy file {}", path.display()))?;
|
||||
Self::from_str(&contents)
|
||||
}
|
||||
|
||||
pub fn evaluate(&self, command: &str) -> ExecPolicyDecision {
|
||||
for (group, rules) in &self.rules {
|
||||
for pattern in &rules.deny {
|
||||
if pattern_matches(pattern, command) {
|
||||
return ExecPolicyDecision::Deny(format!(
|
||||
"execpolicy denied by {group}: {pattern}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (group, rules) in &self.rules {
|
||||
for pattern in &rules.allow {
|
||||
if pattern_matches(pattern, command) {
|
||||
let _ = group;
|
||||
return ExecPolicyDecision::Allow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExecPolicyDecision::AskUser("execpolicy: no matching allow rule".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_execpolicy_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".deepseek").join("execpolicy.toml"))
|
||||
}
|
||||
|
||||
pub fn load_default_policy() -> Result<Option<ExecPolicyConfig>> {
|
||||
let Some(path) = default_execpolicy_path() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
ExecPolicyConfig::from_path(&path).map(Some)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_execpolicy_evaluate() {
|
||||
let config = ExecPolicyConfig {
|
||||
rules: BTreeMap::from([
|
||||
(
|
||||
"git".to_string(),
|
||||
RuleSet {
|
||||
allow: vec!["git status".to_string(), "git log *".to_string()],
|
||||
deny: vec!["git push --force".to_string()],
|
||||
},
|
||||
),
|
||||
(
|
||||
"danger".to_string(),
|
||||
RuleSet {
|
||||
allow: vec![],
|
||||
deny: vec!["rm -rf /".to_string()],
|
||||
},
|
||||
),
|
||||
]),
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
config.evaluate("git status"),
|
||||
ExecPolicyDecision::Allow
|
||||
));
|
||||
assert!(matches!(
|
||||
config.evaluate("git log --oneline"),
|
||||
ExecPolicyDecision::Allow
|
||||
));
|
||||
assert!(matches!(
|
||||
config.evaluate("git push --force"),
|
||||
ExecPolicyDecision::Deny(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
config.evaluate("unknown command"),
|
||||
ExecPolicyDecision::AskUser(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Feature flags and metadata for deepseek-cli.
|
||||
|
||||
// TODO(integrate): Wire feature flags into engine/tool registration — tracked as future work
|
||||
#![allow(dead_code)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
//! Feature flags and metadata for DeepSeek TUI.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Lifecycle stage for a feature flag.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -28,14 +31,23 @@ pub enum Feature {
|
||||
ApplyPatch,
|
||||
/// Enable MCP tools.
|
||||
Mcp,
|
||||
/// Enable RLM tools.
|
||||
Rlm,
|
||||
/// Enable Duo tools.
|
||||
Duo,
|
||||
/// Enable execpolicy integration/tooling.
|
||||
ExecPolicy,
|
||||
}
|
||||
|
||||
impl fmt::Display for Stage {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let label = match self {
|
||||
Self::Experimental => "experimental",
|
||||
Self::Beta => "beta",
|
||||
Self::Stable => "stable",
|
||||
Self::Deprecated => "deprecated",
|
||||
Self::Removed => "removed",
|
||||
};
|
||||
f.write_str(label)
|
||||
}
|
||||
}
|
||||
|
||||
impl Feature {
|
||||
pub fn key(self) -> &'static str {
|
||||
self.info().key
|
||||
@@ -171,18 +183,6 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Rlm,
|
||||
key: "rlm",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Duo,
|
||||
key: "duo",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ExecPolicy,
|
||||
key: "exec_policy",
|
||||
@@ -4,7 +4,6 @@
|
||||
//! - Session start/end
|
||||
//! - Tool call before/after
|
||||
|
||||
#![allow(dead_code)]
|
||||
//! - Mode changes
|
||||
//! - Message submission
|
||||
//! - Error events
|
||||
@@ -36,7 +35,7 @@ pub enum HookEvent {
|
||||
ToolCallBefore,
|
||||
/// Triggered after a tool completes (success or failure)
|
||||
ToolCallAfter,
|
||||
/// Triggered when the user changes modes (Normal, Edit, Agent, Plan)
|
||||
/// Triggered when the user changes modes (Plan, Agent, Yolo)
|
||||
ModeChange,
|
||||
/// Triggered when an error occurs
|
||||
OnError,
|
||||
@@ -44,6 +43,7 @@ pub enum HookEvent {
|
||||
|
||||
impl HookEvent {
|
||||
/// Get string representation for environment variable
|
||||
#[allow(dead_code)] // Used in tests and future hook dispatch
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
HookEvent::SessionStart => "session_start",
|
||||
@@ -77,7 +77,7 @@ pub enum HookCondition {
|
||||
},
|
||||
/// Only run in specific modes
|
||||
Mode {
|
||||
/// Mode: "plan", "agent", "yolo", "rlm", "duo"
|
||||
/// Mode: "plan", "agent", "yolo"
|
||||
mode: String,
|
||||
},
|
||||
/// Only run when exit code matches (for `ToolCallAfter`)
|
||||
@@ -130,6 +130,7 @@ fn default_continue_on_error() -> bool {
|
||||
|
||||
impl Hook {
|
||||
/// Create a new hook with minimal configuration
|
||||
#[allow(dead_code)] // Public builder API, used in tests
|
||||
pub fn new(event: HookEvent, command: &str) -> Self {
|
||||
Self {
|
||||
event,
|
||||
@@ -143,24 +144,28 @@ impl Hook {
|
||||
}
|
||||
|
||||
/// Builder: set condition
|
||||
#[allow(dead_code)] // Public builder API, used in tests
|
||||
pub fn with_condition(mut self, condition: HookCondition) -> Self {
|
||||
self.condition = Some(condition);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: set timeout
|
||||
#[allow(dead_code)] // Public builder API, used in tests
|
||||
pub fn with_timeout(mut self, secs: u64) -> Self {
|
||||
self.timeout_secs = secs;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: run in background
|
||||
#[allow(dead_code)] // Public builder API, used in tests
|
||||
pub fn background(mut self) -> Self {
|
||||
self.background = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: set name
|
||||
#[allow(dead_code)] // Public builder API, used in tests
|
||||
pub fn with_name(mut self, name: &str) -> Self {
|
||||
self.name = Some(name.to_string());
|
||||
self
|
||||
@@ -201,6 +206,7 @@ impl HooksConfig {
|
||||
}
|
||||
|
||||
/// Check if hooks are configured and enabled
|
||||
#[allow(dead_code)] // Public API for hook system consumers
|
||||
pub fn has_hooks(&self) -> bool {
|
||||
self.enabled && !self.hooks.is_empty()
|
||||
}
|
||||
@@ -244,16 +250,19 @@ impl HookContext {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Public builder API, used in tests
|
||||
pub fn with_tool_name(mut self, name: &str) -> Self {
|
||||
self.tool_name = Some(name.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Public builder API
|
||||
pub fn with_tool_args(mut self, args: &serde_json::Value) -> Self {
|
||||
self.tool_args = Some(args.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Public builder API
|
||||
pub fn with_tool_result(mut self, result: &str, success: bool, exit_code: Option<i32>) -> Self {
|
||||
self.tool_result = Some(result.to_string());
|
||||
self.tool_success = Some(success);
|
||||
@@ -261,6 +270,7 @@ impl HookContext {
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Public builder API, used in tests
|
||||
pub fn with_mode(mut self, mode: &str) -> Self {
|
||||
self.mode = Some(mode.to_string());
|
||||
self
|
||||
@@ -271,6 +281,7 @@ impl HookContext {
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Public builder API, used in tests
|
||||
pub fn with_workspace(mut self, path: PathBuf) -> Self {
|
||||
self.workspace = Some(path);
|
||||
self
|
||||
@@ -286,11 +297,13 @@ impl HookContext {
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Public builder API
|
||||
pub fn with_message(mut self, message: &str) -> Self {
|
||||
self.message = Some(message.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Public builder API
|
||||
pub fn with_error(mut self, error: &str) -> Self {
|
||||
self.error_message = Some(error.to_string());
|
||||
self
|
||||
@@ -301,6 +314,7 @@ impl HookContext {
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Public builder API
|
||||
pub fn with_cost(mut self, cost: f64) -> Self {
|
||||
self.session_cost = Some(cost);
|
||||
self
|
||||
@@ -319,7 +333,13 @@ impl HookContext {
|
||||
if let Some(ref result) = self.tool_result {
|
||||
// Truncate result to 10KB to avoid environment variable size limits
|
||||
let truncated = if result.len() > 10000 {
|
||||
format!("{}...[truncated]", &result[..10000])
|
||||
let safe_end = result
|
||||
.char_indices()
|
||||
.take_while(|(i, _)| *i < 10000)
|
||||
.last()
|
||||
.map(|(i, c)| i + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
format!("{}...[truncated]", &result[..safe_end])
|
||||
} else {
|
||||
result.clone()
|
||||
};
|
||||
@@ -343,7 +363,13 @@ impl HookContext {
|
||||
if let Some(ref message) = self.message {
|
||||
// Truncate message to prevent env var issues
|
||||
let truncated = if message.len() > 5000 {
|
||||
format!("{}...[truncated]", &message[..5000])
|
||||
let safe_end = message
|
||||
.char_indices()
|
||||
.take_while(|(i, _)| *i < 5000)
|
||||
.last()
|
||||
.map(|(i, c)| i + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
format!("{}...[truncated]", &message[..safe_end])
|
||||
} else {
|
||||
message.clone()
|
||||
};
|
||||
@@ -371,6 +397,7 @@ impl HookContext {
|
||||
|
||||
/// Result of a hook execution
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // Fields are part of public API for hook consumers
|
||||
pub struct HookResult {
|
||||
/// Hook name (if specified)
|
||||
pub name: Option<String>,
|
||||
@@ -424,6 +451,7 @@ impl HookExecutor {
|
||||
}
|
||||
|
||||
/// Create a disabled `HookExecutor` (no hooks will run)
|
||||
#[allow(dead_code)] // Used in tests and as convenience constructor
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
config: HooksConfig {
|
||||
@@ -436,6 +464,7 @@ impl HookExecutor {
|
||||
}
|
||||
|
||||
/// Check if hooks are enabled
|
||||
#[allow(dead_code)] // Public API for hook system consumers
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.config.enabled
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
//! - `RetryConfig`: Configurable retry behavior with exponential backoff and jitter
|
||||
//! - `LlmError`: Classified errors with retryability information
|
||||
|
||||
#![allow(dead_code)]
|
||||
//! - `with_retry`: Generic retry wrapper for any async operation
|
||||
//!
|
||||
//! # Example
|
||||
@@ -29,6 +28,7 @@ use anyhow::Result;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::time::{Duration, Instant};
|
||||
use uuid::Uuid;
|
||||
|
||||
// === LlmClient Trait ===
|
||||
|
||||
@@ -46,7 +46,7 @@ pub type StreamEventBox =
|
||||
/// - All methods are async and require `Send + Sync` for thread safety
|
||||
/// - The `create_message_stream` method returns a pinned boxed stream for SSE
|
||||
/// - Implementations should handle their own authentication and base URL configuration
|
||||
#[allow(async_fn_in_trait)]
|
||||
#[allow(async_fn_in_trait, dead_code)] // Trait methods are part of the LLM provider interface
|
||||
pub trait LlmClient: Send + Sync {
|
||||
/// Returns the provider name (e.g., "openai", "deepseek")
|
||||
fn provider_name(&self) -> &'static str;
|
||||
@@ -69,6 +69,7 @@ pub trait LlmClient: Send + Sync {
|
||||
}
|
||||
|
||||
/// Trait for clients that support configurable retry behavior
|
||||
#[allow(dead_code)] // Part of LLM provider interface, will be used by additional providers
|
||||
pub trait RetryConfigurable {
|
||||
fn retry_config(&self) -> &RetryConfig;
|
||||
fn set_retry_config(&mut self, config: RetryConfig);
|
||||
@@ -324,9 +325,11 @@ pub struct RetryConfig {
|
||||
pub respect_retry_after: bool,
|
||||
|
||||
/// HTTP status codes that should trigger a retry
|
||||
#[allow(dead_code)] // Used in tests via is_retryable_status()
|
||||
pub retryable_status_codes: Vec<u16>,
|
||||
|
||||
/// Timeout for individual requests (seconds, 0 = no timeout)
|
||||
#[allow(dead_code)] // Configuration field for retry consumers
|
||||
pub request_timeout: f64,
|
||||
|
||||
/// Total timeout for all retry attempts (seconds, 0 = no total timeout)
|
||||
@@ -351,6 +354,7 @@ impl Default for RetryConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Public builder API, used in tests
|
||||
impl RetryConfig {
|
||||
/// Creates a new `RetryConfig` with default values
|
||||
pub fn new() -> Self {
|
||||
@@ -420,16 +424,11 @@ impl RetryConfig {
|
||||
|
||||
let final_delay = if self.jitter {
|
||||
// Add random jitter to prevent thundering herd problem
|
||||
// Uses a simple deterministic approach when rand is not available
|
||||
let jitter_range = capped_delay * self.jitter_factor;
|
||||
|
||||
// Simple pseudo-random jitter based on current time
|
||||
// This avoids adding the rand crate as a dependency
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(0);
|
||||
let random_factor = f64::from(nanos % 1000) / 1000.0; // 0.0 to 0.999
|
||||
// Use UUID v4 entropy for jitter randomness.
|
||||
let bytes = *Uuid::new_v4().as_bytes();
|
||||
let sample = u16::from_le_bytes([bytes[0], bytes[1]]);
|
||||
let random_factor = f64::from(sample) / f64::from(u16::MAX); // 0.0 to 1.0
|
||||
let jitter = jitter_range * (2.0 * random_factor - 1.0); // -range to +range
|
||||
|
||||
(capped_delay + jitter).max(0.0)
|
||||
@@ -636,6 +635,7 @@ where
|
||||
}
|
||||
|
||||
/// Simplified version of `with_retry` without callback
|
||||
#[allow(dead_code)] // Convenience wrapper for with_retry
|
||||
pub async fn with_retry_simple<F, Fut, T>(config: &RetryConfig, operation: F) -> RetryResult<T>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Lightweight verbose logging helpers for the CLI.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use colored::Colorize;
|
||||
|
||||
use crate::palette;
|
||||
static VERBOSE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Enable or disable verbose logging output.
|
||||
pub fn set_verbose(enabled: bool) {
|
||||
VERBOSE.store(enabled, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Return true when supported env logging knobs request verbose output.
|
||||
#[must_use]
|
||||
pub fn env_requests_verbose_logging() -> bool {
|
||||
std::env::var("DEEPSEEK_LOG_LEVEL")
|
||||
.ok()
|
||||
.is_some_and(|value| log_value_enables_verbose(&value))
|
||||
|| std::env::var("RUST_LOG")
|
||||
.ok()
|
||||
.is_some_and(|value| log_value_enables_verbose(&value))
|
||||
}
|
||||
|
||||
fn log_value_enables_verbose(value: &str) -> bool {
|
||||
value.split(',').any(|directive| {
|
||||
let level = directive
|
||||
.rsplit('=')
|
||||
.next()
|
||||
.unwrap_or(directive)
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
matches!(level.as_str(), "trace" | "debug" | "info")
|
||||
})
|
||||
}
|
||||
|
||||
/// Check whether verbose logging is enabled.
|
||||
#[must_use]
|
||||
pub fn is_verbose() -> bool {
|
||||
VERBOSE.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Emit a verbose info message (no-op when verbosity is disabled).
|
||||
pub fn info(message: impl AsRef<str>) {
|
||||
if is_verbose() {
|
||||
let (r, g, b) = palette::DEEPSEEK_SKY_RGB;
|
||||
eprintln!("{} {}", "info".truecolor(r, g, b).bold(), message.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a verbose warning message (no-op when verbosity is disabled).
|
||||
pub fn warn(message: impl AsRef<str>) {
|
||||
if is_verbose() {
|
||||
let (r, g, b) = palette::DEEPSEEK_SKY_RGB;
|
||||
eprintln!("{} {}", "warn".truecolor(r, g, b).bold(), message.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn log_value_parser_accepts_common_rust_log_directives() {
|
||||
assert!(log_value_enables_verbose("debug"));
|
||||
assert!(log_value_enables_verbose("deepseek_cli=debug"));
|
||||
assert!(log_value_enables_verbose("warn,deepseek_tui::client=trace"));
|
||||
assert!(!log_value_enables_verbose("warn"));
|
||||
assert!(!log_value_enables_verbose("deepseek_tui=off"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,364 @@
|
||||
//! MCP server implementation for exposing DeepSeek tools over stdio.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use crate::session_manager::SessionManager;
|
||||
use crate::tools::spec::{ToolError, ToolResult};
|
||||
use crate::tools::{ToolContext, ToolRegistryBuilder};
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct McpServerConfigFile {
|
||||
#[serde(default)]
|
||||
server: McpServerSection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct McpServerSection {
|
||||
expose_tools: Option<Vec<String>>,
|
||||
require_approval: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct McpServerSettings {
|
||||
expose_tools: Vec<String>,
|
||||
require_approval: bool,
|
||||
}
|
||||
|
||||
impl McpServerSettings {
|
||||
fn load() -> Result<Self> {
|
||||
let path = default_config_path();
|
||||
if let Some(path) = path.filter(|p| p.exists()) {
|
||||
let contents = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read MCP server config: {}", path.display()))?;
|
||||
let config: McpServerConfigFile = toml::from_str(&contents).with_context(|| {
|
||||
format!("Failed to parse MCP server config: {}", path.display())
|
||||
})?;
|
||||
let expose_tools = config
|
||||
.server
|
||||
.expose_tools
|
||||
.unwrap_or_else(default_expose_tools);
|
||||
let require_approval = config.server.require_approval.unwrap_or(false);
|
||||
Ok(Self {
|
||||
expose_tools,
|
||||
require_approval,
|
||||
})
|
||||
} else {
|
||||
Ok(Self {
|
||||
expose_tools: default_expose_tools(),
|
||||
require_approval: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ExposedTool {
|
||||
public: String,
|
||||
internal: String,
|
||||
}
|
||||
|
||||
pub fn run_mcp_server(workspace: PathBuf) -> Result<()> {
|
||||
let settings = McpServerSettings::load()?;
|
||||
let mut server = McpServer::new(workspace, settings)?;
|
||||
server.run()
|
||||
}
|
||||
|
||||
struct McpServer {
|
||||
workspace: PathBuf,
|
||||
registry: crate::tools::ToolRegistry,
|
||||
exposed_tools: Vec<ExposedTool>,
|
||||
require_approval: bool,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
fn new(workspace: PathBuf, settings: McpServerSettings) -> Result<Self> {
|
||||
let exposed_tools = build_exposed_tools(&settings.expose_tools);
|
||||
let mut internal_names: HashSet<String> = HashSet::new();
|
||||
for tool in &exposed_tools {
|
||||
internal_names.insert(tool.internal.clone());
|
||||
}
|
||||
|
||||
let mut builder = ToolRegistryBuilder::new()
|
||||
.with_file_tools()
|
||||
.with_search_tools();
|
||||
|
||||
if internal_names.contains("apply_patch") {
|
||||
builder = builder.with_patch_tools();
|
||||
}
|
||||
if internal_names.contains("exec_shell") {
|
||||
builder = builder.with_shell_tools();
|
||||
}
|
||||
|
||||
let context = ToolContext::new(workspace.clone());
|
||||
let registry = builder.build(context);
|
||||
|
||||
Ok(Self {
|
||||
workspace,
|
||||
registry,
|
||||
exposed_tools,
|
||||
require_approval: settings.require_approval,
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&mut self) -> Result<()> {
|
||||
let runtime = Runtime::new().context("Failed to start MCP runtime")?;
|
||||
let stdin = io::stdin();
|
||||
let mut stdout = io::stdout();
|
||||
|
||||
for line in stdin.lock().lines() {
|
||||
let line = line?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(message) = serde_json::from_str::<Value>(trimmed) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(response) = self.handle_message(&runtime, message) {
|
||||
let payload = serde_json::to_string(&response)?;
|
||||
writeln!(stdout, "{payload}")?;
|
||||
stdout.flush()?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_message(&mut self, runtime: &Runtime, message: Value) -> Option<Value> {
|
||||
let method = message.get("method").and_then(Value::as_str)?;
|
||||
let id = message.get("id").cloned();
|
||||
|
||||
match method {
|
||||
"initialize" => respond(id.as_ref(), initialize_response()),
|
||||
"tools/list" => respond(id.as_ref(), self.list_tools_response()),
|
||||
"tools/call" => {
|
||||
let params = message.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
match self.call_tool(runtime, params) {
|
||||
Ok(result) => respond(id.as_ref(), result),
|
||||
Err(err) => respond_error(id.as_ref(), err.code, err.message),
|
||||
}
|
||||
}
|
||||
"resources/list" => respond(id.as_ref(), self.list_resources_response()),
|
||||
"ping" => respond(id.as_ref(), json!({})),
|
||||
"notifications/initialized" => None,
|
||||
_ => respond_error(id.as_ref(), -32601, format!("Method not found: {method}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_tools_response(&self) -> Value {
|
||||
let mut tools = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for entry in &self.exposed_tools {
|
||||
if !seen.insert(entry.public.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(tool) = self.registry.get(&entry.internal) {
|
||||
tools.push(json!({
|
||||
"name": entry.public,
|
||||
"description": tool.description(),
|
||||
"inputSchema": tool.input_schema(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
json!({ "tools": tools, "nextCursor": Value::Null })
|
||||
}
|
||||
|
||||
fn list_resources_response(&self) -> Value {
|
||||
let mut resources = Vec::new();
|
||||
resources.push(json!({
|
||||
"uri": format!("file://{}", self.workspace.display()),
|
||||
"name": "workspace",
|
||||
"description": "Workspace root",
|
||||
"mimeType": "inode/directory",
|
||||
}));
|
||||
|
||||
if let Ok(manager) = SessionManager::default_location()
|
||||
&& let Ok(sessions) = manager.list_sessions()
|
||||
{
|
||||
for session in sessions {
|
||||
resources.push(json!({
|
||||
"uri": format!("deepseek://session/{}", session.id),
|
||||
"name": session.title,
|
||||
"description": format!("{} messages", session.message_count),
|
||||
"mimeType": "application/json",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
json!({ "resources": resources, "nextCursor": Value::Null })
|
||||
}
|
||||
|
||||
fn call_tool(&self, runtime: &Runtime, params: Value) -> Result<Value, RpcError> {
|
||||
let params = params.as_object().ok_or_else(|| RpcError {
|
||||
code: -32602,
|
||||
message: "Invalid params for tools/call".to_string(),
|
||||
})?;
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| RpcError {
|
||||
code: -32602,
|
||||
message: "Missing tool name".to_string(),
|
||||
})?;
|
||||
|
||||
if self.require_approval
|
||||
&& !params
|
||||
.get("approved")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(RpcError {
|
||||
code: -32001,
|
||||
message: "Approval required. Resend with approved=true.".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let internal = self
|
||||
.exposed_tools
|
||||
.iter()
|
||||
.find(|tool| tool.public == name)
|
||||
.map(|tool| tool.internal.clone())
|
||||
.ok_or_else(|| RpcError {
|
||||
code: -32602,
|
||||
message: format!("Tool not exposed: {name}"),
|
||||
})?;
|
||||
|
||||
let arguments = params
|
||||
.get("arguments")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let result = runtime.block_on(self.registry.execute_full(&internal, arguments));
|
||||
Ok(tool_result_to_mcp(result))
|
||||
}
|
||||
}
|
||||
|
||||
fn default_config_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".deepseek").join("mcp_server.toml"))
|
||||
}
|
||||
|
||||
fn default_expose_tools() -> Vec<String> {
|
||||
vec![
|
||||
"file_read".to_string(),
|
||||
"file_write".to_string(),
|
||||
"search".to_string(),
|
||||
"apply_patch".to_string(),
|
||||
"shell".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn build_exposed_tools(names: &[String]) -> Vec<ExposedTool> {
|
||||
let mut tools = Vec::new();
|
||||
for name in names {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let public = trimmed.to_string();
|
||||
let internal = match trimmed {
|
||||
"file_read" => "read_file",
|
||||
"file_write" => "write_file",
|
||||
"file_edit" => "edit_file",
|
||||
"shell" => "exec_shell",
|
||||
"search" => "grep_files",
|
||||
"file_search" => "file_search",
|
||||
other => other,
|
||||
}
|
||||
.to_string();
|
||||
tools.push(ExposedTool { public, internal });
|
||||
}
|
||||
tools
|
||||
}
|
||||
|
||||
fn tool_result_to_mcp(result: Result<ToolResult, ToolError>) -> Value {
|
||||
match result {
|
||||
Ok(tool_result) => {
|
||||
let mut response = json!({
|
||||
"content": [{ "type": "text", "text": tool_result.content }],
|
||||
"isError": !tool_result.success,
|
||||
});
|
||||
if let Some(metadata) = tool_result.metadata {
|
||||
response["structuredContent"] = metadata;
|
||||
}
|
||||
response
|
||||
}
|
||||
Err(err) => json!({
|
||||
"content": [{ "type": "text", "text": err.to_string() }],
|
||||
"isError": true,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_response() -> Value {
|
||||
json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"serverInfo": {
|
||||
"name": "deepseek-mcp-server",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
},
|
||||
"capabilities": {
|
||||
"tools": {},
|
||||
"resources": {},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn respond(id: Option<&Value>, result: Value) -> Option<Value> {
|
||||
id.map(|id| json!({ "jsonrpc": "2.0", "id": id, "result": result }))
|
||||
}
|
||||
|
||||
fn respond_error(id: Option<&Value>, code: i64, message: String) -> Option<Value> {
|
||||
id.map(|id| {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": { "code": code, "message": message }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RpcError {
|
||||
code: i64,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn exposed_tools_map_aliases() {
|
||||
let names = vec![
|
||||
"file_read".to_string(),
|
||||
"file_write".to_string(),
|
||||
"search".to_string(),
|
||||
"apply_patch".to_string(),
|
||||
"shell".to_string(),
|
||||
];
|
||||
let tools = build_exposed_tools(&names);
|
||||
let mut map = HashMap::new();
|
||||
for tool in tools {
|
||||
map.insert(tool.public, tool.internal);
|
||||
}
|
||||
assert_eq!(map.get("file_read").map(String::as_str), Some("read_file"));
|
||||
assert_eq!(
|
||||
map.get("file_write").map(String::as_str),
|
||||
Some("write_file")
|
||||
);
|
||||
assert_eq!(map.get("search").map(String::as_str), Some("grep_files"));
|
||||
assert_eq!(
|
||||
map.get("apply_patch").map(String::as_str),
|
||||
Some("apply_patch")
|
||||
);
|
||||
assert_eq!(map.get("shell").map(String::as_str), Some("exec_shell"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
//! API request/response models for `DeepSeek` and OpenAI-compatible endpoints.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const DEFAULT_CONTEXT_WINDOW_TOKENS: u32 = 128_000;
|
||||
pub const DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS: u32 = 1_000_000;
|
||||
pub const DEFAULT_COMPACTION_TOKEN_THRESHOLD: usize = 50_000;
|
||||
pub const DEFAULT_COMPACTION_MESSAGE_THRESHOLD: usize = 50;
|
||||
const COMPACTION_THRESHOLD_PERCENT: u32 = 80;
|
||||
const COMPACTION_MESSAGE_DIVISOR: u32 = 500;
|
||||
const MAX_COMPACTION_MESSAGE_THRESHOLD: usize = 2_000;
|
||||
|
||||
// === Core Message Types ===
|
||||
|
||||
/// Request payload for sending a message to the API.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct MessageRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<Message>,
|
||||
pub max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system: Option<SystemPrompt>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<Tool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thinking: Option<serde_json::Value>,
|
||||
/// DeepSeek reasoning-effort tier: "off" | "low" | "medium" | "high" | "max".
|
||||
/// Translated by the client into DeepSeek's `reasoning_effort` + `thinking` fields.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
}
|
||||
|
||||
/// System prompt representation (plain text or structured blocks).
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum SystemPrompt {
|
||||
Text(String),
|
||||
Blocks(Vec<SystemBlock>),
|
||||
}
|
||||
|
||||
/// A structured system prompt block.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct SystemBlock {
|
||||
#[serde(rename = "type")]
|
||||
pub block_type: String,
|
||||
pub text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_control: Option<CacheControl>,
|
||||
}
|
||||
|
||||
/// A chat message with role and content blocks.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct Message {
|
||||
pub role: String,
|
||||
pub content: Vec<ContentBlock>,
|
||||
}
|
||||
|
||||
/// A single content block inside a message.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ContentBlock {
|
||||
#[serde(rename = "text")]
|
||||
Text {
|
||||
text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cache_control: Option<CacheControl>,
|
||||
},
|
||||
#[serde(rename = "thinking")]
|
||||
Thinking { thinking: String },
|
||||
#[serde(rename = "tool_use")]
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
caller: Option<ToolCaller>,
|
||||
},
|
||||
#[serde(rename = "tool_result")]
|
||||
ToolResult {
|
||||
tool_use_id: String,
|
||||
content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
is_error: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content_blocks: Option<Vec<serde_json::Value>>,
|
||||
},
|
||||
#[serde(rename = "server_tool_use")]
|
||||
ServerToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
#[serde(rename = "tool_search_tool_result")]
|
||||
ToolSearchToolResult {
|
||||
tool_use_id: String,
|
||||
content: serde_json::Value,
|
||||
},
|
||||
#[serde(rename = "code_execution_tool_result")]
|
||||
CodeExecutionToolResult {
|
||||
tool_use_id: String,
|
||||
content: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// Cache control metadata for tool definitions and blocks.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct CacheControl {
|
||||
#[serde(rename = "type")]
|
||||
pub cache_type: String,
|
||||
}
|
||||
|
||||
/// Metadata describing who invoked a tool call.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct ToolCaller {
|
||||
#[serde(rename = "type")]
|
||||
pub caller_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Tool definition exposed to the model.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct Tool {
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub tool_type: Option<String>,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_callers: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub defer_loading: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_examples: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub strict: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_control: Option<CacheControl>,
|
||||
}
|
||||
|
||||
/// Container metadata for code-execution style server tools.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ContainerInfo {
|
||||
pub id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Server-side tool usage counters.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct ServerToolUsage {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code_execution_requests: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_search_requests: Option<u32>,
|
||||
}
|
||||
|
||||
/// Response payload for a message request.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct MessageResponse {
|
||||
pub id: String,
|
||||
pub r#type: String,
|
||||
pub role: String,
|
||||
pub content: Vec<ContentBlock>,
|
||||
pub model: String,
|
||||
pub stop_reason: Option<String>,
|
||||
pub stop_sequence: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub container: Option<ContainerInfo>,
|
||||
pub usage: Usage,
|
||||
}
|
||||
|
||||
/// Token usage metadata for a response.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct Usage {
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_hit_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_miss_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_tokens: Option<u32>,
|
||||
/// Approximate input tokens spent re-sending prior `reasoning_content`
|
||||
/// across user-message boundaries in DeepSeek V4 thinking-mode tool-calling
|
||||
/// turns (V4 §5.1.1 "Interleaved Thinking"). Estimated client-side at
|
||||
/// ~4 chars/token from the outgoing request body, before the model sees it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_replay_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub server_tool_use: Option<ServerToolUsage>,
|
||||
}
|
||||
|
||||
/// Map known models to their approximate context window sizes.
|
||||
#[must_use]
|
||||
pub fn context_window_for_model(model: &str) -> Option<u32> {
|
||||
let lower = model.to_lowercase();
|
||||
// Unknown DeepSeek model IDs default to 128k unless an explicit *k suffix is present.
|
||||
// DeepSeek-V4 family and current legacy aliases ship with a 1M context window.
|
||||
if lower.contains("deepseek") {
|
||||
if let Some(explicit_window) = deepseek_context_window_hint(&lower) {
|
||||
return Some(explicit_window);
|
||||
}
|
||||
if lower.contains("v4") || is_current_deepseek_v4_alias(&lower) {
|
||||
return Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS);
|
||||
}
|
||||
return Some(DEFAULT_CONTEXT_WINDOW_TOKENS);
|
||||
}
|
||||
if lower.contains("claude") {
|
||||
return Some(200_000);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_current_deepseek_v4_alias(model_lower: &str) -> bool {
|
||||
matches!(
|
||||
model_lower,
|
||||
"deepseek-chat" | "deepseek-reasoner" | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2"
|
||||
)
|
||||
}
|
||||
|
||||
fn deepseek_context_window_hint(model_lower: &str) -> Option<u32> {
|
||||
let bytes = model_lower.as_bytes();
|
||||
let mut i = 0usize;
|
||||
while i < bytes.len() {
|
||||
if bytes[i].is_ascii_digit() {
|
||||
let start = i;
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
if i >= bytes.len() || bytes[i] != b'k' {
|
||||
continue;
|
||||
}
|
||||
|
||||
let before_ok = start == 0 || !bytes[start - 1].is_ascii_alphanumeric();
|
||||
let after_ok = i + 1 >= bytes.len() || !bytes[i + 1].is_ascii_alphanumeric();
|
||||
if !before_ok || !after_ok {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(kilo_tokens) = model_lower[start..i].parse::<u32>()
|
||||
&& (8..=1024).contains(&kilo_tokens)
|
||||
{
|
||||
return Some(kilo_tokens.saturating_mul(1000));
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Derive a compaction token threshold from model context window.
|
||||
///
|
||||
/// Keeps headroom for tool outputs and assistant completion by defaulting to 80%
|
||||
/// of known context windows.
|
||||
#[must_use]
|
||||
pub fn compaction_threshold_for_model(model: &str) -> usize {
|
||||
let Some(window) = context_window_for_model(model) else {
|
||||
return DEFAULT_COMPACTION_TOKEN_THRESHOLD;
|
||||
};
|
||||
|
||||
let threshold = (u64::from(window) * u64::from(COMPACTION_THRESHOLD_PERCENT)) / 100;
|
||||
usize::try_from(threshold).unwrap_or(DEFAULT_COMPACTION_TOKEN_THRESHOLD)
|
||||
}
|
||||
|
||||
/// Mode-aware soft context caps for V4 models.
|
||||
///
|
||||
/// DeepSeek V4 paper Figure 9 shows retrieval quality (MRCR MMR) collapses as
|
||||
/// context grows: 0.90 at 8K, 0.94 at 32K, 0.92 at 128K, 0.66 at 512K, 0.49
|
||||
/// at 1M. The paper's own eval harness uses budget tiers per §5.3.1:
|
||||
///
|
||||
/// | Mode / Reasoning tier | Soft cap | Paper eval window |
|
||||
/// |-----------------------|----------|-------------------|
|
||||
/// | Plan / Non-Think (off) | 64,000 | 8K-128K |
|
||||
/// | Agent / High | 192,000 | 128K |
|
||||
/// | YOLO / Max | 384,000 | 384K-512K |
|
||||
///
|
||||
/// These caps keep the agent inside the regime DeepSeek tuned for, triggering
|
||||
/// compaction *before* recall quality degrades. The 1M hard ceiling remains —
|
||||
/// users can override via config or by declining the /compact suggestion.
|
||||
pub const V4_PLAN_SOFT_CAP: usize = 64_000;
|
||||
pub const V4_AGENT_SOFT_CAP: usize = 192_000;
|
||||
pub const V4_YOLO_SOFT_CAP: usize = 384_000;
|
||||
|
||||
/// Compaction threshold keyed by model and caller-supplied effort tier.
|
||||
///
|
||||
/// For V4-family models the threshold is a mode-aware soft cap (see constants
|
||||
/// above). For all other models the legacy 80%-of-window rule applies.
|
||||
#[must_use]
|
||||
pub fn compaction_threshold_for_model_and_effort(
|
||||
model: &str,
|
||||
reasoning_effort: Option<&str>,
|
||||
) -> usize {
|
||||
let lower = model.to_lowercase();
|
||||
if !lower.contains("deepseek")
|
||||
|| !(lower.contains("v4") || is_current_deepseek_v4_alias(&lower))
|
||||
{
|
||||
return compaction_threshold_for_model(model);
|
||||
}
|
||||
|
||||
match reasoning_effort.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some("off" | "disabled" | "none" | "false") => V4_PLAN_SOFT_CAP,
|
||||
Some("low" | "medium" | "high") => V4_AGENT_SOFT_CAP,
|
||||
Some("max" | "maximum" | "xhigh") => V4_YOLO_SOFT_CAP,
|
||||
_ => V4_AGENT_SOFT_CAP,
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a compaction message-count threshold from model context window.
|
||||
#[must_use]
|
||||
pub fn compaction_message_threshold_for_model(model: &str) -> usize {
|
||||
let Some(window) = context_window_for_model(model) else {
|
||||
return DEFAULT_COMPACTION_MESSAGE_THRESHOLD;
|
||||
};
|
||||
|
||||
let scaled = usize::try_from(window / COMPACTION_MESSAGE_DIVISOR)
|
||||
.unwrap_or(DEFAULT_COMPACTION_MESSAGE_THRESHOLD);
|
||||
scaled.clamp(
|
||||
DEFAULT_COMPACTION_MESSAGE_THRESHOLD,
|
||||
MAX_COMPACTION_MESSAGE_THRESHOLD,
|
||||
)
|
||||
}
|
||||
|
||||
// === Streaming Structures ===
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(tag = "type")]
|
||||
/// Streaming event types for SSE responses.
|
||||
pub enum StreamEvent {
|
||||
#[serde(rename = "message_start")]
|
||||
MessageStart { message: MessageResponse },
|
||||
#[serde(rename = "content_block_start")]
|
||||
ContentBlockStart {
|
||||
index: u32,
|
||||
content_block: ContentBlockStart,
|
||||
},
|
||||
#[serde(rename = "content_block_delta")]
|
||||
ContentBlockDelta { index: u32, delta: Delta },
|
||||
#[serde(rename = "content_block_stop")]
|
||||
ContentBlockStop { index: u32 },
|
||||
#[serde(rename = "message_delta")]
|
||||
MessageDelta {
|
||||
delta: MessageDelta,
|
||||
usage: Option<Usage>,
|
||||
},
|
||||
#[serde(rename = "message_stop")]
|
||||
MessageStop,
|
||||
#[serde(rename = "ping")]
|
||||
Ping,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(tag = "type")]
|
||||
/// Content block types used in streaming starts.
|
||||
pub enum ContentBlockStart {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "thinking")]
|
||||
Thinking { thinking: String },
|
||||
#[serde(rename = "tool_use")]
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: serde_json::Value, // usually empty or partial
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
caller: Option<ToolCaller>,
|
||||
},
|
||||
#[serde(rename = "server_tool_use")]
|
||||
ServerToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
// Variant names match legacy streaming spec, suppressing style warning
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(tag = "type")]
|
||||
/// Delta events emitted during streaming responses.
|
||||
pub enum Delta {
|
||||
#[serde(rename = "text_delta")]
|
||||
TextDelta { text: String },
|
||||
#[serde(rename = "thinking_delta")]
|
||||
ThinkingDelta { thinking: String },
|
||||
#[serde(rename = "input_json_delta")]
|
||||
InputJsonDelta { partial_json: String },
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
/// Delta payload for message-level updates.
|
||||
pub struct MessageDelta {
|
||||
pub stop_reason: Option<String>,
|
||||
pub stop_sequence: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn current_deepseek_aliases_map_to_v4_1m_context_window() {
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-reasoner"),
|
||||
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-chat"),
|
||||
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-v3"),
|
||||
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-v3.2"),
|
||||
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_deepseek_models_map_to_128k_context_window() {
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-coder"),
|
||||
Some(DEFAULT_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-v3.2-0324"),
|
||||
Some(DEFAULT_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_models_map_to_1m_context_window() {
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-v4-pro"),
|
||||
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-v4-flash"),
|
||||
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-ai/deepseek-v4-pro"),
|
||||
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_models_with_k_suffix_use_hint() {
|
||||
assert_eq!(context_window_for_model("deepseek-v3.2-32k"), Some(32_000));
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-v3.2-256k-preview"),
|
||||
Some(256_000)
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-v3.2-2k-preview"),
|
||||
Some(DEFAULT_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_threshold_scales_with_context_window() {
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model("deepseek-v3.2-128k"),
|
||||
102_400
|
||||
);
|
||||
assert_eq!(compaction_threshold_for_model("unknown-model"), 50_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_message_threshold_scales_with_context_window() {
|
||||
assert_eq!(
|
||||
compaction_message_threshold_for_model("deepseek-v3.2-128k"),
|
||||
256
|
||||
);
|
||||
assert_eq!(compaction_message_threshold_for_model("unknown-model"), 50);
|
||||
// 200k / 500 = 400, within the 2k cap.
|
||||
assert_eq!(compaction_message_threshold_for_model("claude-3"), 400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_scales_for_deepseek_v4_1m_context() {
|
||||
assert_eq!(compaction_threshold_for_model("deepseek-v4-pro"), 800_000);
|
||||
assert_eq!(
|
||||
compaction_message_threshold_for_model("deepseek-v4-pro"),
|
||||
2_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_mode_aware_soft_caps() {
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("deepseek-v4-pro", Some("off")),
|
||||
V4_PLAN_SOFT_CAP
|
||||
);
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("deepseek-v4-pro", Some("high")),
|
||||
V4_AGENT_SOFT_CAP
|
||||
);
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("deepseek-v4-pro", Some("max")),
|
||||
V4_YOLO_SOFT_CAP
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_soft_caps_only_apply_to_v4_models() {
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("deepseek-v3.2-128k", Some("max")),
|
||||
102_400
|
||||
);
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("unknown-model", Some("max")),
|
||||
50_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_soft_cap_defaults_to_agent_when_effort_unknown() {
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("deepseek-v4-pro", None),
|
||||
V4_AGENT_SOFT_CAP
|
||||
);
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("deepseek-v4-pro", Some("unknown")),
|
||||
V4_AGENT_SOFT_CAP
|
||||
);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 261 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user