mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-13 17:18:52 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2b2c284f6 | ||
|
|
0578eb701e | ||
|
|
41e8f2b5b2 | ||
|
|
00c92e1c2a | ||
|
|
6d8ab4c2b8 | ||
|
|
0f8c363012 | ||
|
|
35db361a87 | ||
|
|
12b1ae42c4 | ||
|
|
64d1698bde | ||
|
|
096aa91823 | ||
|
|
0e96928f35 | ||
|
|
6396bffcd4 | ||
|
|
78b272e56b | ||
|
|
d7b033d59e | ||
|
|
97846cd63a | ||
|
|
2c8a480fc0 | ||
|
|
fb4c8150f3 | ||
|
|
a02907b89d | ||
|
|
30ec07481f | ||
|
|
49d2be9e5c | ||
|
|
281b712acc | ||
|
|
4fb8372c1c | ||
|
|
27527699db | ||
|
|
41daab3ca0 | ||
|
|
626bfbc65e | ||
|
|
cfbf081eb8 | ||
|
|
3bc54b0bc0 | ||
|
|
0781b7c203 | ||
|
|
fb12e331ab | ||
|
|
8ff4f66b95 | ||
|
|
3dc116b9fc | ||
|
|
03dcc74833 | ||
|
|
de9a3b8b74 | ||
|
|
ad78466ba0 | ||
|
|
05a1032e00 | ||
|
|
18b797b593 | ||
|
|
e3dc49c526 | ||
|
|
26b79312f9 | ||
|
|
87ac2e9bdc | ||
|
|
a054789f79 | ||
|
|
ff21d2268d | ||
|
|
7b6993fecb | ||
|
|
d3dd8a590f | ||
|
|
f36e891117 | ||
|
|
3522040855 | ||
|
|
9db841fc62 | ||
|
|
0626bc80c0 | ||
|
|
f82f162e7f | ||
|
|
abbb86cdd2 | ||
|
|
45727a09f7 | ||
|
|
30d7650bae | ||
|
|
a5cc9d5852 | ||
|
|
f3ada0be88 | ||
|
|
6432d47c53 | ||
|
|
c31fbe9de2 | ||
|
|
ce3d1a73b9 | ||
|
|
eab853e92b | ||
|
|
693fbca4ea | ||
|
|
65f5daa9f9 | ||
|
|
1184662875 | ||
|
|
900825c411 | ||
|
|
07be2f655e | ||
|
|
3c4eb1ed55 | ||
|
|
f88f810b1a | ||
|
|
5f96990739 | ||
|
|
f6c9bf13db | ||
|
|
5e7dbcd32b | ||
|
|
48c30473da | ||
|
|
363f064fce | ||
|
|
0f7252198d | ||
|
|
a3d0134173 | ||
|
|
7819fcc18b | ||
|
|
176a2ba4f4 | ||
|
|
34cba09e22 | ||
|
|
63cb06637b | ||
|
|
d6a7a0e84a | ||
|
|
f118db8201 | ||
|
|
9d4c1c1966 | ||
|
|
36320c5bea | ||
|
|
b759e3f74c | ||
|
|
fd13dffd60 | ||
|
|
8d8c1ad2d4 | ||
|
|
32750cb52d | ||
|
|
4ac7219d77 | ||
|
|
0db918f9e8 | ||
|
|
95d6937d34 | ||
|
|
f95be44bc8 | ||
|
|
2b2bddcf7e | ||
|
|
aeba004c7b | ||
|
|
7c3a01c7b8 | ||
|
|
764aed65ed | ||
|
|
2b0e73a4cf | ||
|
|
10bc2480db | ||
|
|
5b7ff8cb69 | ||
|
|
9358b92af5 | ||
|
|
e075ecd0fe | ||
|
|
8be032b6bd | ||
|
|
f10d5d1829 | ||
|
|
7a06915b0b | ||
|
|
a32148dac9 | ||
|
|
a345a956aa | ||
|
|
6ef2421d61 | ||
|
|
d4b9ccfdb3 | ||
|
|
2787cdc7b9 | ||
|
|
9dd0d12cea | ||
|
|
2865c9a766 | ||
|
|
5cec1534be | ||
|
|
950a66c24a | ||
|
|
bd938a559c | ||
|
|
e8c3e7d1bf | ||
|
|
bb3c460358 | ||
|
|
42c684367f | ||
|
|
ac8a882be5 | ||
|
|
4e46fd06f6 | ||
|
|
2fcc637d4f | ||
|
|
b7c5cb4112 | ||
|
|
60f5f39584 | ||
|
|
6fc4680b91 | ||
|
|
28e8d70ffc | ||
|
|
8d33b92f82 | ||
|
|
e31d6db3ee | ||
|
|
2378fbc26f | ||
|
|
32a977e58e | ||
|
|
6ba43e749a | ||
|
|
904ec869a6 | ||
|
|
a326ef2891 | ||
|
|
5b0af72c3d | ||
|
|
07dc8f2037 | ||
|
|
6a13ce8c29 | ||
|
|
e5954160fb | ||
|
|
54aaba1876 | ||
|
|
633379be0e | ||
|
|
9dd4f65af2 | ||
|
|
44f3b2cae5 | ||
|
|
30ae78ee19 | ||
|
|
83a1060460 | ||
|
|
ec3470fd07 | ||
|
|
697fb5de4d | ||
|
|
bf2a1765fa | ||
|
|
ec98a64711 | ||
|
|
bbdfb26f3c | ||
|
|
97c89745fa | ||
|
|
98ba62915a | ||
|
|
6beb2099e4 | ||
|
|
adb60ba6b0 | ||
|
|
d7b202d996 | ||
|
|
08706f77d6 | ||
|
|
96c979f160 | ||
|
|
c1259eff68 | ||
|
|
a8fe5298a2 | ||
|
|
1f00ac6311 | ||
|
|
9758e26349 | ||
|
|
fa99fb5124 | ||
|
|
6ab2fcc21f | ||
|
|
0d92eb847b | ||
|
|
d98cc58028 | ||
|
|
aa8d0dc73a | ||
|
|
eee5081ef7 | ||
|
|
ab70c40beb | ||
|
|
033fef6cb2 | ||
|
|
6d06595b76 | ||
|
|
70ce26e196 | ||
|
|
2b7800885e | ||
|
|
49673d2ea3 | ||
|
|
9804c92c21 | ||
|
|
7d0450f541 | ||
|
|
9bc8eee927 | ||
|
|
ebc70176ad | ||
|
|
432082e956 | ||
|
|
ac1332565c | ||
|
|
3375fc7285 | ||
|
|
1107b723b1 | ||
|
|
124011a862 | ||
|
|
f342d6508e | ||
|
|
ec92e535e8 | ||
|
|
86f59cd2c2 | ||
|
|
320325e419 | ||
|
|
feb3cf1e0c | ||
|
|
38069700cc | ||
|
|
2adbe398ba | ||
|
|
4f18809d74 | ||
|
|
c58d10ded1 | ||
|
|
cf9fdef9d8 | ||
|
|
e9970fcad3 | ||
|
|
e1ac84ae44 | ||
|
|
c5a584d5c3 | ||
|
|
1a100fe96c | ||
|
|
25cfe11736 | ||
|
|
56308bb5d7 | ||
|
|
4138053dd8 | ||
|
|
5d51143194 | ||
|
|
ca7ca9f75f | ||
|
|
d6bfcda474 | ||
|
|
f3df5e515e | ||
|
|
a4f4f5040f | ||
|
|
a02898545d | ||
|
|
2185b8c3c6 | ||
|
|
06355e3aea | ||
|
|
75de26c7a1 | ||
|
|
9467d26db7 | ||
|
|
93efb09038 | ||
|
|
42fe888d35 | ||
|
|
63d7391ff8 | ||
|
|
585dd2f7d0 | ||
|
|
8f05f272d3 | ||
|
|
1ad0c886b8 | ||
|
|
d111680a3b | ||
|
|
5f223adea6 | ||
|
|
027d6d19b6 | ||
|
|
229b1993ac | ||
|
|
686ef94719 | ||
|
|
ba6bc351da | ||
|
|
ebdda09c29 | ||
|
|
f42f94207c | ||
|
|
d79178a926 | ||
|
|
bcf6ba9a8e | ||
|
|
7a85f182e2 | ||
|
|
f7fe5e09a5 | ||
|
|
24b8945010 | ||
|
|
82e4a564aa | ||
|
|
07ae792068 | ||
|
|
ba40ae4aac | ||
|
|
7f2c382343 | ||
|
|
017ac97d0d | ||
|
|
0a394e1587 | ||
|
|
fafb76063d | ||
|
|
9e7cfc951a | ||
|
|
1be18e691b | ||
|
|
ccc9554ef4 | ||
|
|
97f245d5ac | ||
|
|
19f8d83d3b | ||
|
|
67b232b063 | ||
|
|
41c54f08aa | ||
|
|
56ea63e2c6 | ||
|
|
94834b2eb4 | ||
|
|
dc8f8496d6 | ||
|
|
5c1086fe9e | ||
|
|
dff592bc6d | ||
|
|
29141bc89b | ||
|
|
fe4f261a5d | ||
|
|
7f0444d26b | ||
|
|
298f5c6c51 | ||
|
|
853a39138c | ||
|
|
6ad3727fa0 | ||
|
|
16f62f7abf | ||
|
|
d0dc26ce25 | ||
|
|
7d8c40893f | ||
|
|
411153c549 | ||
|
|
e06071c177 | ||
|
|
f377b0f9d2 | ||
|
|
f3df8f5f26 | ||
|
|
8323bedfb7 | ||
|
|
6c14e2e1f4 | ||
|
|
c4f9078712 | ||
|
|
d713032b9f | ||
|
|
d89b33330f | ||
|
|
ffa75f07e5 | ||
|
|
35595f8edc | ||
|
|
b7bd02d814 | ||
|
|
dc8e94d705 | ||
|
|
f4dbf828c9 | ||
|
|
cbcf35c1bd | ||
|
|
3db7f05c60 | ||
|
|
5c82f08c58 | ||
|
|
ea3cc3d0d2 | ||
|
|
b172b8d306 | ||
|
|
14bb2bfd98 | ||
|
|
fc4884b0c8 | ||
|
|
7b91169017 | ||
|
|
cc0ac49822 | ||
|
|
f2881e7e3d | ||
|
|
f269147286 | ||
|
|
ad76ee39ba | ||
|
|
351b309c7d | ||
|
|
37186c3d95 | ||
|
|
6021fc56c8 | ||
|
|
8bdf7005b8 | ||
|
|
698ec1099c | ||
|
|
d80d31aafb | ||
|
|
5e52b59005 | ||
|
|
70beb250e7 | ||
|
|
9b87804057 | ||
|
|
b3e765cc70 | ||
|
|
c69a73b644 | ||
|
|
7397fb0076 | ||
|
|
a94f29b47b | ||
|
|
7a6be37e2d | ||
|
|
8d904129b5 | ||
|
|
64e29c1b73 | ||
|
|
b88ce88a42 | ||
|
|
6130617b8d | ||
|
|
b04f2860fe | ||
|
|
07531903e7 | ||
|
|
9b647edf92 | ||
|
|
8869541fbe | ||
|
|
304644e448 | ||
|
|
d55a9c1231 | ||
|
|
f3fe107b8e | ||
|
|
8b5f1bc83f | ||
|
|
cfcdce3d03 | ||
|
|
cd41e381e7 | ||
|
|
d0a3d1d7aa | ||
|
|
605591d064 | ||
|
|
6fedee021e | ||
|
|
d2b64b260e | ||
|
|
c81287cdfd | ||
|
|
91721d8a17 | ||
|
|
1a04659a95 | ||
|
|
87884a1e84 | ||
|
|
ccefab8d44 | ||
|
|
5f90e1685e | ||
|
|
b556559fb4 | ||
|
|
19a9724008 | ||
|
|
4212ba9f26 | ||
|
|
6c05c3b3ac | ||
|
|
ab2c708ca7 | ||
|
|
fa3905041d | ||
|
|
ab87765c26 | ||
|
|
b20937ba55 | ||
|
|
6c55d80dea | ||
|
|
4e516a7c1f | ||
|
|
702f9c353c | ||
|
|
4ded9e2a4f | ||
|
|
9f47d78a03 | ||
|
|
67b371ca95 | ||
|
|
858649d458 | ||
|
|
433d7cb6e9 | ||
|
|
e0bccecd5c | ||
|
|
02ecc51e9c | ||
|
|
72d1c43d50 | ||
|
|
9e3aafeabb | ||
|
|
325aaefc00 | ||
|
|
62d2877b3b | ||
|
|
3f8d04178c | ||
|
|
18ce9f96f1 | ||
|
|
5466cd7a19 | ||
|
|
3c3c3f49ce | ||
|
|
a7c5ef9374 | ||
|
|
55148906f2 | ||
|
|
df45aece8f | ||
|
|
32443ae91b | ||
|
|
4ae8c8a3fa |
No files matched your search
@@ -0,0 +1,44 @@
|
||||
# DeepSeek TUI environment
|
||||
# Shell-exported variables override values in this file.
|
||||
# Copy this file to `.env`, then uncomment only the values you want to use.
|
||||
|
||||
# DeepSeek API (default provider)
|
||||
# Get an API key from DeepSeek, then keep it local in `.env`.
|
||||
# DEEPSEEK_API_KEY=
|
||||
|
||||
# Global endpoint:
|
||||
# DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
# China endpoint:
|
||||
# DEEPSEEK_BASE_URL=https://api.deepseeki.com
|
||||
|
||||
# V4 model selection. Compatibility aliases such as `deepseek-chat` normalize
|
||||
# to the current V4 flash model in the TUI.
|
||||
# DEEPSEEK_MODEL=deepseek-v4-pro
|
||||
# DEEPSEEK_MODEL=deepseek-v4-flash
|
||||
|
||||
# NVIDIA NIM-hosted DeepSeek V4
|
||||
# Use this provider when routing through NVIDIA's OpenAI-compatible endpoint.
|
||||
# DEEPSEEK_PROVIDER=nvidia-nim
|
||||
# NVIDIA_API_KEY=
|
||||
# NVIDIA_NIM_API_KEY=
|
||||
# NVIDIA_NIM_BASE_URL=https://integrate.api.nvidia.com/v1
|
||||
# NIM_BASE_URL=https://integrate.api.nvidia.com/v1
|
||||
# NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
|
||||
# NVIDIA_NIM_MODEL=deepseek-ai/deepseek-v4-pro
|
||||
|
||||
# Logging
|
||||
# `DEEPSEEK_LOG_LEVEL` is forwarded by the facade; `RUST_LOG` enables the
|
||||
# TUI's lightweight verbose logs for info/debug/trace directives.
|
||||
# DEEPSEEK_LOG_LEVEL=debug
|
||||
# RUST_LOG=deepseek_tui=debug
|
||||
|
||||
# Agent safety defaults
|
||||
# `on-request` asks before higher-risk work; `workspace-write` keeps writes
|
||||
# inside the workspace unless a sandbox elevation path is explicitly used.
|
||||
# DEEPSEEK_APPROVAL_POLICY=on-request
|
||||
# DEEPSEEK_SANDBOX_MODE=workspace-write
|
||||
# DEEPSEEK_ALLOW_SHELL=true
|
||||
|
||||
# Optional extension paths
|
||||
# DEEPSEEK_SKILLS_DIR=~/.deepseek/skills
|
||||
# DEEPSEEK_MCP_CONFIG=~/.deepseek/mcp.json
|
||||
@@ -0,0 +1,85 @@
|
||||
name: Auto-tag on version bump
|
||||
|
||||
# When the workspace version on `main` advances past the latest existing
|
||||
# `vX.Y.Z` tag, push the matching tag automatically. The push then triggers
|
||||
# `release.yml`, which runs parity, builds binaries, drafts the GitHub
|
||||
# Release, and publishes the npm wrapper.
|
||||
#
|
||||
# IMPORTANT: tag pushes signed by the default `GITHUB_TOKEN` do NOT trigger
|
||||
# downstream `on: push: tags` workflows (GitHub Actions safety rule). For
|
||||
# this auto-tag flow to actually fire `release.yml`, store a PAT (or
|
||||
# fine-grained token) with `contents: write` on this repo as the
|
||||
# `RELEASE_TAG_PAT` secret. Without it, the tag is created but `release.yml`
|
||||
# does NOT run automatically — you'd have to push the tag again manually
|
||||
# (`git push origin v$VERSION` from a developer machine) to trigger release.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'Cargo.toml'
|
||||
- 'npm/deepseek-tui/package.json'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Prefer PAT so the resulting tag push triggers release.yml.
|
||||
# Falls back to GITHUB_TOKEN, which will tag but NOT trigger.
|
||||
token: ${{ secrets.RELEASE_TAG_PAT || github.token }}
|
||||
|
||||
- name: Read workspace version
|
||||
id: ver
|
||||
run: |
|
||||
v="$(grep -E '^version = "' Cargo.toml | head -n1 | sed -E 's/^version = "([^"]+)".*/\1/')"
|
||||
if [ -z "$v" ]; then
|
||||
echo "::error::Could not parse workspace version from Cargo.toml" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v$v" >> "$GITHUB_OUTPUT"
|
||||
echo "Workspace version: $v"
|
||||
|
||||
- name: Check whether tag already exists
|
||||
id: check
|
||||
env:
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
run: |
|
||||
git fetch --tags --quiet
|
||||
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null \
|
||||
|| git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Tag ${TAG} already exists; nothing to do."
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Tag ${TAG} does not exist; will create."
|
||||
fi
|
||||
|
||||
- name: Verify version consistency
|
||||
if: steps.check.outputs.exists == 'false'
|
||||
run: ./scripts/release/check-versions.sh
|
||||
|
||||
- name: Create and push tag
|
||||
if: steps.check.outputs.exists == 'false'
|
||||
env:
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git tag "${TAG}"
|
||||
git push origin "${TAG}"
|
||||
echo "Pushed ${TAG}. release.yml should now run (requires RELEASE_TAG_PAT for trigger)."
|
||||
|
||||
- name: Warn if PAT missing
|
||||
if: steps.check.outputs.exists == 'false' && env.HAS_PAT != 'true'
|
||||
env:
|
||||
HAS_PAT: ${{ secrets.RELEASE_TAG_PAT != '' }}
|
||||
run: |
|
||||
echo "::warning::RELEASE_TAG_PAT secret is not set. The tag was pushed using GITHUB_TOKEN, which does NOT trigger release.yml. Manually re-push the tag from a developer machine, or run 'gh workflow run release.yml --ref ${{ steps.ver.outputs.tag }}'."
|
||||
@@ -11,6 +11,21 @@ env:
|
||||
RUSTFLAGS: -Dwarnings
|
||||
|
||||
jobs:
|
||||
versions:
|
||||
name: Version drift
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install Linux system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Check version drift
|
||||
run: ./scripts/release/check-versions.sh
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
@@ -19,11 +34,18 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- name: Install Linux system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets --all-features
|
||||
# Mirror the release-workflow `parity` gate exactly. Anything that
|
||||
# would fail there must fail here so we never push a `v*` tag that
|
||||
# the npm publish pipeline can't ship. The Release job runs with
|
||||
# `--locked` + `-D warnings`; we do the same.
|
||||
- name: Clippy (release-strict)
|
||||
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
|
||||
test:
|
||||
name: Test
|
||||
@@ -34,11 +56,16 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install Linux system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Run tests
|
||||
run: cargo test --all-features
|
||||
run: cargo test --workspace --all-features --locked
|
||||
- name: Lockfile drift guard
|
||||
run: git diff --exit-code -- Cargo.lock
|
||||
- name: Run Offline Eval Harness
|
||||
run: cargo run --all-features -- eval
|
||||
run: cargo run -p deepseek-tui --all-features -- eval
|
||||
|
||||
build:
|
||||
name: Build
|
||||
@@ -49,10 +76,61 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install Linux system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- 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
|
||||
- name: Install Linux system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build wrapper binaries
|
||||
run: cargo build --release --locked -p deepseek-tui-cli -p deepseek-tui
|
||||
- name: Prepare local release assets
|
||||
run: node scripts/release/prepare-local-release-assets.js "$RUNNER_TEMP/release-assets"
|
||||
- name: Start local release server
|
||||
run: |
|
||||
python3 -m http.server 8123 --directory "$RUNNER_TEMP/release-assets" >/tmp/deepseek-release-server.log 2>&1 &
|
||||
sleep 1
|
||||
- name: Pack npm wrapper
|
||||
working-directory: npm/deepseek-tui
|
||||
env:
|
||||
DEEPSEEK_TUI_FORCE_DOWNLOAD: "1"
|
||||
DEEPSEEK_TUI_RELEASE_BASE_URL: http://127.0.0.1:8123/
|
||||
run: npm pack
|
||||
- name: Install packed wrapper
|
||||
env:
|
||||
DEEPSEEK_TUI_FORCE_DOWNLOAD: "1"
|
||||
DEEPSEEK_TUI_RELEASE_BASE_URL: http://127.0.0.1:8123/
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/npm-smoke"
|
||||
cd "$RUNNER_TEMP/npm-smoke"
|
||||
npm init -y
|
||||
npm install "$GITHUB_WORKSPACE/npm/deepseek-tui"/deepseek-tui-*.tgz
|
||||
- name: Smoke wrapper entrypoints
|
||||
env:
|
||||
DEEPSEEK_TUI_FORCE_DOWNLOAD: "1"
|
||||
DEEPSEEK_TUI_RELEASE_BASE_URL: http://127.0.0.1:8123/
|
||||
run: |
|
||||
cd "$RUNNER_TEMP/npm-smoke"
|
||||
npx --no-install deepseek --help
|
||||
npx --no-install deepseek-tui --help
|
||||
|
||||
# Check documentation builds without warnings
|
||||
docs:
|
||||
name: Documentation
|
||||
@@ -60,8 +138,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install Linux system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build docs
|
||||
run: cargo doc --no-deps
|
||||
run: cargo doc --workspace --no-deps
|
||||
env:
|
||||
RUSTDOCFLAGS: -Dwarnings
|
||||
@@ -7,24 +7,24 @@ on:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish to crates.io
|
||||
name: Publish workspace crates to crates.io
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install Linux system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- 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,62 @@
|
||||
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: Install Linux system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
|
||||
- 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,42 @@
|
||||
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
|
||||
# `release.yml` no longer publishes to npm — see CLAUDE.md "Releases" for
|
||||
# the manual flow we actually use. This workflow remains as inert plumbing
|
||||
# that only works if npm Trusted Publishing is configured for it; trigger
|
||||
# via `gh workflow run publish-npm.yml -f version=X.Y.Z` if you've set
|
||||
# that up on the npm side.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Verify package version
|
||||
working-directory: npm/deepseek-tui
|
||||
run: |
|
||||
actual="$(node -p "require('./package.json').version")"
|
||||
expected="${{ inputs.version }}"
|
||||
if [ "${actual}" != "${expected}" ]; then
|
||||
echo "package.json version ${actual} does not match requested ${expected}" >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Publish wrapper to npm
|
||||
working-directory: npm/deepseek-tui
|
||||
run: npm publish --access public
|
||||
@@ -3,12 +3,53 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Package/release version to publish to npm, without the leading v'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Dwarnings
|
||||
|
||||
jobs:
|
||||
parity:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- name: Install Linux system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
- 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 +66,33 @@ 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 }}
|
||||
- name: Install Linux system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
- run: cargo build --release --locked --target ${{ matrix.target }}
|
||||
- name: Rename binary
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -51,7 +112,25 @@ 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
|
||||
|
||||
# npm publish is intentionally not automated. The npm account requires 2FA OTP
|
||||
# on every publish, and a granular automation token that bypasses 2FA has not
|
||||
# been provisioned. Release the npm wrapper manually from a developer machine
|
||||
# after the GitHub Release has been created — see CLAUDE.md "Releases" for the
|
||||
# exact commands.
|
||||
+26
-7
@@ -11,6 +11,8 @@
|
||||
# Development
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
@@ -33,24 +35,41 @@ dist/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
firebase-debug.log
|
||||
|
||||
# Generated
|
||||
outputs/
|
||||
|
||||
# Rust
|
||||
# Note: Cargo.lock is intentionally NOT ignored for reproducible builds
|
||||
firebase-debug.log
|
||||
tmp/
|
||||
|
||||
# Reference papers / large research blobs (keep locally if needed, don't ship)
|
||||
docs/DeepSeek_V4.pdf
|
||||
docs/*.pdf
|
||||
|
||||
# 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/
|
||||
|
||||
# Claude Code runtime artifacts
|
||||
.claude/scheduled_tasks.lock
|
||||
.claude/worktrees/
|
||||
.worktrees/
|
||||
@@ -0,0 +1,16 @@
|
||||
Hunter Bown <[email protected]> devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> GitHub <[email protected]>
|
||||
Hunter Bown <[email protected]> Claude <[email protected]>
|
||||
Hunter Bown <[email protected]> Claude Opus 4.6 <[email protected]>
|
||||
Hunter Bown <[email protected]> Claude Opus 4.6 (1M context) <[email protected]>
|
||||
Hunter Bown <[email protected]> Claude Opus 4.7 (1M context) <[email protected]>
|
||||
Hunter Bown <[email protected]> Copilot <[email protected]>
|
||||
Hunter Bown <[email protected]> Copilot <1693627+github-copilot-cli[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> Copilot <946600+copilot-pull-request-reviewer[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> Qwen-Coder <[email protected]>
|
||||
Hunter Bown <[email protected]> qwencoder <[email protected]>
|
||||
Hunter Bown <[email protected]> Cursor Agent <[email protected]>
|
||||
Hunter Bown <[email protected]> cursoragent <[email protected]>
|
||||
Hunter Bown <[email protected]> gemini-code-assist[bot] <gemini-code-assist[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> gemini-code-assist[bot] <956858+gemini-code-assist[bot]@users.noreply.github.com>
|
||||
@@ -5,29 +5,41 @@ This file provides context for AI assistants working on this project.
|
||||
## Project Type: Rust
|
||||
|
||||
### Commands
|
||||
- Build: `cargo build`
|
||||
- Test: `cargo test`
|
||||
- Run: `cargo run`
|
||||
- Check: `cargo check`
|
||||
- Format: `cargo fmt`
|
||||
- Lint: `cargo clippy`
|
||||
- Build: `cargo build` (default-members include the `deepseek` dispatcher)
|
||||
- Test: `cargo test --workspace --all-features`
|
||||
- Lint: `cargo clippy --workspace --all-targets --all-features`
|
||||
- Format: `cargo fmt --all`
|
||||
- Run (canonical): `deepseek` — use the **`deepseek` binary**, not `deepseek-tui`. The dispatcher delegates to the TUI for interactive use and is the supported entry point for every flow (`deepseek`, `deepseek -p "..."`, `deepseek doctor`, `deepseek mcp …`, etc.).
|
||||
- Run from source: `cargo run --bin deepseek` (or `cargo run -p deepseek-tui-cli`).
|
||||
- Local dev shorthand: after `cargo build --release`, run `./target/release/deepseek`.
|
||||
|
||||
### Project: deepseek-cli
|
||||
### Build Dependencies
|
||||
- **Rust** 1.85+ (for the workspace)
|
||||
|
||||
### 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
|
||||
|
||||
- **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.
|
||||
|
||||
## Guidelines
|
||||
## GitHub Operations
|
||||
|
||||
- Follow existing code style and patterns
|
||||
- Write tests for new functionality
|
||||
- Keep changes focused and atomic
|
||||
- Document public APIs
|
||||
Use the **`gh` CLI** (`/opt/homebrew/bin/gh`) for all GitHub operations — issues, PRs, branches, labels. It's already authenticated as `Hmbown` (token scopes: `gist`, `read:org`, `repo`, `workflow`). Examples:
|
||||
|
||||
- List open issues: `gh issue list --state open --limit 20`
|
||||
- View an issue: `gh issue view <number>`
|
||||
- Create an issue branch: `gh issue develop <number> --branch-name feat/issue-<number>-<slug>`
|
||||
- Create a PR: `gh pr create --base feat/v0.6.2 --title "..." --body "..."`
|
||||
- Check PR status: `gh pr view <number>`
|
||||
|
||||
Prefer `gh` over `fetch_url` or `web_search` for GitHub data — it's faster, authenticated, and avoids rate limits.
|
||||
|
||||
## 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. All three modes can call the `rlm_query` tool for parallel/batched LLM fan-out (`crates/tui/src/tools/rlm_query.rs`).
|
||||
+514
-11
@@ -7,6 +7,488 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.1] - 2026-04-28
|
||||
|
||||
### Added
|
||||
- Grouped active tool-call cards with compact rails and a live working-status row while tools run. (#142, #149)
|
||||
- Selected-card-aware Alt+V details so the visible or selected tool card opens the matching detail payload. (#143)
|
||||
- Compact terminal-native session context inspector with persisted `@path` and `/attach` reference metadata for resumed transcripts. (#146, #150)
|
||||
|
||||
### Changed
|
||||
- Polished tool cards, diff summaries, and pending context previews for denser terminal-native scanning. (#141, #144, #145, #148)
|
||||
- Ranked Ctrl+P file-picker results with working-set relevance from modified files, recent `@file` mentions, and recent tool paths while keeping fuzzy filtering in memory. (#147)
|
||||
|
||||
## [0.7.0] - 2026-04-28
|
||||
|
||||
### Added
|
||||
- OS keyring-backed auth storage with `deepseek auth` subcommands, migration from plaintext config, provider-aware key resolution, and doctor visibility. (#134)
|
||||
- Egress network policy with allow/deny/prompt decisions, deny-wins matching, audit logging, and enforcement hooks for network-capable tools. (#135)
|
||||
- LSP diagnostics auto-injection after edits so compile feedback can be reinjected into the next agent turn. (#136)
|
||||
- Side-git workspace snapshots, `/restore`, and `revert_turn` so agent edits can be rolled back without moving the user's repository HEAD. (#137)
|
||||
- Esc-Esc backtrack over prior user turns, desktop turn-complete notifications, Alt+V tool-details access, safer command-prefix auto-allow matching, bundled `skill-creator`, and `/skill install` management for community skills. (#131, #132, #133, #138, #139, #140)
|
||||
|
||||
### Changed
|
||||
- Split more engine/tool primitives into focused modules and workspace crates, including shared tool result primitives and extracted turn/capacity flow. (#67, #74)
|
||||
|
||||
### Tests
|
||||
- Added mock LLM and skill-install integration coverage for streaming turns, reasoning replay, tool-call loops, network policy, and skill validation. (#69, #140)
|
||||
|
||||
## [0.6.5] - 2026-04-27
|
||||
|
||||
### Added
|
||||
- **`rlm_process` tool — recursive language model as a tool call.** The previous `/rlm` slash command had a UI rendering gap (the answer never made it back to the model's view) and required the user to remember to invoke it manually. `rlm_process` exposes the full RLM loop as a structured tool the model itself can choose, the same way it reaches for `agent_spawn` or `rlm_query`. Inputs: `task` (small instruction, shown to the root LLM each iteration) plus exactly one of `file_path` (workspace-relative, preferred — keeps the long input out of the model's context entirely) or `content` (inline, capped at 200k chars). Optional `child_model` (default `deepseek-v4-flash`) and `max_depth` (default 1, paper experiments). Returns the synthesized answer with metadata (iterations, duration, tokens, termination reason). Loaded across Plan / Agent / YOLO; never deferred via ToolSearch. (`crates/tui/src/tools/rlm_process.rs`)
|
||||
- **Reference-aligned REPL surface.** Aligned the in-REPL Python helpers with the canonical reference RLM (alexzhang13/rlm). The sub-agent now sees `context` (the full input, not `PROMPT`), `llm_query`, `llm_query_batched`, `rlm_query` (was `sub_rlm`), `rlm_query_batched`, `SHOW_VARS()`, `FINAL(...)`, `FINAL_VAR(...)`, plus `repl_get`/`repl_set`. Same prompt patterns and decomposition strategies from the paper now apply verbatim. (`crates/tui/src/repl/runtime.rs`)
|
||||
- **Concurrent fanout from inside the REPL.** `llm_query_batched(prompts, model=None)` runs up to 16 child completions in parallel via a new `POST /llm_batch` sidecar endpoint — much faster than serial `[llm_query(p) for p in prompts]`. `rlm_query_batched(prompts)` does the same for recursive RLM sub-calls via `POST /rlm_batch`. (`crates/tui/src/rlm/sidecar.rs`)
|
||||
- **`SHOW_VARS()`** — returns `{name: type-name}` for every user variable in the REPL. Lets the model inspect what it has accumulated across rounds before deciding whether to call `FINAL_VAR(name)`.
|
||||
- **Auto-persistence of REPL variables across rounds.** Any top-level JSON-serializable variable the sub-agent creates in a `repl` block now persists to the next round automatically — no `repl_set` ceremony needed unless you want explicit control. Matches the in-process reference REPL semantics.
|
||||
|
||||
### Changed
|
||||
- **Code fence is `repl`, not `python`.** Matches the reference RLM language identifier so the same prompts and few-shot examples work here. Backward-compat fallback to `python` / `py` retained for older model behaviors.
|
||||
- **`FINAL` / `FINAL_VAR` parseable from raw response text.** The reference RLM lets the model write `FINAL(value)` on its own line outside any code block to terminate the loop. Added `parse_text_final()` so that path works alongside the existing in-REPL Python sentinel mechanism. Code-fenced occurrences of `FINAL(...)` are correctly ignored to avoid false positives.
|
||||
- **Strict termination loop.** The sub-agent must emit a ```repl block (or text-level FINAL) to make progress. One fence-less round triggers a reminder; two consecutive trigger a `RlmTermination::DirectAnswer` exit so we don't loop forever.
|
||||
- **`rlm_process` separates `task` (root_prompt) from `file_path`/`content` (context).** The `task` rides along as `root_prompt` and is shown to the root LLM each iteration; the big input lives only in the REPL as `context`. Mirrors the reference's `completion(prompt, root_prompt=...)` API.
|
||||
- **System prompt rewritten** with the reference's strategy patterns (PREVIEW → CHUNK + map-reduce via `llm_query_batched` → RECURSIVE decomposition via `rlm_query` → programmatic computation + LLM interpretation).
|
||||
- The `/rlm` slash command stays for manual experimentation but is no longer the recommended path; the description in `commands/mod.rs` now points the model toward `rlm_process` for the in-agent flow.
|
||||
|
||||
### Reference
|
||||
- Zhang, Kraska, Khattab. "Recursive Language Models." arXiv:2512.24601.
|
||||
- alexzhang13/rlm — reference implementation by the paper authors. Variable names, helper surface, and code-fence convention align with that repo so prompts and patterns transfer.
|
||||
|
||||
|
||||
### Fixed
|
||||
- **`/rlm` actually recurses now (Algorithm 1 substrate, paper-faithful).** The v0.6.3 RLM loop had the right *shape* but its recursive substrate was non-functional: `llm_query()` was a Python stub that returned a hardcoded string, and `child_model` was bound with an underscore prefix and silently dropped. The loop ran but the sub-LLM never fired. v0.6.4 fixes this end-to-end:
|
||||
- **HTTP sidecar.** Each RLM turn spins up a localhost-only axum server on a kernel-assigned port for the duration of the turn. Python's `llm_query()` and `sub_rlm()` are real `urllib.request.urlopen` POSTs; Rust services them via the existing DeepSeek client and returns the completion text. No long-lived python process, no FIFOs, no two-pass replay — Python blocks on HTTP, Rust answers it. (`crates/tui/src/rlm/sidecar.rs`)
|
||||
- **`child_model` is plumbed through.** `Op::RlmQuery` and `AppAction::RlmQuery` carry the configured child model (default `deepseek-v4-flash`) all the way to the sidecar, where every `llm_query()` call uses it. Token usage is folded into `RlmTurnResult.usage` so cost tracking works.
|
||||
- **`sub_rlm()` is exposed as a paper-faithful recursive RLM call.** The Python REPL gets a real `sub_rlm(prompt)` function that runs another full Algorithm-1 turn at depth-1 inside the same process (different sidecar route, decremented recursion budget). Default `max_depth = 2` from the `/rlm` command — the model can recurse twice before the budget hits zero. The recursive opaque-future cycle (`run_rlm_turn_inner` → `start_sidecar` → `sub_rlm_handler` → `run_rlm_turn_inner`) is broken by returning a concrete `Pin<Box<dyn Future + Send>>` from `run_rlm_turn_inner`.
|
||||
- **Strict termination.** The loop only ends via `FINAL(value)` (or the iteration cap). The previous "no fence = direct answer, end loop" early-exit deviated from the paper and could short-circuit on iteration 1 with a chatty model that never saw `PROMPT`. The new behavior tolerates one fence-less round (with a reminder appended), then falls back to a `RlmTermination::DirectAnswer` exit. `RlmTurnResult` now carries a `termination: RlmTermination` enum (`Final | DirectAnswer | Exhausted | Error`) so callers can tell what happened.
|
||||
- **Richer `Metadata(state)`.** The metadata message the root LLM sees now includes paper-required *access patterns* (`repl_get`, slicing, `splitlines`, `repl_set`, `llm_query`, `sub_rlm`, `FINAL`) and a live list of variable keys currently in the REPL state file — so the model can see what it's accumulated across rounds without us shipping the values themselves.
|
||||
- **Unicode-safe truncation.** `truncate_text` now counts Unicode codepoints (was mixing `text.len()` bytes with `chars().take(n)`), so multi-byte previews can no longer mis-count. Per-turn temp state files are cleaned up on completion. `ROOM_TEMPERATURE` typo → `ROOT_TEMPERATURE`.
|
||||
- **End-to-end smoke test.** `rlm::turn::tests::sidecar_url_is_exported_to_python_env` stands up a stand-in axum server that always replies `{"text":"pong-from-sidecar"}`, runs `print(llm_query('hello'))` in the real `PythonRuntime`, and asserts the reply round-trips. This catches future regressions in the sidecar URL passthrough.
|
||||
|
||||
### Reference
|
||||
- Zhang, Kraska, Khattab. "Recursive Language Models." arXiv:2512.24601 (Algorithm 1).
|
||||
|
||||
|
||||
### Added
|
||||
- **Sub-agents surface in the footer status strip.** When N > 0 sub-agents are in flight, the footer grows a "1 agent" / "N agents" chip in DeepSeek-sky color matching the model badge. Hides entirely at zero. (`footer_agents_chip` in `widgets/footer.rs`)
|
||||
- **`@`-mention popup is fully wired in the composer.** Previously only the App state fields existed (`mention_menu_selected`, `mention_menu_hidden`). The popup now renders below the input mirror-style with the slash menu, with `@`-prefixed entries; Up/Down navigates, Enter / Tab apply the selection, Esc hides until the next input edit. Mention takes precedence over slash because the positional check is stricter. (`visible_mention_menu_entries` + `apply_mention_menu_selection` in `file_mention.rs`)
|
||||
|
||||
### Fixed
|
||||
- **Tool-call cells no longer flash `<command>` / `<file>` placeholders.** The engine used to emit `ToolCallStarted` from `ContentBlockStart` with `input: {}` — before any `InputJsonDelta` had streamed in — which baked the placeholder into the cell at creation time. The emission is now deferred to `ContentBlockStop` and routed through `final_tool_input`, so the cell is created with the parsed args already in hand. (engine.rs `final_tool_input`; engine/tests.rs `final_tool_input_*`)
|
||||
- **`parse_invocation_count` flake.** Two `markdown_render` tests both read the global PARSE_INVOCATIONS atomic and raced when other tests called `parse()` in parallel. Switched the counter to `thread_local!<Cell<u64>>`, so each test thread sees only its own invocations. Tested 8 sequential full-suite runs: 8/8 green (was ~40% green).
|
||||
|
||||
### Changed
|
||||
- **System prompts redesigned with decomposition-first philosophy.** All four prompt tiers (base, agent, plan, yolo) now teach the model to decompose tasks before acting — `todo_write` first for granular task tracking, `update_plan` for high-level strategy, and sub-agents for parallelizable work. Inspired by the "mismanaged geniuses hypothesis" (Zhang et al., 2026): frontier LMs are already capable enough; the bottleneck is how we scaffold their self-management. The prompts now make work visible through the sidebar (Plan / Todos / Tasks / Agents) instead of letting the model work invisibly.
|
||||
- **Tool labels use progressive verbs.** "Read foo.rs" → "Reading foo.rs", "List X" → "Listing X", "Search pattern" → "Searching for `pattern`", "List files" → "Listing files". Past-tense labels read wrong while a tool is still in flight; the new forms match what the user actually sees.
|
||||
- **Long-running tools grow an elapsed badge.** From 3 s onward the `running` status segment becomes `running (3s)`, `running (4s)`, … so the user can tell a tool isn't stuck. The status-animation tick (360 ms) drives the redraw; below 3 s the badge stays hidden so quick reads/greps don't churn. (history.rs `running_status_label_with_elapsed`)
|
||||
- **Spinner pulse is twice as fast** — `TOOL_STATUS_SYMBOL_MS` 1800 ms → 720 ms per glyph (full 4-glyph heartbeat in ~2.88 s instead of ~7.2 s).
|
||||
- **`tools/subagent.rs` is now a folder module.** Tests live in `tools/subagent/tests.rs`; runtime + manager + tool implementations stay in `tools/subagent/mod.rs`. Public API unchanged. The runtime / tool-impl split was deferred — `SubAgentTask`, `run_subagent_task`, `build_allowed_tools`, the agent prompt constants, and `normalize_role_alias` are referenced from both layers and need a small API design pass before they cleanly separate.
|
||||
|
||||
### Test hygiene
|
||||
- **5 regression tests pin auto-scroll churn contract.** `mark_history_updated` does not scroll; tool-cell handlers only `mark_history_updated`; `add_message` and `flush_active_cell` gate on `user_scrolled_during_stream`; the per-stream lock clears at TurnComplete and when the user returns to the live tail. (P2.4)
|
||||
|
||||
## [0.6.1] - 2026-04-26
|
||||
|
||||
### Changed
|
||||
- **V4 cache-hit input prices cut to 1/10th per DeepSeek's pricing update.** Pro promo 0.03625→0.003625, Pro base 0.145→0.0145, Flash 0.028→0.0028 per 1M tokens. Cache-miss and output rates unchanged.
|
||||
- **Removed the "light" theme option.** It was never tested, looked bad, and the dark/whale palettes are the supported targets. Theme validation now accepts only `default`, `dark`, and `whale`.
|
||||
- **System prompts redesigned with decomposition-first philosophy.** All five prompt tiers teach the model to `todo_write` before acting, `update_plan` for strategy, and sub-agents for parallel work. Inspired by the mismanaged-geniuses hypothesis (Zhang et al., 2026).
|
||||
|
||||
## [0.6.0] - 2026-04-25
|
||||
|
||||
### Added
|
||||
- **`rlm_query` tool — recursive language models as a first-class structured tool.** Inspired by [Alex Zhang's RLM work](https://github.com/alexzhang13/rlm) and Sakana AI's published novelty-search research, but trimmed to what an agent loop actually needs. The model calls `rlm_query` with one prompt or up to 16 concurrent prompts; children run on `deepseek-v4-flash` by default and can be promoted to Pro per-call. Children dispatch concurrently via `tokio::join_all` against the existing DeepSeek client — no external runtime, no fenced-block DSL, no Python sandbox. Returns plain text for one prompt, indexed `[0] ...\n\n---\n\n[1] ...` blocks for many. Available in Plan / Agent / YOLO. Cost is folded into the session's running total automatically.
|
||||
|
||||
### Changed
|
||||
- **Scroll position survives content rewrites (#56).** `TranscriptScroll::resolve_top` and `scrolled_by` no longer teleport to bottom when the anchor cell vanishes. Three-level fallback chain: same line → same cell, line 0 → nearest surviving cell at-or-before. Previously, any rewrite of the assistant message (e.g. tool-result replacement) silently dropped the user back to the live tail mid-scroll.
|
||||
- **Looser command-safety chains (#57).** `cargo build && cargo test`, `git fetch && git rebase`, and similar chains of known-safe commands now escalate to `RequiresApproval` instead of being hard-blocked as `Dangerous`. Chains containing unknown commands still block.
|
||||
- **`GettingCrowded` no longer surfaces a footer chip.** The context-percent header already covers conversation pressure; the chip now only fires for active engine interventions (`refreshing context`, `verifying`, `resetting plan`).
|
||||
|
||||
## [0.5.2] - 2026-04-25
|
||||
|
||||
### Added
|
||||
- **`/model` opens a Pro/Flash + thinking-effort picker (#39).** Typing `/model` with no argument now pops a two-pane modal: model on the left (`deepseek-v4-pro` flagship, `deepseek-v4-flash` fast/cheap, plus a "current (custom)" row when the active id isn't one of the listed defaults), and thinking effort on the right. Tab/←/→ swaps panes, ↑/↓ moves within the focused pane, Enter applies both selections, Esc cancels. The effort pane intentionally exposes only **Off / High / Max** because [DeepSeek's Thinking Mode docs](https://api-docs.deepseek.com/guides/reasoning_model) state `low`/`medium` are mapped to `high` server-side and `xhigh` is mapped to `max` — the legacy variants stay valid in `~/.deepseek/settings.toml` for back-compat, the picker just doesn't surface them. Apply path persists `default_model` and `reasoning_effort` to settings, forwards `Op::SetModel` + `Op::SetCompaction` to the running engine so the next turn picks up the change without a restart, and resets the per-turn token gauges (cache, replay) so the footer numbers reflect the new model. `/model <id>` keeps working unchanged for power users.
|
||||
|
||||
## [0.5.1] - 2026-04-25
|
||||
|
||||
### Added
|
||||
- **`fetch_url` tool** for direct HTTP GET on a known URL — complements `web_search` for cases where the link is already known. Supports `format` (`markdown` / `text` / `raw`), `max_bytes` (default 1 MB, hard cap 10 MB), `timeout_ms` (default 15 s, max 60 s), redirect following, and structured `{url, status, content_type, content, truncated}` responses. 4xx/5xx bodies are returned (with `success: false`) so the caller can read JSON error envelopes. (#33)
|
||||
- **PDF support in `read_file`.** PDFs are auto-detected by extension or `%PDF-` magic bytes and extracted via `pdftotext -layout` (poppler) when available. New optional `pages` arg (`"5"` or `"1-10"`) reads page slices. Without `pdftotext`, returns a structured `{type: "binary_unavailable", kind: "pdf", reason, hint}` with install commands for macOS/Debian. (#34)
|
||||
- **Reasoning-content replay telemetry, end-to-end (#30).** The chat-completions sanitizer now estimates replayed `reasoning_content` tokens (~4 chars/token), threads the value through the streaming `Usage` payload, stores it on the App, and renders an `rsn N.Nk` chip in the footer next to the cache hit-rate. The chip turns warning-coloured when replay tokens exceed 50% of the input budget, so users on long thinking-mode loops can see at a glance how much of their context window is going to V4's "Interleaved Thinking" replay (paper §5.1.1). Logged at `RUST_LOG=deepseek_tui=info` for tail-friendly diagnosis.
|
||||
- **`@file` Tab-completion (#28).** Typing `@<partial>` and pressing Tab now resolves the mention against the workspace using the existing `ignore::WalkBuilder`. A unique match is spliced into the input; multiple matches with a longer common prefix extend the partial; remaining ambiguity is surfaced via the status line. The mention-expansion path that ships file contents to the model is unchanged — this is purely a discovery aid for typing the path. Inline-contents and a fuzzy popup picker are queued for v0.5.2.
|
||||
- **Per-workspace external trust list (#29).** `~/.deepseek/workspace-trust.json` now records, for each workspace, the absolute paths the user has opted into reading/writing from outside that workspace. The new `/trust` slash command supports `add <path>`, `remove <path>`, `list`, `on`, `off`, and a status read with no args; the engine consults the list when constructing every `ToolContext` so changes apply on the next tool call without restart. `/diagnostics` surfaces the list. The interactive "Allow once / Always allow / Deny" approval prompt is deferred — for now grant access ahead of the turn with `/trust add <path>`.
|
||||
|
||||
### Fixed
|
||||
- **TUI sidebar gutter bleed regression test (#36).** Snapshot tests now lock in that long single-line tool results — including a `todo_write` echo of a multi-kilobyte JSON payload — never write any cells outside `chat_area` at the widths reported in the bug (80, 120, 165, 200 cols). A second test verifies the scrollbar coexists with content along the right edge instead of overdrawing the penultimate column.
|
||||
- **Version drift caught in CI.** New `versions` job in `.github/workflows/ci.yml` runs `scripts/release/check-versions.sh` on every push/PR, verifying every per-crate `Cargo.toml` inherits the workspace version, the npm wrapper matches the workspace version, and `Cargo.lock` is in sync. The release runbook now lists `check-versions.sh` as the first preflight step. (#31)
|
||||
- **Per-mode soft context budget for V4 compaction trigger** (#27).
|
||||
- **Phantom `web.run` references stripped** from prompts and the `web_search` tool surface (#25).
|
||||
- **Unused import + `cargo fmt` drift** that landed with `feat(#27)` and broke Build / Test / npm wrapper smoke under `-Dwarnings`.
|
||||
|
||||
## [0.5.0] - 2026-04-25
|
||||
|
||||
### Fixed
|
||||
- Multi-turn tool calls on thinking-mode models no longer return HTTP 400. Every assistant message in the conversation now carries `reasoning_content` when thinking is enabled — not just tool-call rounds — matching DeepSeek's actual API validation, which rejects any assistant message missing the field even though the docs describe non-tool-call reasoning as "ignored".
|
||||
- Added a final-pass wire-payload sanitizer in the chat-completions client that forces a non-empty `reasoning_content` placeholder onto any assistant message still missing one at request time. This is the last line of defense after engine-side and build-side substitution, so sessions restored from older checkpoints, sub-agents that append messages directly, and cached prefix mismatches all produce a valid request.
|
||||
- On a `reasoning_content`-related 400, the client now logs the offending message indices to make future regressions diagnosable.
|
||||
- Stripped phantom `web.run` references from prompts and the `web_search` tool surface ([#25](https://github.com/Hmbown/DeepSeek-TUI/issues/25)).
|
||||
|
||||
### Changed
|
||||
- Header/UI widget refactor in the TUI (`crates/tui/src/tui/ui.rs`, `widgets/header.rs`) — internal cleanup, no user-visible behavior change.
|
||||
|
||||
## [0.4.9] - 2026-04-27
|
||||
|
||||
### Fixed
|
||||
- DeepSeek thinking-mode tool-call rounds now always replay `reasoning_content` in all subsequent requests (including across new user turns), matching DeepSeek's documented API contract that assistant messages with tool calls must retain their reasoning content forever.
|
||||
- Missing `reasoning_content` on a tool-call assistant message now substitutes a safe placeholder (`"(reasoning omitted)"`) instead of dropping the tool calls and their matching tool results, preventing orphaned conversation chains and API 400 errors.
|
||||
- Session checkpoint now persists a Thinking-block placeholder for tool-call turns that produced no streamed reasoning text, keeping on-disk sessions structurally correct so subsequent requests avoid HTTP 400 rejections.
|
||||
- Token estimation for compaction now counts thinking tokens across all tool-call rounds (not just the current user turn), aligning with the updated reasoning_content replay rule.
|
||||
|
||||
## [0.4.8] - 2026-04-25
|
||||
|
||||
### Fixed
|
||||
- DeepSeek V4 Pro cost estimates now use DeepSeek's current limited-time 75% discount until 2026-05-05 15:59 UTC, then automatically fall back to the base Pro rates.
|
||||
|
||||
## [0.4.5] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- Alternate-screen TUI sessions now capture mouse input by default so wheel scrolling moves the transcript instead of exposing terminal scrollback from before the TUI started. Use `--no-mouse-capture` or `tui.mouse_capture = false` when terminal-native drag selection is preferred.
|
||||
|
||||
## [0.4.2] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- DeepSeek V4 thinking-mode tool turns now checkpoint the engine's authoritative API transcript, including assistant `reasoning_content` on reasoning-to-tool-call turns with no visible assistant text.
|
||||
- Chat Completions request building now drops stale V4 tool-call rounds that are missing required `reasoning_content`, preventing old corrupted checkpoints from triggering DeepSeek HTTP 400 replay errors.
|
||||
- Web search now falls back to Bing HTML results when DuckDuckGo returns a bot challenge or otherwise yields no parseable results.
|
||||
|
||||
## [0.4.1] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- DeepSeek V4 tool-result context now preserves large file reads and command outputs instead of compacting noisy tools to a 900-character snippet after 2k characters.
|
||||
- Capacity guardrail refresh no longer performs destructive summary compaction unless the normal model-aware compaction thresholds are actually crossed.
|
||||
- V4 compaction summaries retain larger tool-result excerpts and summary input when compaction is genuinely needed.
|
||||
- The transcript now follows the bottom again when sending a new message, shows an in-app scrollbar when internally scrolled, and leaves mouse capture off in `--no-alt-screen` mode so terminal-native scrolling can work.
|
||||
|
||||
## [0.4.0] - 2026-04-23
|
||||
|
||||
### Added
|
||||
- **DeepSeek V4 support**: `deepseek-v4-pro` (flagship) and `deepseek-v4-flash` (fast/cheap) are now first-class model IDs with 1M context windows.
|
||||
- **Reasoning-effort tier**: new `reasoning_effort` config field (`off | low | medium | high | max`) mapped to DeepSeek's `reasoning_effort` + `thinking` request fields. Defaults to `max`.
|
||||
- **Shift+Tab cycles reasoning-effort** through the three behaviorally distinct tiers (`off → high → max`). The current tier is shown as a ⚡ chip in the header.
|
||||
- Per-model pricing table: `deepseek-v4-pro` priced at $0.145/$1.74/$3.48 per 1M tokens (cache-hit/miss/output); `deepseek-v4-flash` and legacy aliases at $0.028/$0.14/$0.28.
|
||||
|
||||
### Changed
|
||||
- **Default model flipped to `deepseek-v4-pro`** (from `deepseek-reasoner`).
|
||||
- `deepseek-chat` / `deepseek-reasoner` remain as silent aliases of `deepseek-v4-flash` for API compatibility; priced identically.
|
||||
- **Context compaction**: 1M-context V4 models now compact at 800k input tokens or 2,000 messages, so short/tool-heavy sessions do not compact as if they were 128k-context runs.
|
||||
- Cycling modes is now Tab-only; Shift+Tab is repurposed for reasoning-effort (reverse-mode cycle was low-value with only three modes).
|
||||
- Updated help/hint strings, validator error messages, and the model picker to reference V4 IDs.
|
||||
|
||||
### Fixed
|
||||
- `requires_reasoning_content` now recognizes `deepseek-v4*` so thinking streams render correctly on V4 models.
|
||||
- DeepSeek V4 thinking-mode tool calls now preserve prior assistant `reasoning_content` whenever a tool call is replayed, matching DeepSeek's multi-turn contract and avoiding HTTP 400 rejections on later turns.
|
||||
- Raw Chat Completions requests now send DeepSeek's top-level `thinking` parameter instead of the OpenAI SDK-only `extra_body` wrapper.
|
||||
- Config, env, and UI model selection now normalize legacy DeepSeek aliases to `deepseek-v4-flash` instead of preserving old model labels.
|
||||
- npm wrapper first-run downloads now use process-unique temp files so concurrent `deepseek` / `deepseek-tui` invocations do not race on `*.download` files.
|
||||
|
||||
## [0.3.33] - 2026-04-11
|
||||
|
||||
### Changed
|
||||
- Footer polish: simplified footer rendering, removed footer clock label, updated status line layout
|
||||
- Palette cleanup: removed `FOOTER_HINT` color constant
|
||||
|
||||
### Removed
|
||||
- `FOOTER_HINT` color constant from palette (use `TEXT_MUTED` or `TEXT_HINT` instead)
|
||||
|
||||
### Fixed
|
||||
- Test updates to align with simplified footer logic
|
||||
- Empty state placeholder text removed for cleaner UI
|
||||
|
||||
## [0.3.32] - 2026-04-11
|
||||
|
||||
### Added
|
||||
- Finance tool: Yahoo Finance v8 quote endpoint with chart fallback, supporting stocks, ETFs, indices, forex, and crypto lookups.
|
||||
- Header widget redesign: proportional truncation, context-usage bar with gradient fill, streaming indicator, and graceful narrow-terminal degradation.
|
||||
- Expanded test coverage: 680+ tests including footer state, context spans, plan prompt lifecycle, workspace context refresh, header rendering, and finance tool integration tests with wiremock.
|
||||
- Workspace context refresh with configurable TTL and deferred initial fetch.
|
||||
- Config command additions for runtime settings management.
|
||||
|
||||
### Changed
|
||||
- Redesigned footer status strip with mode/model/status layout, context bar, and narrow-terminal fallback.
|
||||
- Plan prompt now uses numeric selection (1-4) instead of keyword input; old aliases are sent as regular messages.
|
||||
- Archived outdated docs (`workspace_migration_status.md` -> `docs/archive/`).
|
||||
- Trimmed AGENTS.md boilerplate and updated task counts.
|
||||
- Clarified release-surface documentation: crates.io publication may lag the workspace/npm wrapper.
|
||||
|
||||
### Fixed
|
||||
- Header `metadata_spans` now uses `saturating_sub` to prevent underflow on narrow terminals.
|
||||
- Finance tool reuses a single HTTP client instead of rebuilding per request.
|
||||
- Finance tool tests no longer leak temp directories.
|
||||
|
||||
## [0.3.31] - 2026-03-08
|
||||
|
||||
### Added
|
||||
- Replaced the finance tool backend with Yahoo Finance v8 + CoinGecko fallback for reliable real-time market data (stocks, ETFs, indices, forex, crypto).
|
||||
- Added compaction UX: status strip shows animated COMPACTING indicator during context summarization, footer reflects compaction state, and CompactionCompleted events now include message count statistics.
|
||||
- Added send flash: brief tinted background highlight on the last user message after sending.
|
||||
- Added braille typing indicator with smooth 10-frame animation cycle.
|
||||
|
||||
### Changed
|
||||
- Redesigned the footer status strip with mode/model/token/cost layout, quadrant separators, and a context-usage bar.
|
||||
- Added Unicode prefix indicators (▸ You, ◆ Answer, ● System) to chat history cells for visual distinction.
|
||||
- Improved thinking token delineation with labeled delimiters in transcript rendering.
|
||||
- Refactored source code into workspace crates for better modularity and dependency management.
|
||||
|
||||
### Fixed
|
||||
- Fixed Plan mode ESC key dismissing the prompt without clearing `plan_prompt_pending`, which prevented the prompt from reappearing on subsequent plan completions.
|
||||
- Fixed clippy lint (collapsible_if) in web browsing session management.
|
||||
|
||||
## [0.3.30] - 2026-03-06
|
||||
|
||||
### Added
|
||||
- Added a release-ready local npm smoke path that builds binaries, serves release assets locally, packs the wrapper, installs the tarball, and checks both entrypoints before publish.
|
||||
- Added an opt-in full-matrix local release-asset fixture so `npm run release:check` can be exercised before GitHub release assets exist.
|
||||
|
||||
### Changed
|
||||
- Bumped the Rust workspace crates and npm wrapper to `0.3.30`.
|
||||
- Pointed the npm wrapper's default `deepseekBinaryVersion` at `0.3.30` for the next coordinated Rust + npm release.
|
||||
- Updated the crates dry-run helper to work from a dirty workspace and to preflight dependent workspace crates without requiring unpublished versions to already exist on crates.io.
|
||||
|
||||
## [0.3.29] - 2026-03-03
|
||||
|
||||
### Added
|
||||
- Added npm publish-time release asset verification for the `deepseek-tui` package to fail fast when expected GitHub binaries are missing.
|
||||
- Added checksum manifests to GitHub release assets and checksum verification in the npm installer.
|
||||
- Added `npm pack` install-and-smoke CI coverage for the `deepseek-tui` wrapper package.
|
||||
- Added an end-to-end release runbook covering crates.io, GitHub Releases, and npm publication.
|
||||
|
||||
### Changed
|
||||
- Updated npm package documentation for clearer install modes, environment overrides, and release integrity behavior.
|
||||
- Improved installer support-matrix error messaging for unsupported platform/architecture combinations.
|
||||
- Decoupled npm package version from default binary artifact version via `deepseekBinaryVersion`, enabling packaging-only npm releases.
|
||||
- Moved the `deepseek-tui` binary target inside `crates/tui` so `cargo publish --dry-run -p deepseek-tui` works from the workspace package layout.
|
||||
- Replaced the root-level crates publish workflow with an ordered workspace publish flow.
|
||||
- Reworked first-run onboarding and README copy around primary workflows instead of shortcut memorization.
|
||||
- Relaxed onboarding API-key format heuristics so unusual keys warn instead of blocking setup.
|
||||
|
||||
## [0.3.28] - 2026-03-02
|
||||
|
||||
### Added
|
||||
- Converted the project to a modular Cargo workspace using a `crates/` layout.
|
||||
- Added new crate boundaries mirroring a deepseek architecture (`agent`, `config`, `core`, `execpolicy`, `hooks`, `mcp`, `protocol`, `state`, `tools`, `tui-core`, `tui`, and `app-server`).
|
||||
|
||||
### Changed
|
||||
- Added parity CI coverage with protocol/state/snapshot checks.
|
||||
- Updated release workflow to build both `deepseek` and `deepseek-tui` binaries.
|
||||
|
||||
## [0.3.26] - 2026-03-02
|
||||
|
||||
### Fixed
|
||||
- Resolved SSE stream corruption caused by byte/string position mismatch in streaming parse flow.
|
||||
- Hardened base URL validation to reject non-HTTP/HTTPS schemes.
|
||||
- Prevented multi-byte UTF-8 truncation panics in common-prefix and runtime thread summary paths.
|
||||
- Corrected context usage alert thresholds by separating warning and critical trigger levels.
|
||||
|
||||
### Changed
|
||||
- Removed non-code utility tools from the runtime tool registry (`calculator`, `weather`, `sports`, `finance`, `time`) and related wiring.
|
||||
- Consolidated duplicate URL encoding helpers by delegating to shared `crate::utils::url_encode`.
|
||||
- Replaced broad crate-level lint suppressions with targeted `#[allow(...)]` annotations where justified.
|
||||
- Cleaned up dead APIs, unused struct fields, unused builder helpers, and non-integrated modules.
|
||||
- Addressed clippy findings across the codebase (collapsible conditionals, defaults, indexing helpers, and API signature cleanup).
|
||||
|
||||
## [0.3.24] - 2026-02-25
|
||||
|
||||
### Fixed
|
||||
- Preserve reasoning-only assistant turns for DeepSeek reasoning models (`deepseek-reasoner`, R-series markers) when rebuilding chat history.
|
||||
- Align SSE tool streaming indices so each tool block start/delta/stop uses the same block index.
|
||||
- Prevent transcript auto-scroll-to-bottom when a non-empty transcript selection is active.
|
||||
- Allow session picker search mode to accept the current selection with a single `Enter` press.
|
||||
- Preserve tool output whitespace/indentation while still wrapping long unbroken tokens.
|
||||
- Make transcript selection copy/highlighting display-width aware (wide chars and tabs).
|
||||
- Gate execpolicy behavior on the `exec_policy` feature flag across CLI/tool execution paths.
|
||||
- Run doctor API connectivity checks using the effective loaded config/profile (instead of reloading defaults).
|
||||
- Parse DeepSeek model context-window suffix hints such as `-32k` and `-256k`.
|
||||
- Update README config docs with key environment overrides and a direct link to full configuration docs.
|
||||
|
||||
## [0.3.23] - 2026-02-24
|
||||
|
||||
### Changed
|
||||
- Updated project copy to describe the app as a terminal-native TUI/CLI for DeepSeek models (not pinned to a specific model generation).
|
||||
|
||||
### Fixed
|
||||
- Model selection and config validation now accept any valid `deepseek-*` model ID (including future releases), while still normalizing common aliases like `deepseek-v3.2` and `deepseek-r1`.
|
||||
- Tool-call recovery now auto-loads deferred tools when the model requests them directly, instead of failing with manual `tool_search_*` instructions.
|
||||
- YOLO mode now preloads tools by default (including deferred MCP tools), so model tool calls can run immediately without discovery indirection.
|
||||
- Unknown tool-call failures now include discovery guidance and nearest tool-name suggestions instead of generic availability errors.
|
||||
- Slash-command errors now suggest the closest known command (for example `/modle` -> `/model`) instead of only returning a generic unknown-command message.
|
||||
|
||||
## [0.3.22] - 2026-02-19
|
||||
|
||||
### Added
|
||||
- Interactive `/config` editing modal for runtime settings updates.
|
||||
|
||||
### Changed
|
||||
- Retired user-facing `/set` command path (no longer reachable/discoverable).
|
||||
- Replaced `/deepseek` command behavior with `/links` (aliases: `dashboard`, `api`).
|
||||
|
||||
### Fixed
|
||||
- Legacy `/set` and `/deepseek` inputs now return migration guidance instead of generic unknown-command errors.
|
||||
|
||||
## [0.3.21] - 2026-02-19
|
||||
|
||||
### Added
|
||||
- Parallel tool execution in `multi_tool_use.parallel` for independent task workflows.
|
||||
- Session resume-thread coverage in tests.
|
||||
|
||||
### Changed
|
||||
- Desktop and web parity polish across the TUI and runtime surfaces.
|
||||
- Onboarding and approval UX refinement from prior phase 3 iteration.
|
||||
|
||||
### Fixed
|
||||
- Runtime pre-release startup issues and config-path edge cases.
|
||||
- Clippy lint regressions introduced by the last parity pass.
|
||||
|
||||
### Security/Hardening
|
||||
- General pre-release hardening for runtime app behavior.
|
||||
|
||||
## [0.3.17] - 2026-02-16
|
||||
|
||||
### Fixed
|
||||
- Config loading now expands `~` in `DEEPSEEK_CONFIG_PATH` and `--config` paths.
|
||||
- When `DEEPSEEK_CONFIG_PATH` points to a missing file, config loading now falls back to `~/.deepseek/config.toml` if it exists.
|
||||
|
||||
### Changed
|
||||
- Removed committed transient runtime artifacts (`session_*.json`, `.deepseek/trusted`) and added ignore rules to prevent re-commit.
|
||||
|
||||
## [0.3.16] - 2026-02-15
|
||||
|
||||
### Added
|
||||
- `deepseek models` CLI command to fetch and list models from the configured `/v1/models` endpoint (with `--json` output mode).
|
||||
- `/models` slash command to fetch and display live model IDs in the TUI.
|
||||
- Slash-command autocomplete hints in the composer plus `Tab` completion for `/` commands.
|
||||
- Command palette modal (`Ctrl+K`) for quick insertion of slash commands and skills.
|
||||
- Persistent right sidebar in wide terminals showing live plan/todo/sub-agent state.
|
||||
- Expandable tool payload views (`v` in transcript, `v` in approval modal) for full params/output inspection.
|
||||
- Runtime HTTP/SSE API (`deepseek serve --http`) with durable thread/turn/item lifecycle, interrupt/steer, and replayable event timeline.
|
||||
- Background task queue (`/task add|list|show|cancel` and `POST /v1/tasks`) with persistent storage, bounded worker pool, and timeline/artifact tracking.
|
||||
|
||||
### Changed
|
||||
- Centralized the default text model (`DEFAULT_TEXT_MODEL`) and shared common model list to reduce drift across runtime/config paths.
|
||||
- `/model` now clarifies that any valid DeepSeek model ID is accepted (including future releases), while still showing common model IDs.
|
||||
|
||||
### Fixed
|
||||
- Expanded reasoning-model detection for chat history reconstruction (supports R-series and reasoner-style naming without hardcoding single versions).
|
||||
- Aligned docs/config examples with the then-current runtime default model.
|
||||
|
||||
## [0.3.14] - 2026-02-05
|
||||
|
||||
### Added
|
||||
- `web.run` now supports `image_query` (DuckDuckGo image search)
|
||||
- `multi_tool_use.parallel` now supports safe MCP meta tools (`list_mcp_resources`, `mcp_read_resource`, etc.)
|
||||
|
||||
### Fixed
|
||||
- Encode tool-call function names when rebuilding Chat Completions history (keeps dotted tool names API-safe)
|
||||
|
||||
### Changed
|
||||
- Prompts: stronger `web.run` citation placement and quote-limit guidance
|
||||
|
||||
## [0.3.13] - 2026-02-04
|
||||
|
||||
### Fixed
|
||||
- Restore an in-app scrollbar for the transcript view
|
||||
|
||||
## [0.3.12] - 2026-02-04
|
||||
|
||||
### Fixed
|
||||
- Map dotted tool names to API-safe identifiers for DeepSeek tool calls
|
||||
- Encode any invalid tool names for API tool lists while preserving internal names
|
||||
|
||||
## [0.3.11] - 2026-02-04
|
||||
|
||||
### Fixed
|
||||
- Fix tool name mapping for DeepSeek API
|
||||
|
||||
## [0.3.10] - 2026-02-04
|
||||
|
||||
### Fixed
|
||||
- Always enable mouse wheel scrolling in the TUI (even without alt screen)
|
||||
|
||||
## [0.3.9] - 2026-02-04
|
||||
|
||||
### Removed
|
||||
- RLM mode, tools, and documentation pending a faithful implementation of the MIT RLM design
|
||||
- Duo mode tools and prompts pending a citable research spec
|
||||
|
||||
### Fixed
|
||||
- Footer context usage bar remains visible while status toasts are shown
|
||||
|
||||
### Changed
|
||||
- Updated prompts and docs to reflect the simplified mode/tool surface
|
||||
|
||||
## [0.3.8] - 2026-02-03
|
||||
|
||||
### Fixed
|
||||
- Resolve clippy warnings (CI `-D warnings`) in new tool implementations
|
||||
|
||||
## [0.3.7] - 2026-02-03
|
||||
|
||||
### Added
|
||||
- Tooling parity updates: `weather`, `finance`, `sports`, `time`, `calculator`, `request_user_input`, `multi_tool_use.parallel`, `web.run`
|
||||
- Shell streaming helpers: `exec_shell_wait` and `exec_shell_interact`
|
||||
- Sub-agent controls: `send_input` and `wait` (with aliases)
|
||||
- MCP resource helpers: `list_mcp_resources`, `list_mcp_resource_templates`, and `read_mcp_resource` alias
|
||||
|
||||
### Changed
|
||||
- Skills directory selection now prefers workspace `.agents/skills`, then `./skills`, then global
|
||||
- Docs and prompts updated to reflect new tool surface and parity notes
|
||||
|
||||
## [0.3.6] - 2026-02-02
|
||||
|
||||
### Added
|
||||
- New welcome banner on startup showing "Welcome to DeepSeek TUI!" with directory, session ID, and model info
|
||||
- Visual context progress bar in footer showing usage with block characters [████░░░░░░] and percentage
|
||||
|
||||
### Changed
|
||||
- Removed custom block-character scrollbar from chat area - now uses terminal's native scroll
|
||||
- Simplified header bar: removed context percentage indicator (moved to footer as progress bar)
|
||||
|
||||
## [0.3.5] - 2026-01-30
|
||||
|
||||
### Added
|
||||
- Intelligent context offloading: large tool results (>15k chars) are automatically moved to RLM memory to preserve the context window
|
||||
- Persistent history context: compacted messages are offloaded to RLM `history` variable for recall
|
||||
- Full MCP protocol support: SSE transport, Resources (`resources/list`, `resources/read`), and Prompts (`prompts/list`, `prompts/get`)
|
||||
- `mcp_read_resource` and `mcp_get_prompt` virtual tools exposed to the model
|
||||
- Dialectical Duo mode with specialized TUI rendering (`Player` / `Coach` history cells)
|
||||
- Dynamic system prompt refreshing at each turn for up-to-date RLM/Duo/working-set context
|
||||
- `project_map` tool for automatic codebase structure discovery
|
||||
- `delegate_to_agent` alias for streamlined sub-agent delegation
|
||||
|
||||
### Changed
|
||||
- Default theme changed to 'Whale' with updated color palette
|
||||
- `with_agent_tools` now includes `project_map`, `test_runner`, and conditionally RLM tools for all agent modes
|
||||
- MCP `McpServerConfig.command` is now `Option<String>` to support URL-only (SSE) servers
|
||||
|
||||
### Fixed
|
||||
- MCP test compilation errors for updated `McpServerConfig` struct shape
|
||||
|
||||
## [0.3.4] - 2026-01-29
|
||||
|
||||
### Changed
|
||||
- Updated Cargo.lock dependencies
|
||||
|
||||
### Fixed
|
||||
- Compaction tool-call pairing: enforce bidirectional tool-call/tool-result integrity with fixpoint convergence
|
||||
- Safety net scanning to drop orphan tool results in the request builder
|
||||
- Double-dispatch race in parallel tool execution
|
||||
|
||||
## [0.3.3] - 2026-01-28
|
||||
|
||||
### Added
|
||||
- TUI polish: Kimi-style footer with mode/model/token display
|
||||
- Streaming thinking blocks with dedicated rendering
|
||||
- Loading animation improvements
|
||||
|
||||
## [0.3.2] - 2026-01-28
|
||||
|
||||
### Fixed
|
||||
@@ -134,7 +616,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 +627,39 @@ 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.6.1...HEAD
|
||||
[0.6.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.6.0...v0.6.1
|
||||
[0.6.0]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.4.9...v0.6.0
|
||||
[0.4.9]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.4.8...v0.4.9
|
||||
[0.4.8]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.33...v0.4.8
|
||||
[0.3.33]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.32...v0.3.33
|
||||
[0.3.32]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.31...v0.3.32
|
||||
[0.3.31]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.28...v0.3.31
|
||||
[0.3.28]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.27...v0.3.28
|
||||
[0.3.23]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.22...v0.3.23
|
||||
[0.3.22]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.21...v0.3.22
|
||||
[0.3.21]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.17...v0.3.21
|
||||
[0.3.17]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.16...v0.3.17
|
||||
[0.3.16]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.14...v0.3.16
|
||||
[0.3.14]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.13...v0.3.14
|
||||
[0.3.13]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.12...v0.3.13
|
||||
[0.3.12]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.11...v0.3.12
|
||||
[0.3.11]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.10...v0.3.11
|
||||
[0.3.10]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
|
||||
[0.3.6]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.5...v0.3.6
|
||||
[0.3.5]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.4...v0.3.5
|
||||
[0.3.4]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.3...v0.3.4
|
||||
[0.3.3]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.2...v0.3.0
|
||||
[0.2.2]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.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
+1760
-8
File diff suppressed because it is too large.
Load diff
+32
-56
@@ -1,68 +1,44 @@
|
||||
[package]
|
||||
name = "deepseek-tui"
|
||||
version = "0.3.2"
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/agent",
|
||||
"crates/app-server",
|
||||
"crates/cli",
|
||||
"crates/config",
|
||||
"crates/core",
|
||||
"crates/execpolicy",
|
||||
"crates/hooks",
|
||||
"crates/mcp",
|
||||
"crates/protocol",
|
||||
"crates/secrets",
|
||||
"crates/state",
|
||||
"crates/tools",
|
||||
"crates/tui",
|
||||
"crates/tui-core",
|
||||
]
|
||||
default-members = ["crates/cli", "crates/app-server", "crates/tui"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.5"
|
||||
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,254 @@
|
||||
# DeepSeek CLI 🤖
|
||||
# DeepSeek TUI
|
||||
|
||||
Your AI-powered terminal companion for DeepSeek models
|
||||
|
||||
[](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml)
|
||||
[](https://crates.io/crates/deepseek-tui)
|
||||
|
||||
Unofficial terminal UI (TUI) + CLI for the [DeepSeek platform](https://platform.deepseek.com) — chat with DeepSeek models and collaborate with AI assistants that can read, write, execute, and plan with approval-gated tool access.
|
||||
|
||||
**Not affiliated with DeepSeek Inc.**
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Interactive TUI** with multiple modes (Normal, Plan, Agent, YOLO, RLM, Duo)
|
||||
- **Comprehensive tool access** – File operations, shell execution, task management, and sub-agent systems
|
||||
- **File operations**: List directories, read/write/edit files, apply patches, search files with regex
|
||||
- **Shell execution**: Run commands with timeout support, background execution with task management
|
||||
- **Task management**: Todo lists, implementation plans, persistent notes
|
||||
- **Sub-agent system**: Spawn, 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**:
|
||||
> **A terminal-native coding agent for [DeepSeek V4](https://platform.deepseek.com) models — with 1M-token context, thinking-mode reasoning, and full tool-use.**
|
||||
|
||||
```bash
|
||||
# Install via Cargo
|
||||
cargo install deepseek-tui --locked
|
||||
npm i -g deepseek-tui
|
||||
```
|
||||
|
||||
# Set your API key
|
||||
export DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY"
|
||||
[](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml)
|
||||
[](https://www.npmjs.com/package/deepseek-tui)
|
||||
|
||||
# Bootstrap MCP + skills templates (recommended)
|
||||
deepseek setup
|
||||

|
||||
|
||||
# Start chatting
|
||||
---
|
||||
|
||||
## What is it?
|
||||
|
||||
DeepSeek TUI is a coding agent that runs entirely in your terminal. It gives DeepSeek's frontier models direct access to your workspace — reading and editing files, running shell commands, searching the web, managing git, and orchestrating sub-agents — all through a fast, keyboard-driven TUI.
|
||||
|
||||
**Built for DeepSeek V4** (`deepseek-v4-pro` / `deepseek-v4-flash`) with 1M-token context windows and native thinking-mode (chain-of-thought) streaming. See the model's reasoning unfold in real time as it works through your tasks.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Native RLM** (`rlm_query` tool) — fans out 1–16 cheap `deepseek-v4-flash` children in parallel against the existing DeepSeek client for batched analysis, decomposition, or parallel reasoning
|
||||
- **Thinking-mode streaming** — shows DeepSeek's chain-of-thought as it reasons about your code
|
||||
- **Full tool suite** — file ops, shell execution, git, web search/browse, apply-patch, sub-agents, MCP servers
|
||||
- **1M-token context** — automatic intelligent compaction when context fills up
|
||||
- **Three interaction modes** — Plan (read-only explore), Agent (interactive with approval), YOLO (auto-approved). Decomposition-first system prompts teach the model to `checklist_write`, `update_plan`, and spawn sub-agents before acting
|
||||
- **Reasoning-effort tiers** — cycle through `off → high → max` with Shift+Tab
|
||||
- **Session save/resume** — checkpoint and resume long sessions
|
||||
- **Workspace rollback** — side-git pre/post-turn snapshots with `/restore` and `revert_turn`, without touching your repo's `.git`
|
||||
- **HTTP/SSE runtime API** — `deepseek serve --http` for headless agent workflows
|
||||
- **MCP protocol** — connect to Model Context Protocol servers for extended tooling
|
||||
- **Live cost tracking** — per-turn and session-level token usage and cost estimates
|
||||
- **Dark theme** — DeepSeek-blue palette
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
npm install -g deepseek-tui
|
||||
deepseek
|
||||
```
|
||||
|
||||
3. Press `F1` or type `/help` for the in‑app command list.
|
||||
|
||||
If anything looks off, run `deepseek doctor` to diagnose configuration issues.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### From crates.io
|
||||
On first launch you'll be prompted for your [DeepSeek API key](https://platform.deepseek.com/api_keys). You can also set it ahead of time:
|
||||
|
||||
```bash
|
||||
cargo install deepseek-tui --locked
|
||||
# via CLI
|
||||
deepseek login --api-key "YOUR_DEEPSEEK_API_KEY"
|
||||
|
||||
# via env var
|
||||
export DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY"
|
||||
deepseek
|
||||
```
|
||||
|
||||
### Build from source
|
||||
### Using NVIDIA NIM
|
||||
|
||||
```bash
|
||||
deepseek auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"
|
||||
deepseek --provider nvidia-nim
|
||||
|
||||
# or per-process:
|
||||
DEEPSEEK_PROVIDER=nvidia-nim NVIDIA_API_KEY="..." deepseek
|
||||
```
|
||||
|
||||
### Other DeepSeek V4 providers
|
||||
|
||||
```bash
|
||||
deepseek auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"
|
||||
deepseek --provider fireworks --model deepseek-v4-pro
|
||||
|
||||
# SGLang is self-hosted; auth is optional for localhost deployments.
|
||||
SGLANG_BASE_URL="http://localhost:30000/v1" deepseek --provider sglang --model deepseek-v4-flash
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Install from source</summary>
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Hmbown/DeepSeek-TUI.git
|
||||
cd DeepSeek-TUI
|
||||
cargo build --release
|
||||
./target/release/deepseek --help
|
||||
cargo install --path crates/tui --bin deepseek-tui --locked # requires Rust 1.85+
|
||||
cargo install --path crates/cli --bin deepseek --locked
|
||||
```
|
||||
|
||||
### Direct download
|
||||
|
||||
Download a prebuilt binary from [GitHub Releases](https://github.com/Hmbown/DeepSeek-TUI/releases) and put it on your `PATH` as `deepseek`.
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
On first run, the TUI can prompt for your API key and save it to `~/.deepseek/config.toml`. You can also create the file manually:
|
||||
|
||||
```toml
|
||||
# ~/.deepseek/config.toml
|
||||
api_key = "YOUR_DEEPSEEK_API_KEY" # must be non‑empty
|
||||
default_text_model = "deepseek-reasoner" # optional
|
||||
allow_shell = false # optional
|
||||
max_subagents = 3 # optional (1‑20)
|
||||
```
|
||||
|
||||
Useful environment variables:
|
||||
|
||||
- `DEEPSEEK_API_KEY` (overrides `api_key`)
|
||||
- `DEEPSEEK_BASE_URL` (default: `https://api.deepseek.com`; China users may use `https://api.deepseeki.com`)
|
||||
- `DEEPSEEK_PROFILE` (selects `[profiles.<name>]` from the config; errors if missing)
|
||||
- `DEEPSEEK_CONFIG_PATH` (override config path)
|
||||
- `DEEPSEEK_MCP_CONFIG`, `DEEPSEEK_SKILLS_DIR`, `DEEPSEEK_NOTES_PATH`, `DEEPSEEK_MEMORY_PATH`, `DEEPSEEK_ALLOW_SHELL`, `DEEPSEEK_MAX_SUBAGENTS`
|
||||
|
||||
To bootstrap MCP and skills at their resolved locations, run `deepseek setup`. To
|
||||
only create an MCP template, run `deepseek mcp init`.
|
||||
|
||||
See `config.example.toml` and `docs/CONFIGURATION.md` for a full reference.
|
||||
|
||||
## 🎮 Modes
|
||||
|
||||
In the TUI, press `Tab` to cycle modes: **Normal → Plan → Agent → YOLO → RLM → Duo → Normal**.
|
||||
|
||||
| Mode | Description | Approval Behavior |
|
||||
|------|-------------|-------------------|
|
||||
| **Normal** | Chat; asks before file writes or shell | Manual approval for writes & shell |
|
||||
| **Plan** | Design‑first prompting; same approvals as Normal | Manual approval for writes & shell |
|
||||
| **Agent** | Multi‑step tool use; asks before shell | Manual approval for shell, auto‑approve file writes |
|
||||
| **YOLO** | Enables shell + trust + auto‑approves all tools (dangerous) | Auto‑approve all tools |
|
||||
| **RLM** | Externalized context + REPL helpers; auto‑approves tools (best for large files) | Auto‑approve tools |
|
||||
| **Duo** | Player‑coach autocoding with iterative validation (based on g3 paper) | Depends on phase |
|
||||
|
||||
Approval behavior is mode‑dependent, but you can also override it at runtime with `/set approval_mode auto|suggest|never`.
|
||||
|
||||
## 🛠️ Tools
|
||||
|
||||
DeepSeek CLI exposes a comprehensive set of tools to the model across 5 categories, with 16+ individual tools available, all with approval gating based on the current mode.
|
||||
|
||||
### Tool Categories
|
||||
|
||||
#### File Operations
|
||||
- **`list_dir`** – List directory contents with file/directory metadata
|
||||
- **`read_file`** – Read UTF‑8 files from the workspace
|
||||
- **`write_file`** – Create or overwrite files
|
||||
- **`edit_file`** – Search and replace text in files
|
||||
- **`apply_patch`** – Apply unified diff patches with fuzzy matching
|
||||
- **`grep_files`** – Search files by regex pattern with context lines
|
||||
- **`web_search`** – Search the web and return concise results
|
||||
|
||||
#### Shell Execution
|
||||
- **`exec_shell`** – Run shell commands with timeout support
|
||||
- **Background execution** – Run commands in background with task ID return
|
||||
|
||||
#### Task Management
|
||||
- **`todo_write`** – Create and update todo lists with status tracking
|
||||
- **`update_plan`** – Manage structured implementation plans
|
||||
- **`note`** – Append persistent notes across sessions
|
||||
|
||||
#### Sub‑Agents
|
||||
- **`agent_spawn`** – Create background sub‑agents for focused tasks
|
||||
- **`agent_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
|
||||
|
||||
```bash
|
||||
deepseek
|
||||
```
|
||||
|
||||
### One‑shot prompt (non‑interactive)
|
||||
|
||||
```bash
|
||||
deepseek -p "Write a haiku about Rust"
|
||||
```
|
||||
|
||||
### Agentic execution with tool access
|
||||
|
||||
```bash
|
||||
deepseek exec --auto "Fix lint errors in the current directory"
|
||||
```
|
||||
|
||||
### Resume latest session
|
||||
|
||||
```bash
|
||||
deepseek --continue
|
||||
```
|
||||
|
||||
### Work on a specific project
|
||||
|
||||
```bash
|
||||
deepseek --workspace /path/to/project
|
||||
```
|
||||
|
||||
### Review staged git changes
|
||||
|
||||
```bash
|
||||
deepseek review --staged
|
||||
```
|
||||
|
||||
### Apply a patch file
|
||||
|
||||
```bash
|
||||
deepseek apply patch.diff
|
||||
```
|
||||
|
||||
### List saved sessions
|
||||
|
||||
```bash
|
||||
deepseek sessions --limit 50
|
||||
```
|
||||
|
||||
### Generate shell completions
|
||||
|
||||
```bash
|
||||
deepseek completions zsh > _deepseek
|
||||
deepseek completions bash > deepseek.bash
|
||||
deepseek completions fish > deepseek.fish
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### No API key
|
||||
Set `DEEPSEEK_API_KEY` environment variable or run `deepseek` and complete onboarding.
|
||||
|
||||
### Config not found
|
||||
Check `~/.deepseek/config.toml` (or `DEEPSEEK_CONFIG_PATH`).
|
||||
|
||||
### Wrong region / base URL
|
||||
Set `DEEPSEEK_BASE_URL` to `https://api.deepseeki.com` (China).
|
||||
|
||||
### Session issues
|
||||
Run `deepseek sessions` and try `deepseek --resume latest`.
|
||||
|
||||
### 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
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
DeepSeek is a trademark of DeepSeek Inc. This is an unofficial project.
|
||||
## What's new in v0.6.0
|
||||
|
||||
### 🌊 `rlm_query` — recursive language models as a first-class tool
|
||||
|
||||
The model now has direct access to a native recursive-LLM primitive. Inspired by [Alex Zhang's RLM work](https://github.com/alexzhang13/rlm) and Sakana AI's published research on novelty search, but trimmed to what an agent loop actually needs: one tool, structured args, no DSL.
|
||||
|
||||
```jsonc
|
||||
// Single child:
|
||||
rlm_query({ "prompt": "Summarise this 4k-line log: ..." })
|
||||
|
||||
// 8 parallel children, indexed result:
|
||||
rlm_query({
|
||||
"prompts": [
|
||||
"Review src/foo.rs for race conditions: ...",
|
||||
"Review src/foo.rs for input validation: ...",
|
||||
"Review src/foo.rs for error-handling gaps: ...",
|
||||
"..."
|
||||
]
|
||||
})
|
||||
|
||||
// Promote one call to Pro:
|
||||
rlm_query({ "prompt": "Hard reasoning here", "model": "deepseek-v4-pro" })
|
||||
```
|
||||
|
||||
Children run concurrently against the existing DeepSeek client via `tokio` — no external binary, no Python sandbox, no fenced-block DSL. Returns a single string for one prompt or `[i] ...` indexed blocks for many. Available in Plan / Agent / YOLO. The cost is folded into the session's running total automatically.
|
||||
|
||||
### Other changes
|
||||
|
||||
- **Scroll position survives content rewrites** — anchor fallback now clamps to the nearest surviving cell instead of teleporting to the bottom (#56)
|
||||
- **Looser command-safety chains** — `cargo build && cargo test` is no longer blocked outright; chains of known-safe commands escalate to RequiresApproval instead of Dangerous (#57)
|
||||
- **Multi-turn tool calls no longer 400 on thinking-mode models** — `reasoning_content` is replayed across user-message boundaries with a safe placeholder when the round produced none
|
||||
|
||||
Full history: [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
---
|
||||
|
||||
## Models & Pricing
|
||||
|
||||
DeepSeek TUI targets **DeepSeek V4** models with 1M-token context windows by default.
|
||||
|
||||
| Model | Context | Input (cache hit) | Input (cache miss) | Output |
|
||||
|---|---|---|---|---|
|
||||
| `deepseek-v4-pro` | 1M | $0.003625 / 1M* | $0.435 / 1M* | $0.87 / 1M* |
|
||||
| `deepseek-v4-flash` | 1M | $0.0028 / 1M | $0.14 / 1M | $0.28 / 1M |
|
||||
|
||||
Legacy aliases `deepseek-chat` and `deepseek-reasoner` silently map to `deepseek-v4-flash`.
|
||||
|
||||
**NVIDIA NIM** hosted variants (`deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash`) use your NVIDIA account terms — no DeepSeek platform billing.
|
||||
|
||||
*\*DeepSeek lists the Pro rates above as a limited-time 75% discount valid until 2026-05-05 15:59 UTC; the TUI cost estimator falls back to base Pro rates after that timestamp.*
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
deepseek # interactive TUI
|
||||
deepseek "explain this function" # one-shot prompt
|
||||
deepseek --model deepseek-v4-flash "summarize" # model override
|
||||
deepseek --yolo # YOLO mode (auto-approve tools)
|
||||
deepseek login --api-key "..." # save API key
|
||||
deepseek doctor # check setup & connectivity
|
||||
deepseek models # list live API models
|
||||
deepseek sessions # list saved sessions
|
||||
deepseek resume --last # resume latest session
|
||||
deepseek serve --http # HTTP/SSE API server
|
||||
```
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `Tab` | Cycle mode: Plan → Agent → YOLO |
|
||||
| `Shift+Tab` | Cycle reasoning-effort: off → high → max |
|
||||
| `F1` | Help |
|
||||
| `Esc` | Back / dismiss |
|
||||
| `Ctrl+K` | Command palette |
|
||||
| `@path` | Attach file/directory context in composer |
|
||||
| `/attach <path>` | Attach image/video media references |
|
||||
|
||||
---
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|---|---|
|
||||
| **Plan** 🔍 | Read-only investigation — model explores and proposes a decomposition plan (`update_plan` + `checklist_write`) before making changes |
|
||||
| **Agent** 🤖 | Default interactive mode — multi-step tool use with approval gates; model outlines work via `checklist_write` before requesting writes |
|
||||
| **YOLO** ⚡ | Auto-approve all tools in a trusted workspace; model still creates `checklist_write`/`update_plan` to keep work visible and trackable |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
`~/.deepseek/config.toml` — see [config.example.toml](config.example.toml) for every option.
|
||||
|
||||
Key environment overrides:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `DEEPSEEK_API_KEY` | API key |
|
||||
| `DEEPSEEK_BASE_URL` | API base URL |
|
||||
| `DEEPSEEK_MODEL` | Default model |
|
||||
| `DEEPSEEK_PROVIDER` | Provider: `deepseek` (default), `nvidia-nim`, `fireworks`, or `sglang` |
|
||||
| `DEEPSEEK_PROFILE` | Config profile name |
|
||||
| `NVIDIA_API_KEY` | NVIDIA NIM API key |
|
||||
| `FIREWORKS_API_KEY` | Fireworks AI API key |
|
||||
| `SGLANG_BASE_URL` | Self-hosted SGLang endpoint |
|
||||
| `SGLANG_API_KEY` | Optional SGLang bearer token |
|
||||
|
||||
Quick diagnostics:
|
||||
|
||||
```bash
|
||||
deepseek-tui setup --status # read-only status check (API key, MCP, sandbox, .env)
|
||||
deepseek-tui doctor --json # machine-readable doctor output for CI
|
||||
deepseek-tui setup --tools --plugins # scaffold tools/ and plugins/ directories
|
||||
```
|
||||
|
||||
DeepSeek context caching is automatic — when the API returns cache hit/miss token fields, the TUI includes them in usage and cost tracking.
|
||||
|
||||
Full reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md)
|
||||
|
||||
---
|
||||
|
||||
## Publishing your own skill
|
||||
|
||||
DeepSeek-TUI can install community skills directly from a GitHub repo, with no
|
||||
backend service in the loop:
|
||||
|
||||
1. Create a public GitHub repo with a `SKILL.md` at the root containing the
|
||||
usual `---` frontmatter (`name`, `description`).
|
||||
2. Multi-skill bundles use `skills/<name>/SKILL.md` instead — the installer
|
||||
picks the first match and names the install after the frontmatter `name`.
|
||||
3. Push to `main` (or `master`); the installer fetches
|
||||
`archive/refs/heads/main.tar.gz` and falls back to `master.tar.gz`.
|
||||
4. Users install via `/skill install github:<owner>/<repo>` — installs are
|
||||
gated by the `[network]` policy, validated for path traversal and size, and
|
||||
placed under `~/.deepseek/skills/<name>/`.
|
||||
5. Submit a PR to the curated `index.json` (default registry) to make the skill
|
||||
installable by name (`/skill install <name>`) instead of the GitHub spec.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | Topic |
|
||||
|---|---|
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Codebase internals |
|
||||
| [CONFIGURATION.md](docs/CONFIGURATION.md) | Full config reference |
|
||||
| [MODES.md](docs/MODES.md) | Plan / Agent / YOLO modes |
|
||||
| [MCP.md](docs/MCP.md) | Model Context Protocol integration |
|
||||
| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API server |
|
||||
| [RELEASE_RUNBOOK.md](docs/RELEASE_RUNBOOK.md) | Release process |
|
||||
| [OPERATIONS_RUNBOOK.md](docs/OPERATIONS_RUNBOOK.md) | Ops & recovery |
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md). Pull requests welcome!
|
||||
|
||||
*Not affiliated with DeepSeek Inc.*
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 755 KiB |
+249
-27
@@ -1,5 +1,5 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════════════════╗
|
||||
# ║ DeepSeek CLI Configuration ║
|
||||
# ║ DeepSeek TUI Configuration ║
|
||||
# ║ ║
|
||||
# ║ Unofficial CLI for DeepSeek Platform - Not affiliated with DeepSeek Inc. ║
|
||||
# ╚══════════════════════════════════════════════════════════════════════════════╝
|
||||
@@ -7,20 +7,45 @@
|
||||
# See `docs/CONFIGURATION.md` for how config is loaded (profiles, env overrides, etc.).
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# API Keys
|
||||
# Active provider + DeepSeek defaults
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Choose which provider to use by default. Per-provider credentials live in the
|
||||
# `[providers.*]` sections near the bottom of
|
||||
# this file — keeping both stored at once means `/provider deepseek` and
|
||||
# `/provider nvidia-nim` (or `--provider fireworks`, `/provider sglang`) toggle without having to
|
||||
# re-enter keys. Top-level `api_key` / `base_url` are still read as DeepSeek
|
||||
# defaults when `[providers.deepseek]` is absent (backward compatibility).
|
||||
provider = "deepseek" # deepseek | nvidia-nim | openrouter | novita | fireworks | sglang
|
||||
api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Base URLs
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
base_url = "https://api.deepseek.com"
|
||||
# base_url = "https://api.deepseeki.com" # China users
|
||||
# base_url = "https://api.deepseek.com/beta" # DeepSeek beta features such as strict tool mode
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Default Models
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
default_text_model = "deepseek-reasoner" # also: deepseek-chat, deepseek-r1, deepseek-v3, deepseek-v3.2
|
||||
# DeepSeek V4 family:
|
||||
# deepseek-v4-pro — flagship reasoning model on DeepSeek Platform
|
||||
# deepseek-v4-flash — fast, cost-efficient (legacy aliases: deepseek-chat, deepseek-reasoner)
|
||||
# deepseek-ai/deepseek-v4-pro — NVIDIA NIM-hosted Pro model ID
|
||||
# deepseek-ai/deepseek-v4-flash — NVIDIA NIM-hosted Flash model ID
|
||||
# accounts/fireworks/models/deepseek-v4-pro — Fireworks AI Pro model ID
|
||||
# deepseek-ai/DeepSeek-V4-Pro — SGLang self-hosted Pro model ID
|
||||
# deepseek-ai/DeepSeek-V4-Flash — SGLang self-hosted Flash model ID
|
||||
default_text_model = "deepseek-v4-pro"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Thinking Mode (DeepSeek V4 reasoning effort)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# "off" — disables chain-of-thought (thinking.type = disabled)
|
||||
# "low" — compat-maps to "high" server-side
|
||||
# "medium" — compat-maps to "high" server-side
|
||||
# "high" — reasoning_effort = high (DeepSeek default)
|
||||
# "max" — reasoning_effort = max (deepest reasoning)
|
||||
#
|
||||
# Shift+Tab in the TUI cycles between off / high / max. The header shows the
|
||||
# current tier as a ⚡ chip.
|
||||
reasoning_effort = "max"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Paths
|
||||
@@ -29,21 +54,118 @@ 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
|
||||
|
||||
# auto_allow entries match by command prefix, not raw string.
|
||||
# See command_safety.rs for the prefix dictionary.
|
||||
#
|
||||
# Examples:
|
||||
# auto_allow = ["git status"] # auto-approves: git status, git status -s, git status --porcelain
|
||||
# # does NOT auto-approve: git push, git checkout
|
||||
# auto_allow = ["cargo check", "npm run"]
|
||||
#
|
||||
# auto_allow = []
|
||||
max_subagents = 5 # optional (1-20)
|
||||
|
||||
# Optional managed policy paths (defaults to /etc/deepseek/*.toml on unix):
|
||||
# managed_config_path = "/etc/deepseek/managed_config.toml"
|
||||
# requirements_path = "/etc/deepseek/requirements.toml"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Per-provider credentials (peer providers — NIM is first-class, not a flag)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Providers can be stored at once; `provider = "..."` (top of file) or
|
||||
# `/provider deepseek` / `/provider nvidia-nim` / `/provider fireworks` switches between them without
|
||||
# having to re-enter keys. Env vars override anything set here:
|
||||
# DeepSeek: DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL
|
||||
# NIM: NVIDIA_API_KEY (or NVIDIA_NIM_API_KEY), NIM_BASE_URL
|
||||
# (or NVIDIA_NIM_BASE_URL / NVIDIA_BASE_URL), NVIDIA_NIM_MODEL
|
||||
# Fireworks: FIREWORKS_API_KEY, FIREWORKS_BASE_URL
|
||||
# SGLang: SGLANG_BASE_URL, SGLANG_MODEL, optional SGLANG_API_KEY
|
||||
|
||||
# DeepSeek Platform (https://platform.deepseek.com)
|
||||
[providers.deepseek]
|
||||
# api_key = "YOUR_DEEPSEEK_API_KEY"
|
||||
# base_url = "https://api.deepseek.com"
|
||||
# model = "deepseek-v4-pro"
|
||||
|
||||
# NVIDIA NIM-hosted DeepSeek V4 (https://build.nvidia.com)
|
||||
[providers.nvidia_nim]
|
||||
# api_key = "YOUR_NVIDIA_API_KEY"
|
||||
# base_url = "https://integrate.api.nvidia.com/v1"
|
||||
# model = "deepseek-ai/deepseek-v4-pro" # or deepseek-ai/deepseek-v4-flash
|
||||
|
||||
# Fireworks AI-hosted DeepSeek V4 (https://fireworks.ai)
|
||||
[providers.fireworks]
|
||||
# api_key = "YOUR_FIREWORKS_API_KEY"
|
||||
# base_url = "https://api.fireworks.ai/inference/v1"
|
||||
# model = "accounts/fireworks/models/deepseek-v4-pro"
|
||||
|
||||
# Self-hosted SGLang OpenAI-compatible server
|
||||
[providers.sglang]
|
||||
# api_key = "OPTIONAL_SGLANG_TOKEN"
|
||||
# base_url = "http://localhost:30000/v1"
|
||||
# model = "deepseek-ai/DeepSeek-V4-Pro" # or deepseek-ai/DeepSeek-V4-Flash
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Network Policy (#135)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Per-domain allow/deny rules for outbound network calls made by the TUI's
|
||||
# tools (`fetch_url`, `web_search`) and the MCP HTTP transport. Stdio MCP
|
||||
# servers and direct LLM API calls are unaffected.
|
||||
#
|
||||
# Precedence: deny wins. A host listed in both `allow` and `deny` is denied.
|
||||
#
|
||||
# Host-matching rules:
|
||||
# - Exact match: `api.deepseek.com` matches only `api.deepseek.com`.
|
||||
# - Subdomain wildcard: an entry starting with `.` (e.g. `.example.com`)
|
||||
# matches `api.example.com` and `a.b.example.com` but not the apex
|
||||
# `example.com`. To cover both, list both. `*.example.com` is also accepted.
|
||||
#
|
||||
# Defaults are intentionally conservative: when this section is absent, no
|
||||
# policy is enforced (mirrors pre-v0.7.0 behavior). To opt in:
|
||||
#
|
||||
# [network]
|
||||
# default = "prompt" # allow | deny | prompt
|
||||
# allow = ["api.deepseek.com", "github.com", ".githubusercontent.com"]
|
||||
# deny = []
|
||||
# audit = true # one line per call to ~/.deepseek/audit.log
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Skills (#140)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Settings for the `/skill install <spec>` community-skill installer.
|
||||
# * registry_url — curated index.json that resolves bare names to
|
||||
# `github:owner/repo` specs. Override to point at
|
||||
# a private fork or internal mirror.
|
||||
# * max_install_size_bytes — per-skill uncompressed size cap. Tarballs that
|
||||
# exceed this limit are rejected during validation.
|
||||
# Default: 5 MiB.
|
||||
#
|
||||
# `/skill install` is gated by `[network]`. Make sure `github.com` and
|
||||
# `raw.githubusercontent.com` are reachable (default `prompt` is fine — you'll
|
||||
# be asked once and can persist) before running it.
|
||||
#
|
||||
# [skills]
|
||||
# registry_url = "https://raw.githubusercontent.com/Hmbown/deepseek-skills/main/index.json"
|
||||
# max_install_size_bytes = 5_242_880
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# TUI
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
[tui]
|
||||
alternate_screen = "auto" # auto | always | never
|
||||
mouse_capture = true # true keeps wheel scrolling inside the TUI; false allows terminal-native drag selection/copy
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Feature Flags
|
||||
@@ -51,11 +173,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 +189,46 @@ max_delay = 60.0
|
||||
exponential_base = 2.0
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Context Compaction (PLANNED - not yet implemented)
|
||||
# Context Compaction
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# [compaction]
|
||||
# enabled = false # Enable auto-compaction
|
||||
# token_threshold = 50000 # Trigger compaction above this token estimate
|
||||
# message_threshold = 50 # Or above this message count
|
||||
# model = "deepseek-chat" # Model to use for summarization
|
||||
# cache_summary = true # Cache the summary block
|
||||
# Auto-compaction is a saved UI setting edited with `/config` (`auto_compact`).
|
||||
# There is no config-file `[compaction]` table yet; detailed thresholds are
|
||||
# chosen by the TUI from the active model/context budget.
|
||||
|
||||
# Append-only Flash seams are experimental and opt-in while the v0.7.5
|
||||
# context/cache audit validates prefix-cache behavior.
|
||||
[context]
|
||||
enabled = false
|
||||
verbatim_window_turns = 16
|
||||
# Thresholds are based on the active request input estimate, not lifetime
|
||||
# summed API usage.
|
||||
l1_threshold = 192000
|
||||
l2_threshold = 384000
|
||||
l3_threshold = 576000
|
||||
# Hard cycle also reserves the normal 262144-token output budget plus 1024
|
||||
# safety tokens against the model window.
|
||||
cycle_threshold = 768000
|
||||
seam_model = "deepseek-v4-flash"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# 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 = false
|
||||
low_risk_max = 0.50
|
||||
medium_risk_max = 0.62
|
||||
severe_min_slack = -0.25
|
||||
severe_violation_ratio = 0.40
|
||||
refresh_cooldown_turns = 6
|
||||
replan_cooldown_turns = 5
|
||||
max_replay_per_turn = 1
|
||||
min_turns_before_guardrail = 4
|
||||
profile_window = 8
|
||||
deepseek_v3_2_chat_prior = 3.9
|
||||
deepseek_v3_2_reasoner_prior = 4.1
|
||||
deepseek_v4_pro_prior = 3.5
|
||||
deepseek_v4_flash_prior = 4.2
|
||||
fallback_default_prior = 3.8
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Profile Example (for multiple environments)
|
||||
@@ -100,6 +242,80 @@ base_url = "https://api.deepseek.com"
|
||||
api_key = "DEV_DEEPSEEK_API_KEY"
|
||||
allow_shell = true
|
||||
|
||||
[profiles.nvidia-nim]
|
||||
provider = "nvidia-nim"
|
||||
api_key = "YOUR_NVIDIA_API_KEY"
|
||||
base_url = "https://integrate.api.nvidia.com/v1"
|
||||
default_text_model = "deepseek-ai/deepseek-v4-pro"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Desktop Notifications (OSC 9 / BEL on long agent-turn completion)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Emits an escape sequence to the terminal when a turn finishes and took longer
|
||||
# than `threshold_secs`. Useful when you tab away from the TUI and want an alert.
|
||||
#
|
||||
# method = "auto" # auto | osc9 | bel | off
|
||||
# auto: OSC 9 for iTerm.app / Ghostty / WezTerm, BEL otherwise.
|
||||
# osc9: \x1b]9;<msg>\x07 (iTerm2-style; shows macOS notification)
|
||||
# bel: plain \x07 beep
|
||||
# off: disable entirely
|
||||
# threshold_secs = 30 # only notify when the turn took >= this many seconds
|
||||
# include_summary = false # include elapsed time + cost in the notification body
|
||||
[notifications]
|
||||
# method = "auto"
|
||||
# threshold_secs = 30
|
||||
# include_summary = false
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Workspace Snapshots (#137)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Each turn the TUI takes a `pre-turn:<seq>` and `post-turn:<seq>` snapshot of
|
||||
# your workspace into a side-git repo at:
|
||||
#
|
||||
# ~/.deepseek/snapshots/<project_hash>/<worktree_hash>/.git
|
||||
#
|
||||
# Your own `.git` is never touched — `--git-dir` and `--work-tree` are always
|
||||
# set together when shelling out to git. Use `/restore N` (slash command) or
|
||||
# the `revert_turn` tool to roll the working tree back. Conversation history
|
||||
# is unaffected.
|
||||
#
|
||||
# Disk footprint: ~1-2 GB worst case for a 100 MB workspace × 12 turns/day,
|
||||
# typically far less thanks to git's content-addressed storage. The session
|
||||
# boot prunes anything older than `max_age_days` (default 7).
|
||||
#
|
||||
# [snapshots]
|
||||
# enabled = true # Snapshot workspace pre/post each turn for /restore
|
||||
# max_age_days = 7 # Older snapshots pruned at session start
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# LSP Diagnostics (post-edit) (#136)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# After every successful file edit (`edit_file`, `apply_patch`, `write_file`),
|
||||
# the engine asks an LSP server for diagnostics on the file and injects them
|
||||
# as a synthetic system message before the next API call. This lets the agent
|
||||
# see compile breaks immediately without round-tripping through the user.
|
||||
#
|
||||
# Enabled by default. Failure modes are non-blocking: a missing LSP binary,
|
||||
# a crashed server, or a timeout simply skips the post-edit hook for that
|
||||
# turn — the agent's work is never blocked.
|
||||
#
|
||||
# Built-in language → server defaults:
|
||||
# rust → rust-analyzer
|
||||
# go → gopls serve
|
||||
# python → pyright-langserver --stdio
|
||||
# typescript → typescript-language-server --stdio
|
||||
# c, cpp → clangd
|
||||
#
|
||||
# Override the defaults via the `servers` table below.
|
||||
[lsp]
|
||||
# enabled = true
|
||||
# poll_after_edit_ms = 5000
|
||||
# max_diagnostics_per_file = 20
|
||||
# include_warnings = false
|
||||
# [lsp.servers]
|
||||
# rust = ["rust-analyzer"]
|
||||
# go = ["gopls", "serve"]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Hooks (optional)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -112,4 +328,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.7.5" }
|
||||
serde.workspace = true
|
||||
@@ -0,0 +1,307 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use deepseek_config::ProviderKind;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelInfo {
|
||||
pub id: String,
|
||||
pub provider: ProviderKind,
|
||||
pub aliases: Vec<String>,
|
||||
pub supports_tools: bool,
|
||||
pub supports_reasoning: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelResolution {
|
||||
pub requested: Option<String>,
|
||||
pub resolved: ModelInfo,
|
||||
pub used_fallback: bool,
|
||||
pub fallback_chain: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelRegistry {
|
||||
models: Vec<ModelInfo>,
|
||||
alias_map: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Default for ModelRegistry {
|
||||
fn default() -> Self {
|
||||
let models = vec![
|
||||
ModelInfo {
|
||||
id: "deepseek-v4-pro".to_string(),
|
||||
provider: ProviderKind::Deepseek,
|
||||
aliases: vec![],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "deepseek-v4-flash".to_string(),
|
||||
provider: ProviderKind::Deepseek,
|
||||
aliases: vec![
|
||||
"deepseek-chat".to_string(),
|
||||
"deepseek-reasoner".to_string(),
|
||||
"deepseek-r1".to_string(),
|
||||
"deepseek-v3".to_string(),
|
||||
"deepseek-v3.2".to_string(),
|
||||
],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "deepseek-ai/deepseek-v4-pro".to_string(),
|
||||
provider: ProviderKind::NvidiaNim,
|
||||
aliases: vec![
|
||||
"deepseek-v4-pro".to_string(),
|
||||
"nvidia-deepseek-v4-pro".to_string(),
|
||||
"nim-deepseek-v4-pro".to_string(),
|
||||
],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "deepseek-ai/deepseek-v4-flash".to_string(),
|
||||
provider: ProviderKind::NvidiaNim,
|
||||
aliases: vec![
|
||||
"deepseek-v4-flash".to_string(),
|
||||
"deepseek-chat".to_string(),
|
||||
"deepseek-reasoner".to_string(),
|
||||
"nvidia-deepseek-v4-flash".to_string(),
|
||||
"nim-deepseek-v4-flash".to_string(),
|
||||
],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "gpt-4.1".to_string(),
|
||||
provider: ProviderKind::Openai,
|
||||
aliases: vec!["gpt4.1".to_string(), "gpt-4o".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "gpt-4.1-mini".to_string(),
|
||||
provider: ProviderKind::Openai,
|
||||
aliases: vec!["gpt-4o-mini".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: false,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "deepseek/deepseek-v4-pro".to_string(),
|
||||
provider: ProviderKind::Openrouter,
|
||||
aliases: vec![
|
||||
"deepseek-v4-pro".to_string(),
|
||||
"openrouter-deepseek-v4-pro".to_string(),
|
||||
],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "deepseek/deepseek-v4-flash".to_string(),
|
||||
provider: ProviderKind::Openrouter,
|
||||
aliases: vec![
|
||||
"deepseek-v4-flash".to_string(),
|
||||
"deepseek-chat".to_string(),
|
||||
"deepseek-reasoner".to_string(),
|
||||
"openrouter-deepseek-v4-flash".to_string(),
|
||||
],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "deepseek/deepseek-v4-pro".to_string(),
|
||||
provider: ProviderKind::Novita,
|
||||
aliases: vec![
|
||||
"deepseek-v4-pro".to_string(),
|
||||
"novita-deepseek-v4-pro".to_string(),
|
||||
],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "deepseek/deepseek-v4-flash".to_string(),
|
||||
provider: ProviderKind::Novita,
|
||||
aliases: vec![
|
||||
"deepseek-v4-flash".to_string(),
|
||||
"deepseek-chat".to_string(),
|
||||
"deepseek-reasoner".to_string(),
|
||||
"novita-deepseek-v4-flash".to_string(),
|
||||
],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
];
|
||||
Self::new(models)
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelRegistry {
|
||||
#[must_use]
|
||||
pub fn new(models: Vec<ModelInfo>) -> Self {
|
||||
let mut alias_map = HashMap::new();
|
||||
for (idx, model) in models.iter().enumerate() {
|
||||
alias_map.entry(normalize(&model.id)).or_insert(idx);
|
||||
for alias in &model.aliases {
|
||||
alias_map.entry(normalize(alias)).or_insert(idx);
|
||||
}
|
||||
}
|
||||
Self { models, alias_map }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn list(&self) -> Vec<ModelInfo> {
|
||||
self.models.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn resolve(
|
||||
&self,
|
||||
requested: Option<&str>,
|
||||
provider_hint: Option<ProviderKind>,
|
||||
) -> ModelResolution {
|
||||
let mut fallback_chain = Vec::new();
|
||||
|
||||
if let Some(name) = requested {
|
||||
fallback_chain.push(format!("requested:{name}"));
|
||||
if let Some(provider) = provider_hint
|
||||
&& let Some(model) = self
|
||||
.models
|
||||
.iter()
|
||||
.find(|m| m.provider == provider && model_matches(m, name))
|
||||
.cloned()
|
||||
{
|
||||
return ModelResolution {
|
||||
requested: Some(name.to_string()),
|
||||
resolved: model,
|
||||
used_fallback: false,
|
||||
fallback_chain,
|
||||
};
|
||||
}
|
||||
if let Some(idx) = self.alias_map.get(&normalize(name)) {
|
||||
return ModelResolution {
|
||||
requested: Some(name.to_string()),
|
||||
resolved: self.models[*idx].clone(),
|
||||
used_fallback: false,
|
||||
fallback_chain,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let provider = provider_hint.unwrap_or(ProviderKind::Deepseek);
|
||||
fallback_chain.push(format!("provider_default:{}", provider.as_str()));
|
||||
if let Some(model) = self.models.iter().find(|m| m.provider == provider).cloned() {
|
||||
return ModelResolution {
|
||||
requested: requested.map(ToOwned::to_owned),
|
||||
resolved: model,
|
||||
used_fallback: true,
|
||||
fallback_chain,
|
||||
};
|
||||
}
|
||||
|
||||
let final_fallback = self.models.first().cloned().unwrap_or(ModelInfo {
|
||||
id: "deepseek-v4-pro".to_string(),
|
||||
provider: ProviderKind::Deepseek,
|
||||
aliases: Vec::new(),
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
});
|
||||
fallback_chain.push("global_default:deepseek-v4-pro".to_string());
|
||||
ModelResolution {
|
||||
requested: requested.map(ToOwned::to_owned),
|
||||
resolved: final_fallback,
|
||||
used_fallback: true,
|
||||
fallback_chain,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn model_matches(model: &ModelInfo, requested: &str) -> bool {
|
||||
let requested = normalize(requested);
|
||||
normalize(&model.id) == requested
|
||||
|| model
|
||||
.aliases
|
||||
.iter()
|
||||
.any(|alias| normalize(alias) == requested)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_pro_alias_stays_deepseek_by_default() {
|
||||
let registry = ModelRegistry::default();
|
||||
let resolved = registry.resolve(Some("deepseek-v4-pro"), None);
|
||||
|
||||
assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
|
||||
assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_pro_alias_resolves_to_nvidia_nim_when_provider_hinted() {
|
||||
let registry = ModelRegistry::default();
|
||||
let resolved = registry.resolve(Some("deepseek-v4-pro"), Some(ProviderKind::NvidiaNim));
|
||||
|
||||
assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_default_uses_catalog_model_id() {
|
||||
let registry = ModelRegistry::default();
|
||||
let resolved = registry.resolve(None, Some(ProviderKind::NvidiaNim));
|
||||
|
||||
assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_flash_alias_resolves_to_nvidia_nim_when_provider_hinted() {
|
||||
let registry = ModelRegistry::default();
|
||||
let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::NvidiaNim));
|
||||
|
||||
assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openrouter_default_uses_namespaced_model_id() {
|
||||
let registry = ModelRegistry::default();
|
||||
let resolved = registry.resolve(None, Some(ProviderKind::Openrouter));
|
||||
|
||||
assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
|
||||
assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn novita_default_uses_namespaced_model_id() {
|
||||
let registry = ModelRegistry::default();
|
||||
let resolved = registry.resolve(None, Some(ProviderKind::Novita));
|
||||
|
||||
assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
|
||||
assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_flash_alias_resolves_to_openrouter_when_provider_hinted() {
|
||||
let registry = ModelRegistry::default();
|
||||
let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Openrouter));
|
||||
|
||||
assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
|
||||
assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_flash_alias_resolves_to_novita_when_provider_hinted() {
|
||||
let registry = ModelRegistry::default();
|
||||
let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Novita));
|
||||
|
||||
assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
|
||||
assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "deepseek-app-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Codex-style app-server transport for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
deepseek-agent = { path = "../agent", version = "0.7.5" }
|
||||
deepseek-config = { path = "../config", version = "0.7.5" }
|
||||
deepseek-core = { path = "../core", version = "0.7.5" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.7.5" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.7.5" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.7.5" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.7.5" }
|
||||
deepseek-state = { path = "../state", version = "0.7.5" }
|
||||
deepseek-tools = { path = "../tools", version = "0.7.5" }
|
||||
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,32 @@
|
||||
[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.7.5" }
|
||||
deepseek-app-server = { path = "../app-server", version = "0.7.5" }
|
||||
deepseek-config = { path = "../config", version = "0.7.5" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.7.5" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.7.5" }
|
||||
deepseek-secrets = { path = "../secrets", version = "0.7.5" }
|
||||
deepseek-state = { path = "../state", version = "0.7.5" }
|
||||
chrono.workspace = true
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.16"
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,16 @@
|
||||
[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
|
||||
deepseek-secrets = { path = "../secrets", version = "0.7.5" }
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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.7.5" }
|
||||
deepseek-config = { path = "../config", version = "0.7.5" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.7.5" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.7.5" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.7.5" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.7.5" }
|
||||
deepseek-state = { path = "../state", version = "0.7.5" }
|
||||
deepseek-tools = { path = "../tools", version = "0.7.5" }
|
||||
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.7.5" }
|
||||
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.7.5" }
|
||||
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.7.5" }
|
||||
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,26 @@
|
||||
[package]
|
||||
name = "deepseek-secrets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Secret storage backends (OS keyring with file fallback) for DeepSeek workspace"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
keyring = { version = "3", features = ["apple-native"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
keyring = { version = "3", features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
keyring = { version = "3", features = ["linux-native-sync-persistent", "crypto-rust"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.16"
|
||||
@@ -0,0 +1,578 @@
|
||||
//! Secret storage for DeepSeek API keys.
|
||||
//!
|
||||
//! Provides a small abstraction (`KeyringStore`) plus a default
|
||||
//! implementation backed by the OS keyring (`DefaultKeyringStore`),
|
||||
//! a file-based fallback for headless Linux (`FileKeyringStore`), and
|
||||
//! an in-memory store for tests (`InMemoryKeyringStore`).
|
||||
//!
|
||||
//! Higher-level lookup goes through [`Secrets::resolve`], which checks
|
||||
//! the keyring first and falls back to environment variables. The
|
||||
//! caller (typically the config crate) then falls back to plaintext
|
||||
//! TOML if both are empty — that final layer lives outside this crate
|
||||
//! so the precedence is explicit at the call site.
|
||||
//!
|
||||
//! Hard rule: **keyring → env → config-file**. Never swap.
|
||||
#![deny(missing_docs)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Default OS keychain service name. macOS users can verify entries with
|
||||
/// `security find-generic-password -s deepseek -a <provider>`.
|
||||
pub const DEFAULT_SERVICE: &str = "deepseek";
|
||||
|
||||
/// Errors that may arise from a [`KeyringStore`] backend.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SecretsError {
|
||||
/// Underlying OS keyring backend reported an error.
|
||||
#[error("keyring backend error: {0}")]
|
||||
Keyring(String),
|
||||
/// File-backed fallback I/O error.
|
||||
#[error("file-backed secret store I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// File-backed fallback JSON (de)serialisation error.
|
||||
#[error("file-backed secret store JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
/// Caught when a stored secret on disk has unsafe permissions.
|
||||
#[error("file-backed secret store at {path} has insecure permissions {mode:o} (expected 0600)")]
|
||||
InsecurePermissions {
|
||||
/// Absolute path to the secrets file.
|
||||
path: PathBuf,
|
||||
/// Observed unix permission mode.
|
||||
mode: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// Abstract secret store; concrete implementations may use the OS
|
||||
/// keyring, a JSON file under `~/.deepseek/secrets/`, or an in-memory
|
||||
/// map (tests).
|
||||
pub trait KeyringStore: Send + Sync {
|
||||
/// Read a secret. Returns `Ok(None)` if no entry exists.
|
||||
fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
|
||||
/// Write a secret, replacing any existing value.
|
||||
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
|
||||
/// Remove a secret. Should not error if the entry is absent.
|
||||
fn delete(&self, key: &str) -> Result<(), SecretsError>;
|
||||
/// Short, human-readable name of the backend (used by `doctor`).
|
||||
fn backend_name(&self) -> &'static str;
|
||||
}
|
||||
|
||||
/// OS keyring backend (macOS Keychain, Windows Credential Manager,
|
||||
/// Linux Secret Service / kwallet).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DefaultKeyringStore {
|
||||
/// Keyring service name (defaults to [`DEFAULT_SERVICE`]).
|
||||
service: String,
|
||||
}
|
||||
|
||||
impl Default for DefaultKeyringStore {
|
||||
fn default() -> Self {
|
||||
Self::new(DEFAULT_SERVICE)
|
||||
}
|
||||
}
|
||||
|
||||
impl DefaultKeyringStore {
|
||||
/// Build a new store with the given service name.
|
||||
#[must_use]
|
||||
pub fn new(service: impl Into<String>) -> Self {
|
||||
Self {
|
||||
service: service.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the OS keyring without writing anything. Returns `Ok(())` if
|
||||
/// a backend is reachable, otherwise an error describing why not.
|
||||
pub fn probe(&self) -> Result<(), SecretsError> {
|
||||
// `Entry::new` is enough to surface "no backend / no storage" on
|
||||
// headless Linux; no actual read happens until `.get_password()`.
|
||||
let entry = keyring::Entry::new(&self.service, "__probe__")
|
||||
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
|
||||
match entry.get_password() {
|
||||
Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(keyring::Error::PlatformFailure(err)) => {
|
||||
Err(SecretsError::Keyring(format!("platform failure: {err}")))
|
||||
}
|
||||
Err(keyring::Error::NoStorageAccess(err)) => {
|
||||
Err(SecretsError::Keyring(format!("no storage access: {err}")))
|
||||
}
|
||||
Err(other) => Err(SecretsError::Keyring(other.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyringStore for DefaultKeyringStore {
|
||||
fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
|
||||
let entry = keyring::Entry::new(&self.service, key)
|
||||
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
|
||||
match entry.get_password() {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(err) => Err(SecretsError::Keyring(err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
|
||||
let entry = keyring::Entry::new(&self.service, key)
|
||||
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
|
||||
entry
|
||||
.set_password(value)
|
||||
.map_err(|err| SecretsError::Keyring(err.to_string()))
|
||||
}
|
||||
|
||||
fn delete(&self, key: &str) -> Result<(), SecretsError> {
|
||||
let entry = keyring::Entry::new(&self.service, key)
|
||||
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
|
||||
match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(err) => Err(SecretsError::Keyring(err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn backend_name(&self) -> &'static str {
|
||||
"system keyring"
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory keyring (tests only).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InMemoryKeyringStore {
|
||||
entries: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl InMemoryKeyringStore {
|
||||
/// Create an empty store.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyringStore for InMemoryKeyringStore {
|
||||
fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
|
||||
Ok(self.entries.lock().unwrap().get(key).cloned())
|
||||
}
|
||||
|
||||
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(key.to_string(), value.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete(&self, key: &str) -> Result<(), SecretsError> {
|
||||
self.entries.lock().unwrap().remove(key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn backend_name(&self) -> &'static str {
|
||||
"in-memory (test)"
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-on-disk fallback for headless environments without a Secret
|
||||
/// Service / dbus. Stored at `<home>/.deepseek/secrets/secrets.json`
|
||||
/// with mode `0600`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileKeyringStore {
|
||||
/// Absolute path to the JSON file.
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct FileSecretsBlob {
|
||||
#[serde(default)]
|
||||
entries: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl FileKeyringStore {
|
||||
/// Build a store backed by the given JSON file path.
|
||||
#[must_use]
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
Self { path: path.into() }
|
||||
}
|
||||
|
||||
/// Default path: `<home>/.deepseek/secrets/secrets.json`. Honours
|
||||
/// `HOME` (Unix) and `USERPROFILE` (Windows) via the `dirs` crate.
|
||||
pub fn default_path() -> Result<PathBuf, SecretsError> {
|
||||
let home = dirs::home_dir().ok_or_else(|| {
|
||||
SecretsError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"could not resolve home directory for FileKeyringStore",
|
||||
))
|
||||
})?;
|
||||
Ok(home.join(".deepseek").join("secrets").join("secrets.json"))
|
||||
}
|
||||
|
||||
/// Path used for storage.
|
||||
#[must_use]
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
|
||||
if !self.path.exists() {
|
||||
return Ok(FileSecretsBlob::default());
|
||||
}
|
||||
// Reject files with unsafe permissions on unix. On Windows the
|
||||
// ACL model is too different to enforce here; the caller is
|
||||
// responsible for placing the file in a per-user directory.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let meta = fs::metadata(&self.path)?;
|
||||
let mode = meta.permissions().mode() & 0o777;
|
||||
if mode & 0o077 != 0 {
|
||||
return Err(SecretsError::InsecurePermissions {
|
||||
path: self.path.clone(),
|
||||
mode,
|
||||
});
|
||||
}
|
||||
}
|
||||
let raw = fs::read_to_string(&self.path)?;
|
||||
if raw.trim().is_empty() {
|
||||
return Ok(FileSecretsBlob::default());
|
||||
}
|
||||
let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
|
||||
Ok(blob)
|
||||
}
|
||||
|
||||
fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(parent)?.permissions();
|
||||
perms.set_mode(0o700);
|
||||
let _ = fs::set_permissions(parent, perms);
|
||||
}
|
||||
}
|
||||
let body = serde_json::to_string_pretty(blob)?;
|
||||
fs::write(&self.path, body)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(&self.path)?.permissions();
|
||||
perms.set_mode(0o600);
|
||||
fs::set_permissions(&self.path, perms)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyringStore for FileKeyringStore {
|
||||
fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
|
||||
let blob = self.load_unlocked()?;
|
||||
Ok(blob.entries.get(key).cloned())
|
||||
}
|
||||
|
||||
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
|
||||
let mut blob = self.load_unlocked().unwrap_or_default();
|
||||
blob.entries.insert(key.to_string(), value.to_string());
|
||||
self.store_unlocked(&blob)
|
||||
}
|
||||
|
||||
fn delete(&self, key: &str) -> Result<(), SecretsError> {
|
||||
let mut blob = self.load_unlocked().unwrap_or_default();
|
||||
blob.entries.remove(key);
|
||||
self.store_unlocked(&blob)
|
||||
}
|
||||
|
||||
fn backend_name(&self) -> &'static str {
|
||||
"file-based (~/.deepseek/secrets/)"
|
||||
}
|
||||
}
|
||||
|
||||
/// High-level façade combining a [`KeyringStore`] with environment
|
||||
/// variable fallbacks.
|
||||
///
|
||||
/// Lookup precedence: **keyring → env → none**. Callers that also have
|
||||
/// a TOML config layer must wire that themselves at the very end of
|
||||
/// the chain.
|
||||
#[derive(Clone)]
|
||||
pub struct Secrets {
|
||||
/// Underlying secret store.
|
||||
pub store: Arc<dyn KeyringStore>,
|
||||
/// Owner identifier within the keyring (typically "deepseek"); the
|
||||
/// `key` parameter passed to `resolve` is mapped to a slot in the
|
||||
/// store as-is, while envs are looked up by canonical name.
|
||||
service: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Secrets {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Secrets")
|
||||
.field("backend", &self.store.backend_name())
|
||||
.field("service", &self.service)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Secrets {
|
||||
/// Build a new façade around a store.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn KeyringStore>) -> Self {
|
||||
Self {
|
||||
store,
|
||||
service: DEFAULT_SERVICE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct the platform-appropriate default backend. On platforms
|
||||
/// where an OS keyring backend is reachable this returns
|
||||
/// [`DefaultKeyringStore`]; otherwise it falls back to
|
||||
/// [`FileKeyringStore`] under `~/.deepseek/secrets/`.
|
||||
pub fn auto_detect() -> Self {
|
||||
let default_store = DefaultKeyringStore::default();
|
||||
match default_store.probe() {
|
||||
Ok(()) => Self::new(Arc::new(default_store)),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"OS keyring unavailable ({err}); falling back to file-backed secret store"
|
||||
);
|
||||
let path = FileKeyringStore::default_path()
|
||||
.unwrap_or_else(|_| PathBuf::from(".deepseek-secrets.json"));
|
||||
Self::new(Arc::new(FileKeyringStore::new(path)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend label, suitable for `doctor` output.
|
||||
#[must_use]
|
||||
pub fn backend_name(&self) -> &'static str {
|
||||
self.store.backend_name()
|
||||
}
|
||||
|
||||
/// Resolve a secret with `keyring → env → none` precedence.
|
||||
///
|
||||
/// `name` is the canonical provider name (`"deepseek"`,
|
||||
/// `"openrouter"`, `"novita"`, `"nvidia"`/`"nvidia-nim"`, `"openai"`).
|
||||
/// Empty strings on either layer are treated as "not set".
|
||||
#[must_use]
|
||||
pub fn resolve(&self, name: &str) -> Option<String> {
|
||||
if let Ok(Some(v)) = self.store.get(name)
|
||||
&& !v.trim().is_empty()
|
||||
{
|
||||
return Some(v);
|
||||
}
|
||||
env_for(name)
|
||||
}
|
||||
|
||||
/// Convenience: write a secret through the underlying store.
|
||||
pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
|
||||
self.store.set(name, value)
|
||||
}
|
||||
|
||||
/// Convenience: delete a secret through the underlying store.
|
||||
pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
|
||||
self.store.delete(name)
|
||||
}
|
||||
|
||||
/// Convenience: read a secret directly (no env fallback).
|
||||
pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
|
||||
self.store.get(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a canonical provider name to its environment variable, returning
|
||||
/// the value if non-empty.
|
||||
#[must_use]
|
||||
pub fn env_for(name: &str) -> Option<String> {
|
||||
let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
|
||||
"deepseek" => &["DEEPSEEK_API_KEY"],
|
||||
"openrouter" => &["OPENROUTER_API_KEY"],
|
||||
"novita" => &["NOVITA_API_KEY"],
|
||||
// NVIDIA NIM falls back to `DEEPSEEK_API_KEY` last because the
|
||||
// catalog endpoint accepts the same DeepSeek-issued key when no
|
||||
// dedicated NVIDIA token is set. This mirrors pre-v0.7 behaviour.
|
||||
"nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
|
||||
&["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
|
||||
}
|
||||
"openai" => &["OPENAI_API_KEY"],
|
||||
_ => return None,
|
||||
};
|
||||
for var in candidates {
|
||||
if let Ok(value) = std::env::var(var)
|
||||
&& !value.trim().is_empty()
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// Serialise env-mutating tests: tests in this module poke
|
||||
/// `DEEPSEEK_API_KEY` etc., which is process-global.
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
}
|
||||
|
||||
fn clear_known_envs() {
|
||||
for var in [
|
||||
"DEEPSEEK_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"NOVITA_API_KEY",
|
||||
"NVIDIA_API_KEY",
|
||||
"NVIDIA_NIM_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
] {
|
||||
// Safety: tests serialise on env_lock(); the broader
|
||||
// workspace has the same pattern in `crates/config`.
|
||||
unsafe { std::env::remove_var(var) };
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_memory_store_round_trips() {
|
||||
let store = InMemoryKeyringStore::new();
|
||||
assert_eq!(store.get("deepseek").unwrap(), None);
|
||||
store.set("deepseek", "sk-test").unwrap();
|
||||
assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
|
||||
store.set("deepseek", "sk-replaced").unwrap();
|
||||
assert_eq!(
|
||||
store.get("deepseek").unwrap(),
|
||||
Some("sk-replaced".to_string())
|
||||
);
|
||||
store.delete("deepseek").unwrap();
|
||||
assert_eq!(store.get("deepseek").unwrap(), None);
|
||||
// Deleting an absent key is a no-op.
|
||||
store.delete("missing").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_prefers_keyring_over_env() {
|
||||
let _lock = env_lock();
|
||||
clear_known_envs();
|
||||
// Safety: env mutation guarded by env_lock().
|
||||
unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
|
||||
|
||||
let store = Arc::new(InMemoryKeyringStore::new());
|
||||
store.set("deepseek", "ring-key").unwrap();
|
||||
let secrets = Secrets::new(store);
|
||||
|
||||
assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
|
||||
// Safety: env mutation guarded by env_lock().
|
||||
unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_falls_back_to_env_when_keyring_empty() {
|
||||
let _lock = env_lock();
|
||||
clear_known_envs();
|
||||
// Safety: env mutation guarded by env_lock().
|
||||
unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
|
||||
|
||||
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
|
||||
assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
|
||||
// Safety: env mutation guarded by env_lock().
|
||||
unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_none_when_both_layers_empty() {
|
||||
let _lock = env_lock();
|
||||
clear_known_envs();
|
||||
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
|
||||
assert_eq!(secrets.resolve("deepseek"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_treats_blank_keyring_value_as_unset() {
|
||||
let _lock = env_lock();
|
||||
clear_known_envs();
|
||||
// Safety: env mutation guarded by env_lock().
|
||||
unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
|
||||
|
||||
let store = Arc::new(InMemoryKeyringStore::new());
|
||||
store.set("deepseek", " ").unwrap();
|
||||
let secrets = Secrets::new(store);
|
||||
assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
|
||||
// Safety: env mutation guarded by env_lock().
|
||||
unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_env_aliases_resolve() {
|
||||
let _lock = env_lock();
|
||||
clear_known_envs();
|
||||
// Safety: env mutation guarded by env_lock().
|
||||
unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
|
||||
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
|
||||
assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
|
||||
assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
|
||||
// Safety: env mutation guarded by env_lock().
|
||||
unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn file_store_round_trips_with_secure_perms() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("nested").join("secrets.json");
|
||||
let store = FileKeyringStore::new(path.clone());
|
||||
assert_eq!(store.get("deepseek").unwrap(), None);
|
||||
store.set("deepseek", "sk-disk").unwrap();
|
||||
assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
|
||||
|
||||
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
|
||||
|
||||
store.set("openrouter", "or-disk").unwrap();
|
||||
assert_eq!(
|
||||
store.get("openrouter").unwrap(),
|
||||
Some("or-disk".to_string())
|
||||
);
|
||||
// First entry must still be intact.
|
||||
assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
|
||||
|
||||
store.delete("deepseek").unwrap();
|
||||
assert_eq!(store.get("deepseek").unwrap(), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn file_store_rejects_world_readable_file() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("secrets.json");
|
||||
fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
|
||||
let mut perms = fs::metadata(&path).unwrap().permissions();
|
||||
perms.set_mode(0o644);
|
||||
fs::set_permissions(&path, perms).unwrap();
|
||||
|
||||
let store = FileKeyringStore::new(path);
|
||||
let err = store.get("deepseek").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, SecretsError::InsecurePermissions { .. }),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_store_default_path_uses_home() {
|
||||
// We don't override HOME here (other tests do); we just check the
|
||||
// shape of the path is `<home>/.deepseek/secrets/secrets.json`.
|
||||
let path = FileKeyringStore::default_path().unwrap();
|
||||
assert!(
|
||||
path.ends_with("secrets/secrets.json") || path.ends_with("secrets\\secrets.json"),
|
||||
"unexpected default path: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.7.5" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
uuid.workspace = true
|
||||
@@ -0,0 +1,469 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
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;
|
||||
|
||||
/// Capabilities that a tool may have or require.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ToolCapability {
|
||||
/// Tool only reads data, never modifies state.
|
||||
ReadOnly,
|
||||
/// Tool writes to the filesystem.
|
||||
WritesFiles,
|
||||
/// Tool executes arbitrary shell commands.
|
||||
ExecutesCode,
|
||||
/// Tool makes network requests.
|
||||
Network,
|
||||
/// Tool can be run in a sandbox.
|
||||
Sandboxable,
|
||||
/// Tool requires user approval before execution.
|
||||
RequiresApproval,
|
||||
}
|
||||
|
||||
/// Approval requirement for a tool.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ApprovalRequirement {
|
||||
/// Never needs approval: safe read-only operations.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Suggest approval but allow user to skip.
|
||||
Suggest,
|
||||
/// Always require explicit user approval.
|
||||
Required,
|
||||
}
|
||||
|
||||
/// Errors that can occur during tool execution.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ToolError {
|
||||
InvalidInput { message: String },
|
||||
MissingField { field: String },
|
||||
PathEscape { path: PathBuf },
|
||||
ExecutionFailed { message: String },
|
||||
Timeout { seconds: u64 },
|
||||
NotAvailable { message: String },
|
||||
PermissionDenied { message: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ToolError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidInput { message } => {
|
||||
write!(f, "Failed to validate input: {message}")
|
||||
}
|
||||
Self::MissingField { field } => {
|
||||
write!(
|
||||
f,
|
||||
"Failed to validate input: missing required field '{field}'"
|
||||
)
|
||||
}
|
||||
Self::PathEscape { path } => {
|
||||
write!(
|
||||
f,
|
||||
"Failed to resolve path '{}': path escapes workspace",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
Self::ExecutionFailed { message } => {
|
||||
write!(f, "Failed to execute tool: {message}")
|
||||
}
|
||||
Self::Timeout { seconds } => {
|
||||
write!(
|
||||
f,
|
||||
"Failed to execute tool: operation timed out after {seconds}s"
|
||||
)
|
||||
}
|
||||
Self::NotAvailable { message } => {
|
||||
write!(f, "Failed to locate tool: {message}")
|
||||
}
|
||||
Self::PermissionDenied { message } => {
|
||||
write!(f, "Failed to authorize tool execution: {message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ToolError {}
|
||||
|
||||
impl ToolError {
|
||||
#[must_use]
|
||||
pub fn invalid_input(msg: impl Into<String>) -> Self {
|
||||
Self::InvalidInput {
|
||||
message: msg.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn missing_field(field: impl Into<String>) -> Self {
|
||||
Self::MissingField {
|
||||
field: field.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn execution_failed(msg: impl Into<String>) -> Self {
|
||||
Self::ExecutionFailed {
|
||||
message: msg.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn path_escape(path: impl Into<PathBuf>) -> Self {
|
||||
Self::PathEscape { path: path.into() }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn not_available(msg: impl Into<String>) -> Self {
|
||||
Self::NotAvailable {
|
||||
message: msg.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn permission_denied(msg: impl Into<String>) -> Self {
|
||||
Self::PermissionDenied {
|
||||
message: msg.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a tool execution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolResult {
|
||||
/// The output content, which may be JSON or plain text.
|
||||
pub content: String,
|
||||
/// Whether the execution was successful.
|
||||
pub success: bool,
|
||||
/// Optional structured metadata.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<Value>,
|
||||
}
|
||||
|
||||
impl ToolResult {
|
||||
/// Create a successful result with content.
|
||||
#[must_use]
|
||||
pub fn success(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
content: content.into(),
|
||||
success: true,
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an error result with message.
|
||||
#[must_use]
|
||||
pub fn error(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
content: message.into(),
|
||||
success: false,
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a successful result from JSON.
|
||||
pub fn json<T: Serialize>(value: &T) -> std::result::Result<Self, serde_json::Error> {
|
||||
Ok(Self {
|
||||
content: serde_json::to_string_pretty(value)?,
|
||||
success: true,
|
||||
metadata: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Add metadata to the result.
|
||||
#[must_use]
|
||||
pub fn with_metadata(mut self, metadata: Value) -> Self {
|
||||
self.metadata = Some(metadata);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to extract a required string field from JSON input.
|
||||
pub fn required_str<'a>(input: &'a Value, field: &str) -> std::result::Result<&'a str, ToolError> {
|
||||
input.get(field).and_then(Value::as_str).ok_or_else(|| {
|
||||
// When the field is missing, list the fields the caller *did*
|
||||
// supply so the model can spot the mismatch without a retry.
|
||||
let provided: Vec<&str> = input
|
||||
.as_object()
|
||||
.map(|obj| obj.keys().map(|k| k.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
if provided.is_empty() {
|
||||
ToolError::missing_field(field)
|
||||
} else {
|
||||
let hint = format!(
|
||||
"missing required field '{field}'. Input provided: {}",
|
||||
provided.join(", ")
|
||||
);
|
||||
ToolError::invalid_input(hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Helper to extract an optional string field from JSON input.
|
||||
#[must_use]
|
||||
pub fn optional_str<'a>(input: &'a Value, field: &str) -> Option<&'a str> {
|
||||
input.get(field).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
/// Helper to extract a required u64 field from JSON input.
|
||||
pub fn required_u64(input: &Value, field: &str) -> std::result::Result<u64, ToolError> {
|
||||
input
|
||||
.get(field)
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| ToolError::missing_field(field))
|
||||
}
|
||||
|
||||
/// Helper to extract an optional u64 field with default.
|
||||
#[must_use]
|
||||
pub fn optional_u64(input: &Value, field: &str, default: u64) -> u64 {
|
||||
input.get(field).and_then(Value::as_u64).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Helper to extract an optional bool field with default.
|
||||
#[must_use]
|
||||
pub fn optional_bool(input: &Value, field: &str, default: bool) -> bool {
|
||||
input.get(field).and_then(Value::as_bool).unwrap_or(default)
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tool_result_json_round_trips_content() {
|
||||
let result = ToolResult::json(&json!({"ok": true})).expect("json");
|
||||
assert!(result.success);
|
||||
assert!(result.content.contains("\"ok\": true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_extractors_validate_shape() {
|
||||
let input = json!({"name": "demo", "count": 7, "enabled": true});
|
||||
assert_eq!(required_str(&input, "name").expect("name"), "demo");
|
||||
assert_eq!(optional_u64(&input, "count", 0), 7);
|
||||
assert!(optional_bool(&input, "enabled", false));
|
||||
assert!(matches!(
|
||||
required_u64(&input, "name"),
|
||||
Err(ToolError::MissingField { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_str_reports_provided_fields_on_missing_required_field() {
|
||||
let input = json!({"path": "src/lib.rs", "content": "new body"});
|
||||
let err = required_str(&input, "replace").expect_err("replace is missing");
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("missing required field 'replace'"));
|
||||
assert!(message.contains("Input provided:"));
|
||||
assert!(message.contains("path"));
|
||||
assert!(message.contains("content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_error_display_matches_legacy_text() {
|
||||
let err = ToolError::missing_field("path");
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"Failed to validate input: missing required field 'path'"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,76 @@
|
||||
[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"
|
||||
deepseek-secrets = { path = "../secrets", version = "0.7.5" }
|
||||
deepseek-tools = { path = "../tools", version = "0.7.5" }
|
||||
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"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
pdf-extract = "0.7"
|
||||
tar = "0.4"
|
||||
flate2 = "1.1"
|
||||
sha2 = "0.10"
|
||||
|
||||
[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,420 @@
|
||||
<!--
|
||||
This SKILL.md is ported from OpenAI's codex repo (MIT-licensed).
|
||||
Source: https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/skill-creator/SKILL.md
|
||||
-->
|
||||
---
|
||||
name: skill-creator
|
||||
description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends deepseek's capabilities with specialized knowledge, workflows, or tool integrations.
|
||||
metadata:
|
||||
short-description: Create or update a skill
|
||||
---
|
||||
|
||||
# Skill Creator
|
||||
|
||||
This skill provides guidance for creating effective skills.
|
||||
|
||||
## About Skills
|
||||
|
||||
Skills are modular, self-contained folders that extend deepseek's capabilities by providing
|
||||
specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific
|
||||
domains or tasks—they transform deepseek from a general-purpose agent into a specialized agent
|
||||
equipped with procedural knowledge that no model can fully possess.
|
||||
|
||||
### What Skills Provide
|
||||
|
||||
1. Specialized workflows - Multi-step procedures for specific domains
|
||||
2. Tool integrations - Instructions for working with specific file formats or APIs
|
||||
3. Domain expertise - Company-specific knowledge, schemas, business logic
|
||||
4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks
|
||||
|
||||
## Core Principles
|
||||
|
||||
### Concise is Key
|
||||
|
||||
The context window is a public good. Skills share the context window with everything else deepseek needs: system prompt, conversation history, other Skills' metadata, and the actual user request.
|
||||
|
||||
**Default assumption: deepseek is already very smart.** Only add context deepseek doesn't already have. Challenge each piece of information: "Does deepseek really need this explanation?" and "Does this paragraph justify its token cost?"
|
||||
|
||||
Prefer concise examples over verbose explanations.
|
||||
|
||||
### Set Appropriate Degrees of Freedom
|
||||
|
||||
Match the level of specificity to the task's fragility and variability:
|
||||
|
||||
**High freedom (text-based instructions)**: Use when multiple approaches are valid, decisions depend on context, or heuristics guide the approach.
|
||||
|
||||
**Medium freedom (pseudocode or scripts with parameters)**: Use when a preferred pattern exists, some variation is acceptable, or configuration affects behavior.
|
||||
|
||||
**Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed.
|
||||
|
||||
Think of deepseek as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom).
|
||||
|
||||
### Protect Validation Integrity
|
||||
|
||||
You may use subagents during iteration to validate whether a skill works on realistic tasks or whether a suspected problem is real. This is most useful when you want an independent pass on the skill's behavior, outputs, or failure modes after a revision. Only do this when it is possible to start new subagents.
|
||||
|
||||
When using subagents for validation, treat that as an evaluation surface. The goal is to learn whether the skill generalizes, not whether another agent can reconstruct the answer from leaked context.
|
||||
|
||||
Prefer raw artifacts such as example prompts, outputs, diffs, logs, or traces. Give the minimum task-local context needed to perform the validation. Avoid passing the intended answer, suspected bug, intended fix, or your prior conclusions unless the validation explicitly requires them.
|
||||
|
||||
### Anatomy of a Skill
|
||||
|
||||
Every skill consists of a required SKILL.md file and optional bundled resources:
|
||||
|
||||
```
|
||||
skill-name/
|
||||
├── SKILL.md (required)
|
||||
│ ├── YAML frontmatter metadata (required)
|
||||
│ │ ├── name: (required)
|
||||
│ │ └── description: (required)
|
||||
│ └── Markdown instructions (required)
|
||||
├── agents/ (recommended)
|
||||
│ └── openai.yaml - UI metadata for skill lists and chips
|
||||
└── Bundled Resources (optional)
|
||||
├── scripts/ - Executable code (Python/Bash/etc.)
|
||||
├── references/ - Documentation intended to be loaded into context as needed
|
||||
└── assets/ - Files used in output (templates, icons, fonts, etc.)
|
||||
```
|
||||
|
||||
#### SKILL.md (required)
|
||||
|
||||
Every SKILL.md consists of:
|
||||
|
||||
- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that deepseek reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used.
|
||||
- **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all).
|
||||
|
||||
#### Agents metadata (recommended)
|
||||
|
||||
- UI-facing metadata for skill lists and chips
|
||||
- Read references/openai_yaml.md before generating values and follow its descriptions and constraints
|
||||
- Create: human-facing `display_name`, `short_description`, and `default_prompt` by reading the skill
|
||||
- Generate deterministically by passing the values as `--interface key=value` to `scripts/generate_openai_yaml.py` or `scripts/init_skill.py`
|
||||
- On updates: validate `agents/openai.yaml` still matches SKILL.md; regenerate if stale
|
||||
- Only include other optional interface fields (icons, brand color) if explicitly provided
|
||||
- See references/openai_yaml.md for field definitions and examples
|
||||
|
||||
#### Bundled Resources (optional)
|
||||
|
||||
##### Scripts (`scripts/`)
|
||||
|
||||
Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten.
|
||||
|
||||
- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed
|
||||
- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks
|
||||
- **Benefits**: Token efficient, deterministic, may be executed without loading into context
|
||||
- **Note**: Scripts may still need to be read by deepseek for patching or environment-specific adjustments
|
||||
|
||||
##### References (`references/`)
|
||||
|
||||
Documentation and reference material intended to be loaded as needed into context to inform deepseek's process and thinking.
|
||||
|
||||
- **When to include**: For documentation that deepseek should reference while working
|
||||
- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications
|
||||
- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides
|
||||
- **Benefits**: Keeps SKILL.md lean, loaded only when deepseek determines it's needed
|
||||
- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md
|
||||
- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files.
|
||||
|
||||
##### Assets (`assets/`)
|
||||
|
||||
Files not intended to be loaded into context, but rather used within the output deepseek produces.
|
||||
|
||||
- **When to include**: When the skill needs files that will be used in the final output
|
||||
- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography
|
||||
- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified
|
||||
- **Benefits**: Separates output resources from documentation, enables deepseek to use files without loading them into context
|
||||
|
||||
#### What to Not Include in a Skill
|
||||
|
||||
A skill should only contain essential files that directly support its functionality. Do NOT create extraneous documentation or auxiliary files, including:
|
||||
|
||||
- README.md
|
||||
- INSTALLATION_GUIDE.md
|
||||
- QUICK_REFERENCE.md
|
||||
- CHANGELOG.md
|
||||
- etc.
|
||||
|
||||
The skill should only contain the information needed for an AI agent to do the job at hand. It should not contain auxiliary context about the process that went into creating it, setup and testing procedures, user-facing documentation, etc. Creating additional documentation files just adds clutter and confusion.
|
||||
|
||||
### Progressive Disclosure Design Principle
|
||||
|
||||
Skills use a three-level loading system to manage context efficiently:
|
||||
|
||||
1. **Metadata (name + description)** - Always in context (~100 words)
|
||||
2. **SKILL.md body** - When skill triggers (<5k words)
|
||||
3. **Bundled resources** - As needed by deepseek (Unlimited because scripts can be executed without reading into context window)
|
||||
|
||||
#### Progressive Disclosure Patterns
|
||||
|
||||
Keep SKILL.md body to the essentials and under 500 lines to minimize context bloat. Split content into separate files when approaching this limit. When splitting out content into other files, it is very important to reference them from SKILL.md and describe clearly when to read them, to ensure the reader of the skill knows they exist and when to use them.
|
||||
|
||||
**Key principle:** When a skill supports multiple variations, frameworks, or options, keep only the core workflow and selection guidance in SKILL.md. Move variant-specific details (patterns, examples, configuration) into separate reference files.
|
||||
|
||||
**Pattern 1: High-level guide with references**
|
||||
|
||||
```markdown
|
||||
# PDF Processing
|
||||
|
||||
## Quick start
|
||||
|
||||
Extract text with pdfplumber:
|
||||
[code example]
|
||||
|
||||
## Advanced features
|
||||
|
||||
- **Form filling**: See [FORMS.md](FORMS.md) for complete guide
|
||||
- **API reference**: See [REFERENCE.md](REFERENCE.md) for all methods
|
||||
- **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns
|
||||
```
|
||||
|
||||
deepseek loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed.
|
||||
|
||||
**Pattern 2: Domain-specific organization**
|
||||
|
||||
For Skills with multiple domains, organize content by domain to avoid loading irrelevant context:
|
||||
|
||||
```
|
||||
bigquery-skill/
|
||||
├── SKILL.md (overview and navigation)
|
||||
└── reference/
|
||||
├── finance.md (revenue, billing metrics)
|
||||
├── sales.md (opportunities, pipeline)
|
||||
├── product.md (API usage, features)
|
||||
└── marketing.md (campaigns, attribution)
|
||||
```
|
||||
|
||||
When a user asks about sales metrics, deepseek only reads sales.md.
|
||||
|
||||
Similarly, for skills supporting multiple frameworks or variants, organize by variant:
|
||||
|
||||
```
|
||||
cloud-deploy/
|
||||
├── SKILL.md (workflow + provider selection)
|
||||
└── references/
|
||||
├── aws.md (AWS deployment patterns)
|
||||
├── gcp.md (GCP deployment patterns)
|
||||
└── azure.md (Azure deployment patterns)
|
||||
```
|
||||
|
||||
When the user chooses AWS, deepseek only reads aws.md.
|
||||
|
||||
**Pattern 3: Conditional details**
|
||||
|
||||
Show basic content, link to advanced content:
|
||||
|
||||
```markdown
|
||||
# DOCX Processing
|
||||
|
||||
## Creating documents
|
||||
|
||||
Use docx-js for new documents. See [DOCX-JS.md](DOCX-JS.md).
|
||||
|
||||
## Editing documents
|
||||
|
||||
For simple edits, modify the XML directly.
|
||||
|
||||
**For tracked changes**: See [REDLINING.md](REDLINING.md)
|
||||
**For OOXML details**: See [OOXML.md](OOXML.md)
|
||||
```
|
||||
|
||||
deepseek reads REDLINING.md or OOXML.md only when the user needs those features.
|
||||
|
||||
**Important guidelines:**
|
||||
|
||||
- **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md.
|
||||
- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so deepseek can see the full scope when previewing.
|
||||
|
||||
## Skill Creation Process
|
||||
|
||||
Skill creation involves these steps:
|
||||
|
||||
1. Understand the skill with concrete examples
|
||||
2. Plan reusable skill contents (scripts, references, assets)
|
||||
3. Initialize the skill (run init_skill.py)
|
||||
4. Edit the skill (implement resources and write SKILL.md)
|
||||
5. Validate the skill (run quick_validate.py)
|
||||
6. Iterate based on real usage and forward-test complex skills.
|
||||
|
||||
Follow these steps in order, skipping only if there is a clear reason why they are not applicable.
|
||||
|
||||
### Skill Naming
|
||||
|
||||
- Use lowercase letters, digits, and hyphens only; normalize user-provided titles to hyphen-case (e.g., "Plan Mode" -> `plan-mode`).
|
||||
- When generating names, generate a name under 64 characters (letters, digits, hyphens).
|
||||
- Prefer short, verb-led phrases that describe the action.
|
||||
- Namespace by tool when it improves clarity or triggering (e.g., `gh-address-comments`, `linear-address-issue`).
|
||||
- Name the skill folder exactly after the skill name.
|
||||
|
||||
### Step 1: Understanding the Skill with Concrete Examples
|
||||
|
||||
Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill.
|
||||
|
||||
To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback.
|
||||
|
||||
For example, when building an image-editor skill, relevant questions include:
|
||||
|
||||
- "What functionality should the image-editor skill support? Editing, rotating, anything else?"
|
||||
- "Can you give some examples of how this skill would be used?"
|
||||
- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?"
|
||||
- "What would a user say that should trigger this skill?"
|
||||
- "Where should I create this skill? If you do not have a preference, I will place it in `$DEEPSEEK_HOME/skills` (or `~/.deepseek/skills` when `DEEPSEEK_HOME` is unset) so deepseek can discover it automatically."
|
||||
|
||||
To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness.
|
||||
|
||||
Conclude this step when there is a clear sense of the functionality the skill should support.
|
||||
|
||||
### Step 2: Planning the Reusable Skill Contents
|
||||
|
||||
To turn concrete examples into an effective skill, analyze each example by:
|
||||
|
||||
1. Considering how to execute on the example from scratch
|
||||
2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly
|
||||
|
||||
Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows:
|
||||
|
||||
1. Rotating a PDF requires re-writing the same code each time
|
||||
2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill
|
||||
|
||||
Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows:
|
||||
|
||||
1. Writing a frontend webapp requires the same boilerplate HTML/React each time
|
||||
2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill
|
||||
|
||||
Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows:
|
||||
|
||||
1. Querying BigQuery requires re-discovering the table schemas and relationships each time
|
||||
2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill
|
||||
|
||||
To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets.
|
||||
|
||||
### Step 3: Initializing the Skill
|
||||
|
||||
At this point, it is time to actually create the skill.
|
||||
|
||||
Skip this step only if the skill being developed already exists. In this case, continue to the next step.
|
||||
|
||||
Before running `init_skill.py`, ask where the user wants the skill created. If they do not specify a location, default to `$DEEPSEEK_HOME/skills`; when `DEEPSEEK_HOME` is unset, fall back to `~/.deepseek/skills` so the skill is auto-discovered.
|
||||
|
||||
When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable.
|
||||
|
||||
Usage:
|
||||
|
||||
```bash
|
||||
scripts/init_skill.py <skill-name> --path <output-directory> [--resources scripts,references,assets] [--examples]
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
scripts/init_skill.py my-skill --path "${DEEPSEEK_HOME:-$HOME/.deepseek}/skills"
|
||||
scripts/init_skill.py my-skill --path "${DEEPSEEK_HOME:-$HOME/.deepseek}/skills" --resources scripts,references
|
||||
scripts/init_skill.py my-skill --path ~/work/skills --resources scripts --examples
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
- Creates the skill directory at the specified path
|
||||
- Generates a SKILL.md template with proper frontmatter and TODO placeholders
|
||||
- Creates `agents/openai.yaml` using agent-generated `display_name`, `short_description`, and `default_prompt` passed via `--interface key=value`
|
||||
- Optionally creates resource directories based on `--resources`
|
||||
- Optionally adds example files when `--examples` is set
|
||||
|
||||
After initialization, customize the SKILL.md and add resources as needed. If you used `--examples`, replace or delete placeholder files.
|
||||
|
||||
Generate `display_name`, `short_description`, and `default_prompt` by reading the skill, then pass them as `--interface key=value` to `init_skill.py` or regenerate with:
|
||||
|
||||
```bash
|
||||
scripts/generate_openai_yaml.py <path/to/skill-folder> --interface key=value
|
||||
```
|
||||
|
||||
Only include other optional interface fields when the user explicitly provides them. For full field descriptions and examples, see references/openai_yaml.md.
|
||||
|
||||
### Step 4: Edit the Skill
|
||||
|
||||
When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of deepseek to use. Include information that would be beneficial and non-obvious to deepseek. Consider what procedural knowledge, domain-specific details, or reusable assets would help another deepseek instance execute these tasks more effectively.
|
||||
|
||||
After substantial revisions, or if the skill is particularly tricky, you should use subagents to forward-test the skill on realistic tasks or artifacts. When doing so, pass the artifact under validation rather than your diagnosis of what is wrong, and keep the prompt generic enough that success depends on transferable reasoning rather than hidden ground truth.
|
||||
|
||||
#### Start with Reusable Skill Contents
|
||||
|
||||
To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`.
|
||||
|
||||
Added scripts must be tested by actually running them to ensure there are no bugs and that the output matches what is expected. If there are many similar scripts, only a representative sample needs to be tested to ensure confidence that they all work while balancing time to completion.
|
||||
|
||||
If you used `--examples`, delete any placeholder files that are not needed for the skill. Only create resource directories that are actually required.
|
||||
|
||||
#### Update SKILL.md
|
||||
|
||||
**Writing Guidelines:** Always use imperative/infinitive form.
|
||||
|
||||
##### Frontmatter
|
||||
|
||||
Write the YAML frontmatter with `name` and `description`:
|
||||
|
||||
- `name`: The skill name
|
||||
- `description`: This is the primary triggering mechanism for your skill, and helps deepseek understand when to use the skill.
|
||||
- Include both what the Skill does and specific triggers/contexts for when to use it.
|
||||
- Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to deepseek.
|
||||
- Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when deepseek needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks"
|
||||
|
||||
Do not include any other fields in YAML frontmatter.
|
||||
|
||||
##### Body
|
||||
|
||||
Write instructions for using the skill and its bundled resources.
|
||||
|
||||
### Step 5: Validate the Skill
|
||||
|
||||
Once development of the skill is complete, validate the skill folder to catch basic issues early:
|
||||
|
||||
```bash
|
||||
scripts/quick_validate.py <path/to/skill-folder>
|
||||
```
|
||||
|
||||
The validation script checks YAML frontmatter format, required fields, and naming rules. If validation fails, fix the reported issues and run the command again.
|
||||
|
||||
### Step 6: Iterate
|
||||
|
||||
After testing the skill, you may detect the skill is complex enough that it requires forward-testing; or users may request improvements.
|
||||
|
||||
User testing often this happens right after using the skill, with fresh context of how the skill performed.
|
||||
|
||||
**Forward-testing and iteration workflow:**
|
||||
|
||||
1. Use the skill on real tasks
|
||||
2. Notice struggles or inefficiencies
|
||||
3. Identify how SKILL.md or bundled resources should be updated
|
||||
4. Implement changes and test again
|
||||
5. Forward-test if it is reasonable and appropriate
|
||||
|
||||
## Forward-testing
|
||||
|
||||
To forward-test, launch subagents as a way to stress test the skill with minimal context.
|
||||
Subagents should *not* know that they are being asked to test the skill. They should be treated as
|
||||
an agent asked to perform a task by the user. Prompts to subagents should look like:
|
||||
`Use $skill-x at /path/to/skill-x to solve problem y`
|
||||
Not:
|
||||
`Review the skill at /path/to/skill-x; pretend a user asks you to...`
|
||||
|
||||
Decision rule for forward-testing:
|
||||
- Err on the side of forward-testing
|
||||
- Ask for approval if you think there's a risk that forward-testing would:
|
||||
* take a long time,
|
||||
* require additional approvals from the user, or
|
||||
* modify live production systems
|
||||
|
||||
In these cases, show the user your proposed prompt and request (1) a yes/no decision, and
|
||||
(2) any suggested modifictions.
|
||||
|
||||
Considerations when forward-testing:
|
||||
- use fresh threads for independent passes
|
||||
- pass the skill, and a request in a similar way the user would.
|
||||
- pass raw artifacts, not your conclusions
|
||||
- avoid showing expected answers or intended fixes
|
||||
- rebuild context from source artifacts after each iteration
|
||||
- review the subagent's output and reasoning and emitted artifacts
|
||||
- avoid leaving artifacts the agent can find on disk between iterations;
|
||||
clean up subagents' artifacts to avoid additional contamination.
|
||||
|
||||
If forward-testing only succeeds when subagents see leaked context, tighten the skill or the
|
||||
forward-testing setup before trusting the result.
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,406 @@
|
||||
//! Responses API helpers for the experimental DeepSeek endpoint.
|
||||
//!
|
||||
//! Gated behind `DEEPSEEK_EXPERIMENTAL_RESPONSES_API`. Normal traffic uses
|
||||
//! chat completions via `crate::client::chat`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::models::{ContentBlock, Message, MessageRequest, MessageResponse, Tool, ToolCaller};
|
||||
|
||||
use super::{
|
||||
DeepSeekClient, ERROR_BODY_MAX_BYTES, api_url, apply_reasoning_effort, bounded_error_text,
|
||||
from_api_tool_name, parse_usage, system_to_instructions, to_api_tool_name,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ResponsesFallback {
|
||||
pub(super) status: u16,
|
||||
pub(super) body: String,
|
||||
}
|
||||
|
||||
impl DeepSeekClient {
|
||||
pub(super) async fn create_message_responses(
|
||||
&self,
|
||||
request: &MessageRequest,
|
||||
) -> Result<Result<MessageResponse, ResponsesFallback>> {
|
||||
let mut body = json!({
|
||||
"model": request.model,
|
||||
"input": build_responses_input(&request.messages),
|
||||
"store": false,
|
||||
"max_output_tokens": request.max_tokens,
|
||||
});
|
||||
|
||||
if let Some(instructions) = system_to_instructions(request.system.clone()) {
|
||||
body["instructions"] = json!(instructions);
|
||||
}
|
||||
if let Some(temperature) = request.temperature {
|
||||
body["temperature"] = json!(temperature);
|
||||
}
|
||||
if let Some(top_p) = request.top_p {
|
||||
body["top_p"] = json!(top_p);
|
||||
}
|
||||
if let Some(tools) = request.tools.as_ref() {
|
||||
body["tools"] = json!(tools.iter().map(tool_to_responses).collect::<Vec<_>>());
|
||||
}
|
||||
if let Some(choice) = request.tool_choice.as_ref() {
|
||||
body["tool_choice"] = choice.clone();
|
||||
}
|
||||
apply_reasoning_effort(
|
||||
&mut body,
|
||||
request.reasoning_effort.as_deref(),
|
||||
self.api_provider,
|
||||
);
|
||||
|
||||
let url = api_url(&self.base_url, "responses");
|
||||
let response = self
|
||||
.send_with_retry(|| self.http_client.post(&url).json(&body))
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
if status.as_u16() == 404 || status.as_u16() == 405 {
|
||||
let body = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
|
||||
return Ok(Err(ResponsesFallback {
|
||||
status: status.as_u16(),
|
||||
body,
|
||||
}));
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
|
||||
anyhow::bail!("Failed to call DeepSeek Responses API: HTTP {status}: {error_text}");
|
||||
}
|
||||
|
||||
let response_text = response.text().await.unwrap_or_default();
|
||||
let value: Value =
|
||||
serde_json::from_str(&response_text).context("Failed to parse Responses API JSON")?;
|
||||
let message = parse_responses_message(&value)?;
|
||||
Ok(Ok(message))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_responses_input(messages: &[Message]) -> Vec<Value> {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for message in messages {
|
||||
let role = message.role.as_str();
|
||||
let text_type = if role == "user" {
|
||||
"input_text"
|
||||
} else {
|
||||
"output_text"
|
||||
};
|
||||
|
||||
for block in &message.content {
|
||||
match block {
|
||||
ContentBlock::Text { text, .. } => {
|
||||
items.push(json!({
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": [{
|
||||
"type": text_type,
|
||||
"text": text,
|
||||
}]
|
||||
}));
|
||||
}
|
||||
ContentBlock::ToolUse {
|
||||
id,
|
||||
name,
|
||||
input,
|
||||
caller,
|
||||
} => {
|
||||
let args = serde_json::to_string(input).unwrap_or_else(|_| input.to_string());
|
||||
let mut item = json!({
|
||||
"type": "function_call",
|
||||
"call_id": id,
|
||||
"name": to_api_tool_name(name),
|
||||
"arguments": args,
|
||||
});
|
||||
if let Some(caller) = caller {
|
||||
item["caller"] = json!({
|
||||
"type": caller.caller_type,
|
||||
"tool_id": caller.tool_id,
|
||||
});
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
let mut item = json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": tool_use_id,
|
||||
"output": content,
|
||||
});
|
||||
if let Some(is_error) = is_error {
|
||||
item["is_error"] = json!(is_error);
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
ContentBlock::Thinking { .. } => {}
|
||||
ContentBlock::ServerToolUse { id, name, input } => {
|
||||
items.push(json!({
|
||||
"type": "server_tool_use",
|
||||
"id": id,
|
||||
"name": name,
|
||||
"input": input,
|
||||
}));
|
||||
}
|
||||
ContentBlock::ToolSearchToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
} => {
|
||||
items.push(json!({
|
||||
"type": "tool_search_tool_result",
|
||||
"tool_use_id": tool_use_id,
|
||||
"content": content,
|
||||
}));
|
||||
}
|
||||
ContentBlock::CodeExecutionToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
} => {
|
||||
items.push(json!({
|
||||
"type": "code_execution_tool_result",
|
||||
"tool_use_id": tool_use_id,
|
||||
"content": content,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn tool_to_responses(tool: &Tool) -> Value {
|
||||
let tool_type = tool.tool_type.as_deref().unwrap_or("function");
|
||||
let mut value = if tool_type == "function" {
|
||||
json!({
|
||||
"type": "function",
|
||||
"name": to_api_tool_name(&tool.name),
|
||||
"description": tool.description,
|
||||
"parameters": tool.input_schema,
|
||||
})
|
||||
} else if tool_type == "code_execution_20250825" {
|
||||
json!({
|
||||
"type": tool_type,
|
||||
"name": to_api_tool_name(&tool.name),
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"type": tool_type,
|
||||
"name": to_api_tool_name(&tool.name),
|
||||
"description": tool.description,
|
||||
"input_schema": tool.input_schema,
|
||||
})
|
||||
};
|
||||
|
||||
if let Some(allowed_callers) = &tool.allowed_callers {
|
||||
value["allowed_callers"] = json!(allowed_callers);
|
||||
}
|
||||
if let Some(defer_loading) = tool.defer_loading {
|
||||
value["defer_loading"] = json!(defer_loading);
|
||||
}
|
||||
if let Some(input_examples) = &tool.input_examples {
|
||||
value["input_examples"] = json!(input_examples);
|
||||
}
|
||||
if let Some(strict) = tool.strict {
|
||||
value["strict"] = json!(strict);
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn parse_responses_message(payload: &Value) -> Result<MessageResponse> {
|
||||
let id = payload
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("response")
|
||||
.to_string();
|
||||
let model = payload
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let usage = parse_usage(payload.get("usage"));
|
||||
let mut content = Vec::new();
|
||||
|
||||
if let Some(output) = payload.get("output").and_then(Value::as_array) {
|
||||
for item in output {
|
||||
let item_type = item.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
match item_type {
|
||||
"message" => {
|
||||
if let Some(role) = item.get("role").and_then(Value::as_str)
|
||||
&& role != "assistant"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(content_items) = item.get("content").and_then(Value::as_array) {
|
||||
for content_item in content_items {
|
||||
let content_type = content_item
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("output_text");
|
||||
if content_type != "output_text" && content_type != "text" {
|
||||
continue;
|
||||
}
|
||||
if let Some(text) = content_item.get("text").and_then(Value::as_str)
|
||||
&& !text.trim().is_empty()
|
||||
{
|
||||
content.push(ContentBlock::Text {
|
||||
text: text.to_string(),
|
||||
cache_control: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"function_call" => {
|
||||
let call_id = item
|
||||
.get("call_id")
|
||||
.or_else(|| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("tool_call")
|
||||
.to_string();
|
||||
let name = item
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("tool")
|
||||
.to_string();
|
||||
let input = match item.get("arguments") {
|
||||
Some(Value::String(raw)) => {
|
||||
serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()))
|
||||
}
|
||||
Some(other) => other.clone(),
|
||||
None => Value::Null,
|
||||
};
|
||||
let caller = item.get("caller").and_then(|v| {
|
||||
v.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.map(|caller_type| ToolCaller {
|
||||
caller_type: caller_type.to_string(),
|
||||
tool_id: v
|
||||
.get("tool_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(std::string::ToString::to_string),
|
||||
})
|
||||
});
|
||||
content.push(ContentBlock::ToolUse {
|
||||
id: call_id,
|
||||
name: from_api_tool_name(&name),
|
||||
input,
|
||||
caller,
|
||||
});
|
||||
}
|
||||
"function_call_output" => {
|
||||
let tool_use_id = item
|
||||
.get("call_id")
|
||||
.or_else(|| item.get("tool_use_id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("tool_call")
|
||||
.to_string();
|
||||
let content_text = item
|
||||
.get("output")
|
||||
.or_else(|| item.get("content"))
|
||||
.map(|v| {
|
||||
if let Some(s) = v.as_str() {
|
||||
s.to_string()
|
||||
} else {
|
||||
v.to_string()
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let is_error = item.get("is_error").and_then(Value::as_bool);
|
||||
content.push(ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
content: content_text,
|
||||
is_error,
|
||||
content_blocks: None,
|
||||
});
|
||||
}
|
||||
"server_tool_use" => {
|
||||
let id = item
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("server_tool")
|
||||
.to_string();
|
||||
let name = item
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("server_tool")
|
||||
.to_string();
|
||||
let input = item.get("input").cloned().unwrap_or(Value::Null);
|
||||
content.push(ContentBlock::ServerToolUse { id, name, input });
|
||||
}
|
||||
"tool_search_tool_result" => {
|
||||
let tool_use_id = item
|
||||
.get("tool_use_id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("tool_search")
|
||||
.to_string();
|
||||
let content_value = item.get("content").cloned().unwrap_or(Value::Null);
|
||||
content.push(ContentBlock::ToolSearchToolResult {
|
||||
tool_use_id,
|
||||
content: content_value,
|
||||
});
|
||||
}
|
||||
"code_execution_tool_result" => {
|
||||
let tool_use_id = item
|
||||
.get("tool_use_id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("code_execution")
|
||||
.to_string();
|
||||
let content_value = item.get("content").cloned().unwrap_or(Value::Null);
|
||||
content.push(ContentBlock::CodeExecutionToolResult {
|
||||
tool_use_id,
|
||||
content: content_value,
|
||||
});
|
||||
}
|
||||
"reasoning" => {
|
||||
if let Some(summary) = item.get("summary").and_then(Value::as_array) {
|
||||
let summary_text = summary
|
||||
.iter()
|
||||
.filter_map(|s| s.get("text").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !summary_text.trim().is_empty() {
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking: summary_text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if content.is_empty()
|
||||
&& let Some(text) = payload.get("output_text").and_then(Value::as_str)
|
||||
&& !text.trim().is_empty()
|
||||
{
|
||||
content.push(ContentBlock::Text {
|
||||
text: text.to_string(),
|
||||
cache_control: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(MessageResponse {
|
||||
id,
|
||||
r#type: "message".to_string(),
|
||||
role: "assistant".to_string(),
|
||||
content,
|
||||
model,
|
||||
stop_reason: None,
|
||||
stop_sequence: None,
|
||||
container: payload
|
||||
.get("container")
|
||||
.cloned()
|
||||
.and_then(|v| serde_json::from_value(v).ok()),
|
||||
usage,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,125 @@
|
||||
//! Local media attachment commands.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::CommandResult;
|
||||
use crate::tui::app::App;
|
||||
|
||||
pub fn attach(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
let Some(raw_path) = arg.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return CommandResult::error("Usage: /attach <image-or-video-path>");
|
||||
};
|
||||
|
||||
let path = resolve_attachment_path(raw_path, &app.workspace);
|
||||
let Ok(path) = path.canonicalize() else {
|
||||
return CommandResult::error(format!("Attachment not found: {}", path.display()));
|
||||
};
|
||||
if !path.is_file() {
|
||||
return CommandResult::error(format!("Attachment is not a file: {}", path.display()));
|
||||
}
|
||||
|
||||
let Some(kind) = media_kind(&path) else {
|
||||
return CommandResult::error(
|
||||
"Unsupported attachment type. /attach is for image/video paths; use @path for text files or directories.",
|
||||
);
|
||||
};
|
||||
|
||||
app.insert_media_attachment(kind, &path, None);
|
||||
CommandResult::message(format!("Attached {kind}: {}", path.display()))
|
||||
}
|
||||
|
||||
fn resolve_attachment_path(raw_path: &str, workspace: &Path) -> PathBuf {
|
||||
let unquoted = raw_path.trim().trim_matches('"').trim_matches('\'');
|
||||
let path = expand_home(unquoted);
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
workspace.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_home(path: &str) -> PathBuf {
|
||||
if path == "~" {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return PathBuf::from(home);
|
||||
}
|
||||
} else if let Some(rest) = path.strip_prefix("~/")
|
||||
&& let Some(home) = std::env::var_os("HOME")
|
||||
{
|
||||
return PathBuf::from(home).join(rest);
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
fn media_kind(path: &Path) -> Option<&'static str> {
|
||||
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "ppm" => Some("image"),
|
||||
"mp4" | "mov" | "m4v" | "webm" | "avi" | "mkv" => Some("video"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::TuiOptions;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn app_with_workspace(tmpdir: &TempDir) -> App {
|
||||
App::new(
|
||||
TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: false,
|
||||
use_mouse_capture: false,
|
||||
use_bracketed_paste: true,
|
||||
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,881 @@
|
||||
//! Config commands: config, settings, mode switches, trust, logout
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::CommandResult;
|
||||
use crate::config::{COMMON_DEEPSEEK_MODELS, clear_api_key, normalize_model_name};
|
||||
use crate::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}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the `/statusline` multi-select picker for configuring footer items.
|
||||
pub fn status_line(_app: &mut App) -> CommandResult {
|
||||
CommandResult::action(AppAction::OpenStatusPicker)
|
||||
}
|
||||
|
||||
/// Persist `tui.status_items` to `~/.deepseek/config.toml` without disturbing
|
||||
/// the rest of the file. We round-trip through `toml::Value` so any keys we
|
||||
/// don't know about (provider blocks, MCP, etc.) survive the write
|
||||
/// untouched.
|
||||
///
|
||||
/// Returns the path written so the caller can surface it in a status toast.
|
||||
pub fn persist_status_items(items: &[crate::config::StatusItem]) -> anyhow::Result<PathBuf> {
|
||||
use anyhow::Context;
|
||||
use std::fs;
|
||||
|
||||
let path = config_toml_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create config directory {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let mut doc: toml::Value = if path.exists() {
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read config at {}", path.display()))?;
|
||||
toml::from_str(&raw)
|
||||
.with_context(|| format!("failed to parse config at {}", path.display()))?
|
||||
} else {
|
||||
toml::Value::Table(toml::value::Table::new())
|
||||
};
|
||||
|
||||
let table = doc
|
||||
.as_table_mut()
|
||||
.context("config.toml root must be a table")?;
|
||||
let tui_entry = table
|
||||
.entry("tui".to_string())
|
||||
.or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
|
||||
let tui_table = tui_entry
|
||||
.as_table_mut()
|
||||
.context("`tui` section in config.toml must be a table")?;
|
||||
let array = items
|
||||
.iter()
|
||||
.map(|item| toml::Value::String(item.key().to_string()))
|
||||
.collect::<Vec<_>>();
|
||||
tui_table.insert("status_items".to_string(), toml::Value::Array(array));
|
||||
|
||||
let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?;
|
||||
fs::write(&path, body)
|
||||
.with_context(|| format!("failed to write config at {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result<PathBuf> {
|
||||
use anyhow::Context;
|
||||
use std::fs;
|
||||
|
||||
let path = config_toml_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create config directory {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let mut doc: toml::Value = if path.exists() {
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read config at {}", path.display()))?;
|
||||
toml::from_str(&raw)
|
||||
.with_context(|| format!("failed to parse config at {}", path.display()))?
|
||||
} else {
|
||||
toml::Value::Table(toml::value::Table::new())
|
||||
};
|
||||
let table = doc
|
||||
.as_table_mut()
|
||||
.context("config.toml root must be a table")?;
|
||||
table.insert(key.to_string(), toml::Value::String(value.to_string()));
|
||||
let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?;
|
||||
fs::write(&path, body)
|
||||
.with_context(|| format!("failed to write config at {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Resolve the path to `~/.deepseek/config.toml` (or
|
||||
/// `$DEEPSEEK_CONFIG_PATH`). Mirrors what `Config::load` accepts so we
|
||||
/// never write to a different file than the one we read.
|
||||
fn config_toml_path() -> anyhow::Result<PathBuf> {
|
||||
use anyhow::Context;
|
||||
if let Ok(env) = std::env::var("DEEPSEEK_CONFIG_PATH") {
|
||||
let trimmed = env.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(PathBuf::from(trimmed));
|
||||
}
|
||||
}
|
||||
let home = dirs::home_dir().context("failed to resolve home directory for config.toml path")?;
|
||||
Ok(home.join(".deepseek").join("config.toml"))
|
||||
}
|
||||
|
||||
/// 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",
|
||||
),
|
||||
};
|
||||
}
|
||||
"mcp_config_path" | "mcp" => {
|
||||
if value.trim().is_empty() {
|
||||
return CommandResult::error("mcp_config_path cannot be empty");
|
||||
}
|
||||
app.mcp_config_path = PathBuf::from(expand_tilde(value));
|
||||
app.mcp_restart_required = true;
|
||||
let message = if persist {
|
||||
match persist_root_string_key("mcp_config_path", value) {
|
||||
Ok(path) => format!(
|
||||
"mcp_config_path = {} (saved to {}; restart required for MCP tool pool)",
|
||||
app.mcp_config_path.display(),
|
||||
path.display()
|
||||
),
|
||||
Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
|
||||
}
|
||||
} else {
|
||||
format!(
|
||||
"mcp_config_path = {} (session only; restart required for MCP tool pool)",
|
||||
app.mcp_config_path.display()
|
||||
)
|
||||
};
|
||||
return CommandResult::message(message);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
"sidebar_width" | "sidebar" => {
|
||||
app.sidebar_width_percent = settings.sidebar_width_percent;
|
||||
app.mark_history_updated();
|
||||
}
|
||||
"sidebar_focus" | "focus" => {
|
||||
app.set_sidebar_focus(SidebarFocus::from_setting(&settings.sidebar_focus));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let display_value = match key.as_str() {
|
||||
"default_mode" | "mode" => settings.default_mode.clone(),
|
||||
_ => value.to_string(),
|
||||
};
|
||||
|
||||
let message = if persist {
|
||||
if let Err(e) = settings.save() {
|
||||
return CommandResult::error(format!("Failed to save: {e}"));
|
||||
}
|
||||
format!("{key} = {display_value} (saved)")
|
||||
} else {
|
||||
format!("{key} = {display_value} (session only, add --save to persist)")
|
||||
};
|
||||
|
||||
CommandResult {
|
||||
message: Some(message),
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
/// Modify a setting at runtime
|
||||
#[allow(dead_code)]
|
||||
pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
let Some(args) = args else {
|
||||
let available = Settings::available_settings()
|
||||
.iter()
|
||||
.map(|(k, d)| format!(" {k}: {d}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
return CommandResult::message(format!(
|
||||
"Usage: /set <key> <value>\n\n\
|
||||
Available settings:\n{available}\n\n\
|
||||
Session-only settings:\n \
|
||||
model: Current model\n \
|
||||
approval_mode: auto | suggest | never\n\n\
|
||||
Add --save to persist to settings file."
|
||||
));
|
||||
};
|
||||
|
||||
let parts: Vec<&str> = args.splitn(2, ' ').collect();
|
||||
if parts.len() < 2 {
|
||||
return CommandResult::error("Usage: /set <key> <value>");
|
||||
}
|
||||
|
||||
let key = parts[0].to_lowercase();
|
||||
let (value, should_save) = if parts[1].ends_with(" --save") {
|
||||
(parts[1].trim_end_matches(" --save").trim(), true)
|
||||
} else {
|
||||
(parts[1].trim(), false)
|
||||
};
|
||||
|
||||
set_config_value(app, &key, value, should_save)
|
||||
}
|
||||
|
||||
/// Enable YOLO mode (shell + trust + auto-approve)
|
||||
pub fn yolo(app: &mut App) -> CommandResult {
|
||||
app.set_mode(AppMode::Yolo);
|
||||
CommandResult::message("YOLO mode enabled - shell + trust + auto-approve!")
|
||||
}
|
||||
|
||||
/// Legacy alias for the removed normal mode.
|
||||
pub fn normal_mode(app: &mut App) -> CommandResult {
|
||||
app.set_mode(AppMode::Agent);
|
||||
CommandResult::message("Normal mode was removed. Switched to Agent mode.")
|
||||
}
|
||||
|
||||
/// Enable agent mode (autonomous tool use with approvals)
|
||||
pub fn agent_mode(app: &mut App) -> CommandResult {
|
||||
app.set_mode(AppMode::Agent);
|
||||
CommandResult::message("Agent mode enabled.")
|
||||
}
|
||||
|
||||
/// Enable plan mode (tool planning, then choose execution route)
|
||||
pub fn plan_mode(app: &mut App) -> CommandResult {
|
||||
app.set_mode(AppMode::Plan);
|
||||
CommandResult::message(
|
||||
"Plan mode enabled. Describe your goal and I will create a plan before execution.",
|
||||
)
|
||||
}
|
||||
|
||||
/// Manage workspace-level trust and the per-path allowlist.
|
||||
///
|
||||
/// Subcommands:
|
||||
/// - `/trust` – show current state and trusted external paths
|
||||
/// - `/trust on` – legacy: trust the entire workspace (turn off all path checks)
|
||||
/// - `/trust off` – disable workspace-level trust mode
|
||||
/// - `/trust add <path>` – add a directory to the allowlist (#29)
|
||||
/// - `/trust remove <path>` (alias `rm`) – remove a path from the allowlist
|
||||
/// - `/trust list` – list trusted external paths for this workspace
|
||||
pub fn trust(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
let raw = arg.map(str::trim).unwrap_or("");
|
||||
let mut parts = raw.splitn(2, char::is_whitespace);
|
||||
let sub = parts.next().unwrap_or("").to_lowercase();
|
||||
let rest = parts.next().map(str::trim).unwrap_or("");
|
||||
let workspace = app.workspace.clone();
|
||||
|
||||
match sub.as_str() {
|
||||
"" | "status" | "list" => trust_status(&workspace, app, sub == "list"),
|
||||
"on" | "enable" | "yes" | "y" => {
|
||||
app.trust_mode = true;
|
||||
CommandResult::message(
|
||||
"Workspace trust mode enabled — agent file tools can now read/write any path. \
|
||||
Use `/trust off` to revert; prefer `/trust add <path>` for a narrower opt-in.",
|
||||
)
|
||||
}
|
||||
"off" | "disable" | "no" | "n" => {
|
||||
app.trust_mode = false;
|
||||
CommandResult::message("Workspace trust mode disabled.")
|
||||
}
|
||||
"add" => trust_add(&workspace, rest),
|
||||
"remove" | "rm" | "del" | "delete" => trust_remove(&workspace, rest),
|
||||
other => CommandResult::error(format!(
|
||||
"Unknown /trust action `{other}`. Use `/trust`, `/trust on|off`, `/trust add <path>`, or `/trust remove <path>`."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn trust_status(workspace: &Path, app: &App, force_paths: bool) -> CommandResult {
|
||||
let trust = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
|
||||
let mut lines = Vec::new();
|
||||
lines.push(format!(
|
||||
"Workspace trust mode: {}",
|
||||
if app.trust_mode {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
}
|
||||
));
|
||||
if trust.paths().is_empty() {
|
||||
if force_paths {
|
||||
lines.push("No external paths trusted from this workspace.".to_string());
|
||||
} else {
|
||||
lines.push(
|
||||
"No external paths trusted yet. Use `/trust add <path>` to allow a directory."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
lines.push(format!("Trusted external paths ({}):", trust.paths().len()));
|
||||
for path in trust.paths() {
|
||||
lines.push(format!(" • {}", path.display()));
|
||||
}
|
||||
}
|
||||
CommandResult::message(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn trust_add(workspace: &Path, raw: &str) -> CommandResult {
|
||||
if raw.is_empty() {
|
||||
return CommandResult::error(
|
||||
"Usage: /trust add <path>. Supply an absolute path or a path relative to the workspace.",
|
||||
);
|
||||
}
|
||||
let path = PathBuf::from(expand_tilde(raw));
|
||||
if !path.exists() {
|
||||
return CommandResult::error(format!(
|
||||
"Path not found: {} — supply an existing directory or file.",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
match crate::workspace_trust::add(workspace, &path) {
|
||||
Ok(stored) => CommandResult::message(format!(
|
||||
"Added to trust list for this workspace: {}",
|
||||
stored.display()
|
||||
)),
|
||||
Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn trust_remove(workspace: &Path, raw: &str) -> CommandResult {
|
||||
if raw.is_empty() {
|
||||
return CommandResult::error("Usage: /trust remove <path>");
|
||||
}
|
||||
let path = PathBuf::from(expand_tilde(raw));
|
||||
match crate::workspace_trust::remove(workspace, &path) {
|
||||
Ok(true) => CommandResult::message(format!("Removed from trust list: {}", path.display())),
|
||||
Ok(false) => CommandResult::message(format!("Not in trust list: {}", path.display())),
|
||||
Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_tilde(raw: &str) -> String {
|
||||
if let Some(rest) = raw.strip_prefix("~/")
|
||||
&& let Some(home) = dirs::home_dir()
|
||||
{
|
||||
return home.join(rest).to_string_lossy().into_owned();
|
||||
} else if raw == "~"
|
||||
&& let Some(home) = dirs::home_dir()
|
||||
{
|
||||
return home.to_string_lossy().into_owned();
|
||||
}
|
||||
raw.to_string()
|
||||
}
|
||||
|
||||
/// Logout - clear API key and return to onboarding
|
||||
pub fn logout(app: &mut App) -> CommandResult {
|
||||
match clear_api_key() {
|
||||
Ok(()) => {
|
||||
app.onboarding = OnboardingState::ApiKey;
|
||||
app.onboarding_needs_api_key = true;
|
||||
app.api_key_input.clear();
|
||||
app.api_key_cursor = 0;
|
||||
CommandResult::message("Logged out. Enter a new API key to continue.")
|
||||
}
|
||||
Err(e) => CommandResult::error(format!("Failed to clear API key: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::test_support::lock_test_env;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use crate::tui::approval::ApprovalMode;
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct EnvGuard {
|
||||
home: Option<OsString>,
|
||||
userprofile: Option<OsString>,
|
||||
deepseek_config_path: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn new(home: &Path) -> Self {
|
||||
let home_str = OsString::from(home.as_os_str());
|
||||
let config_path = home.join(".deepseek").join("config.toml");
|
||||
let config_str = OsString::from(config_path.as_os_str());
|
||||
let home_prev = env::var_os("HOME");
|
||||
let userprofile_prev = env::var_os("USERPROFILE");
|
||||
let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH");
|
||||
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("HOME", &home_str);
|
||||
env::set_var("USERPROFILE", &home_str);
|
||||
env::set_var("DEEPSEEK_CONFIG_PATH", &config_str);
|
||||
}
|
||||
|
||||
Self {
|
||||
home: home_prev,
|
||||
userprofile: userprofile_prev,
|
||||
deepseek_config_path: deepseek_config_prev,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(value) = self.home.take() {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("HOME", value);
|
||||
}
|
||||
} else {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::remove_var("HOME");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = self.userprofile.take() {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("USERPROFILE", value);
|
||||
}
|
||||
} else {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::remove_var("USERPROFILE");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = self.deepseek_config_path.take() {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_CONFIG_PATH", value);
|
||||
}
|
||||
} else {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::remove_var("DEEPSEEK_CONFIG_PATH");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "test-model".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
use_bracketed_paste: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: false,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yolo_command_sets_all_flags() {
|
||||
let mut app = create_test_app();
|
||||
let _ = yolo(&mut app);
|
||||
assert!(app.allow_shell);
|
||||
assert!(app.trust_mode);
|
||||
assert!(app.yolo);
|
||||
assert_eq!(app.approval_mode, ApprovalMode::Auto);
|
||||
assert_eq!(app.mode, AppMode::Yolo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_switch_commands() {
|
||||
let mut app = create_test_app();
|
||||
let _ = normal_mode(&mut app);
|
||||
assert_eq!(app.mode, AppMode::Agent);
|
||||
let _ = agent_mode(&mut app);
|
||||
assert_eq!(app.mode, AppMode::Agent);
|
||||
let _ = plan_mode(&mut app);
|
||||
assert_eq!(app.mode, AppMode::Plan);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_show_config_opens_config_editor() {
|
||||
let mut app = create_test_app();
|
||||
app.total_tokens = 1234;
|
||||
let result = show_config(&mut app);
|
||||
assert!(result.message.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_show_settings_loads_from_file() {
|
||||
let _lock = lock_test_env();
|
||||
let mut app = create_test_app();
|
||||
let result = show_settings(&mut app);
|
||||
// Settings should load (may use defaults if file doesn't exist)
|
||||
assert!(result.message.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_without_args_shows_usage() {
|
||||
let mut app = create_test_app();
|
||||
let result = set_config(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Usage: /set"));
|
||||
assert!(msg.contains("Available settings:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_model_updates_app_state() {
|
||||
let mut app = create_test_app();
|
||||
let _old_model = app.model.clone();
|
||||
let result = set_config(&mut app, Some("model deepseek-v4-flash"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("model = deepseek-v4-flash"));
|
||||
assert_eq!(app.model, "deepseek-v4-flash");
|
||||
assert!(matches!(
|
||||
result.action,
|
||||
Some(AppAction::UpdateCompaction(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_model_accepts_future_deepseek_model_id() {
|
||||
let mut app = create_test_app();
|
||||
let result = set_config(&mut app, Some("model deepseek-v4"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("model = deepseek-v4"));
|
||||
assert_eq!(app.model, "deepseek-v4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_model_with_save_flag() {
|
||||
let mut app = create_test_app();
|
||||
let _result = set_config(&mut app, Some("model deepseek-v4-flash --save"));
|
||||
// Note: This test may fail in environments where settings can't be saved
|
||||
// The important thing is that the model is updated
|
||||
assert_eq!(app.model, "deepseek-v4-flash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_default_mode_normal_save_reports_normalized_value() {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-tui-default-mode-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root).unwrap();
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
let mut app = create_test_app();
|
||||
let result = set_config(&mut app, Some("default_mode normal --save"));
|
||||
let msg = result.message.unwrap();
|
||||
assert_eq!(msg, "default_mode = agent (saved)");
|
||||
assert_eq!(app.mode, AppMode::Agent);
|
||||
|
||||
let settings_path = Settings::path().unwrap();
|
||||
let saved = fs::read_to_string(settings_path).unwrap();
|
||||
assert!(saved.contains("default_mode = \"agent\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_approval_mode_valid_values() {
|
||||
let mut app = create_test_app();
|
||||
// Test auto
|
||||
let result = set_config(&mut app, Some("approval_mode auto"));
|
||||
assert!(result.message.is_some());
|
||||
assert_eq!(app.approval_mode, ApprovalMode::Auto);
|
||||
|
||||
// Test suggest
|
||||
let result = set_config(&mut app, Some("approval_mode suggest"));
|
||||
assert!(result.message.is_some());
|
||||
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
|
||||
|
||||
// Test never
|
||||
let result = set_config(&mut app, Some("approval_mode never"));
|
||||
assert!(result.message.is_some());
|
||||
assert_eq!(app.approval_mode, ApprovalMode::Never);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_approval_mode_invalid_value() {
|
||||
let mut app = create_test_app();
|
||||
let result = set_config(&mut app, Some("approval_mode invalid"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Invalid approval_mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_without_save_flag() {
|
||||
let _lock = lock_test_env();
|
||||
let mut app = create_test_app();
|
||||
let result = set_config(&mut app, Some("auto_compact true"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("(session only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_composer_border_updates_live_app() {
|
||||
let _lock = lock_test_env();
|
||||
let mut app = create_test_app();
|
||||
app.composer_border = true;
|
||||
|
||||
let result = set_config(&mut app, Some("composer_border false"));
|
||||
|
||||
assert!(result.message.is_some());
|
||||
assert!(!app.composer_border);
|
||||
assert!(app.needs_redraw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_on_enables_flag() {
|
||||
let mut app = create_test_app();
|
||||
assert!(!app.trust_mode);
|
||||
let result = trust(&mut app, Some("on"));
|
||||
let msg = result.message.expect("message");
|
||||
assert!(msg.contains("Workspace trust mode enabled"));
|
||||
assert!(app.trust_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_status_default_lists_state() {
|
||||
let mut app = create_test_app();
|
||||
let result = trust(&mut app, None);
|
||||
let msg = result.message.expect("status message");
|
||||
assert!(msg.contains("Workspace trust mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_add_requires_path() {
|
||||
let mut app = create_test_app();
|
||||
let result = trust(&mut app, Some("add"));
|
||||
let msg = result.message.expect("error message");
|
||||
assert!(msg.starts_with("Error:"), "got {msg:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logout_clears_api_key_state() {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-tui-logout-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root).unwrap();
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
let config_path = temp_root.join(".deepseek").join("config.toml");
|
||||
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
|
||||
fs::write(&config_path, "api_key = \"test-key\"\n").unwrap();
|
||||
|
||||
let mut app = create_test_app();
|
||||
let result = logout(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
assert_eq!(app.onboarding, OnboardingState::ApiKey);
|
||||
assert!(app.onboarding_needs_api_key);
|
||||
assert!(app.api_key_input.is_empty());
|
||||
assert_eq!(app.api_key_cursor, 0);
|
||||
|
||||
let updated = fs::read_to_string(config_path).unwrap();
|
||||
assert!(!updated.contains("api_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_invalid_setting() {
|
||||
let _lock = lock_test_env();
|
||||
let mut app = create_test_app();
|
||||
let _result = set_config(&mut app, Some("nonexistent value"));
|
||||
// Should either error or handle as session setting
|
||||
// The current implementation tries to set it in Settings
|
||||
// which may succeed or fail depending on Settings implementation
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_key_without_value() {
|
||||
let mut app = create_test_app();
|
||||
let result = set_config(&mut app, Some("model"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Usage: /set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_status_items_writes_tui_section_to_config_toml() {
|
||||
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-statusline-persist-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root).unwrap();
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
let items = vec![
|
||||
crate::config::StatusItem::Mode,
|
||||
crate::config::StatusItem::Model,
|
||||
crate::config::StatusItem::Cost,
|
||||
];
|
||||
|
||||
let path = persist_status_items(&items).expect("persist should succeed");
|
||||
let body = fs::read_to_string(&path).expect("written file should be readable");
|
||||
assert!(body.contains("[tui]"), "expected [tui] section in {body}");
|
||||
assert!(
|
||||
body.contains("status_items"),
|
||||
"expected status_items key in {body}"
|
||||
);
|
||||
assert!(body.contains("\"mode\""), "expected mode key in {body}");
|
||||
assert!(body.contains("\"cost\""), "expected cost key in {body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_status_items_preserves_existing_unrelated_keys() {
|
||||
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-statusline-preserve-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root).unwrap();
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
let path = temp_root.join(".deepseek").join("config.toml");
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
// Seed the config with a sentinel key the picker MUST NOT clobber.
|
||||
fs::write(
|
||||
&path,
|
||||
"api_key = \"sentinel-key\"\nmodel = \"deepseek-v4-pro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let written = persist_status_items(&[crate::config::StatusItem::Mode])
|
||||
.expect("persist should succeed");
|
||||
let body = fs::read_to_string(&written).expect("written file should be readable");
|
||||
assert!(
|
||||
body.contains("api_key = \"sentinel-key\""),
|
||||
"round-trip lost api_key: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("model = \"deepseek-v4-pro\""),
|
||||
"round-trip lost model: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("status_items"),
|
||||
"expected status_items in {body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
//! 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.clear_history();
|
||||
app.mark_history_updated();
|
||||
app.api_messages.clear();
|
||||
app.system_prompt = None;
|
||||
app.transcript_selection.clear();
|
||||
app.queued_messages.clear();
|
||||
app.queued_draft = None;
|
||||
app.total_conversation_tokens = 0;
|
||||
let todos_cleared = app.clear_todos();
|
||||
app.tool_log.clear();
|
||||
app.tool_cells.clear();
|
||||
app.tool_details_by_cell.clear();
|
||||
app.exploring_entries.clear();
|
||||
app.ignored_tool_calls.clear();
|
||||
app.pending_tool_uses.clear();
|
||||
app.last_exec_wait_command = None;
|
||||
app.last_prompt_tokens = None;
|
||||
app.last_completion_tokens = None;
|
||||
app.current_session_id = None;
|
||||
let message = if todos_cleared {
|
||||
"Conversation cleared".to_string()
|
||||
} else {
|
||||
"Conversation cleared (plan state busy; run /clear again if needed)".to_string()
|
||||
};
|
||||
CommandResult::with_message_and_action(
|
||||
message,
|
||||
AppAction::SyncSession {
|
||||
messages: Vec::new(),
|
||||
system_prompt: None,
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Exit the application
|
||||
pub fn exit() -> CommandResult {
|
||||
CommandResult::action(AppAction::Quit)
|
||||
}
|
||||
|
||||
/// Switch or view current model. With no argument, open the two-pane
|
||||
/// picker (Pro/Flash + thinking effort) per #39 — gives users a discoverable
|
||||
/// way to flip both knobs without memorising the docs.
|
||||
pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
|
||||
if let Some(name) = model_name {
|
||||
let Some(model_id) = normalize_model_name(name) else {
|
||||
return CommandResult::error(format!(
|
||||
"Invalid model '{name}'. Expected a DeepSeek model ID. Common models: {}",
|
||||
COMMON_DEEPSEEK_MODELS.join(", ")
|
||||
));
|
||||
};
|
||||
let old_model = app.model.clone();
|
||||
app.model = model_id.clone();
|
||||
app.update_model_compaction_budget();
|
||||
app.last_prompt_tokens = None;
|
||||
app.last_completion_tokens = None;
|
||||
CommandResult::with_message_and_action(
|
||||
format!("Model changed: {old_model} → {model_id}"),
|
||||
AppAction::UpdateCompaction(app.compaction_config()),
|
||||
)
|
||||
} else {
|
||||
CommandResult::action(AppAction::OpenModelPicker)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch and list available models from the configured API endpoint.
|
||||
pub fn models(_app: &mut App) -> CommandResult {
|
||||
CommandResult::action(AppAction::FetchModels)
|
||||
}
|
||||
|
||||
/// List sub-agent status from the engine
|
||||
pub fn subagents(app: &mut App) -> CommandResult {
|
||||
if app.view_stack.top_kind() != Some(ModalKind::SubAgents) {
|
||||
app.view_stack
|
||||
.push(SubAgentsView::new(app.subagent_cache.clone()));
|
||||
}
|
||||
app.status_message = Some("Fetching sub-agent status...".to_string());
|
||||
CommandResult::action(AppAction::ListSubAgents)
|
||||
}
|
||||
|
||||
/// Show `DeepSeek` dashboard and docs links
|
||||
pub fn deepseek_links() -> CommandResult {
|
||||
CommandResult::message(
|
||||
"DeepSeek Links:\n\
|
||||
─────────────────────────────\n\
|
||||
Dashboard: https://platform.deepseek.com\n\
|
||||
Docs: https://platform.deepseek.com/docs\n\n\
|
||||
Tip: API keys are available in the dashboard console.",
|
||||
)
|
||||
}
|
||||
|
||||
/// Show home dashboard with stats and quick actions
|
||||
pub fn home_dashboard(app: &mut App) -> CommandResult {
|
||||
let mut stats = String::new();
|
||||
|
||||
// Basic info
|
||||
let _ = writeln!(stats, "DeepSeek TUI Home Dashboard");
|
||||
let _ = writeln!(stats, "============================================");
|
||||
|
||||
// Model & mode
|
||||
let _ = writeln!(stats, "Model: {}", app.model);
|
||||
let _ = writeln!(stats, "Mode: {}", app.mode.label());
|
||||
let _ = writeln!(stats, "Workspace: {}", app.workspace.display());
|
||||
|
||||
// Session stats
|
||||
let history_count = app.history.len();
|
||||
let total_tokens = app.total_conversation_tokens;
|
||||
let queued_messages = app.queued_messages.len();
|
||||
let _ = writeln!(stats, "History: {} messages", history_count);
|
||||
let _ = writeln!(stats, "Tokens: {} (session)", total_tokens);
|
||||
if queued_messages > 0 {
|
||||
let _ = writeln!(stats, "Queued: {} messages", queued_messages);
|
||||
}
|
||||
|
||||
// Sub-agents
|
||||
let subagent_count = app.subagent_cache.len();
|
||||
if subagent_count > 0 {
|
||||
let _ = writeln!(stats, "Sub-agents: {} active", subagent_count);
|
||||
}
|
||||
|
||||
// Active skill
|
||||
if let Some(skill) = &app.active_skill {
|
||||
let _ = writeln!(stats, "Skill: {} (active)", skill);
|
||||
}
|
||||
|
||||
// Quick actions section
|
||||
let _ = writeln!(stats, "\nQuick Actions");
|
||||
let _ = writeln!(stats, "--------------------------------------------");
|
||||
let _ = writeln!(stats, "/links - Dashboard & API links");
|
||||
let _ = writeln!(stats, "/skills - List available skills");
|
||||
let _ = writeln!(
|
||||
stats,
|
||||
"/config - Open interactive configuration editor"
|
||||
);
|
||||
let _ = writeln!(stats, "/settings - Show persistent settings");
|
||||
let _ = writeln!(stats, "/model - Switch or view model");
|
||||
let _ = writeln!(stats, "/subagents - List sub-agent status");
|
||||
let _ = writeln!(stats, "/task list - Show background task queue");
|
||||
let _ = writeln!(stats, "/help - Show help");
|
||||
|
||||
// Mode-specific tips
|
||||
let _ = writeln!(stats, "\nMode Tips");
|
||||
let _ = writeln!(stats, "--------------------------------------------");
|
||||
match app.mode {
|
||||
AppMode::Agent => {
|
||||
let _ = writeln!(stats, "Agent mode - Use tools for autonomous tasks");
|
||||
let _ = writeln!(
|
||||
stats,
|
||||
" Use Ctrl+X to review in Plan mode before executing"
|
||||
);
|
||||
let _ = writeln!(stats, " Type /yolo to enable full tool access");
|
||||
}
|
||||
AppMode::Yolo => {
|
||||
let _ = writeln!(stats, "YOLO mode - Full tool access, no approvals");
|
||||
let _ = writeln!(stats, " Be careful with destructive operations!");
|
||||
}
|
||||
AppMode::Plan => {
|
||||
let _ = writeln!(stats, "Plan mode - Design before implementing");
|
||||
let _ = writeln!(stats, " Use /plan to create structured checklists");
|
||||
}
|
||||
}
|
||||
|
||||
CommandResult::message(stats)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::models::Message;
|
||||
use crate::tui::app::{App, AppMode, TuiOptions};
|
||||
use crate::tui::history::HistoryCell;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: PathBuf::from("/tmp/test-workspace"),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
use_bracketed_paste: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("/tmp/test-skills"),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_unknown_command() {
|
||||
let mut app = create_test_app();
|
||||
let result = help(&mut app, Some("nonexistent"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Unknown command"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_known_command() {
|
||||
let mut app = create_test_app();
|
||||
let result = help(&mut app, Some("clear"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("clear"));
|
||||
assert!(msg.contains("Clear conversation history"));
|
||||
assert!(msg.contains("Usage: /clear"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_config_topic_uses_interactive_editor_text() {
|
||||
let mut app = create_test_app();
|
||||
let result = help(&mut app, Some("config"));
|
||||
let msg = result.message.expect("help topic should return message");
|
||||
assert!(msg.contains("config"));
|
||||
assert!(msg.contains("Open interactive configuration editor"));
|
||||
assert!(msg.contains("Usage: /config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_links_topic_shows_aliases() {
|
||||
let mut app = create_test_app();
|
||||
let result = help(&mut app, Some("links"));
|
||||
let msg = result.message.expect("help topic should return message");
|
||||
assert!(msg.contains("links"));
|
||||
assert!(msg.contains("Show DeepSeek dashboard and docs links"));
|
||||
assert!(msg.contains("Usage: /links"));
|
||||
assert!(msg.contains("Aliases: dashboard, api"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_pushes_overlay() {
|
||||
let mut app = create_test_app();
|
||||
assert_ne!(app.view_stack.top_kind(), Some(ModalKind::Help));
|
||||
let result = help(&mut app, None);
|
||||
assert_eq!(result.message, None);
|
||||
assert_eq!(result.action, None);
|
||||
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_does_not_duplicate_overlay() {
|
||||
let mut app = create_test_app();
|
||||
help(&mut app, None);
|
||||
let initial_kind = app.view_stack.top_kind();
|
||||
help(&mut app, None);
|
||||
assert_eq!(app.view_stack.top_kind(), initial_kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_resets_all_state() {
|
||||
let mut app = create_test_app();
|
||||
// Set up some state
|
||||
app.history.push(HistoryCell::User {
|
||||
content: "test".to_string(),
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![],
|
||||
});
|
||||
app.total_conversation_tokens = 100;
|
||||
app.tool_log.push("test".to_string());
|
||||
app.current_session_id = Some("existing-session".to_string());
|
||||
|
||||
let result = clear(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
assert!(app.history.is_empty());
|
||||
assert!(app.api_messages.is_empty());
|
||||
assert_eq!(app.total_conversation_tokens, 0);
|
||||
assert!(app.tool_log.is_empty());
|
||||
assert!(app.tool_cells.is_empty());
|
||||
assert!(app.tool_details_by_cell.is_empty());
|
||||
assert!(app.current_session_id.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exit_returns_quit_action() {
|
||||
let result = exit();
|
||||
assert!(result.message.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::Quit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_change_updates_state() {
|
||||
let mut app = create_test_app();
|
||||
let old_model = app.model.clone();
|
||||
let result = model(&mut app, Some("deepseek-v4-flash"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains(&old_model));
|
||||
assert!(msg.contains("deepseek-v4-flash"));
|
||||
assert!(matches!(
|
||||
result.action,
|
||||
Some(AppAction::UpdateCompaction(_))
|
||||
));
|
||||
assert_eq!(app.model, "deepseek-v4-flash");
|
||||
assert_eq!(app.last_prompt_tokens, None);
|
||||
assert_eq!(app.last_completion_tokens, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_change_accepts_future_deepseek_model() {
|
||||
let mut app = create_test_app();
|
||||
let result = model(&mut app, Some("deepseek-v4"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("deepseek-v4"));
|
||||
assert_eq!(app.model, "deepseek-v4");
|
||||
assert!(matches!(
|
||||
result.action,
|
||||
Some(AppAction::UpdateCompaction(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_change_rejects_invalid_model() {
|
||||
let mut app = create_test_app();
|
||||
let result = model(&mut app, Some("gpt-4"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Invalid model"));
|
||||
assert!(msg.contains("DeepSeek model ID"));
|
||||
assert!(msg.contains("deepseek-v4-pro"));
|
||||
assert!(msg.contains("deepseek-v4-flash"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_without_args_opens_picker() {
|
||||
let mut app = create_test_app();
|
||||
let result = model(&mut app, None);
|
||||
assert_eq!(result.message, None);
|
||||
assert_eq!(result.action, Some(AppAction::OpenModelPicker));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_models_triggers_fetch_action() {
|
||||
let mut app = create_test_app();
|
||||
let result = models(&mut app);
|
||||
assert!(result.message.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::FetchModels)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subagents_pushes_view_and_sets_status() {
|
||||
let mut app = create_test_app();
|
||||
let result = subagents(&mut app);
|
||||
assert!(result.message.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::ListSubAgents)));
|
||||
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::SubAgents));
|
||||
assert_eq!(
|
||||
app.status_message,
|
||||
Some("Fetching sub-agent status...".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_links() {
|
||||
let result = deepseek_links();
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("DeepSeek Links"));
|
||||
assert!(msg.contains("https://platform.deepseek.com"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_home_dashboard_includes_all_sections() {
|
||||
let mut app = create_test_app();
|
||||
app.total_conversation_tokens = 1234;
|
||||
let result = home_dashboard(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("DeepSeek TUI Home Dashboard"));
|
||||
assert!(msg.contains("Model:"));
|
||||
assert!(msg.contains("Mode:"));
|
||||
assert!(msg.contains("Workspace:"));
|
||||
assert!(msg.contains("History:"));
|
||||
assert!(msg.contains("Tokens:"));
|
||||
assert!(msg.contains("Quick Actions"));
|
||||
assert!(msg.contains("Mode Tips"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_home_dashboard_shows_queued_when_present() {
|
||||
let mut app = create_test_app();
|
||||
app.queued_messages
|
||||
.push_back(crate::tui::app::QueuedMessage::new(
|
||||
"test".to_string(),
|
||||
None,
|
||||
));
|
||||
let result = home_dashboard(&mut app);
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Queued:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_home_dashboard_mode_tips_for_each_mode() {
|
||||
let modes = [AppMode::Agent, AppMode::Yolo, AppMode::Plan];
|
||||
for mode in modes {
|
||||
let mut app = create_test_app();
|
||||
app.mode = mode;
|
||||
let result = home_dashboard(&mut app);
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Mode Tips"), "Missing tips for mode {mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_home_dashboard_quick_actions_reflect_links_and_config_and_hide_removed_commands() {
|
||||
let mut app = create_test_app();
|
||||
let result = home_dashboard(&mut app);
|
||||
let msg = result
|
||||
.message
|
||||
.expect("home dashboard should return message");
|
||||
assert!(msg.contains("/links - Dashboard & API links"));
|
||||
assert!(msg.contains("/config - Open interactive configuration editor"));
|
||||
assert!(
|
||||
!msg.lines()
|
||||
.any(|line| line.trim_start().starts_with("/set "))
|
||||
);
|
||||
assert!(!msg.contains("/deepseek"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Cycle commands: `/cycles` (list past cycle boundaries) and
|
||||
//! `/cycle <n>` (show one cycle's briefing in detail).
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::tui::app::App;
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
/// `/cycles` — list past cycle handoffs in compact form.
|
||||
pub fn list_cycles(app: &App) -> CommandResult {
|
||||
if app.cycle_briefings.is_empty() {
|
||||
let msg = format!(
|
||||
"No cycle boundaries have fired yet (current cycle: 1, threshold: {} tokens for {}).",
|
||||
app.cycle.threshold_for(&app.model),
|
||||
app.model
|
||||
);
|
||||
return CommandResult::message(msg);
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"Cycle handoffs in this session ({} total). Active cycle: {}.",
|
||||
app.cycle_briefings.len(),
|
||||
app.cycle_count.saturating_add(1),
|
||||
);
|
||||
out.push('\n');
|
||||
for brief in &app.cycle_briefings {
|
||||
let preview = first_line(&brief.briefing_text, 80);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" cycle {n} @ {ts} briefing: {tokens} tokens ─ {preview}",
|
||||
n = brief.cycle,
|
||||
ts = brief.timestamp.to_rfc3339(),
|
||||
tokens = brief.token_estimate,
|
||||
preview = preview,
|
||||
);
|
||||
}
|
||||
out.push('\n');
|
||||
out.push_str("Use `/cycle <n>` to show the full briefing for a specific cycle.\n");
|
||||
CommandResult::message(out)
|
||||
}
|
||||
|
||||
/// `/cycle <n>` — print the full briefing for cycle `n`.
|
||||
pub fn show_cycle(app: &App, arg: Option<&str>) -> CommandResult {
|
||||
let Some(raw) = arg.map(str::trim) else {
|
||||
return CommandResult::error(
|
||||
"Usage: /cycle <n> — n is the cycle number from /cycles".to_string(),
|
||||
);
|
||||
};
|
||||
if raw.is_empty() {
|
||||
return CommandResult::error("Usage: /cycle <n>".to_string());
|
||||
}
|
||||
let Ok(n) = raw.parse::<u32>() else {
|
||||
return CommandResult::error(format!(
|
||||
"Cycle number must be a positive integer (got '{raw}')."
|
||||
));
|
||||
};
|
||||
|
||||
let Some(brief) = app.cycle_briefings.iter().find(|b| b.cycle == n) else {
|
||||
let known: Vec<String> = app
|
||||
.cycle_briefings
|
||||
.iter()
|
||||
.map(|b| b.cycle.to_string())
|
||||
.collect();
|
||||
let known_str = if known.is_empty() {
|
||||
"(none)".to_string()
|
||||
} else {
|
||||
known.join(", ")
|
||||
};
|
||||
return CommandResult::error(format!(
|
||||
"Cycle {n} not found in this session. Known cycles: {known_str}."
|
||||
));
|
||||
};
|
||||
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"── Cycle {n} ({ts}) briefing: {tokens} tokens ──",
|
||||
n = brief.cycle,
|
||||
ts = brief.timestamp.to_rfc3339(),
|
||||
tokens = brief.token_estimate,
|
||||
);
|
||||
out.push('\n');
|
||||
out.push_str(brief.briefing_text.trim());
|
||||
out.push('\n');
|
||||
CommandResult::message(out)
|
||||
}
|
||||
|
||||
/// `/recall <query>` — user-initiated BM25 search of cycle archives.
|
||||
///
|
||||
/// Synchronous wrapper around `tools::recall_archive::RecallArchiveTool` so
|
||||
/// users can probe the archive without invoking the model. Output is the
|
||||
/// same JSON payload the agent would see; the assistant pretty-prints
|
||||
/// short results and dumps long ones inline.
|
||||
pub fn recall_archive(app: &App, arg: Option<&str>) -> CommandResult {
|
||||
use crate::tools::recall_archive::RecallArchiveTool;
|
||||
use crate::tools::spec::{ToolContext, ToolSpec};
|
||||
|
||||
let Some(raw) = arg.map(str::trim) else {
|
||||
return CommandResult::error("Usage: /recall <query>".to_string());
|
||||
};
|
||||
if raw.is_empty() {
|
||||
return CommandResult::error("Usage: /recall <query>".to_string());
|
||||
}
|
||||
|
||||
let session_id = app
|
||||
.current_session_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "workspace".to_string());
|
||||
|
||||
let context = ToolContext::new(app.workspace.clone()).with_state_namespace(session_id);
|
||||
let tool = RecallArchiveTool;
|
||||
let input = serde_json::json!({"query": raw});
|
||||
|
||||
let result = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(tool.execute(input, &context))
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(res) => CommandResult::message(res.content),
|
||||
Err(err) => CommandResult::error(format!("recall_archive failed: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate `text` to its first non-empty line, capped at `max_chars`.
|
||||
fn first_line(text: &str, max_chars: usize) -> String {
|
||||
let line = text
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.unwrap_or("");
|
||||
if line.chars().count() <= max_chars {
|
||||
line.to_string()
|
||||
} else {
|
||||
let prefix: String = line.chars().take(max_chars).collect();
|
||||
format!("{prefix}…")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cycle_manager::CycleBriefing;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use chrono::Utc;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn test_options() -> TuiOptions {
|
||||
TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
use_bracketed_paste: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_cycles_reports_no_boundaries_yet() {
|
||||
let app = App::new(test_options(), &crate::config::Config::default());
|
||||
let res = list_cycles(&app);
|
||||
assert!(res.message.is_some());
|
||||
assert!(
|
||||
res.message
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("No cycle boundaries")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_cycle_rejects_nonexistent_cycle() {
|
||||
let app = App::new(test_options(), &crate::config::Config::default());
|
||||
let res = show_cycle(&app, Some("3"));
|
||||
let msg = res.message.expect("error message");
|
||||
assert!(msg.contains("Cycle 3 not found"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_and_show_cycles_render_briefings() {
|
||||
let mut app = App::new(test_options(), &crate::config::Config::default());
|
||||
app.cycle_briefings.push(CycleBriefing {
|
||||
cycle: 1,
|
||||
timestamp: Utc::now(),
|
||||
briefing_text: "Decision: chose A; constraint: no async.".to_string(),
|
||||
token_estimate: 12,
|
||||
});
|
||||
app.cycle_count = 1;
|
||||
|
||||
let listed = list_cycles(&app).message.expect("list message");
|
||||
assert!(listed.contains("cycle 1"));
|
||||
assert!(listed.contains("12 tokens"));
|
||||
|
||||
let shown = show_cycle(&app, Some("1")).message.expect("show message");
|
||||
assert!(shown.contains("Decision: chose A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_cycle_validates_argument() {
|
||||
let app = App::new(test_options(), &crate::config::Config::default());
|
||||
let res = show_cycle(&app, None);
|
||||
let msg = res.message.expect("error message");
|
||||
assert!(msg.contains("Usage: /cycle"));
|
||||
|
||||
let res = show_cycle(&app, Some("not-a-number"));
|
||||
let msg = res.message.expect("error message");
|
||||
assert!(msg.contains("must be a positive integer"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
#![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::{SystemPrompt, context_window_for_model};
|
||||
use crate::tui::app::{App, AppAction};
|
||||
use crate::tui::history::HistoryCell;
|
||||
|
||||
fn token_count(value: Option<u32>) -> String {
|
||||
value.map_or_else(|| "not reported".to_string(), |tokens| tokens.to_string())
|
||||
}
|
||||
|
||||
fn active_context_summary(app: &App) -> String {
|
||||
let estimated =
|
||||
estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref());
|
||||
match context_window_for_model(&app.model) {
|
||||
Some(window) => {
|
||||
let used = estimated.min(window as usize);
|
||||
let percent = (used as f64 / f64::from(window) * 100.0).clamp(0.0, 100.0);
|
||||
format!("~{used} / {window} ({percent:.1}%)")
|
||||
}
|
||||
None => format!("~{estimated} / unknown window"),
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_summary(app: &App) -> String {
|
||||
match (
|
||||
app.last_prompt_cache_hit_tokens,
|
||||
app.last_prompt_cache_miss_tokens,
|
||||
) {
|
||||
(Some(hit), Some(miss)) => format!("{hit} hit / {miss} miss"),
|
||||
(Some(hit), None) => format!("{hit} hit / miss not reported"),
|
||||
(None, Some(miss)) => format!("hit not reported / {miss} miss"),
|
||||
(None, None) => "not reported".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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\
|
||||
Active context: {}\n\
|
||||
Last API input: {} (turn telemetry; may count repeated prefix across tool rounds)\n\
|
||||
Last API output: {}\n\
|
||||
Cache hit/miss: {} (telemetry/cost only)\n\
|
||||
Cumulative tokens: {} (session usage telemetry)\n\
|
||||
Approx session cost: ${:.4}\n\
|
||||
API messages: {}\n\
|
||||
Chat messages: {}\n\
|
||||
Model: {}",
|
||||
active_context_summary(app),
|
||||
token_count(app.last_prompt_tokens),
|
||||
token_count(app.last_completion_tokens),
|
||||
cache_summary(app),
|
||||
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\
|
||||
Approx total spent: ${:.4}\n\n\
|
||||
Cost estimates are approximate and use provider usage telemetry when available.\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 {
|
||||
CommandResult::action(AppAction::OpenContextInspector)
|
||||
}
|
||||
|
||||
#[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,
|
||||
use_bracketed_paste: true,
|
||||
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.last_prompt_tokens = Some(100);
|
||||
app.last_completion_tokens = Some(25);
|
||||
app.last_prompt_cache_hit_tokens = Some(70);
|
||||
app.last_prompt_cache_miss_tokens = Some(30);
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "test".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
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("Active context:"));
|
||||
assert!(msg.contains("Last API input:"));
|
||||
assert!(msg.contains("Last API output:"));
|
||||
assert!(msg.contains("Cache hit/miss:"));
|
||||
assert!(msg.contains("70 hit / 30 miss"));
|
||||
assert!(msg.contains("Cumulative tokens:"));
|
||||
assert!(msg.contains("Approx 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("Approx total spent:"));
|
||||
assert!(msg.contains("approximate"));
|
||||
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!(matches!(
|
||||
result.action,
|
||||
Some(AppAction::OpenContextInspector)
|
||||
));
|
||||
assert!(result.message.is_none());
|
||||
}
|
||||
|
||||
#[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.pop_history();
|
||||
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,124 @@ 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,
|
||||
use_bracketed_paste: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
notes_path: tmpdir.path().join("notes.txt"),
|
||||
mcp_config_path: tmpdir.path().join("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_creates_agents_md() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = init(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Created AGENTS.md"));
|
||||
let agents_path = tmpdir.path().join("AGENTS.md");
|
||||
assert!(agents_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_fails_if_exists() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
// Create file first
|
||||
std::fs::write(tmpdir.path().join("AGENTS.md"), "existing").unwrap();
|
||||
let result = init(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("already exists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_project_type_rust() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
std::fs::write(
|
||||
tmpdir.path().join("Cargo.toml"),
|
||||
"[package]\nname = \"test\"",
|
||||
)
|
||||
.unwrap();
|
||||
let info = detect_project_type(tmpdir.path());
|
||||
assert!(info.contains("Project Type: Rust"));
|
||||
assert!(info.contains("cargo build"));
|
||||
assert!(info.contains("cargo test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_project_type_node() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
std::fs::write(tmpdir.path().join("package.json"), "{}").unwrap();
|
||||
let info = detect_project_type(tmpdir.path());
|
||||
assert!(info.contains("Project Type: Node.js"));
|
||||
assert!(info.contains("npm install"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_project_type_python() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
std::fs::write(tmpdir.path().join("pyproject.toml"), "[project]").unwrap();
|
||||
let info = detect_project_type(tmpdir.path());
|
||||
assert!(info.contains("Project Type: Python"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_project_type_go() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
std::fs::write(tmpdir.path().join("go.mod"), "module test").unwrap();
|
||||
let info = detect_project_type(tmpdir.path());
|
||||
assert!(info.contains("Project Type: Go"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_project_type_unknown() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let info = detect_project_type(tmpdir.path());
|
||||
assert!(info.contains("Project Type: Unknown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_cargo_name() {
|
||||
let cargo = r#"
|
||||
[package]
|
||||
name = "my-project"
|
||||
version = "1.0.0"
|
||||
"#;
|
||||
assert_eq!(extract_cargo_name(cargo), Some("my-project".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_cargo_name_single_quotes() {
|
||||
let cargo = r#"name = 'single-quoted'"#;
|
||||
assert_eq!(extract_cargo_name(cargo), Some("single-quoted".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_cargo_name_not_found() {
|
||||
let cargo = "[package]\nversion = \"1.0.0\"";
|
||||
assert_eq!(extract_cargo_name(cargo), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Shell job-center commands.
|
||||
|
||||
use crate::tui::app::{App, AppAction, ShellJobAction};
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
pub fn jobs(_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::ShellJob(ShellJobAction::List));
|
||||
}
|
||||
|
||||
let mut parts = raw.splitn(3, char::is_whitespace);
|
||||
let action = parts.next().unwrap_or("").to_ascii_lowercase();
|
||||
let id = parts.next().map(str::trim).filter(|s| !s.is_empty());
|
||||
let rest = parts.next().map(str::trim).unwrap_or("");
|
||||
|
||||
match action.as_str() {
|
||||
"list" => CommandResult::action(AppAction::ShellJob(ShellJobAction::List)),
|
||||
"show" | "inspect" => match id {
|
||||
Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Show {
|
||||
id: id.to_string(),
|
||||
})),
|
||||
None => CommandResult::error("Usage: /jobs show <id>"),
|
||||
},
|
||||
"poll" | "wait" => match id {
|
||||
Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Poll {
|
||||
id: id.to_string(),
|
||||
wait: action == "wait",
|
||||
})),
|
||||
None => CommandResult::error("Usage: /jobs poll <id>"),
|
||||
},
|
||||
"stdin" | "send" => match id {
|
||||
Some(id) if !rest.is_empty() => {
|
||||
CommandResult::action(AppAction::ShellJob(ShellJobAction::SendStdin {
|
||||
id: id.to_string(),
|
||||
input: rest.to_string(),
|
||||
close: false,
|
||||
}))
|
||||
}
|
||||
_ => CommandResult::error("Usage: /jobs stdin <id> <input>"),
|
||||
},
|
||||
"close-stdin" | "eof" => match id {
|
||||
Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::SendStdin {
|
||||
id: id.to_string(),
|
||||
input: String::new(),
|
||||
close: true,
|
||||
})),
|
||||
None => CommandResult::error("Usage: /jobs close-stdin <id>"),
|
||||
},
|
||||
"cancel" | "kill" | "stop" => match id {
|
||||
Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Cancel {
|
||||
id: id.to_string(),
|
||||
})),
|
||||
None => CommandResult::error("Usage: /jobs cancel <id>"),
|
||||
},
|
||||
_ => CommandResult::error(
|
||||
"Usage: /jobs [list|show <id>|poll <id>|wait <id>|stdin <id> <input>|close-stdin <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,
|
||||
use_bracketed_paste: true,
|
||||
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_job_actions() {
|
||||
let mut app = app();
|
||||
let show = jobs(&mut app, Some("show shell_abcd"));
|
||||
assert!(matches!(
|
||||
show.action,
|
||||
Some(AppAction::ShellJob(ShellJobAction::Show { id })) if id == "shell_abcd"
|
||||
));
|
||||
|
||||
let send = jobs(&mut app, Some("stdin shell_abcd y"));
|
||||
assert!(matches!(
|
||||
send.action,
|
||||
Some(AppAction::ShellJob(ShellJobAction::SendStdin { id, input, close: false }))
|
||||
if id == "shell_abcd" && input == "y"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! In-TUI MCP manager command parser.
|
||||
|
||||
use crate::tui::app::{App, AppAction, McpUiAction};
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
pub fn mcp(_app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
let raw = args.unwrap_or("").trim();
|
||||
if raw.is_empty() || raw.eq_ignore_ascii_case("status") || raw.eq_ignore_ascii_case("list") {
|
||||
return CommandResult::action(AppAction::Mcp(McpUiAction::Show));
|
||||
}
|
||||
|
||||
let mut parts = raw.split_whitespace();
|
||||
let action = parts.next().unwrap_or("").to_ascii_lowercase();
|
||||
match action.as_str() {
|
||||
"init" => CommandResult::action(AppAction::Mcp(McpUiAction::Init {
|
||||
force: parts.any(|part| part == "--force" || part == "-f"),
|
||||
})),
|
||||
"add" => parse_add(parts.collect()),
|
||||
"enable" => match parse_name(parts.next(), "Usage: /mcp enable <name>") {
|
||||
Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Enable { name })),
|
||||
Err(msg) => CommandResult::error(msg),
|
||||
},
|
||||
"disable" => match parse_name(parts.next(), "Usage: /mcp disable <name>") {
|
||||
Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Disable { name })),
|
||||
Err(msg) => CommandResult::error(msg),
|
||||
},
|
||||
"remove" | "rm" => match parse_name(parts.next(), "Usage: /mcp remove <name>") {
|
||||
Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Remove { name })),
|
||||
Err(msg) => CommandResult::error(msg),
|
||||
},
|
||||
"validate" => CommandResult::action(AppAction::Mcp(McpUiAction::Validate)),
|
||||
"reload" | "reconnect" => CommandResult::action(AppAction::Mcp(McpUiAction::Reload)),
|
||||
_ => CommandResult::error(
|
||||
"Usage: /mcp [init|add stdio <name> <command> [args...]|add http <name> <url>|enable <name>|disable <name>|remove <name>|validate|reload]",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_name(name: Option<&str>, usage: &str) -> Result<String, String> {
|
||||
match name {
|
||||
Some(name) if !name.trim().is_empty() => Ok(name.to_string()),
|
||||
_ => Err(usage.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_add(parts: Vec<&str>) -> CommandResult {
|
||||
if parts.len() < 3 {
|
||||
return CommandResult::error(
|
||||
"Usage: /mcp add stdio <name> <command> [args...] OR /mcp add http <name> <url>",
|
||||
);
|
||||
}
|
||||
match parts[0].to_ascii_lowercase().as_str() {
|
||||
"stdio" => CommandResult::action(AppAction::Mcp(McpUiAction::AddStdio {
|
||||
name: parts[1].to_string(),
|
||||
command: parts[2].to_string(),
|
||||
args: parts[3..].iter().map(|s| (*s).to_string()).collect(),
|
||||
})),
|
||||
"http" | "sse" => CommandResult::action(AppAction::Mcp(McpUiAction::AddHttp {
|
||||
name: parts[1].to_string(),
|
||||
url: parts[2].to_string(),
|
||||
})),
|
||||
_ => CommandResult::error(
|
||||
"Usage: /mcp add stdio <name> <command> [args...] OR /mcp add http <name> <url>",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
use_bracketed_paste: true,
|
||||
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_validate() {
|
||||
let mut app = app();
|
||||
let add = mcp(&mut app, Some("add stdio local node server.js"));
|
||||
assert!(matches!(
|
||||
add.action,
|
||||
Some(AppAction::Mcp(McpUiAction::AddStdio { name, command, args }))
|
||||
if name == "local" && command == "node" && args == vec!["server.js".to_string()]
|
||||
));
|
||||
|
||||
let validate = mcp(&mut app, Some("validate"));
|
||||
assert!(matches!(
|
||||
validate.action,
|
||||
Some(AppAction::Mcp(McpUiAction::Validate))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
//! Slash command registry and dispatch system
|
||||
//!
|
||||
//! This module provides a modular command system inspired by Codex-rs.
|
||||
//! Commands are organized by category and dispatched through a central registry.
|
||||
|
||||
mod attachment;
|
||||
mod config;
|
||||
mod core;
|
||||
mod cycle;
|
||||
mod debug;
|
||||
mod init;
|
||||
mod jobs;
|
||||
mod mcp;
|
||||
mod note;
|
||||
mod provider;
|
||||
mod queue;
|
||||
mod restore;
|
||||
mod review;
|
||||
mod session;
|
||||
mod skills;
|
||||
mod task;
|
||||
|
||||
use crate::tui::app::{App, AppAction};
|
||||
|
||||
/// Result of executing a command
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandResult {
|
||||
/// Optional message to display to the user
|
||||
pub message: Option<String>,
|
||||
/// Optional action for the app to take
|
||||
pub action: Option<AppAction>,
|
||||
}
|
||||
|
||||
impl CommandResult {
|
||||
/// Create an empty result (command succeeded with no output)
|
||||
pub fn ok() -> Self {
|
||||
Self {
|
||||
message: None,
|
||||
action: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a result with just a message
|
||||
pub fn message(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: Some(msg.into()),
|
||||
action: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a result with an action
|
||||
pub fn action(action: AppAction) -> Self {
|
||||
Self {
|
||||
message: None,
|
||||
action: Some(action),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a result with both message and action
|
||||
#[allow(dead_code)]
|
||||
pub fn with_message_and_action(msg: impl Into<String>, action: AppAction) -> Self {
|
||||
Self {
|
||||
message: Some(msg.into()),
|
||||
action: Some(action),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an error message result
|
||||
pub fn error(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: Some(format!("Error: {}", msg.into())),
|
||||
action: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command metadata for help and autocomplete
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CommandInfo {
|
||||
pub name: &'static str,
|
||||
pub aliases: &'static [&'static str],
|
||||
pub description: &'static str,
|
||||
pub usage: &'static str,
|
||||
}
|
||||
|
||||
impl CommandInfo {
|
||||
pub fn requires_argument(&self) -> bool {
|
||||
self.usage.contains('<') || self.usage.contains('[')
|
||||
}
|
||||
|
||||
pub fn palette_command(&self) -> String {
|
||||
if self.requires_argument() {
|
||||
format!("/{} ", self.name)
|
||||
} else {
|
||||
format!("/{}", self.name)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn palette_description(&self) -> String {
|
||||
if self.aliases.is_empty() {
|
||||
self.description.to_string()
|
||||
} else {
|
||||
format!("{} aliases: {}", self.description, self.aliases.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All registered commands
|
||||
pub const COMMANDS: &[CommandInfo] = &[
|
||||
// Core commands
|
||||
CommandInfo {
|
||||
name: "help",
|
||||
aliases: &["?"],
|
||||
description: "Show help information",
|
||||
usage: "/help [command]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "clear",
|
||||
aliases: &[],
|
||||
description: "Clear conversation history",
|
||||
usage: "/clear",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "exit",
|
||||
aliases: &["quit", "q"],
|
||||
description: "Exit the application",
|
||||
usage: "/exit",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "model",
|
||||
aliases: &[],
|
||||
description: "Switch or view current model",
|
||||
usage: "/model [name]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "models",
|
||||
aliases: &[],
|
||||
description: "List available models from API",
|
||||
usage: "/models",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "provider",
|
||||
aliases: &[],
|
||||
description: "Switch or view the active LLM backend (deepseek | nvidia-nim)",
|
||||
usage: "/provider [name]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "queue",
|
||||
aliases: &["queued"],
|
||||
description: "View or edit queued messages",
|
||||
usage: "/queue [list|edit <n>|drop <n>|clear]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "subagents",
|
||||
aliases: &["agents"],
|
||||
description: "List sub-agent status",
|
||||
usage: "/subagents",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "links",
|
||||
aliases: &["dashboard", "api"],
|
||||
description: "Show DeepSeek dashboard and docs links",
|
||||
usage: "/links",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "home",
|
||||
aliases: &["stats", "overview"],
|
||||
description: "Show home dashboard with stats and quick actions",
|
||||
usage: "/home",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "note",
|
||||
aliases: &[],
|
||||
description: "Append note to persistent notes file (.deepseek/notes.md)",
|
||||
usage: "/note <text>",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "attach",
|
||||
aliases: &["image", "media"],
|
||||
description: "Attach image/video media; use @path for text files or directories",
|
||||
usage: "/attach <path>",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "task",
|
||||
aliases: &["tasks"],
|
||||
description: "Manage background tasks",
|
||||
usage: "/task [add <prompt>|list|show <id>|cancel <id>]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "jobs",
|
||||
aliases: &["job"],
|
||||
description: "Inspect and control background shell jobs",
|
||||
usage: "/jobs [list|show <id>|poll <id>|wait <id>|stdin <id> <input>|cancel <id>]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "mcp",
|
||||
aliases: &[],
|
||||
description: "Open or manage MCP servers",
|
||||
usage: "/mcp [init|add stdio <name> <command> [args...]|add http <name> <url>|enable <name>|disable <name>|remove <name>|validate|reload]",
|
||||
},
|
||||
// Session commands
|
||||
CommandInfo {
|
||||
name: "save",
|
||||
aliases: &[],
|
||||
description: "Save session to file",
|
||||
usage: "/save [path]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "sessions",
|
||||
aliases: &["resume"],
|
||||
description: "Open session picker",
|
||||
usage: "/sessions",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "load",
|
||||
aliases: &[],
|
||||
description: "Load session from file",
|
||||
usage: "/load [path]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "compact",
|
||||
aliases: &[],
|
||||
description: "Trigger context compaction to free up space (legacy; v0.6.6 prefers cycle restart)",
|
||||
usage: "/compact",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "context",
|
||||
aliases: &["ctx"],
|
||||
description: "Open compact session context inspector",
|
||||
usage: "/context",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "cycles",
|
||||
aliases: &[],
|
||||
description: "List checkpoint-restart cycle handoffs in this session",
|
||||
usage: "/cycles",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "cycle",
|
||||
aliases: &[],
|
||||
description: "Show the carry-forward briefing for a specific cycle",
|
||||
usage: "/cycle <n>",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "recall",
|
||||
aliases: &[],
|
||||
description: "Search prior cycle archives (BM25 over message text)",
|
||||
usage: "/recall <query>",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "export",
|
||||
aliases: &[],
|
||||
description: "Export conversation to markdown",
|
||||
usage: "/export [path]",
|
||||
},
|
||||
// Config commands
|
||||
CommandInfo {
|
||||
name: "config",
|
||||
aliases: &[],
|
||||
description: "Open interactive configuration editor",
|
||||
usage: "/config",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "yolo",
|
||||
aliases: &[],
|
||||
description: "Enable YOLO mode (shell + trust + auto-approve)",
|
||||
usage: "/yolo",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "agent",
|
||||
aliases: &[],
|
||||
description: "Switch to agent mode",
|
||||
usage: "/agent",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "plan",
|
||||
aliases: &[],
|
||||
description: "Switch to plan mode and review suggested implementation steps",
|
||||
usage: "/plan",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "trust",
|
||||
aliases: &[],
|
||||
description: "Manage workspace trust and per-path allowlist (`/trust add <path>`, `/trust list`, `/trust on|off`)",
|
||||
usage: "/trust [on|off|add <path>|remove <path>|list]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "logout",
|
||||
aliases: &[],
|
||||
description: "Clear API key and return to setup",
|
||||
usage: "/logout",
|
||||
},
|
||||
// Debug commands
|
||||
CommandInfo {
|
||||
name: "tokens",
|
||||
aliases: &[],
|
||||
description: "Show token usage for session",
|
||||
usage: "/tokens",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "system",
|
||||
aliases: &[],
|
||||
description: "Show current system prompt",
|
||||
usage: "/system",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "context",
|
||||
aliases: &[],
|
||||
description: "Show context window usage",
|
||||
usage: "/context",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "undo",
|
||||
aliases: &[],
|
||||
description: "Remove last message pair",
|
||||
usage: "/undo",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "retry",
|
||||
aliases: &[],
|
||||
description: "Retry the last request",
|
||||
usage: "/retry",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "init",
|
||||
aliases: &[],
|
||||
description: "Generate AGENTS.md for project",
|
||||
usage: "/init",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "settings",
|
||||
aliases: &[],
|
||||
description: "Show persistent settings",
|
||||
usage: "/settings",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "statusline",
|
||||
aliases: &["status"],
|
||||
description: "Configure which items appear in the footer",
|
||||
usage: "/statusline",
|
||||
},
|
||||
// Skills commands
|
||||
CommandInfo {
|
||||
name: "skills",
|
||||
aliases: &[],
|
||||
description: "List local skills (or --remote to browse the curated registry)",
|
||||
usage: "/skills [--remote]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "skill",
|
||||
aliases: &[],
|
||||
description: "Activate a skill, or install/update/uninstall/trust a community skill",
|
||||
usage: "/skill <name|install <spec>|update <name>|uninstall <name>|trust <name>>",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "review",
|
||||
aliases: &[],
|
||||
description: "Run a structured code review on a file, diff, or PR",
|
||||
usage: "/review <target>",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "restore",
|
||||
aliases: &[],
|
||||
description: "Roll back the workspace to a prior pre/post-turn snapshot. With no arg, lists recent snapshots.",
|
||||
usage: "/restore [N]",
|
||||
},
|
||||
// RLM command
|
||||
CommandInfo {
|
||||
name: "rlm",
|
||||
aliases: &["recursive"],
|
||||
description: "Recursive Language Model (RLM) turn — store the prompt in a Python REPL and let the model write code to process it, with `llm_query()` / `sub_rlm()` for sub-LLM calls.",
|
||||
usage: "/rlm <prompt>",
|
||||
},
|
||||
// Debug/cost command
|
||||
CommandInfo {
|
||||
name: "cost",
|
||||
aliases: &[],
|
||||
description: "Show session cost breakdown",
|
||||
usage: "/cost",
|
||||
},
|
||||
];
|
||||
|
||||
/// Execute a slash command
|
||||
pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
let parts: Vec<&str> = cmd.trim().splitn(2, ' ').collect();
|
||||
let command = parts[0].to_lowercase();
|
||||
let command = command.strip_prefix('/').unwrap_or(&command);
|
||||
let arg = parts.get(1).map(|s| s.trim());
|
||||
|
||||
// Match command or alias
|
||||
match command {
|
||||
// Core commands
|
||||
"help" | "?" => core::help(app, arg),
|
||||
"clear" => core::clear(app),
|
||||
"exit" | "quit" | "q" => core::exit(),
|
||||
"model" => core::model(app, arg),
|
||||
"models" => core::models(app),
|
||||
"provider" => provider::provider(app, arg),
|
||||
"queue" | "queued" => queue::queue(app, arg),
|
||||
"subagents" | "agents" => core::subagents(app),
|
||||
"links" | "dashboard" | "api" => core::deepseek_links(),
|
||||
"home" | "stats" | "overview" => core::home_dashboard(app),
|
||||
"note" => note::note(app, arg),
|
||||
"attach" | "image" | "media" => attachment::attach(app, arg),
|
||||
"task" | "tasks" => task::task(app, arg),
|
||||
"jobs" | "job" => jobs::jobs(app, arg),
|
||||
"mcp" => mcp::mcp(app, arg),
|
||||
|
||||
// Session commands
|
||||
"save" => session::save(app, arg),
|
||||
"sessions" | "resume" => session::sessions(app),
|
||||
"load" => session::load(app, arg),
|
||||
"compact" => session::compact(app),
|
||||
"cycles" => cycle::list_cycles(app),
|
||||
"cycle" => cycle::show_cycle(app, arg),
|
||||
"recall" => cycle::recall_archive(app, arg),
|
||||
"export" => session::export(app, arg),
|
||||
|
||||
// Config commands
|
||||
"config" => config::show_config(app),
|
||||
"settings" => config::show_settings(app),
|
||||
"statusline" | "status" => config::status_line(app),
|
||||
"yolo" => config::yolo(app),
|
||||
"agent" => config::agent_mode(app),
|
||||
"plan" => config::plan_mode(app),
|
||||
"trust" => config::trust(app, arg),
|
||||
"logout" => config::logout(app),
|
||||
|
||||
// Debug commands
|
||||
"tokens" => debug::tokens(app),
|
||||
"cost" => debug::cost(app),
|
||||
"system" => debug::system_prompt(app),
|
||||
"context" | "ctx" => debug::context(app),
|
||||
"undo" => debug::undo(app),
|
||||
"retry" => debug::retry(app),
|
||||
|
||||
// Project commands
|
||||
"init" => init::init(app),
|
||||
|
||||
// Skills commands
|
||||
"skills" => skills::list_skills(app, arg),
|
||||
"skill" => skills::run_skill(app, arg),
|
||||
"review" => review::review(app, arg),
|
||||
"restore" => restore::restore(app, arg),
|
||||
|
||||
// RLM command
|
||||
"rlm" | "recursive" => rlm(app, arg),
|
||||
|
||||
// Legacy command migrations (kept out of registry/autocomplete intentionally).
|
||||
"set" => CommandResult::error(
|
||||
"The /set command was retired. Use /config to edit settings and /settings to inspect current values.",
|
||||
),
|
||||
"normal" => config::normal_mode(app),
|
||||
"deepseek" => CommandResult::error(
|
||||
"The /deepseek command was renamed. Use /links (aliases: /dashboard, /api).",
|
||||
),
|
||||
|
||||
_ => {
|
||||
let suggestions = suggest_command_names(command, 3);
|
||||
if suggestions.is_empty() {
|
||||
CommandResult::error(format!(
|
||||
"Unknown command: /{command}. Type /help for available commands."
|
||||
))
|
||||
} else {
|
||||
let list = suggestions
|
||||
.into_iter()
|
||||
.map(|name| format!("/{name}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
CommandResult::error(format!(
|
||||
"Unknown command: /{command}. Did you mean: {list}? Type /help for available commands."
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a configuration value programmatically (used by interactive UI views).
|
||||
pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult {
|
||||
config::set_config_value(app, key, value, persist)
|
||||
}
|
||||
|
||||
/// Persist the user's chosen footer items to `~/.deepseek/config.toml` under
|
||||
/// `tui.status_items`. See [`config::persist_status_items`] for details.
|
||||
pub fn persist_status_items(
|
||||
items: &[crate::config::StatusItem],
|
||||
) -> anyhow::Result<std::path::PathBuf> {
|
||||
config::persist_status_items(items)
|
||||
}
|
||||
|
||||
/// Execute a Recursive Language Model (RLM) turn — Algorithm 1 from
|
||||
/// Zhang et al. (arXiv:2512.24601).
|
||||
///
|
||||
/// The user's prompt text is passed as the argument. It will be stored
|
||||
/// in the REPL as the `PROMPT` variable. The root LLM will only see
|
||||
/// metadata about the REPL state, never the prompt text directly.
|
||||
pub fn rlm(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
let prompt = match arg {
|
||||
Some(p) if !p.trim().is_empty() => p.trim().to_string(),
|
||||
_ => {
|
||||
return CommandResult::error(
|
||||
"Usage: /rlm <prompt>\n\n\
|
||||
Process a prompt using a Recursive Language Model (RLM).\n\
|
||||
The prompt is stored in a REPL and the model writes code\n\
|
||||
to decompose and process it recursively."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Sanity-check: RLM is most useful for longer prompts.
|
||||
if prompt.len() < 50 {
|
||||
return CommandResult::message(
|
||||
"Tip: RLM is designed for processing LONG prompts (>100 chars). \
|
||||
For short queries, just type the message directly."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let model = app.model.clone();
|
||||
let child_model = "deepseek-v4-flash".to_string();
|
||||
// Paper experiments use depth=1 (one level of `sub_rlm`); we default to
|
||||
// depth=2 so the model can recurse twice if it chooses to.
|
||||
let max_depth: u32 = 2;
|
||||
|
||||
CommandResult::with_message_and_action(
|
||||
format!(
|
||||
"Starting RLM turn for {} chars of prompt using {} (child={}, depth={})...",
|
||||
prompt.len(),
|
||||
model,
|
||||
child_model,
|
||||
max_depth,
|
||||
),
|
||||
AppAction::Rlm {
|
||||
prompt,
|
||||
model,
|
||||
child_model,
|
||||
max_depth,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Get command info by name or alias
|
||||
pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> {
|
||||
let name = name.strip_prefix('/').unwrap_or(name);
|
||||
COMMANDS
|
||||
.iter()
|
||||
.find(|cmd| cmd.name == name || cmd.aliases.contains(&name))
|
||||
}
|
||||
|
||||
/// Get all commands matching a prefix (for autocomplete)
|
||||
#[allow(dead_code)]
|
||||
pub fn commands_matching(prefix: &str) -> Vec<&'static CommandInfo> {
|
||||
let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase();
|
||||
COMMANDS
|
||||
.iter()
|
||||
.filter(|cmd| {
|
||||
cmd.name.starts_with(&prefix) || cmd.aliases.iter().any(|a| a.starts_with(&prefix))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn edit_distance(a: &str, b: &str) -> usize {
|
||||
if a == b {
|
||||
return 0;
|
||||
}
|
||||
if a.is_empty() {
|
||||
return b.chars().count();
|
||||
}
|
||||
if b.is_empty() {
|
||||
return a.chars().count();
|
||||
}
|
||||
|
||||
let b_chars: Vec<char> = b.chars().collect();
|
||||
let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
|
||||
let mut curr = vec![0usize; b_chars.len() + 1];
|
||||
|
||||
for (i, a_ch) in a.chars().enumerate() {
|
||||
curr[0] = i + 1;
|
||||
for (j, b_ch) in b_chars.iter().enumerate() {
|
||||
let cost = if a_ch == *b_ch { 0 } else { 1 };
|
||||
let delete = prev[j + 1] + 1;
|
||||
let insert = curr[j] + 1;
|
||||
let substitute = prev[j] + cost;
|
||||
curr[j + 1] = delete.min(insert).min(substitute);
|
||||
}
|
||||
std::mem::swap(&mut prev, &mut curr);
|
||||
}
|
||||
|
||||
prev[b_chars.len()]
|
||||
}
|
||||
|
||||
fn suggest_command_names(input: &str, limit: usize) -> Vec<String> {
|
||||
let query = input.trim().to_ascii_lowercase();
|
||||
if query.is_empty() || limit == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut scored: Vec<(u8, usize, String)> = Vec::new();
|
||||
for command in COMMANDS {
|
||||
let mut best: Option<(u8, usize)> = None;
|
||||
for candidate in std::iter::once(command.name).chain(command.aliases.iter().copied()) {
|
||||
let candidate = candidate.to_ascii_lowercase();
|
||||
let prefix_match = candidate.starts_with(&query) || query.starts_with(&candidate);
|
||||
let contains_match = candidate.contains(&query) || query.contains(&candidate);
|
||||
let distance = edit_distance(&candidate, &query);
|
||||
let close_typo = distance <= 2;
|
||||
if !(prefix_match || contains_match || close_typo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rank = if prefix_match {
|
||||
0
|
||||
} else if contains_match {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
|
||||
match best {
|
||||
Some((best_rank, best_distance))
|
||||
if rank > best_rank || (rank == best_rank && distance >= best_distance) => {}
|
||||
_ => best = Some((rank, distance)),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((rank, distance)) = best {
|
||||
scored.push((rank, distance, command.name.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
scored.sort_by(|a, b| {
|
||||
a.0.cmp(&b.0)
|
||||
.then_with(|| a.1.cmp(&b.1))
|
||||
.then_with(|| a.2.cmp(&b.2))
|
||||
});
|
||||
scored
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|(_, _, name)| name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, AppAction, TuiOptions};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
use_bracketed_paste: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_registry_contains_config_and_links_but_not_set_or_deepseek() {
|
||||
assert!(COMMANDS.iter().any(|cmd| cmd.name == "config"));
|
||||
assert!(COMMANDS.iter().any(|cmd| cmd.name == "links"));
|
||||
assert!(!COMMANDS.iter().any(|cmd| cmd.name == "set"));
|
||||
assert!(!COMMANDS.iter().any(|cmd| cmd.name == "deepseek"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_command_has_dashboard_and_api_aliases() {
|
||||
let links = COMMANDS
|
||||
.iter()
|
||||
.find(|cmd| cmd.name == "links")
|
||||
.expect("links command should exist");
|
||||
assert_eq!(links.aliases, &["dashboard", "api"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_config_opens_config_view_action() {
|
||||
let mut app = create_test_app();
|
||||
let result = execute("/config", &mut app);
|
||||
assert!(result.message.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_links_and_aliases_return_links_message() {
|
||||
let mut app = create_test_app();
|
||||
for cmd in ["/links", "/dashboard", "/api"] {
|
||||
let result = execute(cmd, &mut app);
|
||||
let msg = result.message.expect("links commands should return text");
|
||||
assert!(msg.contains("https://platform.deepseek.com"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_set_and_deepseek_commands_show_migration_hints() {
|
||||
let mut app = create_test_app();
|
||||
let set_result = execute("/set model deepseek-v4-pro", &mut app);
|
||||
let set_msg = set_result
|
||||
.message
|
||||
.expect("legacy command should return an error message");
|
||||
assert!(set_msg.contains("The /set command was retired"));
|
||||
assert!(set_msg.contains("/config"));
|
||||
assert!(set_msg.contains("/settings"));
|
||||
assert!(set_result.action.is_none());
|
||||
|
||||
let deepseek_result = execute("/deepseek", &mut app);
|
||||
let deepseek_msg = deepseek_result
|
||||
.message
|
||||
.expect("legacy command should return an error message");
|
||||
assert!(deepseek_msg.contains("The /deepseek command was renamed"));
|
||||
assert!(deepseek_msg.contains("/links"));
|
||||
assert!(deepseek_msg.contains("/dashboard"));
|
||||
assert!(deepseek_msg.contains("/api"));
|
||||
assert!(deepseek_result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_suggests_nearest_match() {
|
||||
let mut app = create_test_app();
|
||||
let result = execute("/modle", &mut app);
|
||||
let msg = result
|
||||
.message
|
||||
.expect("unknown command should return an error message");
|
||||
assert!(msg.contains("Unknown command: /modle"));
|
||||
assert!(msg.contains("Did you mean:"));
|
||||
assert!(msg.contains("/model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_without_close_match_keeps_help_guidance() {
|
||||
let mut app = create_test_app();
|
||||
let result = execute("/zzzzzz", &mut app);
|
||||
let msg = result
|
||||
.message
|
||||
.expect("unknown command should return an error message");
|
||||
assert!(msg.contains("Unknown command: /zzzzzz"));
|
||||
assert!(msg.contains("Type /help for available commands."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! 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,
|
||||
use_bracketed_paste: true,
|
||||
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,234 @@
|
||||
//! Provider switching: flip between DeepSeek, hosted providers, and self-hosted
|
||||
//! OpenAI-compatible DeepSeek V4 servers at runtime.
|
||||
//!
|
||||
//! `/provider` with no args opens the picker modal (#52). `/provider <name>`
|
||||
//! keeps the v0.6.6 CLI form for muscle-memory + scripted use.
|
||||
|
||||
use crate::config::{ApiProvider, normalize_model_name};
|
||||
use crate::tui::app::{App, AppAction};
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
/// Switch or view the current LLM backend.
|
||||
///
|
||||
/// With no args, opens the picker modal. With `<provider> [model]`, performs
|
||||
/// the switch directly (e.g. `/provider nim flash` lands on
|
||||
/// `deepseek-ai/deepseek-v4-flash`). The optional model accepts shorthand
|
||||
/// (`flash`, `pro`, `v4-flash`, `v4-pro`) or any normal DeepSeek model ID.
|
||||
pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
let trimmed = args.map(str::trim).filter(|s| !s.is_empty());
|
||||
let Some(args) = trimmed else {
|
||||
return CommandResult::action(AppAction::OpenProviderPicker);
|
||||
};
|
||||
|
||||
let mut parts = args.split_whitespace();
|
||||
let name = parts.next().unwrap_or("");
|
||||
let model_arg = parts.next();
|
||||
|
||||
let Some(target) = ApiProvider::parse(name) else {
|
||||
return CommandResult::error(format!(
|
||||
"Unknown provider '{name}'. Expected: deepseek, nvidia-nim, openrouter, novita, fireworks, or sglang."
|
||||
));
|
||||
};
|
||||
|
||||
let model = match model_arg {
|
||||
None => None,
|
||||
Some(raw) => match normalize_model_name(&expand_model_alias(raw)) {
|
||||
Some(normalized) => Some(normalized),
|
||||
None => {
|
||||
return CommandResult::error(format!(
|
||||
"Invalid model '{raw}'. Try: flash, pro, deepseek-v4-flash, deepseek-v4-pro."
|
||||
));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if target == app.api_provider && model.is_none() {
|
||||
return CommandResult::message(format!("Already on provider: {}", target.as_str()));
|
||||
}
|
||||
|
||||
CommandResult::action(AppAction::SwitchProvider {
|
||||
provider: target,
|
||||
model,
|
||||
})
|
||||
}
|
||||
|
||||
fn expand_model_alias(name: &str) -> String {
|
||||
match name.trim().to_ascii_lowercase().as_str() {
|
||||
"pro" | "v4-pro" => "deepseek-v4-pro".to_string(),
|
||||
"flash" | "v4-flash" => "deepseek-v4-flash".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::TuiOptions;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
use_bracketed_paste: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_args_opens_picker_modal() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, None);
|
||||
assert!(result.message.is_none());
|
||||
assert_eq!(result.action, Some(AppAction::OpenProviderPicker));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_provider_returns_error() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("anthropic"));
|
||||
let msg = result.message.expect("expected error message");
|
||||
assert!(msg.contains("Unknown provider"));
|
||||
assert!(msg.contains("openrouter"));
|
||||
assert!(msg.contains("novita"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_to_openrouter_emits_action() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("openrouter"));
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::Openrouter);
|
||||
assert_eq!(model, None);
|
||||
}
|
||||
other => panic!("expected SwitchProvider, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_to_novita_emits_action() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("novita"));
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::Novita);
|
||||
assert_eq!(model, None);
|
||||
}
|
||||
other => panic!("expected SwitchProvider, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_to_fireworks_emits_action() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("fireworks pro"));
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::Fireworks);
|
||||
assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
|
||||
}
|
||||
other => panic!("expected SwitchProvider, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_to_sglang_flash_emits_action() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("sglang flash"));
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::Sglang);
|
||||
assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
|
||||
}
|
||||
other => panic!("expected SwitchProvider, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_to_active_provider_without_model_is_a_noop() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("deepseek"));
|
||||
let msg = result.message.expect("expected message");
|
||||
assert!(msg.contains("Already on provider"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_to_nim_emits_action_without_model_override() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("nvidia-nim"));
|
||||
assert!(result.message.is_none());
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::NvidiaNim);
|
||||
assert_eq!(model, None);
|
||||
}
|
||||
other => panic!("expected SwitchProvider action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nim_flash_shorthand_emits_action_with_model_override() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("nim flash"));
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::NvidiaNim);
|
||||
assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
|
||||
}
|
||||
other => panic!("expected SwitchProvider action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nim_pro_shorthand_emits_action_with_model_override() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("nim pro"));
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::NvidiaNim);
|
||||
assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
|
||||
}
|
||||
other => panic!("expected SwitchProvider action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_to_active_provider_with_new_model_still_emits_action() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("deepseek flash"));
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::Deepseek);
|
||||
assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
|
||||
}
|
||||
other => panic!("expected SwitchProvider action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_model_returns_error() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("nim gpt-4"));
|
||||
let msg = result.message.expect("expected error message");
|
||||
assert!(msg.contains("Invalid model"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
//! 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,
|
||||
use_bracketed_paste: true,
|
||||
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,255 @@
|
||||
//! `/restore` slash command — roll back the workspace to a prior snapshot.
|
||||
//!
|
||||
//! `/restore` (no arg) lists the most recent snapshots so the user can
|
||||
//! see what's available. `/restore <N>` restores the *N*th-most-recent
|
||||
//! snapshot, where `N=1` is the newest. In non-YOLO mode we refuse to
|
||||
//! mutate files unless the user has explicitly trusted the workspace
|
||||
//! (`/trust on` or YOLO) — the user can always view the list, just not
|
||||
//! one-shot revert without a safety net.
|
||||
|
||||
use super::CommandResult;
|
||||
use crate::snapshot::SnapshotRepo;
|
||||
use crate::tui::app::App;
|
||||
|
||||
const LIST_LIMIT: usize = 10;
|
||||
|
||||
/// Entry point for `/restore [N]`.
|
||||
pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
let workspace = app.workspace.clone();
|
||||
let repo = match SnapshotRepo::open_or_init(&workspace) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return CommandResult::error(format!(
|
||||
"Snapshot repo unavailable for {}: {e}",
|
||||
workspace.display(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let snapshots = match repo.list(LIST_LIMIT) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")),
|
||||
};
|
||||
|
||||
if snapshots.is_empty() {
|
||||
return CommandResult::message(
|
||||
"No snapshots yet. Send a message to create the first pre-turn snapshot.",
|
||||
);
|
||||
}
|
||||
|
||||
let Some(arg) = arg.map(str::trim).filter(|s| !s.is_empty()) else {
|
||||
return CommandResult::message(format_listing(&snapshots));
|
||||
};
|
||||
|
||||
let n: usize = match arg.parse() {
|
||||
Ok(n) if n >= 1 => n,
|
||||
_ => {
|
||||
return CommandResult::error(format!(
|
||||
"Usage: /restore <N> (N is 1-based; got '{arg}')",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if n > snapshots.len() {
|
||||
return CommandResult::error(format!(
|
||||
"Only {} snapshot(s) available; asked for #{n}.",
|
||||
snapshots.len(),
|
||||
));
|
||||
}
|
||||
|
||||
// Non-YOLO sessions get a confirmation gate. We don't have a true
|
||||
// modal-confirmation path inside slash commands today, so the gate
|
||||
// is "require trust mode" — `/trust on` or YOLO. Users in plain
|
||||
// Agent mode get a clear message explaining how to proceed.
|
||||
if !(app.yolo || app.trust_mode) {
|
||||
return CommandResult::message(format!(
|
||||
"Refusing to restore snapshot #{n} ('{}') outside trusted mode.\n\
|
||||
Run `/trust on` or `/yolo` first, then re-run `/restore {n}`.",
|
||||
snapshots[n - 1].label,
|
||||
));
|
||||
}
|
||||
|
||||
let target = &snapshots[n - 1];
|
||||
if let Err(e) = repo.restore(&target.id) {
|
||||
return CommandResult::error(format!("Restore failed: {e}"));
|
||||
}
|
||||
|
||||
CommandResult::message(format!(
|
||||
"Restored snapshot #{n} ('{}', {}). Workspace files have been reverted; conversation history is unchanged.",
|
||||
target.label,
|
||||
short_sha(target.id.as_str()),
|
||||
))
|
||||
}
|
||||
|
||||
fn format_listing(snapshots: &[crate::snapshot::Snapshot]) -> String {
|
||||
let mut out = String::from("Recent snapshots (newest first; pass /restore <N> to revert):\n");
|
||||
for (i, s) in snapshots.iter().enumerate() {
|
||||
out.push_str(&format!(
|
||||
" #{:<2} {} {}\n",
|
||||
i + 1,
|
||||
short_sha(s.id.as_str()),
|
||||
s.label,
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn short_sha(sha: &str) -> &str {
|
||||
&sha[..sha.len().min(8)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::test_support::lock_test_env;
|
||||
use crate::tui::app::TuiOptions;
|
||||
use std::sync::MutexGuard;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_app(tmp: &TempDir, yolo: bool) -> App {
|
||||
let workspace = tmp.path().to_path_buf();
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace,
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
use_bracketed_paste: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmp.path().join("skills"),
|
||||
memory_path: tmp.path().join("memory.md"),
|
||||
notes_path: tmp.path().join("notes.txt"),
|
||||
mcp_config_path: tmp.path().join("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
/// Pins HOME to a tempdir for the duration of the test under the
|
||||
/// crate-wide env mutex.
|
||||
struct ScopedHome {
|
||||
prev: Option<std::ffi::OsString>,
|
||||
_guard: MutexGuard<'static, ()>,
|
||||
}
|
||||
impl Drop for ScopedHome {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: process-wide lock still held.
|
||||
unsafe {
|
||||
match self.prev.take() {
|
||||
Some(v) => std::env::set_var("HOME", v),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn scoped_home(tmp: &TempDir) -> ScopedHome {
|
||||
let guard = lock_test_env();
|
||||
let prev = std::env::var_os("HOME");
|
||||
// SAFETY: serialised by the global env lock.
|
||||
unsafe {
|
||||
std::env::set_var("HOME", tmp.path());
|
||||
}
|
||||
ScopedHome {
|
||||
prev,
|
||||
_guard: guard,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_with_no_snapshots_shows_empty_message() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _home = scoped_home(&tmp);
|
||||
let mut app = make_app(&tmp, true);
|
||||
let result = restore(&mut app, None);
|
||||
let msg = result.message.expect("expected message");
|
||||
assert!(msg.contains("No snapshots"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_lists_when_no_arg_provided() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _home = scoped_home(&tmp);
|
||||
let mut app = make_app(&tmp, true);
|
||||
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
|
||||
std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap();
|
||||
repo.snapshot("pre-turn:1").unwrap();
|
||||
std::fs::write(app.workspace.join("a.txt"), b"v2").unwrap();
|
||||
repo.snapshot("post-turn:1").unwrap();
|
||||
|
||||
let result = restore(&mut app, None);
|
||||
let msg = result.message.expect("expected message");
|
||||
assert!(msg.contains("post-turn:1"));
|
||||
assert!(msg.contains("pre-turn:1"));
|
||||
assert!(msg.contains("#1"));
|
||||
assert!(msg.contains("#2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_in_yolo_reverts_workspace() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _home = scoped_home(&tmp);
|
||||
let mut app = make_app(&tmp, true);
|
||||
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
|
||||
let f = app.workspace.join("a.txt");
|
||||
|
||||
std::fs::write(&f, b"original").unwrap();
|
||||
repo.snapshot("pre-turn:1").unwrap();
|
||||
std::fs::write(&f, b"clobbered").unwrap();
|
||||
repo.snapshot("post-turn:1").unwrap();
|
||||
|
||||
let result = restore(&mut app, Some("2"));
|
||||
assert!(result.message.unwrap().contains("Restored"));
|
||||
let after = std::fs::read_to_string(&f).unwrap();
|
||||
assert_eq!(after, "original");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_outside_trust_mode_refuses() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _home = scoped_home(&tmp);
|
||||
let mut app = make_app(&tmp, false);
|
||||
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
|
||||
std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap();
|
||||
repo.snapshot("pre-turn:1").unwrap();
|
||||
|
||||
let result = restore(&mut app, Some("1"));
|
||||
let msg = result.message.expect("expected message");
|
||||
assert!(msg.contains("Refusing"));
|
||||
assert!(msg.contains("/trust on"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_invalid_index_returns_error() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _home = scoped_home(&tmp);
|
||||
let mut app = make_app(&tmp, true);
|
||||
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
|
||||
std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap();
|
||||
repo.snapshot("pre-turn:1").unwrap();
|
||||
|
||||
let result = restore(&mut app, Some("99"));
|
||||
let msg = result.message.expect("expected message");
|
||||
assert!(msg.contains("Only 1 snapshot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_zero_index_returns_error() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _home = scoped_home(&tmp);
|
||||
let mut app = make_app(&tmp, true);
|
||||
// Need at least one snapshot so we exercise the parse-index
|
||||
// branch instead of the "no snapshots" early return.
|
||||
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
|
||||
std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap();
|
||||
repo.snapshot("pre-turn:1").unwrap();
|
||||
|
||||
let result = restore(&mut app, Some("0"));
|
||||
let msg = result.message.expect("expected message");
|
||||
assert!(msg.contains("Usage:"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! 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,
|
||||
use_bracketed_paste: true,
|
||||
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,417 @@
|
||||
//! 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.clear_history();
|
||||
let cells_to_add: Vec<_> = app
|
||||
.api_messages
|
||||
.iter()
|
||||
.flat_map(history_cells_from_message)
|
||||
.collect();
|
||||
app.extend_history(cells_to_add);
|
||||
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::Error { message, severity } => match severity {
|
||||
crate::error_taxonomy::ErrorSeverity::Warning => ("**Warning:**", message.clone()),
|
||||
crate::error_taxonomy::ErrorSeverity::Info => ("*Info:*", message.clone()),
|
||||
_ => ("**Error:**", message.clone()),
|
||||
},
|
||||
HistoryCell::Thinking { content, .. } => ("*Thinking:*", content.clone()),
|
||||
HistoryCell::Tool(tool) => ("**Tool:**", render_tool_cell(tool, 80)),
|
||||
HistoryCell::SubAgent(sub) => ("**Sub-agent:**", render_subagent_cell(sub, 80)),
|
||||
HistoryCell::ArchivedContext {
|
||||
level,
|
||||
range,
|
||||
summary,
|
||||
..
|
||||
} => (
|
||||
"**Archived Context:**",
|
||||
format!("L{level} [{range}]: {summary}"),
|
||||
),
|
||||
};
|
||||
|
||||
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 render_subagent_cell(cell: &crate::tui::history::SubAgentCell, width: u16) -> String {
|
||||
cell.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,
|
||||
use_bracketed_paste: true,
|
||||
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,474 @@
|
||||
//! Skills commands: skills, skill
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::network_policy::NetworkPolicy;
|
||||
use crate::skills::SkillRegistry;
|
||||
use crate::skills::install::{
|
||||
self, DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL, InstallOutcome, InstallSource,
|
||||
RegistryFetchResult, UpdateResult,
|
||||
};
|
||||
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. Pass `--remote` (or `remote`) to fetch the
|
||||
/// curated registry instead of scanning the local skills directory.
|
||||
pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
if let Some(arg) = arg {
|
||||
let trimmed = arg.trim();
|
||||
if trimmed == "--remote" || trimmed == "remote" {
|
||||
return list_remote_skills(app);
|
||||
}
|
||||
if !trimmed.is_empty() {
|
||||
return CommandResult::error("Usage: /skills [--remote]");
|
||||
}
|
||||
}
|
||||
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, or
|
||||
/// dispatches a sub-command (`install`, `update`, `uninstall`, `trust`).
|
||||
pub fn run_skill(app: &mut App, name: Option<&str>) -> CommandResult {
|
||||
let raw = match name {
|
||||
Some(n) => n.trim(),
|
||||
None => {
|
||||
return CommandResult::error(
|
||||
"Usage: /skill <name>\n\nSubcommands:\n /skill install <github:owner/repo|https://…|<registry-name>>\n /skill update <name>\n /skill uninstall <name>\n /skill trust <name>",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Sub-command dispatch happens before the activation path so users can't
|
||||
// accidentally activate a skill literally named "install".
|
||||
let mut iter = raw.splitn(2, char::is_whitespace);
|
||||
let head = iter.next().unwrap_or("").trim();
|
||||
let rest = iter.next().unwrap_or("").trim();
|
||||
match head {
|
||||
"install" => return install_skill(app, rest),
|
||||
"update" => return update_skill(app, rest),
|
||||
"uninstall" => return uninstall_skill(app, rest),
|
||||
"trust" => return trust_skill(app, rest),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
activate_skill(app, raw)
|
||||
}
|
||||
|
||||
fn activate_skill(app: &mut App, name: &str) -> CommandResult {
|
||||
// `/skill new` is a friendly alias for `/skill skill-creator`.
|
||||
let name = if name == "new" { "skill-creator" } else { 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
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /skill install ────────────────────────────────────────────────────────
|
||||
|
||||
fn install_skill(app: &mut App, spec: &str) -> CommandResult {
|
||||
if spec.is_empty() {
|
||||
return CommandResult::error(
|
||||
"Usage: /skill install <github:owner/repo|https://…|<registry-name>>",
|
||||
);
|
||||
}
|
||||
let source = match InstallSource::parse(spec) {
|
||||
Ok(s) => s,
|
||||
Err(err) => return CommandResult::error(format!("Invalid install source: {err}")),
|
||||
};
|
||||
let skills_dir = app.skills_dir.clone();
|
||||
let (network, max_size, registry_url) = installer_settings(app);
|
||||
|
||||
let outcome = run_async(async move {
|
||||
install::install_with_registry(
|
||||
source,
|
||||
&skills_dir,
|
||||
max_size,
|
||||
&network,
|
||||
false,
|
||||
®istry_url,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
match outcome {
|
||||
Ok(InstallOutcome::Installed(installed)) => {
|
||||
let path_str = path_or_default(&installed.path);
|
||||
CommandResult::message(format!(
|
||||
"Installed skill '{}' from {}.\nLocation: {}\n\nRun /skills to see it in the list.",
|
||||
installed.name, spec, path_str
|
||||
))
|
||||
}
|
||||
Ok(InstallOutcome::NeedsApproval(host)) => {
|
||||
CommandResult::error(needs_approval_message(&host))
|
||||
}
|
||||
Ok(InstallOutcome::NetworkDenied(host)) => {
|
||||
CommandResult::error(network_denied_message(&host))
|
||||
}
|
||||
Err(err) => CommandResult::error(format!("Install failed: {err:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /skill update ─────────────────────────────────────────────────────────
|
||||
|
||||
fn update_skill(app: &mut App, name: &str) -> CommandResult {
|
||||
if name.is_empty() {
|
||||
return CommandResult::error("Usage: /skill update <name>");
|
||||
}
|
||||
let skills_dir = app.skills_dir.clone();
|
||||
let (network, max_size, registry_url) = installer_settings(app);
|
||||
let owned_name = name.to_string();
|
||||
let outcome = run_async(async move {
|
||||
install::update_with_registry(&owned_name, &skills_dir, max_size, &network, ®istry_url)
|
||||
.await
|
||||
});
|
||||
|
||||
match outcome {
|
||||
Ok(UpdateResult::NoChange) => {
|
||||
CommandResult::message(format!("Skill '{name}': no upstream change."))
|
||||
}
|
||||
Ok(UpdateResult::Updated(installed)) => CommandResult::message(format!(
|
||||
"Skill '{}' updated. Location: {}",
|
||||
installed.name,
|
||||
path_or_default(&installed.path)
|
||||
)),
|
||||
Ok(UpdateResult::NeedsApproval(host)) => {
|
||||
CommandResult::error(needs_approval_message(&host))
|
||||
}
|
||||
Ok(UpdateResult::NetworkDenied(host)) => {
|
||||
CommandResult::error(network_denied_message(&host))
|
||||
}
|
||||
Err(err) => CommandResult::error(format!("Update failed: {err:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /skill uninstall ──────────────────────────────────────────────────────
|
||||
|
||||
fn uninstall_skill(app: &mut App, name: &str) -> CommandResult {
|
||||
if name.is_empty() {
|
||||
return CommandResult::error("Usage: /skill uninstall <name>");
|
||||
}
|
||||
match install::uninstall(name, &app.skills_dir) {
|
||||
Ok(()) => CommandResult::message(format!("Removed skill '{name}'.")),
|
||||
Err(err) => CommandResult::error(format!("Uninstall failed: {err:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /skill trust ──────────────────────────────────────────────────────────
|
||||
|
||||
fn trust_skill(app: &mut App, name: &str) -> CommandResult {
|
||||
if name.is_empty() {
|
||||
return CommandResult::error("Usage: /skill trust <name>");
|
||||
}
|
||||
match install::trust(name, &app.skills_dir) {
|
||||
Ok(()) => CommandResult::message(format!(
|
||||
"Marked skill '{name}' as trusted. Tools that consult the .trusted marker may now invoke its scripts/."
|
||||
)),
|
||||
Err(err) => CommandResult::error(format!("Trust failed: {err:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /skills --remote ──────────────────────────────────────────────────────
|
||||
|
||||
/// List skills available in the configured curated registry.
|
||||
pub fn list_remote_skills(app: &mut App) -> CommandResult {
|
||||
let (network, _max_size, registry_url) = installer_settings(app);
|
||||
let registry = run_async(async move { install::fetch_registry(&network, ®istry_url).await });
|
||||
match registry {
|
||||
Ok(RegistryFetchResult::Loaded(doc)) => {
|
||||
if doc.skills.is_empty() {
|
||||
return CommandResult::message("Registry is empty.");
|
||||
}
|
||||
let mut out = format!("Available remote skills ({}):\n", doc.skills.len());
|
||||
out.push_str("─────────────────────────────\n");
|
||||
for (name, entry) in &doc.skills {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {name} — {} (source: {})",
|
||||
entry.description.clone().unwrap_or_default(),
|
||||
entry.source
|
||||
);
|
||||
}
|
||||
let _ = write!(out, "\nInstall with: /skill install <name>");
|
||||
CommandResult::message(out)
|
||||
}
|
||||
Ok(RegistryFetchResult::NeedsApproval(host)) => {
|
||||
CommandResult::error(needs_approval_message(&host))
|
||||
}
|
||||
Ok(RegistryFetchResult::Denied(host)) => {
|
||||
CommandResult::error(network_denied_message(&host))
|
||||
}
|
||||
Err(err) => CommandResult::error(format!("Failed to fetch registry: {err:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Read the active config knobs for the installer.
|
||||
///
|
||||
/// We load `Config::load` on demand because [`App`] does not carry a `Config`
|
||||
/// field — and loading is cheap (small TOML file) compared to the network
|
||||
/// round-trip the install/update operation will incur next. If the config
|
||||
/// fails to parse, we fall back to defaults so the user still gets a
|
||||
/// network-gated install rather than a silent crash.
|
||||
fn installer_settings(_app: &App) -> (NetworkPolicy, u64, String) {
|
||||
let cfg = crate::config::Config::load(None, None).unwrap_or_default();
|
||||
let network = cfg
|
||||
.network
|
||||
.clone()
|
||||
.map(|policy| policy.into_runtime())
|
||||
.unwrap_or_default();
|
||||
let skills_cfg = cfg.skills.as_ref();
|
||||
let max_size = skills_cfg
|
||||
.and_then(|s| s.max_install_size_bytes)
|
||||
.unwrap_or(DEFAULT_MAX_SIZE_BYTES);
|
||||
let registry_url = skills_cfg
|
||||
.and_then(|s| s.registry_url.clone())
|
||||
.unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string());
|
||||
(network, max_size, registry_url)
|
||||
}
|
||||
|
||||
fn run_async<F, T>(future: F) -> T
|
||||
where
|
||||
F: std::future::Future<Output = T>,
|
||||
{
|
||||
// We're on the TUI's thread, which is part of the multi-threaded runtime.
|
||||
// `block_in_place` + `Handle::current().block_on` is the pattern used by
|
||||
// `commands/cycle.rs` to bridge sync slash-command handlers back into the
|
||||
// async ecosystem.
|
||||
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
|
||||
}
|
||||
|
||||
fn path_or_default(path: &std::path::Path) -> String {
|
||||
path.file_name()
|
||||
.map(|n| {
|
||||
// Display with parent so the user sees the full skill location.
|
||||
// We intentionally use `display()` here because it's just for
|
||||
// user-facing output, not for path comparisons.
|
||||
let parent = path
|
||||
.parent()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_default();
|
||||
if parent.is_empty() {
|
||||
n.to_string_lossy().to_string()
|
||||
} else {
|
||||
format!("{parent}/{}", n.to_string_lossy())
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| path.display().to_string())
|
||||
}
|
||||
|
||||
fn needs_approval_message(host: &str) -> String {
|
||||
format!(
|
||||
"Network policy requires approval for {host}.\n\
|
||||
Add it to your allow list with `/network allow {host}` (or set [network].default = \"allow\" in ~/.deepseek/config.toml), then retry."
|
||||
)
|
||||
}
|
||||
|
||||
fn network_denied_message(host: &str) -> String {
|
||||
format!(
|
||||
"Network policy denied access to {host}.\n\
|
||||
Remove the deny entry from ~/.deepseek/config.toml under [network] or contact your administrator."
|
||||
)
|
||||
}
|
||||
|
||||
#[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,
|
||||
use_bracketed_paste: true,
|
||||
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, None);
|
||||
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, None);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Available skills"));
|
||||
assert!(msg.contains("/test-skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skill_subcommand_dispatch_install_usage() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
// Empty install spec → usage hint, not invalid-source error.
|
||||
let result = run_skill(&mut app, Some("install"));
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("/skill install"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skill_subcommand_dispatch_uninstall_missing() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = run_skill(&mut app, Some("uninstall absent-skill"));
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("not installed"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[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,97 @@
|
||||
//! 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,
|
||||
use_bracketed_paste: true,
|
||||
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,781 @@
|
||||
//! 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);
|
||||
model_priors.insert("deepseek_v4_pro".to_string(), 3.5);
|
||||
model_priors.insert("deepseek_v4_flash".to_string(), 4.2);
|
||||
|
||||
Self {
|
||||
// OFF BY DEFAULT. The capacity controller's main intervention,
|
||||
// `TargetedContextRefresh`, runs `compact_messages_safe` which
|
||||
// rewrites the live conversation — visually identical to the
|
||||
// agent "restarting" mid-turn. Power users running V4 on a 1M
|
||||
// context window simply don't need this guardrail; the failure
|
||||
// mode it protects against (context overflow) is rare in
|
||||
// practice and self-correcting (the model surfaces a clear
|
||||
// error). Users who do want the controller back can enable it
|
||||
// via `capacity.enabled = true` in `~/.deepseek/config.toml`.
|
||||
enabled: false,
|
||||
// Thresholds retained for the opt-in path; tuning notes live
|
||||
// in git history (#63 follow-up).
|
||||
low_risk_max: 0.50,
|
||||
medium_risk_max: 0.62,
|
||||
severe_min_slack: -0.25,
|
||||
severe_violation_ratio: 0.40,
|
||||
refresh_cooldown_turns: 6,
|
||||
replan_cooldown_turns: 5,
|
||||
max_replay_per_turn: 1,
|
||||
min_turns_before_guardrail: 4,
|
||||
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.deepseek_v4_pro_prior {
|
||||
out.model_priors.insert("deepseek_v4_pro".to_string(), v);
|
||||
}
|
||||
if let Some(v) = capacity.deepseek_v4_flash_prior {
|
||||
out.model_priors.insert("deepseek_v4_flash".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 {
|
||||
// Strip optional "deepseek-ai/" NIM namespace prefix before pattern matching.
|
||||
let model = model.strip_prefix("deepseek-ai/").unwrap_or(model);
|
||||
let lower = model.to_ascii_lowercase();
|
||||
// V4 variants must be checked before the generic V3/chat/reasoner branches
|
||||
// because those branches do not contain "v4" tokens and the ordering prevents
|
||||
// accidental cross-matches.
|
||||
if lower.contains("v4-pro") || lower.contains("v4_pro") {
|
||||
"deepseek_v4_pro"
|
||||
} else if lower.contains("v4-flash") || lower.contains("v4_flash") {
|
||||
"deepseek_v4_flash"
|
||||
} else 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 default_controller_is_disabled_and_does_not_observe() {
|
||||
let cfg = CapacityControllerConfig::default();
|
||||
assert!(!cfg.enabled);
|
||||
|
||||
let mut controller = CapacityController::new(cfg);
|
||||
let snapshot = controller.observe_pre_turn(CapacityObservationInput {
|
||||
turn_index: 1,
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
action_count_this_turn: 10,
|
||||
tool_calls_recent_window: 10,
|
||||
unique_reference_ids_recent_window: 10,
|
||||
context_used_ratio: 0.95,
|
||||
});
|
||||
|
||||
assert!(snapshot.is_none());
|
||||
let decision = controller.decide(1, snapshot.as_ref());
|
||||
assert_eq!(decision.action, GuardrailAction::NoIntervention);
|
||||
assert_eq!(decision.reason, "capacity_controller_disabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_config_without_capacity_keeps_controller_disabled() {
|
||||
let cfg = CapacityControllerConfig::from_app_config(&crate::config::Config::default());
|
||||
assert!(!cfg.enabled);
|
||||
assert_eq!(cfg.low_risk_max, 0.50);
|
||||
assert_eq!(cfg.refresh_cooldown_turns, 6);
|
||||
assert_eq!(cfg.min_turns_before_guardrail, 4);
|
||||
assert_eq!(cfg.model_priors.get("deepseek_v4_pro"), Some(&3.5));
|
||||
assert_eq!(cfg.model_priors.get("deepseek_v4_flash"), Some(&4.2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_v4_pro_variants() {
|
||||
assert_eq!(
|
||||
normalize_model_prior_key("deepseek-v4-pro"),
|
||||
"deepseek_v4_pro"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_model_prior_key("deepseek-v4_pro"),
|
||||
"deepseek_v4_pro"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_model_prior_key("deepseek-ai/deepseek-v4-pro"),
|
||||
"deepseek_v4_pro"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_model_prior_key("deepseek-ai/deepseek-v4_pro"),
|
||||
"deepseek_v4_pro"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_v4_flash_variants() {
|
||||
assert_eq!(
|
||||
normalize_model_prior_key("deepseek-v4-flash"),
|
||||
"deepseek_v4_flash"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_model_prior_key("deepseek-v4_flash"),
|
||||
"deepseek_v4_flash"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_model_prior_key("deepseek-ai/deepseek-v4-flash"),
|
||||
"deepseek_v4_flash"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_model_prior_key("deepseek-ai/deepseek-v4_flash"),
|
||||
"deepseek_v4_flash"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_v3_and_reasoner_unchanged() {
|
||||
assert_eq!(
|
||||
normalize_model_prior_key("deepseek-chat"),
|
||||
"deepseek_v3_2_chat"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_model_prior_key("deepseek-v3-chat"),
|
||||
"deepseek_v3_2_chat"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_model_prior_key("deepseek-reasoner"),
|
||||
"deepseek_v3_2_reasoner"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_model_prior_key("deepseek-r1"),
|
||||
"deepseek_v3_2_reasoner"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_model_prior_key("unknown-model"),
|
||||
"fallback_default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_priors_loaded_into_default_config() {
|
||||
let cfg = CapacityControllerConfig::default();
|
||||
assert_eq!(cfg.model_priors.get("deepseek_v4_pro").copied(), Some(3.5));
|
||||
assert_eq!(
|
||||
cfg.model_priors.get("deepseek_v4_flash").copied(),
|
||||
Some(4.2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_blocks_repeated_action() {
|
||||
// Capacity controller is opt-in (off by default since v0.6.2). This
|
||||
// test exercises the cooldown logic, so explicitly enable it.
|
||||
let config = CapacityControllerConfig {
|
||||
enabled: true,
|
||||
..CapacityControllerConfig::default()
|
||||
};
|
||||
let mut controller = CapacityController::new(config);
|
||||
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);
|
||||
}
|
||||
|
||||
/// Hot-path microbench for `compute_profile`. Run with:
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -p deepseek-tui --release capacity::tests::bench_compute_profile -- --ignored --nocapture
|
||||
/// ```
|
||||
///
|
||||
/// Establishes a baseline cost so we can detect regressions when the
|
||||
/// observation cadence is high (50+ message turns × per-step calls). Adds
|
||||
/// no dev-deps; we measure with `Instant` and print rather than gating CI.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_compute_profile() {
|
||||
use std::time::Instant;
|
||||
|
||||
for &window_len in &[16usize, 64, 256, 1024] {
|
||||
let mut window: VecDeque<f64> = VecDeque::with_capacity(window_len);
|
||||
for i in 0..window_len {
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
window.push_back((i as f64).sin() * 0.5);
|
||||
}
|
||||
|
||||
let iters = 100_000usize;
|
||||
let start = Instant::now();
|
||||
for _ in 0..iters {
|
||||
let profile = compute_profile(&window);
|
||||
std::hint::black_box(profile);
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
let per_call_ns = elapsed.as_nanos() as f64 / iters as f64;
|
||||
println!(
|
||||
"compute_profile window={window_len:>4} total={:?} per-call={per_call_ns:>8.0}ns",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Persistent memory snapshots for capacity controller interventions.
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Canonical compact state persisted by interventions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CanonicalState {
|
||||
pub goal: String,
|
||||
pub constraints: Vec<String>,
|
||||
pub confirmed_facts: Vec<String>,
|
||||
pub open_loops: Vec<String>,
|
||||
pub pending_actions: Vec<String>,
|
||||
pub critical_refs: Vec<String>,
|
||||
}
|
||||
|
||||
/// Replay verification metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReplayInfo {
|
||||
pub tool_id: String,
|
||||
pub tool_name: String,
|
||||
pub pass: bool,
|
||||
pub diff_summary: String,
|
||||
}
|
||||
|
||||
/// JSONL record written for each intervention.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CapacityMemoryRecord {
|
||||
pub id: String,
|
||||
pub ts: String,
|
||||
pub turn_index: u64,
|
||||
pub action_trigger: String,
|
||||
pub h_hat: f64,
|
||||
pub c_hat: f64,
|
||||
pub slack: f64,
|
||||
pub risk_band: String,
|
||||
pub canonical_state: CanonicalState,
|
||||
pub source_message_ids: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub replay_info: Option<ReplayInfo>,
|
||||
}
|
||||
|
||||
fn capacity_memory_dirs() -> Vec<PathBuf> {
|
||||
if let Ok(raw) = std::env::var("DEEPSEEK_CAPACITY_MEMORY_DIR") {
|
||||
let trimmed = raw.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return vec![PathBuf::from(shellexpand::tilde(trimmed).as_ref())];
|
||||
}
|
||||
}
|
||||
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
dirs.push(home.join(".deepseek").join("memory"));
|
||||
}
|
||||
|
||||
let cwd = std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join(".deepseek")
|
||||
.join("memory");
|
||||
dirs.push(cwd);
|
||||
|
||||
dirs.dedup();
|
||||
dirs
|
||||
}
|
||||
|
||||
pub fn append_capacity_record(session_id: &str, record: &CapacityMemoryRecord) -> Result<PathBuf> {
|
||||
let candidates = candidate_session_memory_paths(session_id);
|
||||
append_capacity_record_to_candidates(&candidates, record)
|
||||
}
|
||||
|
||||
pub fn append_capacity_record_to_path(path: &Path, record: &CapacityMemoryRecord) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create memory directory {}", parent.display()))?;
|
||||
}
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.with_context(|| format!("Failed to open memory log {}", path.display()))?;
|
||||
let line =
|
||||
serde_json::to_string(record).context("Failed to serialize capacity memory record")?;
|
||||
writeln!(file, "{line}")
|
||||
.with_context(|| format!("Failed to write memory record {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_last_k_capacity_records(
|
||||
session_id: &str,
|
||||
k: usize,
|
||||
) -> Result<Vec<CapacityMemoryRecord>> {
|
||||
let candidates = candidate_session_memory_paths(session_id);
|
||||
load_last_k_capacity_records_from_candidates(&candidates, k)
|
||||
}
|
||||
|
||||
pub fn load_last_k_capacity_records_from_path(
|
||||
path: &Path,
|
||||
k: usize,
|
||||
) -> Result<Vec<CapacityMemoryRecord>> {
|
||||
if k == 0 || !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.open(path)
|
||||
.with_context(|| format!("Failed to open memory log {}", path.display()))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut records = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line.with_context(|| format!("Failed reading {}", path.display()))?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(record) = serde_json::from_str::<CapacityMemoryRecord>(&line) {
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
if records.len() > k {
|
||||
Ok(records.split_off(records.len() - k))
|
||||
} else {
|
||||
Ok(records)
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_session_memory_paths(session_id: &str) -> Vec<PathBuf> {
|
||||
capacity_memory_dirs()
|
||||
.into_iter()
|
||||
.map(|dir| dir.join(format!("{session_id}.jsonl")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn append_capacity_record_to_candidates(
|
||||
paths: &[PathBuf],
|
||||
record: &CapacityMemoryRecord,
|
||||
) -> Result<PathBuf> {
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for path in paths {
|
||||
match append_capacity_record_to_path(path, record) {
|
||||
Ok(()) => return Ok(path.clone()),
|
||||
Err(err) => last_err = Some(err),
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err.unwrap_or_else(|| anyhow!("No capacity memory path candidates available")))
|
||||
}
|
||||
|
||||
fn load_last_k_capacity_records_from_candidates(
|
||||
paths: &[PathBuf],
|
||||
k: usize,
|
||||
) -> Result<Vec<CapacityMemoryRecord>> {
|
||||
if k == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut newest: Option<(SystemTime, Vec<CapacityMemoryRecord>)> = None;
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
|
||||
for path in paths {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match load_last_k_capacity_records_from_path(path, k) {
|
||||
Ok(records) => {
|
||||
if records.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let modified = fs::metadata(path)
|
||||
.and_then(|meta| meta.modified())
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
let should_replace = newest
|
||||
.as_ref()
|
||||
.map(|(current, _)| modified >= *current)
|
||||
.unwrap_or(true);
|
||||
if should_replace {
|
||||
newest = Some((modified, records));
|
||||
}
|
||||
}
|
||||
Err(err) => last_err = Some(err),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((_, records)) = newest {
|
||||
return Ok(records);
|
||||
}
|
||||
if let Some(err) = last_err {
|
||||
return Err(err);
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_record_id() -> String {
|
||||
format!("cap_{}", &uuid::Uuid::new_v4().to_string()[..8])
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn now_rfc3339() -> String {
|
||||
Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn memory_jsonl_round_trip() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let path = tmp.path().join("session.jsonl");
|
||||
|
||||
let record = CapacityMemoryRecord {
|
||||
id: "cap_1".to_string(),
|
||||
ts: now_rfc3339(),
|
||||
turn_index: 2,
|
||||
action_trigger: "targeted_context_refresh".to_string(),
|
||||
h_hat: 1.2,
|
||||
c_hat: 3.8,
|
||||
slack: 2.6,
|
||||
risk_band: "medium".to_string(),
|
||||
canonical_state: CanonicalState {
|
||||
goal: "Ship feature".to_string(),
|
||||
..CanonicalState::default()
|
||||
},
|
||||
source_message_ids: vec!["m1".to_string()],
|
||||
replay_info: None,
|
||||
};
|
||||
|
||||
append_capacity_record_to_path(&path, &record).expect("append");
|
||||
let records = load_last_k_capacity_records_from_path(&path, 1).expect("load");
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].canonical_state.goal, "Ship feature");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_falls_back_to_next_candidate_path() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let blocked_root = tmp.path().join("blocked");
|
||||
fs::write(&blocked_root, "file").expect("create blocking file");
|
||||
let blocked_path = blocked_root.join("session.jsonl");
|
||||
let fallback_path = tmp.path().join("fallback").join("session.jsonl");
|
||||
|
||||
let record = CapacityMemoryRecord {
|
||||
id: "cap_fallback".to_string(),
|
||||
ts: now_rfc3339(),
|
||||
turn_index: 1,
|
||||
action_trigger: "targeted_context_refresh".to_string(),
|
||||
h_hat: 1.0,
|
||||
c_hat: 3.8,
|
||||
slack: 2.8,
|
||||
risk_band: "medium".to_string(),
|
||||
canonical_state: CanonicalState::default(),
|
||||
source_message_ids: vec!["m1".to_string()],
|
||||
replay_info: None,
|
||||
};
|
||||
|
||||
let chosen = append_capacity_record_to_candidates(
|
||||
&[blocked_path.clone(), fallback_path.clone()],
|
||||
&record,
|
||||
)
|
||||
.expect("append with fallback");
|
||||
assert_eq!(chosen, fallback_path);
|
||||
assert!(chosen.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_prefers_newest_candidate_records() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let older = tmp.path().join("older.jsonl");
|
||||
let newer = tmp.path().join("newer.jsonl");
|
||||
|
||||
let old_record = CapacityMemoryRecord {
|
||||
id: "cap_old".to_string(),
|
||||
ts: now_rfc3339(),
|
||||
turn_index: 1,
|
||||
action_trigger: "targeted_context_refresh".to_string(),
|
||||
h_hat: 1.0,
|
||||
c_hat: 3.8,
|
||||
slack: 2.8,
|
||||
risk_band: "medium".to_string(),
|
||||
canonical_state: CanonicalState {
|
||||
goal: "old".to_string(),
|
||||
..CanonicalState::default()
|
||||
},
|
||||
source_message_ids: vec!["m1".to_string()],
|
||||
replay_info: None,
|
||||
};
|
||||
let new_record = CapacityMemoryRecord {
|
||||
id: "cap_new".to_string(),
|
||||
ts: now_rfc3339(),
|
||||
turn_index: 2,
|
||||
action_trigger: "verify_and_replan".to_string(),
|
||||
h_hat: 1.4,
|
||||
c_hat: 3.8,
|
||||
slack: 2.4,
|
||||
risk_band: "high".to_string(),
|
||||
canonical_state: CanonicalState {
|
||||
goal: "new".to_string(),
|
||||
..CanonicalState::default()
|
||||
},
|
||||
source_message_ids: vec!["m2".to_string()],
|
||||
replay_info: None,
|
||||
};
|
||||
|
||||
append_capacity_record_to_path(&older, &old_record).expect("write older");
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
append_capacity_record_to_path(&newer, &new_record).expect("write newer");
|
||||
|
||||
let records = load_last_k_capacity_records_from_candidates(&[older, newer], 1)
|
||||
.expect("load newest records");
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].canonical_state.goal, "new");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Plain-language session coherence state derived from capacity events.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::capacity::{GuardrailAction, RiskBand};
|
||||
|
||||
/// User-facing coherence ladder for session health.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CoherenceState {
|
||||
#[default]
|
||||
Healthy,
|
||||
GettingCrowded,
|
||||
RefreshingContext,
|
||||
VerifyingRecentWork,
|
||||
ResettingPlan,
|
||||
}
|
||||
|
||||
impl CoherenceState {
|
||||
#[must_use]
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Healthy => "healthy",
|
||||
Self::GettingCrowded => "getting crowded",
|
||||
Self::RefreshingContext => "refreshing context",
|
||||
Self::VerifyingRecentWork => "verifying recent work",
|
||||
Self::ResettingPlan => "resetting plan",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
Self::Healthy => "The session is stable and focused.",
|
||||
Self::GettingCrowded => "The session is approaching context pressure.",
|
||||
Self::RefreshingContext => "The engine is refreshing context before continuing.",
|
||||
Self::VerifyingRecentWork => {
|
||||
"The engine is checking recent tool results before continuing."
|
||||
}
|
||||
Self::ResettingPlan => {
|
||||
"The engine is rebuilding from canonical context and replanning."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthetic input to the coherence reducer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CoherenceSignal {
|
||||
CapacityDecision {
|
||||
risk_band: RiskBand,
|
||||
action: GuardrailAction,
|
||||
cooldown_blocked: bool,
|
||||
},
|
||||
CapacityIntervention {
|
||||
action: GuardrailAction,
|
||||
},
|
||||
CompactionStarted,
|
||||
CompactionCompleted,
|
||||
CompactionFailed,
|
||||
}
|
||||
|
||||
/// Pure transition function for the plain-language coherence ladder.
|
||||
#[must_use]
|
||||
pub fn next_coherence_state(current: CoherenceState, signal: CoherenceSignal) -> CoherenceState {
|
||||
match signal {
|
||||
CoherenceSignal::CompactionStarted => CoherenceState::RefreshingContext,
|
||||
CoherenceSignal::CompactionCompleted => CoherenceState::Healthy,
|
||||
CoherenceSignal::CompactionFailed => CoherenceState::GettingCrowded,
|
||||
CoherenceSignal::CapacityIntervention { action }
|
||||
| CoherenceSignal::CapacityDecision { action, .. } => match action {
|
||||
GuardrailAction::NoIntervention => match signal {
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band,
|
||||
cooldown_blocked,
|
||||
..
|
||||
} => {
|
||||
if cooldown_blocked {
|
||||
return current;
|
||||
}
|
||||
match risk_band {
|
||||
RiskBand::Low => CoherenceState::Healthy,
|
||||
RiskBand::Medium | RiskBand::High => CoherenceState::GettingCrowded,
|
||||
}
|
||||
}
|
||||
_ => current,
|
||||
},
|
||||
GuardrailAction::TargetedContextRefresh => CoherenceState::RefreshingContext,
|
||||
GuardrailAction::VerifyWithToolReplay => CoherenceState::VerifyingRecentWork,
|
||||
GuardrailAction::VerifyAndReplan => CoherenceState::ResettingPlan,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn synthetic_capacity_event_log_drives_plain_language_ladder() {
|
||||
let log = [
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band: RiskBand::Low,
|
||||
action: GuardrailAction::NoIntervention,
|
||||
cooldown_blocked: false,
|
||||
},
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band: RiskBand::Medium,
|
||||
action: GuardrailAction::NoIntervention,
|
||||
cooldown_blocked: false,
|
||||
},
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band: RiskBand::Medium,
|
||||
action: GuardrailAction::TargetedContextRefresh,
|
||||
cooldown_blocked: false,
|
||||
},
|
||||
CoherenceSignal::CompactionCompleted,
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band: RiskBand::High,
|
||||
action: GuardrailAction::VerifyWithToolReplay,
|
||||
cooldown_blocked: false,
|
||||
},
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band: RiskBand::High,
|
||||
action: GuardrailAction::VerifyAndReplan,
|
||||
cooldown_blocked: false,
|
||||
},
|
||||
];
|
||||
|
||||
let mut state = CoherenceState::Healthy;
|
||||
let mut states = Vec::new();
|
||||
for signal in log {
|
||||
state = next_coherence_state(state, signal);
|
||||
states.push(state);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
states,
|
||||
vec![
|
||||
CoherenceState::Healthy,
|
||||
CoherenceState::GettingCrowded,
|
||||
CoherenceState::RefreshingContext,
|
||||
CoherenceState::Healthy,
|
||||
CoherenceState::VerifyingRecentWork,
|
||||
CoherenceState::ResettingPlan,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,127 @@
|
||||
//! Approval + user-input handshake for the agent loop.
|
||||
//!
|
||||
//! Extracted from `core/engine.rs` (P1.3). The agent loop blocks on these
|
||||
//! two futures whenever a tool requires explicit approval (`await_tool_approval`)
|
||||
//! or whenever a tool requests live user input (`await_user_input`). Channels
|
||||
//! and engine state stay private to the parent module.
|
||||
|
||||
use crate::core::events::Event;
|
||||
use crate::tools::spec::ToolError;
|
||||
use crate::tools::user_input::{UserInputRequest, UserInputResponse};
|
||||
|
||||
use super::Engine;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) enum ApprovalDecision {
|
||||
Approved {
|
||||
id: String,
|
||||
},
|
||||
Denied {
|
||||
id: String,
|
||||
},
|
||||
/// Retry a tool with an elevated sandbox policy.
|
||||
RetryWithPolicy {
|
||||
id: String,
|
||||
policy: crate::sandbox::SandboxPolicy,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) enum UserInputDecision {
|
||||
Submitted {
|
||||
id: String,
|
||||
response: UserInputResponse,
|
||||
},
|
||||
Cancelled {
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Result of awaiting tool approval from the user.
|
||||
#[derive(Debug)]
|
||||
pub(super) enum ApprovalResult {
|
||||
/// User approved the tool execution.
|
||||
Approved,
|
||||
/// User denied the tool execution.
|
||||
Denied,
|
||||
/// User requested retry with an elevated sandbox policy.
|
||||
RetryWithPolicy(crate::sandbox::SandboxPolicy),
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub(super) async fn await_tool_approval(
|
||||
&mut self,
|
||||
tool_id: &str,
|
||||
) -> Result<ApprovalResult, ToolError> {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = self.cancel_token.cancelled() => {
|
||||
return Err(ToolError::execution_failed(
|
||||
"Request cancelled while awaiting approval".to_string(),
|
||||
));
|
||||
}
|
||||
decision = self.rx_approval.recv() => {
|
||||
let Some(decision) = decision else {
|
||||
return Err(ToolError::execution_failed(
|
||||
"Approval channel closed".to_string(),
|
||||
));
|
||||
};
|
||||
match decision {
|
||||
ApprovalDecision::Approved { id } if id == tool_id => {
|
||||
return Ok(ApprovalResult::Approved);
|
||||
}
|
||||
ApprovalDecision::Denied { id } if id == tool_id => {
|
||||
return Ok(ApprovalResult::Denied);
|
||||
}
|
||||
ApprovalDecision::RetryWithPolicy { id, policy } if id == tool_id => {
|
||||
return Ok(ApprovalResult::RetryWithPolicy(policy));
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn await_user_input(
|
||||
&mut self,
|
||||
tool_id: &str,
|
||||
request: UserInputRequest,
|
||||
) -> Result<UserInputResponse, ToolError> {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::UserInputRequired {
|
||||
id: tool_id.to_string(),
|
||||
request,
|
||||
})
|
||||
.await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = self.cancel_token.cancelled() => {
|
||||
return Err(ToolError::execution_failed(
|
||||
"Request cancelled while awaiting user input".to_string(),
|
||||
));
|
||||
}
|
||||
decision = self.rx_user_input.recv() => {
|
||||
let Some(decision) = decision else {
|
||||
return Err(ToolError::execution_failed(
|
||||
"User input channel closed".to_string(),
|
||||
));
|
||||
};
|
||||
match decision {
|
||||
UserInputDecision::Submitted { id, response } if id == tool_id => {
|
||||
return Ok(response);
|
||||
}
|
||||
UserInputDecision::Cancelled { id } if id == tool_id => {
|
||||
return Err(ToolError::execution_failed(
|
||||
"User input cancelled".to_string(),
|
||||
));
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,973 @@
|
||||
//! Capacity-controller checkpoints and interventions for the engine loop.
|
||||
//!
|
||||
//! Extracted from `core/engine.rs` for issue #74. The main turn loop still
|
||||
//! decides when checkpoints run; this module owns the guardrail policy side
|
||||
//! effects, replay verification, canonical-state persistence, and event
|
||||
//! emission helpers.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl Engine {
|
||||
pub(super) async fn run_capacity_pre_request_checkpoint(
|
||||
&mut self,
|
||||
turn: &TurnContext,
|
||||
client: Option<&DeepSeekClient>,
|
||||
mode: AppMode,
|
||||
) -> bool {
|
||||
let snapshot = self
|
||||
.capacity_controller
|
||||
.observe_pre_turn(self.capacity_observation(turn));
|
||||
let decision = self
|
||||
.capacity_controller
|
||||
.decide(self.turn_counter, snapshot.as_ref());
|
||||
self.emit_capacity_decision(turn, snapshot.as_ref(), &decision)
|
||||
.await;
|
||||
|
||||
if decision.action != GuardrailAction::TargetedContextRefresh {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.apply_targeted_context_refresh(turn, client, mode, snapshot.as_ref())
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn run_capacity_post_tool_checkpoint(
|
||||
&mut self,
|
||||
turn: &TurnContext,
|
||||
mode: AppMode,
|
||||
tool_registry: Option<&crate::tools::ToolRegistry>,
|
||||
tool_exec_lock: Arc<RwLock<()>>,
|
||||
mcp_pool: Option<Arc<AsyncMutex<McpPool>>>,
|
||||
_step_error_count: usize,
|
||||
_consecutive_tool_error_steps: u32,
|
||||
) -> bool {
|
||||
let snapshot = self
|
||||
.capacity_controller
|
||||
.observe_post_tool(self.capacity_observation(turn));
|
||||
let decision = self
|
||||
.capacity_controller
|
||||
.decide(self.turn_counter, snapshot.as_ref());
|
||||
self.emit_capacity_decision(turn, snapshot.as_ref(), &decision)
|
||||
.await;
|
||||
|
||||
match decision.action {
|
||||
GuardrailAction::VerifyWithToolReplay => {
|
||||
let _ = self
|
||||
.apply_verify_with_tool_replay(
|
||||
turn,
|
||||
mode,
|
||||
snapshot.as_ref(),
|
||||
tool_registry,
|
||||
tool_exec_lock,
|
||||
mcp_pool,
|
||||
)
|
||||
.await;
|
||||
false
|
||||
}
|
||||
GuardrailAction::VerifyAndReplan => {
|
||||
self.apply_verify_and_replan(turn, mode, snapshot.as_ref(), "high_risk_post_tool")
|
||||
.await
|
||||
}
|
||||
GuardrailAction::NoIntervention | GuardrailAction::TargetedContextRefresh => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn run_capacity_error_escalation_checkpoint(
|
||||
&mut self,
|
||||
turn: &TurnContext,
|
||||
mode: AppMode,
|
||||
step_error_count: usize,
|
||||
consecutive_tool_error_steps: u32,
|
||||
error_categories: &[ErrorCategory],
|
||||
) -> bool {
|
||||
if step_error_count == 0 && consecutive_tool_error_steps < 2 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Categorize this step's failures by typed `ErrorCategory` rather than
|
||||
// substring-matching error strings. Context overflow always escalates;
|
||||
// network / rate-limit / timeout are transient and skip escalation;
|
||||
// anything else only escalates with consecutive consecutive failures.
|
||||
let has_context_overflow = error_categories.contains(&ErrorCategory::InvalidInput);
|
||||
let only_transient = !error_categories.is_empty()
|
||||
&& error_categories.iter().all(|c| {
|
||||
matches!(
|
||||
c,
|
||||
ErrorCategory::Network | ErrorCategory::RateLimit | ErrorCategory::Timeout
|
||||
)
|
||||
});
|
||||
if only_transient && !has_context_overflow {
|
||||
return false;
|
||||
}
|
||||
if !has_context_overflow && consecutive_tool_error_steps < 2 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let snapshot = self
|
||||
.capacity_controller
|
||||
.last_snapshot()
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
self.capacity_controller
|
||||
.observe_pre_turn(self.capacity_observation(turn))
|
||||
});
|
||||
let Some(snapshot) = snapshot else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let repeated_failures = step_error_count >= 2 || consecutive_tool_error_steps >= 2;
|
||||
let mut forced = snapshot.clone();
|
||||
if repeated_failures && !(snapshot.risk_band == RiskBand::High && snapshot.severe) {
|
||||
forced.risk_band = RiskBand::High;
|
||||
forced.severe = true;
|
||||
}
|
||||
|
||||
let decision = self
|
||||
.capacity_controller
|
||||
.decide(self.turn_counter, Some(&forced));
|
||||
self.emit_capacity_decision(turn, Some(&forced), &decision)
|
||||
.await;
|
||||
|
||||
if decision.action != GuardrailAction::VerifyAndReplan {
|
||||
return false;
|
||||
}
|
||||
|
||||
let category_labels: Vec<String> = error_categories.iter().map(|c| c.to_string()).collect();
|
||||
self.apply_verify_and_replan(
|
||||
turn,
|
||||
mode,
|
||||
Some(&forced),
|
||||
&format!(
|
||||
"error_escalation: step_errors={}, consecutive_steps={}, categories={}",
|
||||
step_error_count,
|
||||
consecutive_tool_error_steps,
|
||||
category_labels.join(",")
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) fn capacity_observation(&self, turn: &TurnContext) -> CapacityObservationInput {
|
||||
let message_window = self.config.capacity.profile_window.max(8) * 3;
|
||||
let action_count_this_turn = usize::try_from(turn.step)
|
||||
.unwrap_or(usize::MAX)
|
||||
.saturating_add(turn.tool_calls.len())
|
||||
.saturating_add(1);
|
||||
let tool_calls_recent_window = self.recent_tool_call_count(message_window);
|
||||
let unique_reference_ids_recent_window =
|
||||
self.recent_unique_reference_count(message_window, turn);
|
||||
let context_window = usize::try_from(
|
||||
context_window_for_model(&self.session.model).unwrap_or(DEFAULT_CONTEXT_WINDOW_TOKENS),
|
||||
)
|
||||
.unwrap_or(usize::try_from(DEFAULT_CONTEXT_WINDOW_TOKENS).unwrap_or(128_000))
|
||||
.max(1);
|
||||
let context_used_ratio = (self.estimated_input_tokens() as f64) / (context_window as f64);
|
||||
|
||||
CapacityObservationInput {
|
||||
turn_index: self.turn_counter,
|
||||
model: self.session.model.clone(),
|
||||
action_count_this_turn,
|
||||
tool_calls_recent_window,
|
||||
unique_reference_ids_recent_window,
|
||||
context_used_ratio,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn recent_tool_call_count(&self, message_window: usize) -> usize {
|
||||
self.session
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.take(message_window)
|
||||
.map(|msg| {
|
||||
msg.content
|
||||
.iter()
|
||||
.filter(|block| {
|
||||
matches!(
|
||||
block,
|
||||
ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. }
|
||||
)
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub(super) fn recent_unique_reference_count(
|
||||
&self,
|
||||
message_window: usize,
|
||||
turn: &TurnContext,
|
||||
) -> usize {
|
||||
let mut refs = std::collections::HashSet::new();
|
||||
for msg in self.session.messages.iter().rev().take(message_window) {
|
||||
for block in &msg.content {
|
||||
match block {
|
||||
ContentBlock::ToolUse { id, .. } => {
|
||||
refs.insert(id.clone());
|
||||
}
|
||||
ContentBlock::ToolResult { tool_use_id, .. } => {
|
||||
refs.insert(tool_use_id.clone());
|
||||
}
|
||||
ContentBlock::Text { text, .. } => {
|
||||
for token in text.split_whitespace() {
|
||||
if token.contains('/') || token.contains('.') {
|
||||
refs.insert(
|
||||
token
|
||||
.trim_matches(|c: char| ",.;:()[]{}".contains(c))
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
ContentBlock::Thinking { .. }
|
||||
| ContentBlock::ServerToolUse { .. }
|
||||
| ContentBlock::ToolSearchToolResult { .. }
|
||||
| ContentBlock::CodeExecutionToolResult { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
for tool_call in turn.tool_calls.iter().rev().take(8) {
|
||||
refs.insert(tool_call.id.clone());
|
||||
}
|
||||
for path in self.session.working_set.top_paths(8) {
|
||||
refs.insert(path);
|
||||
}
|
||||
refs.retain(|item| !item.is_empty());
|
||||
refs.len()
|
||||
}
|
||||
|
||||
pub(super) async fn emit_coherence_signal(
|
||||
&mut self,
|
||||
signal: CoherenceSignal,
|
||||
reason: impl Into<String>,
|
||||
) {
|
||||
let next = next_coherence_state(self.coherence_state, signal);
|
||||
self.coherence_state = next;
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CoherenceState {
|
||||
state: next,
|
||||
label: next.label().to_string(),
|
||||
description: next.description().to_string(),
|
||||
reason: reason.into(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(super) async fn emit_compaction_started(
|
||||
&mut self,
|
||||
id: String,
|
||||
auto: bool,
|
||||
message: String,
|
||||
) {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionStarted {
|
||||
id,
|
||||
auto,
|
||||
message: message.clone(),
|
||||
})
|
||||
.await;
|
||||
self.emit_coherence_signal(CoherenceSignal::CompactionStarted, message)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(super) async fn emit_compaction_completed(
|
||||
&mut self,
|
||||
id: String,
|
||||
auto: bool,
|
||||
message: String,
|
||||
messages_before: Option<usize>,
|
||||
messages_after: Option<usize>,
|
||||
) {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionCompleted {
|
||||
id,
|
||||
auto,
|
||||
message: message.clone(),
|
||||
messages_before,
|
||||
messages_after,
|
||||
})
|
||||
.await;
|
||||
self.emit_coherence_signal(CoherenceSignal::CompactionCompleted, message)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(super) async fn emit_compaction_failed(&mut self, id: String, auto: bool, message: String) {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionFailed {
|
||||
id,
|
||||
auto,
|
||||
message: message.clone(),
|
||||
})
|
||||
.await;
|
||||
self.emit_coherence_signal(CoherenceSignal::CompactionFailed, message)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(super) async fn emit_capacity_decision(
|
||||
&mut self,
|
||||
turn: &TurnContext,
|
||||
snapshot: Option<&CapacitySnapshot>,
|
||||
decision: &CapacityDecision,
|
||||
) {
|
||||
let Some(snapshot) = snapshot else {
|
||||
return;
|
||||
};
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CapacityDecision {
|
||||
session_id: self.session.id.clone(),
|
||||
turn_id: turn.id.clone(),
|
||||
h_hat: snapshot.h_hat,
|
||||
c_hat: snapshot.c_hat,
|
||||
slack: snapshot.slack,
|
||||
min_slack: snapshot.profile.min_slack,
|
||||
violation_ratio: snapshot.profile.violation_ratio,
|
||||
p_fail: snapshot.p_fail,
|
||||
risk_band: snapshot.risk_band.as_str().to_string(),
|
||||
action: decision.action.as_str().to_string(),
|
||||
cooldown_blocked: decision.cooldown_blocked,
|
||||
reason: decision.reason.clone(),
|
||||
})
|
||||
.await;
|
||||
self.emit_coherence_signal(
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band: snapshot.risk_band,
|
||||
action: decision.action,
|
||||
cooldown_blocked: decision.cooldown_blocked,
|
||||
},
|
||||
format!(
|
||||
"capacity_decision: risk={} action={} reason={}",
|
||||
snapshot.risk_band.as_str(),
|
||||
decision.action.as_str(),
|
||||
decision.reason
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(super) async fn emit_capacity_intervention(
|
||||
&mut self,
|
||||
turn: &TurnContext,
|
||||
action: GuardrailAction,
|
||||
before_prompt_tokens: usize,
|
||||
after_prompt_tokens: usize,
|
||||
replay_outcome: Option<String>,
|
||||
replan_performed: bool,
|
||||
) {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CapacityIntervention {
|
||||
session_id: self.session.id.clone(),
|
||||
turn_id: turn.id.clone(),
|
||||
action: action.as_str().to_string(),
|
||||
before_prompt_tokens,
|
||||
after_prompt_tokens,
|
||||
compaction_size_reduction: before_prompt_tokens.saturating_sub(after_prompt_tokens),
|
||||
replay_outcome,
|
||||
replan_performed,
|
||||
})
|
||||
.await;
|
||||
self.emit_coherence_signal(
|
||||
CoherenceSignal::CapacityIntervention { action },
|
||||
format!("capacity_intervention: action={}", action.as_str()),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(super) async fn apply_targeted_context_refresh(
|
||||
&mut self,
|
||||
turn: &TurnContext,
|
||||
client: Option<&DeepSeekClient>,
|
||||
mode: AppMode,
|
||||
snapshot: Option<&CapacitySnapshot>,
|
||||
) -> bool {
|
||||
let before_tokens = self.estimated_input_tokens();
|
||||
let compaction_pins = self
|
||||
.session
|
||||
.working_set
|
||||
.pinned_message_indices(&self.session.messages, &self.session.workspace);
|
||||
let compaction_paths = self.session.working_set.top_paths(24);
|
||||
|
||||
let mut refreshed = false;
|
||||
let should_run_summary_compaction = self.config.compaction.enabled
|
||||
&& should_compact(
|
||||
&self.session.messages,
|
||||
&self.config.compaction,
|
||||
Some(&self.session.workspace),
|
||||
Some(&compaction_pins),
|
||||
Some(&compaction_paths),
|
||||
);
|
||||
if should_run_summary_compaction && let Some(client) = client {
|
||||
match compact_messages_safe(
|
||||
client,
|
||||
&self.session.messages,
|
||||
&self.config.compaction,
|
||||
Some(&self.session.workspace),
|
||||
Some(&compaction_pins),
|
||||
Some(&compaction_paths),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
if !result.messages.is_empty() || self.session.messages.is_empty() {
|
||||
self.session.messages = result.messages;
|
||||
self.merge_compaction_summary(result.summary_prompt);
|
||||
refreshed = true;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(format!(
|
||||
"Capacity refresh compaction failed: {err}. Falling back to local trim."
|
||||
)))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !refreshed {
|
||||
let target_budget = context_input_budget(&self.session.model, TURN_MAX_OUTPUT_TOKENS)
|
||||
.unwrap_or(self.config.compaction.token_threshold.max(1));
|
||||
if self.estimated_input_tokens() > target_budget {
|
||||
let trimmed = self.trim_oldest_messages_to_budget(target_budget);
|
||||
refreshed = trimmed > 0;
|
||||
}
|
||||
}
|
||||
|
||||
if !refreshed {
|
||||
return false;
|
||||
}
|
||||
|
||||
let canonical = self.build_canonical_state(turn, None);
|
||||
let source_message_ids = self.capacity_source_message_ids(turn);
|
||||
let record = self.build_capacity_record(
|
||||
turn,
|
||||
GuardrailAction::TargetedContextRefresh,
|
||||
snapshot,
|
||||
canonical.clone(),
|
||||
source_message_ids,
|
||||
None,
|
||||
);
|
||||
let pointer = self
|
||||
.persist_capacity_record(turn, GuardrailAction::TargetedContextRefresh, &record)
|
||||
.await;
|
||||
self.merge_compaction_summary(Some(self.canonical_prompt(
|
||||
&canonical,
|
||||
&pointer,
|
||||
GuardrailAction::TargetedContextRefresh,
|
||||
None,
|
||||
)));
|
||||
self.refresh_system_prompt(mode);
|
||||
self.emit_session_updated().await;
|
||||
|
||||
let after_tokens = self.estimated_input_tokens();
|
||||
self.emit_capacity_intervention(
|
||||
turn,
|
||||
GuardrailAction::TargetedContextRefresh,
|
||||
before_tokens,
|
||||
after_tokens,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
self.capacity_controller
|
||||
.mark_intervention_applied(self.turn_counter, GuardrailAction::TargetedContextRefresh);
|
||||
true
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn apply_verify_with_tool_replay(
|
||||
&mut self,
|
||||
turn: &TurnContext,
|
||||
mode: AppMode,
|
||||
snapshot: Option<&CapacitySnapshot>,
|
||||
tool_registry: Option<&crate::tools::ToolRegistry>,
|
||||
tool_exec_lock: Arc<RwLock<()>>,
|
||||
mut mcp_pool: Option<Arc<AsyncMutex<McpPool>>>,
|
||||
) -> bool {
|
||||
let before_tokens = self.estimated_input_tokens();
|
||||
let Some(candidate) = self.select_replay_candidate(turn, tool_registry) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if McpPool::is_mcp_tool(&candidate.name) && mcp_pool.is_none() {
|
||||
mcp_pool = self.ensure_mcp_pool().await.ok();
|
||||
}
|
||||
|
||||
let supports_parallel = if McpPool::is_mcp_tool(&candidate.name) {
|
||||
mcp_tool_is_parallel_safe(&candidate.name)
|
||||
} else {
|
||||
tool_registry
|
||||
.and_then(|registry| registry.get(&candidate.name))
|
||||
.is_some_and(|spec| spec.supports_parallel())
|
||||
};
|
||||
let interactive = (candidate.name == "exec_shell"
|
||||
&& candidate
|
||||
.input
|
||||
.get("interactive")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
== Some(true))
|
||||
|| candidate.name == REQUEST_USER_INPUT_NAME;
|
||||
|
||||
let replay_result = Self::execute_tool_with_lock(
|
||||
tool_exec_lock,
|
||||
supports_parallel,
|
||||
interactive,
|
||||
self.tx_event.clone(),
|
||||
candidate.name.clone(),
|
||||
candidate.input.clone(),
|
||||
tool_registry,
|
||||
mcp_pool.clone(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (pass, replay_outcome, diff_summary) = match replay_result {
|
||||
Ok(output) => {
|
||||
let original = candidate.result.as_deref().unwrap_or_default();
|
||||
let replay = output.content.as_str();
|
||||
let equal = original.trim() == replay.trim();
|
||||
let diff = if equal {
|
||||
"output_match".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"output_mismatch: original='{}' replay='{}'",
|
||||
summarize_text(original, 140),
|
||||
summarize_text(replay, 140)
|
||||
)
|
||||
};
|
||||
(
|
||||
equal,
|
||||
if equal {
|
||||
"pass".to_string()
|
||||
} else {
|
||||
"conflict".to_string()
|
||||
},
|
||||
diff,
|
||||
)
|
||||
}
|
||||
Err(err) => {
|
||||
self.capacity_controller
|
||||
.mark_replay_failed(self.turn_counter);
|
||||
(
|
||||
false,
|
||||
"error".to_string(),
|
||||
format!("replay_error: {}", summarize_text(&err.to_string(), 180)),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let verification_note = format!(
|
||||
"[verification replay] tool={} pass={} details={}",
|
||||
candidate.name, pass, diff_summary
|
||||
);
|
||||
self.add_session_message(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
tool_use_id: candidate.id.clone(),
|
||||
content: verification_note.clone(),
|
||||
is_error: None,
|
||||
content_blocks: None,
|
||||
}],
|
||||
})
|
||||
.await;
|
||||
|
||||
if !pass {
|
||||
self.capacity_controller
|
||||
.mark_replay_failed(self.turn_counter);
|
||||
}
|
||||
|
||||
let canonical = self.build_canonical_state(
|
||||
turn,
|
||||
Some(if pass {
|
||||
"replay verification passed"
|
||||
} else {
|
||||
"replay verification failed or conflicted"
|
||||
}),
|
||||
);
|
||||
let replay_info = Some(ReplayInfo {
|
||||
tool_id: candidate.id.clone(),
|
||||
tool_name: candidate.name.clone(),
|
||||
pass,
|
||||
diff_summary: diff_summary.clone(),
|
||||
});
|
||||
let source_message_ids = self.capacity_source_message_ids(turn);
|
||||
let record = self.build_capacity_record(
|
||||
turn,
|
||||
GuardrailAction::VerifyWithToolReplay,
|
||||
snapshot,
|
||||
canonical.clone(),
|
||||
source_message_ids,
|
||||
replay_info,
|
||||
);
|
||||
let pointer = self
|
||||
.persist_capacity_record(turn, GuardrailAction::VerifyWithToolReplay, &record)
|
||||
.await;
|
||||
self.merge_compaction_summary(Some(self.canonical_prompt(
|
||||
&canonical,
|
||||
&pointer,
|
||||
GuardrailAction::VerifyWithToolReplay,
|
||||
Some(&verification_note),
|
||||
)));
|
||||
self.refresh_system_prompt(mode);
|
||||
self.emit_session_updated().await;
|
||||
|
||||
let after_tokens = self.estimated_input_tokens();
|
||||
self.emit_capacity_intervention(
|
||||
turn,
|
||||
GuardrailAction::VerifyWithToolReplay,
|
||||
before_tokens,
|
||||
after_tokens,
|
||||
Some(replay_outcome),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
self.capacity_controller
|
||||
.mark_intervention_applied(self.turn_counter, GuardrailAction::VerifyWithToolReplay);
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) async fn apply_verify_and_replan(
|
||||
&mut self,
|
||||
turn: &TurnContext,
|
||||
mode: AppMode,
|
||||
snapshot: Option<&CapacitySnapshot>,
|
||||
reason: &str,
|
||||
) -> bool {
|
||||
let before_tokens = self.estimated_input_tokens();
|
||||
let canonical = self.build_canonical_state(turn, Some(reason));
|
||||
let source_message_ids = self.capacity_source_message_ids(turn);
|
||||
let record = self.build_capacity_record(
|
||||
turn,
|
||||
GuardrailAction::VerifyAndReplan,
|
||||
snapshot,
|
||||
canonical.clone(),
|
||||
source_message_ids,
|
||||
None,
|
||||
);
|
||||
let pointer = self
|
||||
.persist_capacity_record(turn, GuardrailAction::VerifyAndReplan, &record)
|
||||
.await;
|
||||
|
||||
let latest_user = self
|
||||
.session
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|msg| {
|
||||
msg.role == "user"
|
||||
&& msg
|
||||
.content
|
||||
.iter()
|
||||
.any(|block| matches!(block, ContentBlock::Text { .. }))
|
||||
})
|
||||
.cloned();
|
||||
let latest_verified = self
|
||||
.session
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|msg| {
|
||||
msg.role == "user"
|
||||
&& msg.content.iter().any(|block| match block {
|
||||
ContentBlock::ToolResult { content, .. } => {
|
||||
content.contains("[verification replay]")
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
})
|
||||
.cloned();
|
||||
|
||||
self.session.messages.clear();
|
||||
if let Some(msg) = latest_user {
|
||||
self.session.messages.push(msg);
|
||||
}
|
||||
if let Some(msg) = latest_verified {
|
||||
self.session.messages.push(msg);
|
||||
}
|
||||
|
||||
self.merge_compaction_summary(Some(self.canonical_prompt(
|
||||
&canonical,
|
||||
&pointer,
|
||||
GuardrailAction::VerifyAndReplan,
|
||||
Some("Replan now from canonical state. Keep steps minimal and verifiable."),
|
||||
)));
|
||||
self.refresh_system_prompt(mode);
|
||||
self.emit_session_updated().await;
|
||||
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(
|
||||
"Capacity guardrail: context reset to canonical state; replanning step."
|
||||
.to_string(),
|
||||
))
|
||||
.await;
|
||||
|
||||
let after_tokens = self.estimated_input_tokens();
|
||||
self.emit_capacity_intervention(
|
||||
turn,
|
||||
GuardrailAction::VerifyAndReplan,
|
||||
before_tokens,
|
||||
after_tokens,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
self.capacity_controller
|
||||
.mark_intervention_applied(self.turn_counter, GuardrailAction::VerifyAndReplan);
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn select_replay_candidate(
|
||||
&self,
|
||||
turn: &TurnContext,
|
||||
tool_registry: Option<&crate::tools::ToolRegistry>,
|
||||
) -> Option<TurnToolCall> {
|
||||
turn.tool_calls
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|call| {
|
||||
call.error.is_none()
|
||||
&& call.result.is_some()
|
||||
&& self.tool_is_replayable_read_only(&call.name, tool_registry)
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(super) fn tool_is_replayable_read_only(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
tool_registry: Option<&crate::tools::ToolRegistry>,
|
||||
) -> bool {
|
||||
if tool_name == MULTI_TOOL_PARALLEL_NAME || tool_name == REQUEST_USER_INPUT_NAME {
|
||||
return false;
|
||||
}
|
||||
if McpPool::is_mcp_tool(tool_name) {
|
||||
return mcp_tool_is_read_only(tool_name);
|
||||
}
|
||||
tool_registry
|
||||
.and_then(|registry| registry.get(tool_name))
|
||||
.is_some_and(|spec| spec.is_read_only())
|
||||
}
|
||||
|
||||
pub(super) fn build_canonical_state(
|
||||
&self,
|
||||
turn: &TurnContext,
|
||||
note: Option<&str>,
|
||||
) -> CanonicalState {
|
||||
let goal = self
|
||||
.session
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|msg| {
|
||||
if msg.role != "user" {
|
||||
return None;
|
||||
}
|
||||
msg.content.iter().find_map(|block| match block {
|
||||
ContentBlock::Text { text, .. } => Some(summarize_text(text, 220)),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| "Continue current task from compact state".to_string());
|
||||
|
||||
let mut constraints = vec![
|
||||
format!("model={}", self.session.model),
|
||||
format!("workspace={}", self.session.workspace.display()),
|
||||
];
|
||||
if let Some(note) = note {
|
||||
constraints.push(summarize_text(note, 180));
|
||||
}
|
||||
|
||||
let mut confirmed_facts = Vec::new();
|
||||
for msg in self.session.messages.iter().rev() {
|
||||
for block in &msg.content {
|
||||
if let ContentBlock::ToolResult { content, .. } = block {
|
||||
if content.starts_with("Error:") {
|
||||
continue;
|
||||
}
|
||||
confirmed_facts.push(summarize_text(content, 180));
|
||||
if confirmed_facts.len() >= 4 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if confirmed_facts.len() >= 4 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let open_loops: Vec<String> = turn
|
||||
.tool_calls
|
||||
.iter()
|
||||
.rev()
|
||||
.filter_map(|call| {
|
||||
call.error
|
||||
.as_ref()
|
||||
.map(|error| format!("{}: {}", call.name, summarize_text(error, 180)))
|
||||
})
|
||||
.take(4)
|
||||
.collect();
|
||||
|
||||
let pending_actions: Vec<String> = if open_loops.is_empty() {
|
||||
vec!["Continue with next smallest verifiable step".to_string()]
|
||||
} else {
|
||||
vec![
|
||||
"Re-evaluate failed tool steps with narrower scope".to_string(),
|
||||
"Re-derive plan from canonical facts before further edits".to_string(),
|
||||
]
|
||||
};
|
||||
|
||||
let mut critical_refs = self.session.working_set.top_paths(8);
|
||||
for tool_call in turn.tool_calls.iter().rev().take(4) {
|
||||
critical_refs.push(format!("tool:{}", tool_call.id));
|
||||
}
|
||||
critical_refs.dedup();
|
||||
|
||||
CanonicalState {
|
||||
goal,
|
||||
constraints,
|
||||
confirmed_facts,
|
||||
open_loops,
|
||||
pending_actions,
|
||||
critical_refs,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn canonical_prompt(
|
||||
&self,
|
||||
canonical: &CanonicalState,
|
||||
pointer: &str,
|
||||
action: GuardrailAction,
|
||||
extra: Option<&str>,
|
||||
) -> SystemPrompt {
|
||||
let mut lines = vec![
|
||||
COMPACTION_SUMMARY_MARKER.to_string(),
|
||||
format!("Capacity Canonical State [{}]", action.as_str()),
|
||||
format!("Goal: {}", canonical.goal),
|
||||
"Constraints:".to_string(),
|
||||
];
|
||||
for item in &canonical.constraints {
|
||||
lines.push(format!("- {}", summarize_text(item, 200)));
|
||||
}
|
||||
lines.push("Confirmed Facts:".to_string());
|
||||
for item in &canonical.confirmed_facts {
|
||||
lines.push(format!("- {}", summarize_text(item, 200)));
|
||||
}
|
||||
lines.push("Open Loops:".to_string());
|
||||
if canonical.open_loops.is_empty() {
|
||||
lines.push("- none".to_string());
|
||||
} else {
|
||||
for item in &canonical.open_loops {
|
||||
lines.push(format!("- {}", summarize_text(item, 200)));
|
||||
}
|
||||
}
|
||||
lines.push("Pending Actions:".to_string());
|
||||
for item in &canonical.pending_actions {
|
||||
lines.push(format!("- {}", summarize_text(item, 200)));
|
||||
}
|
||||
lines.push("Critical Refs:".to_string());
|
||||
for item in &canonical.critical_refs {
|
||||
lines.push(format!("- {}", summarize_text(item, 200)));
|
||||
}
|
||||
if let Some(extra) = extra {
|
||||
lines.push(format!("Instruction: {}", summarize_text(extra, 240)));
|
||||
}
|
||||
lines.push(format!("Memory Pointer: {pointer}"));
|
||||
|
||||
SystemPrompt::Blocks(vec![crate::models::SystemBlock {
|
||||
block_type: "text".to_string(),
|
||||
text: lines.join("\n"),
|
||||
cache_control: None,
|
||||
}])
|
||||
}
|
||||
|
||||
pub(super) fn capacity_source_message_ids(&self, turn: &TurnContext) -> Vec<String> {
|
||||
let mut ids: Vec<String> = turn
|
||||
.tool_calls
|
||||
.iter()
|
||||
.rev()
|
||||
.take(8)
|
||||
.map(|call| call.id.clone())
|
||||
.collect();
|
||||
ids.reverse();
|
||||
ids
|
||||
}
|
||||
|
||||
pub(super) fn build_capacity_record(
|
||||
&self,
|
||||
turn: &TurnContext,
|
||||
action: GuardrailAction,
|
||||
snapshot: Option<&CapacitySnapshot>,
|
||||
canonical: CanonicalState,
|
||||
source_message_ids: Vec<String>,
|
||||
replay_info: Option<ReplayInfo>,
|
||||
) -> CapacityMemoryRecord {
|
||||
let (h_hat, c_hat, slack, risk_band) = snapshot
|
||||
.map(|s| (s.h_hat, s.c_hat, s.slack, s.risk_band.as_str().to_string()))
|
||||
.unwrap_or_else(|| (0.0, 0.0, 0.0, "unknown".to_string()));
|
||||
|
||||
CapacityMemoryRecord {
|
||||
id: new_record_id(),
|
||||
ts: now_rfc3339(),
|
||||
turn_index: self.turn_counter,
|
||||
action_trigger: action.as_str().to_string(),
|
||||
h_hat,
|
||||
c_hat,
|
||||
slack,
|
||||
risk_band,
|
||||
canonical_state: canonical,
|
||||
source_message_ids: if source_message_ids.is_empty() {
|
||||
vec![turn.id.clone()]
|
||||
} else {
|
||||
source_message_ids
|
||||
},
|
||||
replay_info,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn persist_capacity_record(
|
||||
&mut self,
|
||||
turn: &TurnContext,
|
||||
action: GuardrailAction,
|
||||
record: &CapacityMemoryRecord,
|
||||
) -> String {
|
||||
let pointer = format!("memory://{}/{}", self.session.id, record.id);
|
||||
if let Err(err) = append_capacity_record(&self.session.id, record) {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CapacityMemoryPersistFailed {
|
||||
session_id: self.session.id.clone(),
|
||||
turn_id: turn.id.clone(),
|
||||
action: action.as_str().to_string(),
|
||||
error: summarize_text(&err.to_string(), 280),
|
||||
})
|
||||
.await;
|
||||
return format!("{pointer}?persist=failed");
|
||||
}
|
||||
pointer
|
||||
}
|
||||
|
||||
pub(super) fn rehydrate_latest_canonical_state(&mut self) {
|
||||
let Ok(records) = load_last_k_capacity_records(&self.session.id, 1) else {
|
||||
return;
|
||||
};
|
||||
let Some(last) = records.last() else {
|
||||
return;
|
||||
};
|
||||
let pointer = format!("memory://{}/{}", self.session.id, last.id);
|
||||
let prompt = self.canonical_prompt(
|
||||
&last.canonical_state,
|
||||
&pointer,
|
||||
GuardrailAction::NoIntervention,
|
||||
Some("Rehydrated canonical state from memory."),
|
||||
);
|
||||
self.merge_compaction_summary(Some(prompt));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//! Tool dispatch — plan/execute helpers for the per-turn tool batch.
|
||||
//!
|
||||
//! Extracted from `core/engine.rs` (P1.3). The high-level ordering still
|
||||
//! lives in `Engine::handle_deepseek_turn`; this module owns:
|
||||
//!
|
||||
//! * Streaming-buffer parsing into a finalized `serde_json::Value` tool input
|
||||
//! (`final_tool_input`, `parse_tool_input`, fenced/JSON segment helpers).
|
||||
//! * The `multi_tool_use.parallel` payload parser.
|
||||
//! * Policy predicates the turn loop consults — when a batch can run in
|
||||
//! parallel, when an `update_plan` step should stop the turn, when a Plan
|
||||
//! prompt should force a plan-first hop, and the small set of read-only
|
||||
//! MCP tools that are safe to run in parallel.
|
||||
//! * The tool execution plan/outcome types the batch driver passes around.
|
||||
//!
|
||||
//! All items are `pub(super)`-only: the public engine surface (Op/Event,
|
||||
//! `EngineHandle`, `spawn_engine`) stays in `core/engine.rs`.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::models::ToolCaller;
|
||||
use crate::tools::spec::{ToolError, ToolResult};
|
||||
use crate::tui::app::AppMode;
|
||||
|
||||
use super::ToolUseState;
|
||||
|
||||
// === Types ============================================================
|
||||
|
||||
#[allow(dead_code)] // `index` mirrors batch order for diagnostic ergonomics.
|
||||
pub(super) struct ToolExecOutcome {
|
||||
pub(super) index: usize,
|
||||
pub(super) id: String,
|
||||
pub(super) name: String,
|
||||
pub(super) input: serde_json::Value,
|
||||
pub(super) started_at: std::time::Instant,
|
||||
pub(super) result: Result<ToolResult, ToolError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ToolExecutionPlan {
|
||||
pub(super) index: usize,
|
||||
pub(super) id: String,
|
||||
pub(super) name: String,
|
||||
pub(super) input: serde_json::Value,
|
||||
pub(super) caller: Option<ToolCaller>,
|
||||
pub(super) interactive: bool,
|
||||
pub(super) approval_required: bool,
|
||||
pub(super) approval_description: String,
|
||||
pub(super) supports_parallel: bool,
|
||||
pub(super) read_only: bool,
|
||||
pub(super) blocked_error: Option<ToolError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub(super) struct ParallelToolResultEntry {
|
||||
pub(super) tool_name: String,
|
||||
pub(super) success: bool,
|
||||
pub(super) content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub(super) struct ParallelToolResult {
|
||||
pub(super) results: Vec<ParallelToolResultEntry>,
|
||||
}
|
||||
|
||||
// Hold the lock guard for the duration of a tool execution.
|
||||
// The inner guards are held for RAII purposes (dropped when the guard is dropped).
|
||||
pub(super) enum ToolExecGuard<'a> {
|
||||
Read(#[allow(dead_code)] tokio::sync::RwLockReadGuard<'a, ()>),
|
||||
Write(#[allow(dead_code)] tokio::sync::RwLockWriteGuard<'a, ()>),
|
||||
}
|
||||
|
||||
// === Streaming-buffer parsing =========================================
|
||||
|
||||
/// Promote a streaming `ToolUseState` to a finalized JSON input.
|
||||
///
|
||||
/// Order of preference:
|
||||
///
|
||||
/// 1. `input_buffer` (the raw streamed delta concatenation) — parsed as
|
||||
/// JSON. This is the most authoritative because it's what the model
|
||||
/// actually emitted.
|
||||
/// 2. `input` (the per-delta best-effort parse mirror) — used when the
|
||||
/// buffer is empty (pre-streaming tool calls take this path).
|
||||
/// 3. `input_buffer` non-empty but unparseable → fall back to `input`
|
||||
/// (the per-delta parser has already mirrored the most recent valid
|
||||
/// partial parse into `tool_state.input`).
|
||||
pub(super) fn final_tool_input(state: &ToolUseState) -> serde_json::Value {
|
||||
if !state.input_buffer.trim().is_empty()
|
||||
&& let Some(parsed) = parse_tool_input(&state.input_buffer)
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
state.input.clone()
|
||||
}
|
||||
|
||||
pub(super) fn parse_tool_input(buffer: &str) -> Option<serde_json::Value> {
|
||||
let trimmed = buffer.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
return Some(value);
|
||||
}
|
||||
if let Some(stripped) = strip_code_fences(trimmed)
|
||||
&& let Ok(value) = serde_json::from_str::<serde_json::Value>(&stripped)
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
if let Ok(serde_json::Value::String(inner)) = serde_json::from_str::<serde_json::Value>(trimmed)
|
||||
&& let Ok(value) = serde_json::from_str::<serde_json::Value>(&inner)
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
extract_json_segment(trimmed)
|
||||
.and_then(|segment| serde_json::from_str::<serde_json::Value>(&segment).ok())
|
||||
}
|
||||
|
||||
fn strip_code_fences(text: &str) -> Option<String> {
|
||||
if !text.contains("```") {
|
||||
return None;
|
||||
}
|
||||
let mut lines = Vec::new();
|
||||
for line in text.lines() {
|
||||
if line.trim_start().starts_with("```") {
|
||||
continue;
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
let stripped = lines.join("\n");
|
||||
let stripped = stripped.trim();
|
||||
if stripped.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(stripped.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_json_segment(text: &str) -> Option<String> {
|
||||
extract_balanced_segment(text, '{', '}').or_else(|| extract_balanced_segment(text, '[', ']'))
|
||||
}
|
||||
|
||||
fn extract_balanced_segment(text: &str, open: char, close: char) -> Option<String> {
|
||||
let start = text.find(open)?;
|
||||
let mut depth = 0i32;
|
||||
let mut end = None;
|
||||
for (offset, ch) in text[start..].char_indices() {
|
||||
if ch == open {
|
||||
depth += 1;
|
||||
} else if ch == close {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
end = Some(start + offset + ch.len_utf8());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
end.map(|end_idx| text[start..end_idx].to_string())
|
||||
}
|
||||
|
||||
fn normalize_parallel_tool_name(raw: &str) -> String {
|
||||
let mut name = raw.trim();
|
||||
for prefix in ["functions.", "tools.", "tool."] {
|
||||
if let Some(stripped) = name.strip_prefix(prefix) {
|
||||
name = stripped;
|
||||
break;
|
||||
}
|
||||
}
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
pub(super) fn parse_parallel_tool_calls(
|
||||
input: &serde_json::Value,
|
||||
) -> Result<Vec<(String, serde_json::Value)>, ToolError> {
|
||||
let tool_uses = input
|
||||
.get("tool_uses")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| ToolError::missing_field("tool_uses"))?;
|
||||
if tool_uses.is_empty() {
|
||||
return Err(ToolError::invalid_input(
|
||||
"multi_tool_use.parallel requires at least one tool call",
|
||||
));
|
||||
}
|
||||
|
||||
let mut calls = Vec::with_capacity(tool_uses.len());
|
||||
for item in tool_uses {
|
||||
let name = item
|
||||
.get("recipient_name")
|
||||
.or_else(|| item.get("tool_name"))
|
||||
.or_else(|| item.get("name"))
|
||||
.or_else(|| item.get("tool"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::missing_field("recipient_name"))?;
|
||||
let params = item
|
||||
.get("parameters")
|
||||
.or_else(|| item.get("input"))
|
||||
.or_else(|| item.get("args"))
|
||||
.or_else(|| item.get("arguments"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}));
|
||||
calls.push((normalize_parallel_tool_name(name), params));
|
||||
}
|
||||
|
||||
Ok(calls)
|
||||
}
|
||||
|
||||
// === Dispatch policy ==================================================
|
||||
|
||||
pub(super) fn should_parallelize_tool_batch(plans: &[ToolExecutionPlan]) -> bool {
|
||||
!plans.is_empty()
|
||||
&& plans.iter().all(|plan| {
|
||||
plan.read_only && plan.supports_parallel && !plan.approval_required && !plan.interactive
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn should_stop_after_plan_tool(
|
||||
mode: AppMode,
|
||||
tool_name: &str,
|
||||
result: &Result<ToolResult, ToolError>,
|
||||
) -> bool {
|
||||
mode == AppMode::Plan && tool_name == "update_plan" && result.is_ok()
|
||||
}
|
||||
|
||||
pub(super) fn should_force_update_plan_first(mode: AppMode, content: &str) -> bool {
|
||||
if mode != AppMode::Plan {
|
||||
return false;
|
||||
}
|
||||
|
||||
let lower = content.to_ascii_lowercase();
|
||||
let asks_for_direct_plan = [
|
||||
"quick plan",
|
||||
"short plan",
|
||||
"simple plan",
|
||||
"3-step plan",
|
||||
"3 step plan",
|
||||
"three-step plan",
|
||||
"three step plan",
|
||||
"high-level plan",
|
||||
"high level plan",
|
||||
"give me a plan",
|
||||
"make a plan",
|
||||
"outline a plan",
|
||||
"draft a plan",
|
||||
]
|
||||
.iter()
|
||||
.any(|needle| lower.contains(needle));
|
||||
|
||||
if !asks_for_direct_plan {
|
||||
return false;
|
||||
}
|
||||
|
||||
let asks_for_repo_exploration = [
|
||||
"inspect the repo",
|
||||
"inspect the code",
|
||||
"explore the repo",
|
||||
"search the repo",
|
||||
"read the code",
|
||||
"review the code",
|
||||
"analyze the code",
|
||||
"investigate",
|
||||
"look through",
|
||||
"understand the current",
|
||||
"ground it in the codebase",
|
||||
"based on the codebase",
|
||||
]
|
||||
.iter()
|
||||
.any(|needle| lower.contains(needle));
|
||||
|
||||
!asks_for_repo_exploration
|
||||
}
|
||||
|
||||
pub(super) fn mcp_tool_is_parallel_safe(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"list_mcp_resources"
|
||||
| "list_mcp_resource_templates"
|
||||
| "mcp_read_resource"
|
||||
| "read_mcp_resource"
|
||||
| "mcp_get_prompt"
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn mcp_tool_is_read_only(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"list_mcp_resources"
|
||||
| "list_mcp_resource_templates"
|
||||
| "mcp_read_resource"
|
||||
| "read_mcp_resource"
|
||||
| "mcp_get_prompt"
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn mcp_tool_approval_description(name: &str) -> String {
|
||||
if mcp_tool_is_read_only(name) {
|
||||
format!("Read-only MCP tool '{name}'")
|
||||
} else {
|
||||
format!("MCP tool '{name}' may have side effects")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,278 @@
|
||||
//! Events emitted by the core engine to the UI.
|
||||
//!
|
||||
//! These events flow from the engine to the TUI via a channel,
|
||||
//! enabling non-blocking, real-time updates.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::core::coherence::CoherenceState;
|
||||
use crate::error_taxonomy::ErrorEnvelope;
|
||||
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,
|
||||
},
|
||||
|
||||
/// Checkpoint-restart cycle boundary advanced (issue #124). The previous
|
||||
/// cycle has already been archived to disk; the engine has swapped its
|
||||
/// in-memory message buffer for the seed messages of cycle `to`.
|
||||
/// Carries the full briefing record so the UI can populate
|
||||
/// `app.cycle_briefings` for `/cycle <n>`.
|
||||
CycleAdvanced {
|
||||
from: u32,
|
||||
to: u32,
|
||||
briefing: crate::cycle_manager::CycleBriefing,
|
||||
},
|
||||
|
||||
/// Capacity decision telemetry.
|
||||
#[allow(dead_code)]
|
||||
CapacityDecision {
|
||||
session_id: String,
|
||||
turn_id: String,
|
||||
h_hat: f64,
|
||||
c_hat: f64,
|
||||
slack: f64,
|
||||
min_slack: f64,
|
||||
violation_ratio: f64,
|
||||
p_fail: f64,
|
||||
risk_band: String,
|
||||
action: String,
|
||||
cooldown_blocked: bool,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
/// Capacity intervention telemetry.
|
||||
#[allow(dead_code)]
|
||||
CapacityIntervention {
|
||||
session_id: String,
|
||||
turn_id: String,
|
||||
action: String,
|
||||
before_prompt_tokens: usize,
|
||||
after_prompt_tokens: usize,
|
||||
compaction_size_reduction: usize,
|
||||
replay_outcome: Option<String>,
|
||||
replan_performed: bool,
|
||||
},
|
||||
|
||||
/// Capacity memory persistence failure telemetry.
|
||||
#[allow(dead_code)]
|
||||
CapacityMemoryPersistFailed {
|
||||
session_id: String,
|
||||
turn_id: String,
|
||||
action: String,
|
||||
error: String,
|
||||
},
|
||||
|
||||
/// Plain-language session coherence state.
|
||||
CoherenceState {
|
||||
state: CoherenceState,
|
||||
label: String,
|
||||
description: String,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
// === Sub-Agent Events ===
|
||||
/// A sub-agent has been spawned
|
||||
AgentSpawned { id: String, prompt: String },
|
||||
|
||||
/// Sub-agent progress update
|
||||
AgentProgress { id: String, status: String },
|
||||
|
||||
/// Sub-agent completed
|
||||
AgentComplete { id: String, result: String },
|
||||
|
||||
/// Sub-agent listing
|
||||
AgentList { agents: Vec<SubAgentResult> },
|
||||
|
||||
/// Structured sub-agent mailbox envelope (issue #128). Carries the
|
||||
/// monotonic seq + the typed `MailboxMessage` so the UI can route each
|
||||
/// envelope to the correct in-transcript card.
|
||||
SubAgentMailbox {
|
||||
seq: u64,
|
||||
message: crate::tools::subagent::MailboxMessage,
|
||||
},
|
||||
|
||||
// === System Events ===
|
||||
/// An error occurred
|
||||
Error {
|
||||
envelope: ErrorEnvelope,
|
||||
#[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,
|
||||
/// Fingerprint key for per‑call approval caching (§5.A).
|
||||
approval_key: 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 an error event from a categorized envelope. The envelope's own
|
||||
/// `recoverable` flag controls whether the UI flips into offline mode.
|
||||
pub fn error(envelope: ErrorEnvelope) -> Self {
|
||||
let recoverable = envelope.recoverable;
|
||||
Event::Error {
|
||||
envelope,
|
||||
recoverable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new status event
|
||||
pub fn status(message: impl Into<String>) -> Self {
|
||||
Event::Status {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,9 @@
|
||||
//! - `session`: Session state management
|
||||
//! - `turn`: Turn context and tracking
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod capacity;
|
||||
pub mod capacity_memory;
|
||||
pub mod coherence;
|
||||
pub mod engine;
|
||||
pub mod events;
|
||||
pub mod ops;
|
||||
@@ -16,29 +16,39 @@ pub enum Op {
|
||||
content: String,
|
||||
mode: AppMode,
|
||||
model: String,
|
||||
/// Reasoning-effort tier: `"off" | "low" | "medium" | "high" | "max"`.
|
||||
/// `None` lets the provider apply its default.
|
||||
reasoning_effort: Option<String>,
|
||||
allow_shell: bool,
|
||||
trust_mode: bool,
|
||||
auto_approve: bool,
|
||||
},
|
||||
|
||||
/// Cancel the current request
|
||||
#[allow(dead_code)]
|
||||
CancelRequest,
|
||||
|
||||
/// Approve a tool call that requires permission
|
||||
#[allow(dead_code)]
|
||||
ApproveToolCall { id: String },
|
||||
|
||||
/// Deny a tool call that requires permission
|
||||
#[allow(dead_code)]
|
||||
DenyToolCall { id: String },
|
||||
|
||||
/// Spawn a sub-agent (for RLM mode)
|
||||
/// Spawn a sub-agent
|
||||
#[allow(dead_code)]
|
||||
SpawnSubAgent { prompt: String },
|
||||
|
||||
/// List current sub-agents and their status
|
||||
ListSubAgents,
|
||||
|
||||
/// Change the operating mode
|
||||
#[allow(dead_code)]
|
||||
ChangeMode { mode: AppMode },
|
||||
|
||||
/// Update the model being used
|
||||
#[allow(dead_code)]
|
||||
SetModel { model: String },
|
||||
|
||||
/// Update auto-compaction settings
|
||||
@@ -52,6 +62,24 @@ pub enum Op {
|
||||
workspace: PathBuf,
|
||||
},
|
||||
|
||||
/// Run context compaction immediately.
|
||||
CompactContext,
|
||||
|
||||
/// Run a Recursive Language Model (RLM) turn per Algorithm 1 of
|
||||
/// Zhang et al. (arXiv:2512.24601). The prompt is stored in the REPL
|
||||
/// as `context`; the root LLM only sees metadata.
|
||||
Rlm {
|
||||
/// The user's prompt — stored in REPL, NOT in the LLM context.
|
||||
content: String,
|
||||
/// The model to use for root LLM calls.
|
||||
model: String,
|
||||
/// The model to use for sub-LLM (llm_query) calls.
|
||||
child_model: String,
|
||||
/// Recursion budget for `sub_rlm()` calls. Paper experiments use
|
||||
/// depth=1; defaults set by the `/rlm` command.
|
||||
max_depth: u32,
|
||||
},
|
||||
|
||||
/// Shutdown the engine
|
||||
Shutdown,
|
||||
}
|
||||
@@ -62,20 +90,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
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
//!
|
||||
//! Tracks conversation history, token usage, and session metadata.
|
||||
|
||||
use crate::cycle_manager::CycleBriefing;
|
||||
use crate::models::{Message, SystemPrompt, Usage};
|
||||
use crate::project_context::{ProjectContext, load_project_context_with_parents};
|
||||
use crate::working_set::WorkingSet;
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Session state for the engine.
|
||||
@@ -13,11 +15,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 +40,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,
|
||||
|
||||
@@ -45,6 +57,19 @@ pub struct Session {
|
||||
|
||||
/// Repo-aware working set for context management.
|
||||
pub working_set: WorkingSet,
|
||||
|
||||
/// Number of cycle boundaries crossed in this session (issue #124). The
|
||||
/// active cycle index is `cycle_count + 1` (cycles are 1-based for users).
|
||||
pub cycle_count: u32,
|
||||
|
||||
/// UTC start time of the *current* cycle. Updated when the engine resets
|
||||
/// the conversation buffer. Used by archive headers and the `/cycles`
|
||||
/// command's display.
|
||||
pub current_cycle_started: DateTime<Utc>,
|
||||
|
||||
/// Briefings produced at past cycle boundaries, in chronological order.
|
||||
/// Bounded growth: one entry per cycle, briefing capped at ~3,000 tokens.
|
||||
pub cycle_briefings: Vec<CycleBriefing>,
|
||||
}
|
||||
|
||||
/// Cumulative usage statistics for a session.
|
||||
@@ -53,7 +78,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 +89,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 +114,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(),
|
||||
@@ -101,16 +132,12 @@ impl Session {
|
||||
None
|
||||
},
|
||||
working_set: WorkingSet::default(),
|
||||
cycle_count: 0,
|
||||
current_cycle_started: Utc::now(),
|
||||
cycle_briefings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +148,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::*;
|
||||
@@ -2,8 +2,20 @@
|
||||
//!
|
||||
//! A "turn" is one user message and the resulting AI response,
|
||||
//! including any tool calls that occur.
|
||||
//!
|
||||
//! ## Snapshot lifecycle hooks
|
||||
//!
|
||||
//! [`pre_turn_snapshot`] and [`post_turn_snapshot`] book-end a turn by
|
||||
//! taking a workspace-level snapshot into a side git repo (see
|
||||
//! `crate::snapshot`). They are intentionally non-blocking and
|
||||
//! non-fatal: any IO error is logged at WARN and swallowed so a busted
|
||||
//! filesystem or missing `git` binary never derails the agent loop.
|
||||
//! `/restore N` and the `revert_turn` tool both consume these
|
||||
//! snapshots.
|
||||
|
||||
use crate::models::Usage;
|
||||
use crate::snapshot::SnapshotRepo;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Context for a single turn (user message + AI response).
|
||||
@@ -13,6 +25,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 +38,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 +69,7 @@ impl TurnContext {
|
||||
usage: Usage {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
..Usage::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -76,11 +91,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 +106,55 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a `pre-turn:<seq>` workspace snapshot.
|
||||
///
|
||||
/// Returns the snapshot SHA on success, `None` on any error. Errors are
|
||||
/// logged at WARN; the turn loop must not block on this.
|
||||
pub fn pre_turn_snapshot(workspace: &Path, turn_seq: u64) -> Option<String> {
|
||||
snapshot_with_label(workspace, &format!("pre-turn:{turn_seq}"))
|
||||
}
|
||||
|
||||
/// Take a `post-turn:<seq>` workspace snapshot. Same failure model as
|
||||
/// [`pre_turn_snapshot`].
|
||||
pub fn post_turn_snapshot(workspace: &Path, turn_seq: u64) -> Option<String> {
|
||||
snapshot_with_label(workspace, &format!("post-turn:{turn_seq}"))
|
||||
}
|
||||
|
||||
fn snapshot_with_label(workspace: &Path, label: &str) -> Option<String> {
|
||||
match SnapshotRepo::open_or_init(workspace) {
|
||||
Ok(repo) => match repo.snapshot(label) {
|
||||
Ok(id) => Some(id.0),
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "snapshot", "snapshot '{label}' failed: {e}");
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "snapshot", "snapshot repo init failed: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,176 @@
|
||||
//! Whale/DeepSeek terminal theme tokens.
|
||||
//!
|
||||
//! A small, deliberately flat module that names the color, border, and
|
||||
//! padding choices the TUI is already making. All values match the dark
|
||||
//! palette previously hard-coded against [`crate::palette`]; a single
|
||||
//! source-of-truth change here can swap the skin later. Visible output
|
||||
//! is not changed by introducing this module.
|
||||
//!
|
||||
//! The only consumers today are the plan and tool cell renderers in
|
||||
//! [`crate::tui::history`] and the sidebar section chrome in
|
||||
//! [`crate::tui::ui`]. All other call sites continue to use [`crate::palette`]
|
||||
//! directly until they are migrated in a later slice.
|
||||
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::widgets::{BorderType, Borders, Padding};
|
||||
|
||||
use crate::palette;
|
||||
use crate::tui::history::ToolStatus;
|
||||
|
||||
/// Visual variant exposed by the theme.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Variant {
|
||||
Dark,
|
||||
}
|
||||
|
||||
/// Centralized visual tokens for sidebar, plan, and tool rendering.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Theme {
|
||||
pub variant: Variant,
|
||||
|
||||
// Sidebar / section chrome
|
||||
pub section_borders: Borders,
|
||||
pub section_border_type: BorderType,
|
||||
pub section_border_color: Color,
|
||||
pub section_bg: Color,
|
||||
pub section_title_color: Color,
|
||||
pub section_padding: Padding,
|
||||
|
||||
// Tool cell color tokens
|
||||
pub tool_title_color: Color,
|
||||
pub tool_value_color: Color,
|
||||
pub tool_label_color: Color,
|
||||
pub tool_running_accent: Color,
|
||||
pub tool_success_accent: Color,
|
||||
pub tool_failed_accent: Color,
|
||||
|
||||
// Plan cell color tokens
|
||||
pub plan_progress_color: Color,
|
||||
pub plan_summary_color: Color,
|
||||
pub plan_explanation_color: Color,
|
||||
pub plan_pending_color: Color,
|
||||
pub plan_in_progress_color: Color,
|
||||
pub plan_completed_color: Color,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// The current dark theme. Visible output today uses these values.
|
||||
#[must_use]
|
||||
pub const fn dark() -> Self {
|
||||
Self {
|
||||
variant: Variant::Dark,
|
||||
section_borders: Borders::ALL,
|
||||
section_border_type: BorderType::Plain,
|
||||
section_border_color: palette::BORDER_COLOR,
|
||||
section_bg: palette::DEEPSEEK_INK,
|
||||
section_title_color: palette::DEEPSEEK_BLUE,
|
||||
// Horizontal padding only. `Padding::uniform(1)` ate two rows of
|
||||
// each sidebar panel — for compact terminals where Plan/Todos/Tasks
|
||||
// get ~3 rows total via the 25% layout split, that left zero rows
|
||||
// for content (#63 follow-up: panels rendered as empty boxes even
|
||||
// when "No todos" / "No active plan" should have shown).
|
||||
section_padding: Padding::horizontal(1),
|
||||
tool_title_color: palette::TEXT_SOFT,
|
||||
tool_value_color: palette::TEXT_MUTED,
|
||||
tool_label_color: palette::TEXT_DIM,
|
||||
tool_running_accent: palette::ACCENT_TOOL_LIVE,
|
||||
tool_success_accent: palette::TEXT_DIM,
|
||||
tool_failed_accent: palette::ACCENT_TOOL_ISSUE,
|
||||
plan_progress_color: palette::STATUS_SUCCESS,
|
||||
plan_summary_color: palette::TEXT_MUTED,
|
||||
plan_explanation_color: palette::TEXT_DIM,
|
||||
plan_pending_color: palette::TEXT_MUTED,
|
||||
plan_in_progress_color: palette::STATUS_WARNING,
|
||||
plan_completed_color: palette::STATUS_SUCCESS,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the right tool accent for a given [`ToolStatus`].
|
||||
#[must_use]
|
||||
pub const fn tool_status_color(self, status: ToolStatus) -> Color {
|
||||
match status {
|
||||
ToolStatus::Running => self.tool_running_accent,
|
||||
ToolStatus::Success => self.tool_success_accent,
|
||||
ToolStatus::Failed => self.tool_failed_accent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bold tool title style (e.g. "Plan", "Shell").
|
||||
#[must_use]
|
||||
pub fn tool_title_style(self) -> Style {
|
||||
Style::default()
|
||||
.fg(self.tool_title_color)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
/// Right-side status text ("running", "done", "issue") style.
|
||||
#[must_use]
|
||||
pub fn tool_status_style(self, status: ToolStatus) -> Style {
|
||||
Style::default().fg(self.tool_status_color(status))
|
||||
}
|
||||
|
||||
/// Detail label style ("command:", "time:", step markers).
|
||||
#[must_use]
|
||||
pub fn tool_label_style(self) -> Style {
|
||||
Style::default().fg(self.tool_label_color)
|
||||
}
|
||||
|
||||
/// Default value style for tool detail rows.
|
||||
#[must_use]
|
||||
pub fn tool_value_style(self) -> Style {
|
||||
Style::default().fg(self.tool_value_color)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the active theme used by the TUI today.
|
||||
///
|
||||
/// Today this is always `Theme::dark()`. A future PR can wire this to an
|
||||
/// `App` field or a config setting in five lines.
|
||||
#[must_use]
|
||||
pub const fn active_theme() -> Theme {
|
||||
Theme::dark()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Theme, Variant, active_theme};
|
||||
use crate::palette;
|
||||
use crate::tui::history::ToolStatus;
|
||||
|
||||
#[test]
|
||||
fn active_theme_returns_dark() {
|
||||
assert_eq!(active_theme(), Theme::dark());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_theme_matches_existing_palette_choices() {
|
||||
let theme = Theme::dark();
|
||||
assert_eq!(theme.variant, Variant::Dark);
|
||||
assert_eq!(theme.section_border_color, palette::BORDER_COLOR);
|
||||
assert_eq!(theme.section_bg, palette::DEEPSEEK_INK);
|
||||
assert_eq!(theme.section_title_color, palette::DEEPSEEK_BLUE);
|
||||
assert_eq!(theme.tool_title_color, palette::TEXT_SOFT);
|
||||
assert_eq!(theme.tool_value_color, palette::TEXT_MUTED);
|
||||
assert_eq!(theme.tool_label_color, palette::TEXT_DIM);
|
||||
assert_eq!(theme.tool_running_accent, palette::ACCENT_TOOL_LIVE);
|
||||
assert_eq!(theme.tool_success_accent, palette::TEXT_DIM);
|
||||
assert_eq!(theme.tool_failed_accent, palette::ACCENT_TOOL_ISSUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_status_color_maps_each_status() {
|
||||
let theme = Theme::dark();
|
||||
assert_eq!(
|
||||
theme.tool_status_color(ToolStatus::Running),
|
||||
theme.tool_running_accent
|
||||
);
|
||||
assert_eq!(
|
||||
theme.tool_status_color(ToolStatus::Success),
|
||||
theme.tool_success_accent
|
||||
);
|
||||
assert_eq!(
|
||||
theme.tool_status_color(ToolStatus::Failed),
|
||||
theme.tool_failed_accent
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
//! Shared error taxonomy across client, tools, runtime, and UI.
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recoverable internal error — stream stalls, transient retries, generic
|
||||
/// engine errors that the user can resolve by retrying. Severity is
|
||||
/// `Warning` so the UI surfaces it in amber rather than red.
|
||||
#[must_use]
|
||||
pub fn transient(message: impl Into<String>) -> Self {
|
||||
Self::new(
|
||||
ErrorCategory::Internal,
|
||||
ErrorSeverity::Warning,
|
||||
true,
|
||||
"transient",
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
/// Non-recoverable internal error — missing client, spawn failure, etc.
|
||||
/// Flips the session into offline mode.
|
||||
#[must_use]
|
||||
pub fn fatal(message: impl Into<String>) -> Self {
|
||||
Self::new(
|
||||
ErrorCategory::Internal,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"fatal",
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
/// Authentication failure — fatal and blocks the session.
|
||||
#[must_use]
|
||||
pub fn fatal_auth(message: impl Into<String>) -> Self {
|
||||
Self::new(
|
||||
ErrorCategory::Authentication,
|
||||
ErrorSeverity::Critical,
|
||||
false,
|
||||
"auth_fatal",
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
/// Context length / overflow — invalid input, recoverable via /compact.
|
||||
#[must_use]
|
||||
pub fn context_overflow(message: impl Into<String>) -> Self {
|
||||
Self::new(
|
||||
ErrorCategory::InvalidInput,
|
||||
ErrorSeverity::Error,
|
||||
true,
|
||||
"context_overflow",
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
/// Recoverable network / transport hiccup.
|
||||
#[must_use]
|
||||
pub fn network(message: impl Into<String>) -> Self {
|
||||
Self::new(
|
||||
ErrorCategory::Network,
|
||||
ErrorSeverity::Warning,
|
||||
true,
|
||||
"network_transient",
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
/// Tool execution failure.
|
||||
#[must_use]
|
||||
pub fn tool(message: impl Into<String>) -> Self {
|
||||
Self::new(
|
||||
ErrorCategory::Tool,
|
||||
ErrorSeverity::Error,
|
||||
true,
|
||||
"tool_failed",
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build an envelope by classifying a raw error message string. Used at
|
||||
/// boundaries where the underlying error type was already stringified.
|
||||
#[must_use]
|
||||
pub fn classify(message: impl Into<String>, recoverable: bool) -> Self {
|
||||
let message = message.into();
|
||||
let category = classify_error_message(&message);
|
||||
let severity = match category {
|
||||
ErrorCategory::Authentication => ErrorSeverity::Critical,
|
||||
ErrorCategory::RateLimit | ErrorCategory::Timeout | ErrorCategory::Network => {
|
||||
ErrorSeverity::Warning
|
||||
}
|
||||
ErrorCategory::InvalidInput | ErrorCategory::Authorization | ErrorCategory::Parse => {
|
||||
ErrorSeverity::Error
|
||||
}
|
||||
ErrorCategory::Tool | ErrorCategory::State | ErrorCategory::Internal => {
|
||||
if recoverable {
|
||||
ErrorSeverity::Warning
|
||||
} else {
|
||||
ErrorSeverity::Error
|
||||
}
|
||||
}
|
||||
};
|
||||
Self::new(
|
||||
category,
|
||||
severity,
|
||||
recoverable,
|
||||
category.to_string(),
|
||||
message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify an error message string into an ErrorCategory.
|
||||
///
|
||||
/// Uses heuristic keyword matching on the lowercased message.
|
||||
/// This is a replacement for ad-hoc string matching in callers.
|
||||
#[must_use]
|
||||
pub fn classify_error_message(message: &str) -> ErrorCategory {
|
||||
let lower = message.to_lowercase();
|
||||
|
||||
if lower.contains("maximum context length")
|
||||
|| lower.contains("context length")
|
||||
|| lower.contains("context_length")
|
||||
|| lower.contains("prompt is too long")
|
||||
|| (lower.contains("requested") && lower.contains("tokens") && lower.contains("maximum"))
|
||||
|| lower.contains("context window")
|
||||
{
|
||||
return ErrorCategory::InvalidInput;
|
||||
}
|
||||
if lower.contains("rate limit")
|
||||
|| lower.contains("too many requests")
|
||||
|| lower.contains("429")
|
||||
|| lower.contains("quota")
|
||||
{
|
||||
return ErrorCategory::RateLimit;
|
||||
}
|
||||
if lower.contains("timeout") || lower.contains("timed out") {
|
||||
return ErrorCategory::Timeout;
|
||||
}
|
||||
if lower.contains("auth") || lower.contains("unauthorized") || lower.contains("api key") {
|
||||
return ErrorCategory::Authentication;
|
||||
}
|
||||
if lower.contains("permission") || lower.contains("forbidden") || lower.contains("denied") {
|
||||
return ErrorCategory::Authorization;
|
||||
}
|
||||
if lower.contains("network")
|
||||
|| lower.contains("connection")
|
||||
|| lower.contains("dns")
|
||||
|| lower.contains("temporarily unavailable")
|
||||
|| lower.contains(" 502 ")
|
||||
|| lower.contains(" 503 ")
|
||||
|| lower.contains(" 504 ")
|
||||
|| lower.starts_with("502 ")
|
||||
|| lower.starts_with("503 ")
|
||||
|| lower.starts_with("504 ")
|
||||
|| lower.ends_with(" 502")
|
||||
|| lower.ends_with(" 503")
|
||||
|| lower.ends_with(" 504")
|
||||
|| lower == "502"
|
||||
|| lower == "503"
|
||||
|| lower == "504"
|
||||
{
|
||||
return ErrorCategory::Network;
|
||||
}
|
||||
if lower.contains("parse") || lower.contains("syntax") || lower.contains("malformed") {
|
||||
return ErrorCategory::Parse;
|
||||
}
|
||||
if lower.contains("not found")
|
||||
|| lower.contains("unavailable")
|
||||
|| lower.contains("not available")
|
||||
{
|
||||
return ErrorCategory::State;
|
||||
}
|
||||
if lower.contains("tool") {
|
||||
return ErrorCategory::Tool;
|
||||
}
|
||||
|
||||
ErrorCategory::Internal
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream‑level error discriminated by origin.
|
||||
///
|
||||
/// Each variant maps to an `ErrorCategory` so the UI can render
|
||||
/// stream‑specific icons or formatting. Wired into engine.rs at the three
|
||||
/// stream guard sites (chunk timeout, max-bytes overflow, max-duration).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StreamError {
|
||||
/// Stream stalled — no chunk received within the idle timeout.
|
||||
Stall { timeout_secs: u64 },
|
||||
/// Stream exceeded content size limit.
|
||||
Overflow { limit_bytes: usize },
|
||||
/// Stream exceeded wall‑clock duration limit.
|
||||
DurationLimit { limit_secs: u64 },
|
||||
}
|
||||
|
||||
impl StreamError {
|
||||
/// Convert directly into an `ErrorEnvelope` for emission on the engine
|
||||
/// event channel. Stalls are warning-severity and recoverable; size and
|
||||
/// duration limits are errors (the user must restart the turn).
|
||||
#[must_use]
|
||||
pub fn into_envelope(self) -> ErrorEnvelope {
|
||||
match self {
|
||||
Self::Stall { timeout_secs } => ErrorEnvelope::new(
|
||||
ErrorCategory::Timeout,
|
||||
ErrorSeverity::Warning,
|
||||
true,
|
||||
"stream_stall",
|
||||
format!("Stream stalled: no data received for {timeout_secs}s, closing stream"),
|
||||
),
|
||||
Self::Overflow { limit_bytes } => ErrorEnvelope::new(
|
||||
ErrorCategory::Internal,
|
||||
ErrorSeverity::Error,
|
||||
true,
|
||||
"stream_overflow",
|
||||
format!("Stream exceeded maximum content size of {limit_bytes} bytes, closing"),
|
||||
),
|
||||
Self::DurationLimit { limit_secs } => ErrorEnvelope::new(
|
||||
ErrorCategory::Timeout,
|
||||
ErrorSeverity::Error,
|
||||
true,
|
||||
"stream_duration_limit",
|
||||
format!("Stream exceeded maximum duration of {limit_secs}s, closing"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for StreamError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Stall { timeout_secs } => {
|
||||
write!(f, "Stream stalled after {timeout_secs}s idle")
|
||||
}
|
||||
Self::Overflow { limit_bytes } => {
|
||||
write!(f, "Stream exceeded {limit_bytes} bytes limit")
|
||||
}
|
||||
Self::DurationLimit { limit_secs } => {
|
||||
write!(f, "Stream exceeded {limit_secs}s duration limit")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StreamError {}
|
||||
@@ -6,9 +6,10 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use ignore::WalkBuilder;
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -102,6 +103,14 @@ pub struct EvalHarnessConfig {
|
||||
pub shell_expect_token: String,
|
||||
/// Maximum characters stored for step output summaries.
|
||||
pub max_output_chars: usize,
|
||||
/// When set, every step is appended as a JSON Lines fixture to a file
|
||||
/// inside this directory. The fixture file is named after the scenario
|
||||
/// (e.g. `offline-tool-loop.jsonl`). Each line follows the schema:
|
||||
/// `{ "request": <step descriptor>, "response_events": [<events>] }`.
|
||||
/// The mock LLM client (`crate::llm_client::mock`) can replay these
|
||||
/// fixtures for deterministic offline tests. See
|
||||
/// `crates/tui/tests/README.md` for the full record/replay flow.
|
||||
pub record_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for EvalHarnessConfig {
|
||||
@@ -117,6 +126,7 @@ impl Default for EvalHarnessConfig {
|
||||
shell_command,
|
||||
shell_expect_token: "eval-harness".to_string(),
|
||||
max_output_chars: 240,
|
||||
record_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,26 +283,122 @@ impl EvalHarness {
|
||||
success: true,
|
||||
duration,
|
||||
error: None,
|
||||
output: Some(output),
|
||||
output: Some(output.clone()),
|
||||
});
|
||||
if let Some(dir) = self.config.record_dir.as_deref() {
|
||||
let _ = record_fixture(
|
||||
dir,
|
||||
&self.config.scenario_name,
|
||||
FixtureRecord::ok(kind, &output),
|
||||
);
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
Err(err) => {
|
||||
stats.errors += 1;
|
||||
let err_str = err.to_string();
|
||||
steps.push(EvalStep {
|
||||
kind,
|
||||
tool_name: kind.tool_name(),
|
||||
success: false,
|
||||
duration,
|
||||
error: Some(err.to_string()),
|
||||
error: Some(err_str.clone()),
|
||||
output: None,
|
||||
});
|
||||
if let Some(dir) = self.config.record_dir.as_deref() {
|
||||
let _ = record_fixture(
|
||||
dir,
|
||||
&self.config.scenario_name,
|
||||
FixtureRecord::err(kind, &err_str),
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Fixture record/replay format ===========================================
|
||||
//
|
||||
// The `--record` flag writes one JSON object per line to a `.jsonl` file:
|
||||
//
|
||||
// { "request": { "step": "list_dir", "kind": "List" },
|
||||
// "response_events": [{ "type": "ok", "output": "…" }] }
|
||||
//
|
||||
// The mock LLM client replays these fixtures via
|
||||
// `MockLlmClient::push_message_response` (or the streaming variant) by mapping
|
||||
// each `response_events` array onto a canned `Vec<StreamEvent>`.
|
||||
//
|
||||
// This format is intentionally minimal — additional fields (timing, model,
|
||||
// usage) can be added without breaking older fixtures because each line is a
|
||||
// self-contained JSON object.
|
||||
|
||||
/// Schema for one line of a `--record` JSONL fixture file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FixtureRecord {
|
||||
/// Step descriptor (`{ step, kind }`).
|
||||
pub request: serde_json::Value,
|
||||
/// One or more synthetic response events.
|
||||
pub response_events: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl FixtureRecord {
|
||||
fn ok(kind: ScenarioStepKind, output: &str) -> Self {
|
||||
Self {
|
||||
request: serde_json::json!({
|
||||
"step": kind.tool_name(),
|
||||
"kind": format!("{kind:?}"),
|
||||
}),
|
||||
response_events: vec![serde_json::json!({
|
||||
"type": "ok",
|
||||
"output": output,
|
||||
})],
|
||||
}
|
||||
}
|
||||
|
||||
fn err(kind: ScenarioStepKind, error: &str) -> Self {
|
||||
Self {
|
||||
request: serde_json::json!({
|
||||
"step": kind.tool_name(),
|
||||
"kind": format!("{kind:?}"),
|
||||
}),
|
||||
response_events: vec![serde_json::json!({
|
||||
"type": "error",
|
||||
"error": error,
|
||||
})],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append one fixture record to `<dir>/<scenario>.jsonl` (creating dir + file
|
||||
/// if missing). Best-effort: I/O errors are returned but generally ignored by
|
||||
/// the harness so a recording failure does not mask the run's primary result.
|
||||
pub fn record_fixture(dir: &Path, scenario_name: &str, record: FixtureRecord) -> Result<PathBuf> {
|
||||
fs::create_dir_all(dir)
|
||||
.with_context(|| format!("failed to create fixture dir: {}", dir.display()))?;
|
||||
let safe_scenario = scenario_name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let path = dir.join(format!("{safe_scenario}.jsonl"));
|
||||
let line = serde_json::to_string(&record).context("failed to serialize fixture record")?;
|
||||
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.with_context(|| format!("failed to open fixture file: {}", path.display()))?;
|
||||
writeln!(file, "{line}")
|
||||
.with_context(|| format!("failed to write fixture line to {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
impl Default for EvalHarness {
|
||||
fn default() -> Self {
|
||||
Self::new(EvalHarnessConfig::default())
|
||||
@@ -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.
Loaded 100 of 360 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user