mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 01:51:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
Whitespace-only changes.
@@ -23,7 +23,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,9 +36,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 --all-features -- eval
|
||||
run: cargo run -p deepseek-tui --all-features -- eval
|
||||
|
||||
build:
|
||||
name: Build
|
||||
@@ -53,6 +53,51 @@ jobs:
|
||||
- name: Build
|
||||
run: cargo build --release
|
||||
|
||||
npm-wrapper-smoke:
|
||||
name: npm wrapper smoke
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-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
|
||||
@@ -62,6 +107,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,39 @@
|
||||
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
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
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
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --provenance --access public
|
||||
@@ -4,11 +4,42 @@ on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Dwarnings
|
||||
|
||||
jobs:
|
||||
parity:
|
||||
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 +56,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 +99,39 @@ 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 — no NPM_TOKEN required.
|
||||
# Configure the trusted publisher on the npm package settings page.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Publish wrapper to npm
|
||||
working-directory: npm/deepseek-tui
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --provenance --access public
|
||||
+15
-7
@@ -11,6 +11,7 @@
|
||||
# Development
|
||||
.env
|
||||
.env.*
|
||||
node_modules/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
@@ -33,25 +34,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 @@
|
||||
Hunter Bown <[email protected]> devin-ai-integration[bot] <158243242+devin-ai-integration[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.
|
||||
+363
-11
@@ -7,6 +7,341 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
@@ -134,7 +469,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)
|
||||
@@ -145,18 +480,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Hooks system and config profiles
|
||||
- Example skills and launch assets
|
||||
|
||||
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.2...HEAD
|
||||
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.33...HEAD
|
||||
[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.1...v0.2.2
|
||||
[0.2.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.0...v0.2.1
|
||||
[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
+1034
-6
File diff suppressed because it is too large.
Load diff
+31
-56
@@ -1,68 +1,43 @@
|
||||
[package]
|
||||
name = "deepseek-tui"
|
||||
version = "0.3.3"
|
||||
[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.4.4"
|
||||
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"
|
||||
ignore = "0.4"
|
||||
|
||||
[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"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.60", features = ["Win32_Foundation"] }
|
||||
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,191 +0,0 @@
|
||||
# Parity Spec: Codex vs Claude Code
|
||||
|
||||
This document defines "parity" as measurable behavior in this repository.
|
||||
It is intended to be short, testable, and easy to run during reviews.
|
||||
|
||||
## Scope
|
||||
|
||||
Parity is evaluated on:
|
||||
|
||||
- Instruction following (including `AGENTS.md` and task constraints)
|
||||
- Rust/Cargo workflow discipline
|
||||
- Change quality and scope control
|
||||
- Safety and repo hygiene
|
||||
- Clear, audit-friendly reporting
|
||||
|
||||
Unless a task says otherwise, parity targets the default Rust workflow:
|
||||
|
||||
1) search with `rg` 2) edit minimally 3) validate with Cargo commands.
|
||||
|
||||
## Parity Behaviors (Measurable)
|
||||
|
||||
An agent is considered at parity when it reliably exhibits the following
|
||||
behaviors on eval tasks.
|
||||
|
||||
### 1) Instruction and Scope Compliance
|
||||
|
||||
Required behaviors:
|
||||
|
||||
- Respects path constraints (for example: "do not edit `src/*`")
|
||||
- Does not revert or disturb unrelated user changes
|
||||
- Avoids destructive git commands (for example: `git reset --hard`)
|
||||
- Stops and reports if unexpected repo changes appear mid-task
|
||||
|
||||
Suggested metrics:
|
||||
|
||||
- `scope_violations = 0` (no edits outside allowed paths)
|
||||
- `destructive_git_cmds = 0`
|
||||
- `unrelated_reverts = 0`
|
||||
|
||||
### 2) Rust/Cargo Workflow Discipline
|
||||
|
||||
Required behaviors:
|
||||
|
||||
- Uses Cargo as the source of truth for validation
|
||||
- Chooses appropriate checks for the task size/scope
|
||||
- Reports validation outcomes clearly (pass/fail + command)
|
||||
|
||||
Suggested metrics (binary unless noted):
|
||||
|
||||
- `cargo_check_pass`
|
||||
- `cargo_test_pass` (required for most parity gates)
|
||||
- `cargo_fmt_check_pass` (when formatting could be affected)
|
||||
- `cargo_clippy_pass` (recommended for non-trivial code edits)
|
||||
- `validation_reported = 1` (commands + outcomes are stated)
|
||||
|
||||
### 3) Change Quality and Minimality
|
||||
|
||||
Required behaviors:
|
||||
|
||||
- Keeps edits focused and atomic
|
||||
- Preserves existing style and patterns
|
||||
- Updates documentation when public behavior changes
|
||||
|
||||
Suggested metrics:
|
||||
|
||||
- `task_acceptance_pass = 1` (task-specific checks succeed)
|
||||
- `files_touched_within_expectation = 1`
|
||||
- `style_regressions = 0` (via `fmt`/`clippy`/review)
|
||||
|
||||
### 4) Reporting Quality
|
||||
|
||||
Required behaviors:
|
||||
|
||||
- States what changed, where, and why
|
||||
- Provides clickable file references
|
||||
- Separates results from speculation
|
||||
|
||||
Suggested metrics:
|
||||
|
||||
- `changed_files_listed = 1`
|
||||
- `key_paths_cited = 1`
|
||||
- `claims_match_repo_state = 1`
|
||||
|
||||
## Parity Metrics and Gates
|
||||
|
||||
Use these gates for pass/fail decisions.
|
||||
|
||||
### Hard Gates (must pass)
|
||||
|
||||
- No scope violations
|
||||
- No destructive git commands
|
||||
- `cargo test` exits 0
|
||||
- Task-specific acceptance checks pass
|
||||
|
||||
### Soft Gates (should pass; track as %)
|
||||
|
||||
- `cargo check` exits 0
|
||||
- `cargo fmt --check` exits 0
|
||||
- `cargo clippy --all-targets --all-features` exits 0
|
||||
- Edits are minimal and well-scoped
|
||||
- Reporting is complete and auditable
|
||||
|
||||
A simple parity score can be computed as:
|
||||
|
||||
- Fail immediately on any hard-gate violation
|
||||
- Otherwise: `score = soft_gates_passed / soft_gates_total`
|
||||
|
||||
Target: `score >= 0.8` over a representative eval set.
|
||||
|
||||
## Evaluation Rubric (Short)
|
||||
|
||||
Score each dimension 0-2. Parity requires both conditions:
|
||||
|
||||
- No hard-gate violations
|
||||
- Total score >= 7/8
|
||||
|
||||
Dimensions:
|
||||
|
||||
- Correctness: solution satisfies the task and acceptance checks
|
||||
- Scope/Safety: constraints honored; no risky repo operations
|
||||
- Rust Workflow: appropriate Cargo validation is used and reported
|
||||
- Communication: changes and evidence are clear and well-referenced
|
||||
|
||||
Suggested anchors:
|
||||
|
||||
- 2 = consistently strong, no notable gaps
|
||||
- 1 = acceptable but with minor gaps or ambiguity
|
||||
- 0 = missing, incorrect, or risky
|
||||
|
||||
## Rust/Cargo Eval Task Categories
|
||||
|
||||
Use a small mix from each category to assess parity.
|
||||
|
||||
### A. Cargo Validation Loops
|
||||
|
||||
- Fix a failing test, then run `cargo test`
|
||||
- Resolve a compiler error, validate with `cargo check`
|
||||
- Address a lint warning, validate with `cargo clippy`
|
||||
|
||||
### B. Tests and Behavior Lock-In
|
||||
|
||||
- Add unit tests for a small module
|
||||
- Add an integration test under `tests/`
|
||||
- Convert a bug report into a regression test + fix
|
||||
|
||||
### C. Dependencies and Features
|
||||
|
||||
- Add a small crate and wire it into `Cargo.toml`
|
||||
- Gate behavior behind a feature flag
|
||||
- Make code compile cleanly with `--all-features`
|
||||
|
||||
### D. CLI and Config Surface
|
||||
|
||||
- Adjust a Clap flag/help string and update docs
|
||||
- Add/modify a config field and update documentation
|
||||
- Ensure `--help` output remains accurate
|
||||
|
||||
### E. Repo-Safe Documentation Tasks
|
||||
|
||||
- Update `README.md` or `docs/*` without touching `src/*`
|
||||
- Add a short spec doc (like this one) and validate with tests
|
||||
- Reconcile docs with current Cargo commands and project norms
|
||||
|
||||
## Milestone Checklist
|
||||
|
||||
Track parity progress in small, observable steps.
|
||||
|
||||
### M1: Safety + Docs Parity
|
||||
|
||||
- [ ] No scope violations on doc-only tasks
|
||||
- [ ] No destructive git commands across evals
|
||||
- [ ] `cargo test` is run and reported
|
||||
|
||||
### M2: Core Rust Workflow Parity
|
||||
|
||||
- [ ] `cargo check`/`test` used appropriately by default
|
||||
- [ ] Formatting and linting considered when relevant
|
||||
- [ ] Changes remain minimal and consistent with repo patterns
|
||||
|
||||
### M3: Feature and Regression Parity
|
||||
|
||||
- [ ] Bugs are captured with tests before or with fixes
|
||||
- [ ] `--all-features` and integration tests are handled cleanly
|
||||
- [ ] Public behavior changes include doc updates
|
||||
|
||||
### M4: Review-Ready Parity
|
||||
|
||||
- [ ] Reports include commands, outcomes, and key file refs
|
||||
- [ ] Soft-gate score >= 0.8 across the eval set
|
||||
- [ ] Maintainers can reproduce validation steps quickly
|
||||
|
||||
@@ -1,288 +1,130 @@
|
||||
# DeepSeek CLI 🤖
|
||||
# DeepSeek TUI
|
||||
|
||||
Your AI-powered terminal companion for DeepSeek models
|
||||
`npm i -g deepseek-tui`
|
||||
|
||||
A coding agent for [DeepSeek](https://platform.deepseek.com) models that runs in your terminal.
|
||||
|
||||
[](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml)
|
||||
[](https://crates.io/crates/deepseek-tui)
|
||||
[](https://www.npmjs.com/package/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.
|
||||
<p align="center">
|
||||
<img src="assets/hero.png" alt="DeepSeek TUI" width="800">
|
||||
</p>
|
||||
|
||||
**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, coordinate, and cancel background agents (including swarms)
|
||||
- **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**:
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
# Install via Cargo
|
||||
cargo install deepseek-tui --locked
|
||||
npm install -g deepseek-tui
|
||||
```
|
||||
|
||||
# Set your API key
|
||||
export DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY"
|
||||
Start the TUI:
|
||||
|
||||
# Bootstrap MCP + skills templates (recommended)
|
||||
deepseek setup
|
||||
|
||||
# Start chatting
|
||||
```bash
|
||||
deepseek
|
||||
```
|
||||
|
||||
3. Press `F1` or type `/help` for the in‑app command list.
|
||||
On first launch, it will prompt for your API key if one is not already configured.
|
||||
The package also installs `deepseek-tui`; both commands share the same
|
||||
`~/.deepseek/config.toml` for DeepSeek auth and default model settings.
|
||||
|
||||
If anything looks off, run `deepseek doctor` to diagnose configuration issues.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### From crates.io
|
||||
You can also set auth ahead of time with either of these:
|
||||
|
||||
```bash
|
||||
cargo install deepseek-tui --locked
|
||||
deepseek login --api-key "YOUR_DEEPSEEK_API_KEY"
|
||||
deepseek-tui login --api-key "YOUR_DEEPSEEK_API_KEY"
|
||||
DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY" deepseek-tui
|
||||
```
|
||||
|
||||
### Build from source
|
||||
<details>
|
||||
<summary>Other install methods</summary>
|
||||
|
||||
```bash
|
||||
# From crates.io (requires Rust 1.85+)
|
||||
cargo install deepseek-tui --locked # TUI
|
||||
cargo install deepseek-tui-cli --locked # deepseek CLI facade
|
||||
|
||||
# From source
|
||||
git clone https://github.com/Hmbown/DeepSeek-TUI.git
|
||||
cd DeepSeek-TUI
|
||||
cargo build --release
|
||||
./target/release/deepseek --help
|
||||
cargo install --path crates/tui --locked
|
||||
```
|
||||
|
||||
### Direct download
|
||||
The canonical crates.io packages for this repository are `deepseek-tui` and
|
||||
`deepseek-tui-cli`. The unrelated `deepseek-cli` crate is not part of this
|
||||
project. crates.io publication can lag the repository workspace version and the
|
||||
npm wrapper, so use npm or install from source if you need the newest release
|
||||
surface immediately.
|
||||
|
||||
Download a prebuilt binary from [GitHub Releases](https://github.com/Hmbown/DeepSeek-TUI/releases) and put it on your `PATH` as `deepseek`.
|
||||
</details>
|
||||
|
||||
## ⚙️ Configuration
|
||||
## What it does
|
||||
|
||||
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:
|
||||
A terminal coding agent for DeepSeek models with file editing, shell execution, `web.run` browsing, git operations, session resume, and [MCP](https://modelcontextprotocol.io) server integration.
|
||||
|
||||
```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‑20)
|
||||
```
|
||||
Three visible modes (**Tab** to cycle):
|
||||
|
||||
Useful environment variables:
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| **Plan** | Review a plan before the agent starts making changes |
|
||||
| **Agent** | Default interactive mode with multi-step tool use |
|
||||
| **YOLO** | Auto-approve tools in a trusted workspace |
|
||||
|
||||
- `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`
|
||||
**Shift+Tab** cycles the reasoning-effort tier for DeepSeek thinking mode:
|
||||
`off` → `high` → `max`. The current tier is shown as a ⚡ chip in the header.
|
||||
Set a default in config with `reasoning_effort = "max"` (or `off` / `low` /
|
||||
`medium` / `high`).
|
||||
|
||||
To bootstrap MCP and skills at their resolved locations, run `deepseek setup`. To
|
||||
only create an MCP template, run `deepseek mcp init`.
|
||||
## Models & pricing
|
||||
|
||||
See `config.example.toml` and `docs/CONFIGURATION.md` for a full reference.
|
||||
| Model | Thinking | Context | Input cache hit | Input cache miss | Output |
|
||||
|---|---|---|---|---|---|
|
||||
| `deepseek-v4-pro` | default | 1M | $0.145 / 1M | $1.74 / 1M | $3.48 / 1M |
|
||||
| `deepseek-v4-flash` | default | 1M | $0.028 / 1M | $0.14 / 1M | $0.28 / 1M |
|
||||
|
||||
## 🎮 Modes
|
||||
Legacy `deepseek-chat` and `deepseek-reasoner` remain as silent aliases for
|
||||
`deepseek-v4-flash` (priced identically). Pricing is per 1M tokens as published
|
||||
by DeepSeek and is subject to change.
|
||||
|
||||
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_swarm`** – Launch a dependency‑aware swarm of sub‑agents
|
||||
- **`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`, or `./skills` per workspace). Use `/skills` and `/skill <name>`. Bootstrap with `deepseek setup --skills` (add `--local` for `./skills`).
|
||||
- **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
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
deepseek
|
||||
deepseek # interactive TUI
|
||||
deepseek "explain this in 2 sentences" # one-shot prompt
|
||||
deepseek --model deepseek-v4-flash "summarize" # one-shot with model override
|
||||
deepseek --yolo # YOLO mode
|
||||
deepseek login --api-key "..." # save API key to shared config
|
||||
deepseek doctor # check setup
|
||||
deepseek models # list live DeepSeek API models
|
||||
deepseek sessions # list saved sessions
|
||||
deepseek resume --last # resume the latest session
|
||||
deepseek serve --http # HTTP/SSE API server
|
||||
```
|
||||
|
||||
### One‑shot prompt (non‑interactive)
|
||||
Controls: `F1` help, `Esc` backs out of the current action, `Ctrl+K` command palette.
|
||||
|
||||
```bash
|
||||
deepseek -p "Write a haiku about Rust"
|
||||
```
|
||||
## Configuration
|
||||
|
||||
### Agentic execution with tool access
|
||||
`~/.deepseek/config.toml` — see [config.example.toml](config.example.toml) for all options.
|
||||
|
||||
```bash
|
||||
deepseek exec --auto "Fix lint errors in the current directory"
|
||||
```
|
||||
Key environment overrides: `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`,
|
||||
`DEEPSEEK_MODEL`, `DEEPSEEK_PROFILE`.
|
||||
|
||||
### Resume latest session
|
||||
The client targets DeepSeek's documented OpenAI-compatible Chat Completions API
|
||||
(`/chat/completions`). DeepSeek context caching is automatic; when the API
|
||||
returns cache hit/miss token fields, the TUI includes them in usage and cost
|
||||
tracking.
|
||||
|
||||
```bash
|
||||
deepseek --continue
|
||||
```
|
||||
Full reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
|
||||
|
||||
### Work on a specific project
|
||||
## Docs
|
||||
|
||||
```bash
|
||||
deepseek --workspace /path/to/project
|
||||
```
|
||||
[docs/](docs/) — configuration, modes, MCP integration, runtime API, and release runbooks.
|
||||
|
||||
### Review staged git changes
|
||||
## Contributing
|
||||
|
||||
```bash
|
||||
deepseek review --staged
|
||||
```
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md). Not affiliated with DeepSeek Inc.
|
||||
|
||||
### Apply a patch file
|
||||
## License
|
||||
|
||||
```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`.
|
||||
|
||||
### Skills missing
|
||||
Run `deepseek setup --skills` to create a global skills directory, or add `--local`
|
||||
to create `./skills` for the current workspace. Then run `deepseek doctor` to see
|
||||
which skills directory is selected.
|
||||
|
||||
### MCP tools missing
|
||||
Run `deepseek mcp init` (or `deepseek setup --mcp`), then restart. `deepseek doctor`
|
||||
now checks the MCP path resolved from your config/env overrides.
|
||||
|
||||
### Sandbox errors (macOS)
|
||||
Run `deepseek doctor` to confirm sandbox availability. On macOS, ensure
|
||||
`/usr/bin/sandbox-exec` exists. 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
|
||||
|
||||
---
|
||||
|
||||
DeepSeek is a trademark of DeepSeek Inc. This is an unofficial project.
|
||||
[MIT](LICENSE)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
+55
-18
@@ -1,5 +1,5 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════════════════╗
|
||||
# ║ DeepSeek CLI Configuration ║
|
||||
# ║ DeepSeek TUI Configuration ║
|
||||
# ║ ║
|
||||
# ║ Unofficial CLI for DeepSeek Platform - Not affiliated with DeepSeek Inc. ║
|
||||
# ╚══════════════════════════════════════════════════════════════════════════════╝
|
||||
@@ -16,11 +16,28 @@ api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
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
|
||||
# deepseek-v4-flash — fast, cost-efficient (legacy aliases: deepseek-chat, deepseek-reasoner)
|
||||
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,21 +46,29 @@ 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"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# TUI
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
[tui]
|
||||
alternate_screen = "auto" # auto | always | never
|
||||
mouse_capture = false # false keeps terminal-native drag selection/copy; true enables internal mouse scrolling/selection
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Feature Flags
|
||||
@@ -51,11 +76,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
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -69,24 +92,32 @@ max_delay = 60.0
|
||||
exponential_base = 2.0
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Context Compaction (PLANNED - not yet implemented)
|
||||
# Context Compaction (config-level tuning not yet wired; use /set auto_compact on|off)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# [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
|
||||
# model = "deepseek-v4-flash" # Model to use for summarization
|
||||
# cache_summary = true # Keep summary blocks stable; DeepSeek context caching is automatic
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# 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)
|
||||
@@ -112,4 +143,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.4.4" }
|
||||
serde.workspace = true
|
||||
@@ -0,0 +1,139 @@
|
||||
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: "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.insert(normalize(&model.id), idx);
|
||||
for alias in &model.aliases {
|
||||
alias_map.insert(normalize(alias), 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(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()
|
||||
}
|
||||
@@ -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.4.4" }
|
||||
deepseek-config = { path = "../config", version = "0.4.4" }
|
||||
deepseek-core = { path = "../core", version = "0.4.4" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.4.4" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.4.4" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.4.4" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.4.4" }
|
||||
deepseek-state = { path = "../state", version = "0.4.4" }
|
||||
deepseek-tools = { path = "../tools", version = "0.4.4" }
|
||||
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.4.4" }
|
||||
deepseek-app-server = { path = "../app-server", version = "0.4.4" }
|
||||
deepseek-config = { path = "../config", version = "0.4.4" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.4.4" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.4.4" }
|
||||
deepseek-state = { path = "../state", version = "0.4.4" }
|
||||
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,645 @@
|
||||
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_OPENAI_MODEL: &str = "gpt-4.1";
|
||||
const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com";
|
||||
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,
|
||||
Openai,
|
||||
}
|
||||
|
||||
impl ProviderKind {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Deepseek => "deepseek",
|
||||
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),
|
||||
"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 openai: ProviderConfigToml,
|
||||
}
|
||||
|
||||
impl ProvidersToml {
|
||||
#[must_use]
|
||||
pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
|
||||
match provider {
|
||||
ProviderKind::Deepseek => &self.deepseek,
|
||||
ProviderKind::Openai => &self.openai,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
|
||||
match provider {
|
||||
ProviderKind::Deepseek => &mut self.deepseek,
|
||||
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.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()),
|
||||
_ => {
|
||||
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,
|
||||
_ => {
|
||||
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());
|
||||
}
|
||||
|
||||
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::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::Openai => DEFAULT_OPENAI_MODEL.to_string(),
|
||||
});
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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>,
|
||||
deepseek_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()),
|
||||
deepseek_base_url: std::env::var("DEEPSEEK_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::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::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>,
|
||||
}
|
||||
|
||||
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"),
|
||||
};
|
||||
// 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");
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 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.4.4" }
|
||||
deepseek-config = { path = "../config", version = "0.4.4" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.4.4" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.4.4" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.4.4" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.4.4" }
|
||||
deepseek-state = { path = "../state", version = "0.4.4" }
|
||||
deepseek-tools = { path = "../tools", version = "0.4.4" }
|
||||
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.4.4" }
|
||||
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.4.4" }
|
||||
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.4.4" }
|
||||
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.4.4" }
|
||||
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. Use an image or video file path.",
|
||||
);
|
||||
};
|
||||
|
||||
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,571 @@
|
||||
//! Config commands: config, settings, mode switches, trust, logout
|
||||
|
||||
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.",
|
||||
)
|
||||
}
|
||||
|
||||
/// Enable trust mode (file access outside workspace)
|
||||
pub fn trust(app: &mut App) -> CommandResult {
|
||||
app.trust_mode = true;
|
||||
CommandResult::message("Trust mode enabled - can access files outside workspace")
|
||||
}
|
||||
|
||||
/// 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_enables_flag() {
|
||||
let mut app = create_test_app();
|
||||
assert!(!app.trust_mode);
|
||||
let result = trust(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Trust mode enabled"));
|
||||
assert!(app.trust_mode);
|
||||
}
|
||||
|
||||
#[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,474 @@
|
||||
//! 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
|
||||
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 {
|
||||
let common = COMMON_DEEPSEEK_MODELS.join(", ");
|
||||
CommandResult::message(format!(
|
||||
"Current model: {}\nCommon models: {}\nTip: any valid DeepSeek model ID is accepted. Run /models to fetch live IDs from your API endpoint.",
|
||||
app.model, common
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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_shows_info() {
|
||||
let mut app = create_test_app();
|
||||
let result = model(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Current model:"));
|
||||
assert!(msg.contains("Common models:"));
|
||||
assert!(msg.contains("any valid DeepSeek model ID"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 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;
|
||||
@@ -10,11 +11,11 @@ mod init;
|
||||
mod note;
|
||||
mod queue;
|
||||
mod review;
|
||||
pub mod rlm;
|
||||
mod session;
|
||||
mod skills;
|
||||
mod task;
|
||||
|
||||
use crate::tui::app::{App, AppAction, AppMode};
|
||||
use crate::tui::app::{App, AppAction};
|
||||
|
||||
/// Result of executing a command
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -77,6 +78,28 @@ pub struct CommandInfo {
|
||||
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
|
||||
@@ -104,6 +127,12 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
description: "Switch or view current model",
|
||||
usage: "/model [name]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "models",
|
||||
aliases: &[],
|
||||
description: "List available models from API",
|
||||
usage: "/models",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "queue",
|
||||
aliases: &["queued"],
|
||||
@@ -117,10 +146,10 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
usage: "/subagents",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "deepseek",
|
||||
name: "links",
|
||||
aliases: &["dashboard", "api"],
|
||||
description: "Show DeepSeek dashboard and docs links",
|
||||
usage: "/deepseek",
|
||||
usage: "/links",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "home",
|
||||
@@ -134,6 +163,18 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
description: "Append note to persistent notes file (.deepseek/notes.md)",
|
||||
usage: "/note <text>",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "attach",
|
||||
aliases: &["image", "media"],
|
||||
description: "Attach a local image or video path to the next message",
|
||||
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",
|
||||
@@ -150,43 +191,13 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
CommandInfo {
|
||||
name: "load",
|
||||
aliases: &[],
|
||||
description: "Load session from file (or RLM context in RLM mode)",
|
||||
description: "Load session from file",
|
||||
usage: "/load [path]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "rlm",
|
||||
aliases: &[],
|
||||
description: "Enter RLM (Aleph) mode and show quickstart",
|
||||
usage: "/rlm",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "aleph",
|
||||
aliases: &[],
|
||||
description: "Alias for /rlm (external memory quickstart)",
|
||||
usage: "/aleph",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "save-session",
|
||||
aliases: &["save_session"],
|
||||
description: "Save RLM session to file",
|
||||
usage: "/save-session [path]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "status",
|
||||
aliases: &[],
|
||||
description: "Show RLM context status",
|
||||
usage: "/status",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "repl",
|
||||
aliases: &[],
|
||||
description: "Toggle RLM REPL mode",
|
||||
usage: "/repl",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "compact",
|
||||
aliases: &[],
|
||||
description: "Toggle auto-compaction",
|
||||
description: "Trigger context compaction to free up space",
|
||||
usage: "/compact",
|
||||
},
|
||||
CommandInfo {
|
||||
@@ -199,21 +210,27 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
CommandInfo {
|
||||
name: "config",
|
||||
aliases: &[],
|
||||
description: "Display current configuration",
|
||||
description: "Open interactive configuration editor",
|
||||
usage: "/config",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "set",
|
||||
aliases: &[],
|
||||
description: "Modify a setting",
|
||||
usage: "/set <key> <value>",
|
||||
},
|
||||
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: &[],
|
||||
@@ -311,35 +328,28 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"clear" => core::clear(app),
|
||||
"exit" | "quit" | "q" => core::exit(),
|
||||
"model" => core::model(app, arg),
|
||||
"models" => core::models(app),
|
||||
"queue" | "queued" => queue::queue(app, arg),
|
||||
"subagents" | "agents" => core::subagents(app),
|
||||
"deepseek" | "dashboard" | "api" => core::deepseek_links(),
|
||||
"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" => {
|
||||
let force_rlm = arg.is_some_and(|raw| raw.trim_start().starts_with('@'));
|
||||
if app.mode == AppMode::Rlm || force_rlm {
|
||||
rlm::load(app, arg)
|
||||
} else {
|
||||
session::load(app, arg)
|
||||
}
|
||||
}
|
||||
"rlm" | "aleph" => rlm::enter(app),
|
||||
"save-session" | "save_session" => rlm::save_session(app, arg),
|
||||
"status" => rlm::status(app),
|
||||
"repl" => rlm::repl(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),
|
||||
"set" => config::set_config(app, arg),
|
||||
"yolo" => config::yolo(app),
|
||||
"agent" => config::agent_mode(app),
|
||||
"plan" => config::plan_mode(app),
|
||||
"trust" => config::trust(app),
|
||||
"logout" => config::logout(app),
|
||||
|
||||
@@ -359,12 +369,40 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"skill" => skills::run_skill(app, arg),
|
||||
"review" => review::review(app, arg),
|
||||
|
||||
_ => CommandResult::error(format!(
|
||||
"Unknown command: /{command}. Type /help for available commands."
|
||||
)),
|
||||
// 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);
|
||||
@@ -385,25 +423,103 @@ pub fn commands_matching(prefix: &str) -> Vec<&'static CommandInfo> {
|
||||
.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::execute;
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, AppMode, TuiOptions};
|
||||
use std::fs;
|
||||
use crate::tui::app::{App, AppAction, TuiOptions};
|
||||
use std::path::PathBuf;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn make_test_app(workspace: PathBuf) -> App {
|
||||
let skills_dir = workspace.join("skills");
|
||||
let _ = fs::create_dir_all(&skills_dir);
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "test-model".to_string(),
|
||||
workspace,
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
allow_shell: false,
|
||||
use_alt_screen: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
@@ -417,22 +533,84 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_at_path_uses_rlm_outside_rlm_mode() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let file = tmp.path().join("example.txt");
|
||||
fs::write(&file, "hello").expect("write");
|
||||
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"));
|
||||
}
|
||||
|
||||
let mut app = make_test_app(tmp.path().to_path_buf());
|
||||
app.mode = AppMode::Normal;
|
||||
#[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"]);
|
||||
}
|
||||
|
||||
let result = execute("/load @example.txt", &mut app);
|
||||
let message = result.message.unwrap_or_default();
|
||||
assert!(
|
||||
message.starts_with("Loaded "),
|
||||
"expected RLM load message, got: {message}"
|
||||
);
|
||||
#[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)));
|
||||
}
|
||||
|
||||
let session = app.rlm_session.lock().expect("lock session");
|
||||
assert!(!session.contexts.is_empty());
|
||||
#[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,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");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,748 @@
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! 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::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,
|
||||
},
|
||||
|
||||
// === 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,8 @@
|
||||
//! - `session`: Session state management
|
||||
//! - `turn`: Turn context and tracking
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod capacity;
|
||||
pub mod capacity_memory;
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -13,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>,
|
||||
@@ -31,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,
|
||||
|
||||
@@ -53,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,
|
||||
}
|
||||
|
||||
@@ -62,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,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(),
|
||||
@@ -104,13 +120,6 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -121,14 +130,4 @@ impl Session {
|
||||
self.working_set
|
||||
.rebuild_from_messages(&self.messages, &self.workspace);
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
@@ -448,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,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,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -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.
File renamed without changes.
@@ -1,3 +1,5 @@
|
||||
// TODO(integrate): Wire exec-policy into shell tool — tracked as future work
|
||||
#![allow(dead_code)]
|
||||
#![allow(unused_imports)]
|
||||
|
||||
pub mod amend;
|
||||
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.
File renamed without changes.
@@ -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,
|
||||
File renamed without changes.
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File renamed without changes.
@@ -0,0 +1,453 @@
|
||||
//! 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>,
|
||||
#[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)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
);
|
||||
}
|
||||
|
||||
#[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() {
|
||||
// 80% of 1M = 800k tokens before token-based compaction.
|
||||
assert_eq!(compaction_threshold_for_model("deepseek-v4-pro"), 800_000);
|
||||
// 1M / 500 = 2k messages before message-count compaction.
|
||||
assert_eq!(
|
||||
compaction_message_threshold_for_model("deepseek-v4-pro"),
|
||||
2_000
|
||||
);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -180,6 +180,7 @@ async fn process_deepseek_turn(
|
||||
tool_choice: options.tool_choice.clone(),
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
reasoning_effort: None,
|
||||
stream: Some(options.stream),
|
||||
temperature: options.temperature,
|
||||
top_p: options.top_p,
|
||||
@@ -282,6 +283,7 @@ async fn process_deepseek_turn(
|
||||
id,
|
||||
name,
|
||||
input: parsed,
|
||||
caller: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -517,7 +519,7 @@ fn handle_command_official(
|
||||
}
|
||||
|
||||
fn print_banner(mode: &str) {
|
||||
println!("{}", ds_blue("DeepSeek CLI").bold());
|
||||
println!("{}", ds_blue("DeepSeek TUI").bold());
|
||||
println!("Mode: {mode}");
|
||||
println!("Type /help for commands. Use /exit to quit.\n");
|
||||
}
|
||||
@@ -12,6 +12,9 @@ pub const DEEPSEEK_INK_RGB: (u8, u8, u8) = (11, 21, 38);
|
||||
pub const DEEPSEEK_SLATE_RGB: (u8, u8, u8) = (18, 28, 46);
|
||||
pub const DEEPSEEK_RED_RGB: (u8, u8, u8) = (226, 80, 96);
|
||||
|
||||
// New semantic colors
|
||||
pub const BORDER_COLOR_RGB: (u8, u8, u8) = (42, 74, 127); // #2A4A7F
|
||||
|
||||
pub const DEEPSEEK_BLUE: Color = Color::Rgb(
|
||||
DEEPSEEK_BLUE_RGB.0,
|
||||
DEEPSEEK_BLUE_RGB.1,
|
||||
@@ -41,23 +44,63 @@ pub const DEEPSEEK_SLATE: Color = Color::Rgb(
|
||||
pub const DEEPSEEK_RED: Color =
|
||||
Color::Rgb(DEEPSEEK_RED_RGB.0, DEEPSEEK_RED_RGB.1, DEEPSEEK_RED_RGB.2);
|
||||
|
||||
pub const TEXT_PRIMARY: Color = Color::White;
|
||||
pub const TEXT_MUTED: Color = Color::DarkGray;
|
||||
pub const TEXT_DIM: Color = Color::Gray;
|
||||
pub const TEXT_BODY: Color = Color::White;
|
||||
pub const TEXT_SECONDARY: Color = Color::Rgb(192, 192, 192); // #C0C0C0
|
||||
pub const TEXT_HINT: Color = Color::Rgb(160, 160, 160); // #A0A0A0
|
||||
pub const TEXT_ACCENT: Color = DEEPSEEK_SKY;
|
||||
pub const SELECTION_TEXT: Color = Color::White;
|
||||
pub const TEXT_SOFT: Color = Color::Rgb(214, 223, 235); // #D6DFEB
|
||||
|
||||
// Compatibility aliases for existing call sites.
|
||||
pub const TEXT_PRIMARY: Color = TEXT_BODY;
|
||||
pub const TEXT_MUTED: Color = TEXT_SECONDARY;
|
||||
pub const TEXT_DIM: Color = TEXT_HINT;
|
||||
|
||||
// New semantic colors for UI theming
|
||||
pub const BORDER_COLOR: Color =
|
||||
Color::Rgb(BORDER_COLOR_RGB.0, BORDER_COLOR_RGB.1, BORDER_COLOR_RGB.2);
|
||||
#[allow(dead_code)]
|
||||
pub const ACCENT_PRIMARY: Color = DEEPSEEK_BLUE; // #3578E5
|
||||
#[allow(dead_code)]
|
||||
pub const ACCENT_SECONDARY: Color = TEXT_ACCENT; // #6AAEF2
|
||||
#[allow(dead_code)]
|
||||
pub const BACKGROUND_LIGHT: Color = Color::Rgb(30, 47, 71); // #1E2F47
|
||||
#[allow(dead_code)]
|
||||
pub const BACKGROUND_DARK: Color = Color::Rgb(13, 26, 48); // #0D1A30
|
||||
#[allow(dead_code)]
|
||||
pub const STATUS_NEUTRAL: Color = Color::Rgb(160, 160, 160); // #A0A0A0
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_PANEL: Color = Color::Rgb(21, 33, 52); // #152134
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_ELEVATED: Color = Color::Rgb(28, 42, 64); // #1C2A40
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_REASONING: Color = Color::Rgb(54, 44, 26); // #362C1A
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_REASONING_ACTIVE: Color = Color::Rgb(68, 53, 28); // #44351C
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_TOOL: Color = Color::Rgb(24, 39, 60); // #18273C
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_TOOL_ACTIVE: Color = Color::Rgb(29, 48, 73); // #1D3049
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_SUCCESS: Color = Color::Rgb(22, 56, 63); // #16383F
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_ERROR: Color = Color::Rgb(63, 27, 36); // #3F1B24
|
||||
pub const ACCENT_REASONING_LIVE: Color = Color::Rgb(146, 198, 248); // #92C6F8
|
||||
pub const ACCENT_TOOL_LIVE: Color = Color::Rgb(133, 184, 234); // #85B8EA
|
||||
pub const ACCENT_TOOL_ISSUE: Color = Color::Rgb(192, 143, 153); // #C08F99
|
||||
pub const TEXT_TOOL_OUTPUT: Color = Color::Rgb(205, 216, 228); // #CDD8E4
|
||||
|
||||
// Legacy status colors - keep for backward compatibility
|
||||
pub const STATUS_SUCCESS: Color = DEEPSEEK_SKY;
|
||||
pub const STATUS_WARNING: Color = DEEPSEEK_SKY;
|
||||
pub const STATUS_WARNING: Color = Color::Rgb(255, 170, 60); // Amber
|
||||
pub const STATUS_ERROR: Color = DEEPSEEK_RED;
|
||||
#[allow(dead_code)]
|
||||
pub const STATUS_INFO: Color = DEEPSEEK_BLUE;
|
||||
|
||||
// Mode-specific accent colors for mode badges
|
||||
pub const MODE_NORMAL: Color = Color::Gray;
|
||||
pub const MODE_AGENT: Color = Color::Rgb(80, 150, 255); // Bright blue
|
||||
pub const MODE_YOLO: Color = Color::Rgb(255, 100, 100); // Warning red
|
||||
pub const MODE_PLAN: Color = Color::Rgb(255, 170, 60); // Orange
|
||||
pub const MODE_RLM: Color = Color::Rgb(180, 100, 255); // Purple (was INK!)
|
||||
pub const MODE_DUO: Color = Color::Rgb(100, 220, 180); // Teal
|
||||
|
||||
pub const SELECTION_BG: Color = Color::Rgb(26, 44, 74);
|
||||
pub const COMPOSER_BG: Color = DEEPSEEK_SLATE;
|
||||
@@ -75,15 +118,21 @@ pub fn ui_theme(name: &str) -> UiTheme {
|
||||
"dark" => UiTheme {
|
||||
name: "dark",
|
||||
composer_bg: DEEPSEEK_INK,
|
||||
selection_bg: Color::Rgb(30, 52, 92),
|
||||
selection_bg: SELECTION_BG,
|
||||
header_bg: DEEPSEEK_INK,
|
||||
},
|
||||
"light" => UiTheme {
|
||||
name: "light",
|
||||
composer_bg: Color::Rgb(26, 38, 58),
|
||||
selection_bg: Color::Rgb(38, 64, 112),
|
||||
selection_bg: SELECTION_BG,
|
||||
header_bg: DEEPSEEK_SLATE,
|
||||
},
|
||||
"whale" => UiTheme {
|
||||
name: "whale",
|
||||
composer_bg: DEEPSEEK_SLATE,
|
||||
selection_bg: SELECTION_BG,
|
||||
header_bg: DEEPSEEK_INK,
|
||||
},
|
||||
_ => UiTheme {
|
||||
name: "default",
|
||||
composer_bg: COMPOSER_BG,
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Cost estimation for DeepSeek API usage.
|
||||
//!
|
||||
//! Pricing based on DeepSeek's published rates (per million tokens).
|
||||
|
||||
use crate::models::Usage;
|
||||
|
||||
/// Per-million-token pricing for a model.
|
||||
struct ModelPricing {
|
||||
input_cache_hit_per_million: f64,
|
||||
input_cache_miss_per_million: f64,
|
||||
output_per_million: f64,
|
||||
}
|
||||
|
||||
/// Look up pricing for a model name.
|
||||
fn pricing_for_model(model: &str) -> Option<ModelPricing> {
|
||||
let lower = model.to_lowercase();
|
||||
if !lower.contains("deepseek") {
|
||||
return None;
|
||||
}
|
||||
if lower.contains("v4-pro") || lower.contains("v4pro") {
|
||||
Some(ModelPricing {
|
||||
input_cache_hit_per_million: 0.145,
|
||||
input_cache_miss_per_million: 1.74,
|
||||
output_per_million: 3.48,
|
||||
})
|
||||
} else {
|
||||
// deepseek-v4-flash and legacy aliases (deepseek-chat, deepseek-reasoner,
|
||||
// deepseek-v3*) all price as v4-flash.
|
||||
Some(ModelPricing {
|
||||
input_cache_hit_per_million: 0.028,
|
||||
input_cache_miss_per_million: 0.14,
|
||||
output_per_million: 0.28,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate cost for a turn given token usage and model.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub fn calculate_turn_cost(model: &str, input_tokens: u32, output_tokens: u32) -> Option<f64> {
|
||||
let pricing = pricing_for_model(model)?;
|
||||
let input_cost = (input_tokens as f64 / 1_000_000.0) * pricing.input_cache_miss_per_million;
|
||||
let output_cost = (output_tokens as f64 / 1_000_000.0) * pricing.output_per_million;
|
||||
Some(input_cost + output_cost)
|
||||
}
|
||||
|
||||
/// Calculate cost from provider usage, honoring DeepSeek context-cache fields.
|
||||
#[must_use]
|
||||
pub fn calculate_turn_cost_from_usage(model: &str, usage: &Usage) -> Option<f64> {
|
||||
let pricing = pricing_for_model(model)?;
|
||||
let hit_tokens = usage.prompt_cache_hit_tokens.unwrap_or(0);
|
||||
let miss_tokens = usage
|
||||
.prompt_cache_miss_tokens
|
||||
.unwrap_or_else(|| usage.input_tokens.saturating_sub(hit_tokens));
|
||||
let accounted_input = hit_tokens.saturating_add(miss_tokens);
|
||||
let uncategorized_input = usage.input_tokens.saturating_sub(accounted_input);
|
||||
|
||||
let hit_cost = (hit_tokens as f64 / 1_000_000.0) * pricing.input_cache_hit_per_million;
|
||||
let miss_cost = ((miss_tokens.saturating_add(uncategorized_input)) as f64 / 1_000_000.0)
|
||||
* pricing.input_cache_miss_per_million;
|
||||
let output_cost = (usage.output_tokens as f64 / 1_000_000.0) * pricing.output_per_million;
|
||||
Some(hit_cost + miss_cost + output_cost)
|
||||
}
|
||||
|
||||
/// Format a USD cost for compact display.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub fn format_cost(cost: f64) -> String {
|
||||
if cost < 0.0001 {
|
||||
"<$0.0001".to_string()
|
||||
} else if cost < 0.01 {
|
||||
format!("${:.4}", cost)
|
||||
} else if cost < 1.0 {
|
||||
format!("${:.3}", cost)
|
||||
} else {
|
||||
format!("${:.2}", cost)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 244 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user