feat: setup status/clean/dirs and protocol-recovery hardening

Adds a compact `setup --status` view, a `setup --clean` for regenerable
session checkpoints, and `--tools`/`--plugins` scaffolding for
~/.deepseek/{tools,plugins} so the extension model has a documented home
that doctor can count. `doctor --json` lands as a CI-safe alternative to
the human-readable doctor (skips the live API probe).

Also locks down the engine's hostility to fake tool-call wrappers:
filter_tool_call_delta and the marker constants are now testable, the
streaming loop emits one compact status notice per turn when it strips
a wrapper, and a new protocol_recovery integration test asserts that
the legacy text parser never turns <function_calls> into a real tool
call. Adds 23 unit tests + 14 integration tests covering both slices.
This commit is contained in:
Hunter Bown
2026-04-25 06:26:07 +00:00
parent 7d8c40893f
commit 853a39138c
6 changed files with 1122 additions and 14 deletions
+8
View File
@@ -106,6 +106,14 @@ Controls: `F1` help, `Esc` backs out of the current action, `Ctrl+K` command pal
Key environment overrides: `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`,
`DEEPSEEK_MODEL`, `DEEPSEEK_PROFILE`.
Quick checks and scaffolding:
- `deepseek-tui setup --status` — read-only, network-free status of API key,
MCP/skills/tools/plugins, sandbox, and `.env`.
- `deepseek-tui setup --tools --plugins` — scaffold `~/.deepseek/tools/` and
`~/.deepseek/plugins/` with self-describing example templates.
- `deepseek-tui doctor --json` — machine-readable doctor output for CI.
The client targets DeepSeek's documented OpenAI-compatible Chat Completions API
(`/chat/completions`). DeepSeek context caching is automatic; when the API
returns cache hit/miss token fields, the TUI includes them in usage and cost
+33 -3
View File
@@ -370,7 +370,7 @@ const TOOL_RESULT_METADATA_SUMMARY_CHARS: usize = 320;
const COMPACTION_SUMMARY_MARKER: &str = "Conversation Summary (Auto-Generated)";
const WORKING_SET_SUMMARY_MARKER: &str = "## Repo Working Set";
const TOOL_CALL_START_MARKERS: [&str; 5] = [
pub(crate) const TOOL_CALL_START_MARKERS: [&str; 5] = [
"[TOOL_CALL]",
"<deepseek:tool_call",
"<tool_call",
@@ -386,7 +386,7 @@ const TOOL_SEARCH_REGEX_NAME: &str = "tool_search_tool_regex";
const TOOL_SEARCH_REGEX_TYPE: &str = "tool_search_tool_regex_20251119";
const TOOL_SEARCH_BM25_NAME: &str = "tool_search_tool_bm25";
const TOOL_SEARCH_BM25_TYPE: &str = "tool_search_tool_bm25_20251119";
const TOOL_CALL_END_MARKERS: [&str; 5] = [
pub(crate) const TOOL_CALL_END_MARKERS: [&str; 5] = [
"[/TOOL_CALL]",
"</deepseek:tool_call>",
"</tool_call>",
@@ -394,6 +394,19 @@ const TOOL_CALL_END_MARKERS: [&str; 5] = [
"</function_calls>",
];
/// Compact one-shot notice emitted when a model attempts to forge a tool-call
/// wrapper in plain text instead of using the API tool channel. The visible
/// content is still scrubbed; this exists so the user can see why their text
/// shrank.
pub(crate) const FAKE_WRAPPER_NOTICE: &str =
"Stripped non-API tool-call wrapper from model output (use the API tool channel)";
/// True if `text` contains any of the known fake-wrapper start markers. Used by
/// the streaming loop to decide whether to emit `FAKE_WRAPPER_NOTICE`.
pub(crate) fn contains_fake_tool_wrapper(text: &str) -> bool {
TOOL_CALL_START_MARKERS.iter().any(|m| text.contains(m))
}
fn find_first_marker(text: &str, markers: &[&str]) -> Option<(usize, usize)> {
markers
.iter()
@@ -401,7 +414,7 @@ fn find_first_marker(text: &str, markers: &[&str]) -> Option<(usize, usize)> {
.min_by_key(|(idx, _)| *idx)
}
fn filter_tool_call_delta(delta: &str, in_tool_call: &mut bool) -> String {
pub(crate) fn filter_tool_call_delta(delta: &str, in_tool_call: &mut bool) -> String {
if delta.is_empty() {
return String::new();
}
@@ -2617,6 +2630,7 @@ impl Engine {
let mut current_block_kind: Option<ContentBlockKind> = None;
let mut current_tool_index: Option<usize> = None;
let mut in_tool_call_block = false;
let mut fake_wrapper_notice_emitted = false;
let mut pending_message_complete = false;
let mut last_text_index: Option<usize> = None;
let mut stream_errors = 0u32;
@@ -2720,6 +2734,14 @@ impl Engine {
in_tool_call_block = false;
let filtered =
filter_tool_call_delta(&current_text_raw, &mut in_tool_call_block);
if !fake_wrapper_notice_emitted
&& filtered.len() < current_text_raw.len()
&& contains_fake_tool_wrapper(&current_text_raw)
{
let _ =
self.tx_event.send(Event::status(FAKE_WRAPPER_NOTICE)).await;
fake_wrapper_notice_emitted = true;
}
current_text_visible.push_str(&filtered);
current_block_kind = Some(ContentBlockKind::Text);
last_text_index = Some(index as usize);
@@ -2797,6 +2819,14 @@ impl Engine {
stream_content_bytes = stream_content_bytes.saturating_add(text.len());
current_text_raw.push_str(&text);
let filtered = filter_tool_call_delta(&text, &mut in_tool_call_block);
if !fake_wrapper_notice_emitted
&& filtered.len() < text.len()
&& contains_fake_tool_wrapper(&text)
{
let _ =
self.tx_event.send(Event::status(FAKE_WRAPPER_NOTICE)).await;
fake_wrapper_notice_emitted = true;
}
if !filtered.is_empty() {
current_text_visible.push_str(&filtered);
let _ = self
+137
View File
@@ -746,3 +746,140 @@ fn missing_tool_error_message_includes_discovery_guidance_when_no_match() {
assert!(message.contains("not available in the current tool catalog"));
assert!(message.contains(TOOL_SEARCH_BM25_NAME));
}
#[test]
fn filter_tool_call_delta_strips_bracket_marker() {
let mut in_block = false;
let visible = filter_tool_call_delta(
"intro [TOOL_CALL]\n{\"tool\":\"x\"}\n[/TOOL_CALL] outro",
&mut in_block,
);
assert!(!in_block);
assert!(!visible.contains("[TOOL_CALL]"));
assert!(!visible.contains("[/TOOL_CALL]"));
assert!(!visible.contains("\"tool\":\"x\""));
assert!(visible.contains("intro"));
assert!(visible.contains("outro"));
}
#[test]
fn filter_tool_call_delta_strips_deepseek_xml_marker() {
let mut in_block = false;
let visible = filter_tool_call_delta(
"before <deepseek:tool_call name=\"x\">payload</deepseek:tool_call> after",
&mut in_block,
);
assert!(!in_block);
for marker in TOOL_CALL_START_MARKERS {
assert!(
!visible.contains(marker),
"visible text leaked start marker `{marker}`: {visible:?}"
);
}
assert!(visible.contains("before"));
assert!(visible.contains("after"));
}
#[test]
fn filter_tool_call_delta_strips_generic_tool_call_marker() {
let mut in_block = false;
let visible = filter_tool_call_delta(
"lead <tool_call>\n{\"name\":\"do\"}\n</tool_call> tail",
&mut in_block,
);
assert!(!in_block);
assert!(!visible.contains("<tool_call"));
assert!(!visible.contains("</tool_call>"));
assert!(visible.contains("lead"));
assert!(visible.contains("tail"));
}
#[test]
fn filter_tool_call_delta_strips_invoke_marker() {
let mut in_block = false;
let visible = filter_tool_call_delta(
"alpha <invoke name=\"x\"><parameter name=\"k\">v</parameter></invoke> beta",
&mut in_block,
);
assert!(!in_block);
assert!(!visible.contains("<invoke "));
assert!(!visible.contains("</invoke>"));
assert!(visible.contains("alpha"));
assert!(visible.contains("beta"));
}
#[test]
fn filter_tool_call_delta_strips_function_calls_marker() {
let mut in_block = false;
let visible = filter_tool_call_delta(
"head <function_calls>\n{\"name\":\"x\"}\n</function_calls> tail",
&mut in_block,
);
assert!(!in_block);
assert!(!visible.contains("<function_calls>"));
assert!(!visible.contains("</function_calls>"));
assert!(visible.contains("head"));
assert!(visible.contains("tail"));
}
#[test]
fn filter_tool_call_delta_handles_chunk_split_marker() {
let mut in_block = false;
// First chunk opens the wrapper but does not close it.
let visible_a = filter_tool_call_delta("hello <tool_call>partial", &mut in_block);
assert!(in_block, "filter must remember it is mid-wrapper");
assert_eq!(visible_a, "hello ");
// Second chunk continues inside the wrapper, then closes it and adds tail.
let visible_b = filter_tool_call_delta("payload</tool_call> tail", &mut in_block);
assert!(!in_block);
assert_eq!(visible_b, " tail");
}
#[test]
fn filter_tool_call_delta_unmatched_open_suppresses_remainder() {
let mut in_block = false;
let visible = filter_tool_call_delta("ok [TOOL_CALL]rest of stream", &mut in_block);
assert_eq!(visible, "ok ");
assert!(
in_block,
"unmatched open must leave filter in tool-call mode"
);
}
#[test]
fn filter_tool_call_delta_passes_through_clean_text() {
let mut in_block = false;
let input = "no markers here, just prose with code `<not a tag>`.";
let visible = filter_tool_call_delta(input, &mut in_block);
assert!(!in_block);
assert_eq!(visible, input);
}
#[test]
fn contains_fake_tool_wrapper_detects_each_marker() {
for marker in TOOL_CALL_START_MARKERS {
let needle = format!("noise {marker} more noise");
assert!(
contains_fake_tool_wrapper(&needle),
"marker `{marker}` should be detected"
);
}
}
#[test]
fn contains_fake_tool_wrapper_returns_false_on_clean_text() {
assert!(!contains_fake_tool_wrapper(
"plain assistant text without wrappers"
));
assert!(!contains_fake_tool_wrapper(
"`<tool` lookalike but not a real start marker"
));
}
#[test]
fn fake_wrapper_notice_is_compact_and_actionable() {
// Keep this short so it fits cleanly in a single status line.
assert!(FAKE_WRAPPER_NOTICE.len() < 120);
assert!(FAKE_WRAPPER_NOTICE.contains("API tool channel"));
}
+747 -11
View File
@@ -133,7 +133,7 @@ struct Cli {
#[allow(clippy::large_enum_variant)]
enum Commands {
/// Run system diagnostics and check configuration
Doctor,
Doctor(DoctorArgs),
/// Bootstrap MCP config and/or skills directories
Setup(SetupArgs),
/// Generate shell completions
@@ -230,7 +230,13 @@ struct SetupArgs {
/// Initialize skills directory and an example skill
#[arg(long, default_value_t = false)]
skills: bool,
/// Initialize both MCP config and skills (default when no flags provided)
/// Initialize tools directory with a self-describing example script
#[arg(long, default_value_t = false)]
tools: bool,
/// Initialize plugins directory with a self-describing example
#[arg(long, default_value_t = false)]
plugins: bool,
/// Initialize MCP config, skills, tools, and plugins
#[arg(long, default_value_t = false)]
all: bool,
/// Create a local workspace skills directory (./skills)
@@ -239,6 +245,19 @@ struct SetupArgs {
/// Overwrite existing template files
#[arg(long, default_value_t = false)]
force: bool,
/// Print a compact, read-only status report (no network calls)
#[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "clean"])]
status: bool,
/// Remove regenerable session checkpoints (latest + offline_queue)
#[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "status"])]
clean: bool,
}
#[derive(Args, Debug, Clone, Default)]
struct DoctorArgs {
/// Emit machine-readable JSON output (skips live API connectivity check)
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Args, Debug, Clone)]
@@ -479,11 +498,15 @@ async fn main() -> Result<()> {
// Handle subcommands first
if let Some(command) = cli.command.clone() {
return match command {
Commands::Doctor => {
Commands::Doctor(args) => {
let config = load_config_from_cli(&cli)?;
let workspace = resolve_workspace(&cli);
run_doctor(&config, &workspace, cli.config.as_deref()).await;
Ok(())
if args.json {
run_doctor_json(&config, &workspace, cli.config.as_deref())
} else {
run_doctor(&config, &workspace, cli.config.as_deref()).await;
Ok(())
}
}
Commands::Setup(args) => {
let config = load_config_from_cli(&cli)?;
@@ -793,19 +816,151 @@ fn init_skills_dir(skills_dir: &Path, force: bool) -> Result<(PathBuf, WriteStat
Ok((skill_path, status))
}
fn tools_readme_template() -> &'static str {
"# Local tools\n\n\
Drop self-describing scripts here so they can be discovered by\n\
`deepseek-tui setup --status` and surfaced in `deepseek-tui doctor`.\n\n\
Each script should start with a frontmatter-style header so the\n\
description is visible without executing the file:\n\n\
```\n\
# name: my-tool\n\
# description: One-line summary of what this tool does\n\
# usage: my-tool [args...]\n\
```\n\n\
The directory is intentionally not auto-loaded into the agent's tool\n\
catalog. Wire individual tools through MCP, hooks, or skills when you\n\
want them available inside a session.\n"
}
fn tools_example_script() -> &'static str {
"#!/usr/bin/env sh\n\
# name: example\n\
# description: Print a confirmation that local tool discovery works\n\
# usage: example [name]\n\
printf 'deepseek-tui local tool ok: %s\\n' \"${1:-world}\"\n"
}
fn init_tools_dir(tools_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus, WriteStatus)> {
std::fs::create_dir_all(tools_dir)
.with_context(|| format!("Failed to create tools dir {}", tools_dir.display()))?;
let readme_path = tools_dir.join("README.md");
let readme_status = write_template_file(&readme_path, tools_readme_template(), force)?;
let example_path = tools_dir.join("example.sh");
let example_status = write_template_file(&example_path, tools_example_script(), force)?;
Ok((tools_dir.to_path_buf(), readme_status, example_status))
}
fn plugins_readme_template() -> &'static str {
"# Local plugins\n\n\
Plugins are richer than tools: each one lives in its own subdirectory\n\
with a `PLUGIN.md` describing what it does and how to enable it. The\n\
directory is created so users have a documented place to drop\n\
experiments without touching `~/.deepseek/skills/`.\n\n\
A plugin layout looks like:\n\n\
```\n\
plugins/\n\
my-plugin/\n\
PLUGIN.md # frontmatter + body, same shape as SKILL.md\n\
scripts/ # optional helpers invoked by the plugin\n\
```\n\n\
Plugins are not loaded automatically. Wire them up through skills,\n\
hooks, or MCP servers when you want them active in a session.\n"
}
fn plugin_example_template() -> &'static str {
"---\n\
name: example\n\
description: Placeholder plugin so /skills and doctor have something to show\n\
status: example\n\
---\n\n\
This is a starter plugin layout. Edit or replace it once you have a\n\
real plugin. The agent does not load this file directly; reference it\n\
from a skill or MCP wrapper if you want it active in a session.\n"
}
fn init_plugins_dir(
plugins_dir: &Path,
force: bool,
) -> Result<(PathBuf, PathBuf, WriteStatus, WriteStatus)> {
std::fs::create_dir_all(plugins_dir)
.with_context(|| format!("Failed to create plugins dir {}", plugins_dir.display()))?;
let readme_path = plugins_dir.join("README.md");
let readme_status = write_template_file(&readme_path, plugins_readme_template(), force)?;
let example_path = plugins_dir.join("example").join("PLUGIN.md");
ensure_parent_dir(&example_path)?;
let example_status = write_template_file(&example_path, plugin_example_template(), force)?;
Ok((readme_path, example_path, readme_status, example_status))
}
fn deepseek_home_dir() -> PathBuf {
dirs::home_dir().map_or_else(|| PathBuf::from(".deepseek"), |h| h.join(".deepseek"))
}
/// Resolve the default tools directory. Mirrors `default_skills_dir` shape.
fn default_tools_dir() -> PathBuf {
deepseek_home_dir().join("tools")
}
/// Resolve the default plugins directory.
fn default_plugins_dir() -> PathBuf {
deepseek_home_dir().join("plugins")
}
/// Default location for crash/offline-queue checkpoints managed by the TUI.
fn default_checkpoints_dir() -> PathBuf {
deepseek_home_dir().join("sessions").join("checkpoints")
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CleanPlan {
targets: Vec<PathBuf>,
}
fn collect_clean_targets(checkpoints_dir: &Path) -> CleanPlan {
let candidates = ["latest.json", "offline_queue.json"];
let targets = candidates
.iter()
.map(|name| checkpoints_dir.join(name))
.filter(|p| p.exists())
.collect();
CleanPlan { targets }
}
fn execute_clean_plan(plan: &CleanPlan) -> Result<Vec<PathBuf>> {
let mut removed = Vec::with_capacity(plan.targets.len());
for path in &plan.targets {
std::fs::remove_file(path)
.with_context(|| format!("Failed to remove {}", path.display()))?;
removed.push(path.clone());
}
Ok(removed)
}
fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
if args.status {
return run_setup_status(config, workspace);
}
if args.clean {
return run_setup_clean(&default_checkpoints_dir(), args.force);
}
use crate::palette;
use colored::Colorize;
let (aqua_r, aqua_g, aqua_b) = palette::DEEPSEEK_SKY_RGB;
let (sky_r, sky_g, sky_b) = palette::DEEPSEEK_SKY_RGB;
let mut run_mcp = args.mcp || args.all;
let mut run_skills = args.skills || args.all;
if !run_mcp && !run_skills {
run_mcp = true;
run_skills = true;
}
let any_explicit = args.mcp || args.skills || args.tools || args.plugins;
let run_mcp = args.mcp || args.all || !any_explicit;
let run_skills = args.skills || args.all || !any_explicit;
let run_tools = args.tools || args.all;
let run_plugins = args.plugins || args.all;
println!(
"{}",
@@ -863,6 +1018,25 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
println!(" Next: run the TUI and use `/skills` then `/skill getting-started`.");
}
if run_tools {
let tools_dir = default_tools_dir();
let (dir, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?;
report_write_status("Tools README", &dir.join("README.md"), readme_status);
report_write_status("Example tool", &dir.join("example.sh"), example_status);
println!(" Tools dir: {}", dir.display());
println!(" Next: drop scripts here; surface them via skills/MCP when ready.");
}
if run_plugins {
let plugins_dir = default_plugins_dir();
let (readme_path, example_path, readme_status, example_status) =
init_plugins_dir(&plugins_dir, args.force)?;
report_write_status("Plugins README", &readme_path, readme_status);
report_write_status("Example plugin", &example_path, example_status);
println!(" Plugins dir: {}", plugins_dir.display());
println!(" Next: copy the example dir, edit PLUGIN.md, wire via skill/MCP.");
}
let sandbox = crate::sandbox::get_platform_sandbox();
if let Some(kind) = sandbox {
println!(" ✓ Sandbox available: {kind}");
@@ -873,6 +1047,210 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
Ok(())
}
fn report_write_status(label: &str, path: &Path, status: WriteStatus) {
match status {
WriteStatus::Created => {
println!(" ✓ Created {label} at {}", path.display());
}
WriteStatus::Overwritten => {
println!(" ✓ Overwrote {label} at {}", path.display());
}
WriteStatus::SkippedExists => {
println!(" · {label} already exists at {}", path.display());
}
}
}
/// Source of the resolved DeepSeek API key, used in status reports.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ApiKeySource {
Env,
Config,
Missing,
}
fn resolve_api_key_source(config: &Config) -> ApiKeySource {
if std::env::var("DEEPSEEK_API_KEY")
.ok()
.filter(|k| !k.trim().is_empty())
.is_some()
{
ApiKeySource::Env
} else if config.deepseek_api_key().is_ok() {
ApiKeySource::Config
} else {
ApiKeySource::Missing
}
}
fn count_dir_entries(dir: &Path) -> usize {
std::fs::read_dir(dir)
.map(|entries| entries.filter_map(std::result::Result::ok).count())
.unwrap_or(0)
}
fn skills_count_for(dir: &Path) -> usize {
if !dir.exists() {
return 0;
}
crate::skills::SkillRegistry::discover(dir).len()
}
fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
use crate::palette;
use colored::Colorize;
let (aqua_r, aqua_g, aqua_b) = palette::DEEPSEEK_SKY_RGB;
let (sky_r, sky_g, sky_b) = palette::DEEPSEEK_SKY_RGB;
let (red_r, red_g, red_b) = palette::DEEPSEEK_RED_RGB;
println!(
"{}",
"DeepSeek Status".truecolor(aqua_r, aqua_g, aqua_b).bold()
);
println!("{}", "===============".truecolor(sky_r, sky_g, sky_b));
println!("workspace: {}", workspace.display());
match resolve_api_key_source(config) {
ApiKeySource::Env => println!(
" {} api_key: set via DEEPSEEK_API_KEY",
"".truecolor(aqua_r, aqua_g, aqua_b)
),
ApiKeySource::Config => println!(
" {} api_key: set via config",
"".truecolor(aqua_r, aqua_g, aqua_b)
),
ApiKeySource::Missing => println!(
" {} api_key: missing (set DEEPSEEK_API_KEY or run `deepseek login`)",
"".truecolor(red_r, red_g, red_b)
),
}
println!(
" · base_url: {}",
config
.base_url
.as_deref()
.unwrap_or("https://api.deepseek.com")
);
let model = config
.default_text_model
.clone()
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
println!(" · default_text_model: {model}");
let mcp_path = config.mcp_config_path();
let mcp_count = match load_mcp_config(&mcp_path) {
Ok(cfg) => cfg.servers.len(),
Err(_) => 0,
};
let mcp_present = if mcp_path.exists() { "" } else { " (missing)" };
println!(
" · mcp servers: {mcp_count} at {}{mcp_present}",
mcp_path.display()
);
let skills_dir = config.skills_dir();
println!(
" · skills: {} at {}",
skills_count_for(&skills_dir),
skills_dir.display()
);
let tools_dir = default_tools_dir();
let tools_present = if tools_dir.exists() {
""
} else {
" (missing — run `setup --tools`)"
};
println!(
" · tools: {} entries at {}{tools_present}",
if tools_dir.exists() {
count_dir_entries(&tools_dir)
} else {
0
},
tools_dir.display()
);
let plugins_dir = default_plugins_dir();
let plugins_present = if plugins_dir.exists() {
""
} else {
" (missing — run `setup --plugins`)"
};
println!(
" · plugins: {} entries at {}{plugins_present}",
if plugins_dir.exists() {
count_dir_entries(&plugins_dir)
} else {
0
},
plugins_dir.display()
);
let sandbox = crate::sandbox::get_platform_sandbox();
match sandbox {
Some(kind) => println!(
" {} sandbox: {kind}",
"".truecolor(aqua_r, aqua_g, aqua_b)
),
None => println!(
" {} sandbox: unavailable (commands run best-effort)",
"!".truecolor(sky_r, sky_g, sky_b)
),
}
let dotenv = workspace.join(".env");
if dotenv.exists() {
println!(" {} .env present at {}", "·".dimmed(), dotenv.display());
} else {
println!(" {} .env not present in workspace", "·".dimmed());
}
println!();
println!("Run `deepseek-tui doctor --json` for a machine-readable check.");
Ok(())
}
fn run_setup_clean(checkpoints_dir: &Path, force: bool) -> Result<()> {
use colored::Colorize;
if !checkpoints_dir.exists() {
println!(
"Nothing to clean — checkpoints dir does not exist: {}",
checkpoints_dir.display()
);
return Ok(());
}
let plan = collect_clean_targets(checkpoints_dir);
if plan.targets.is_empty() {
println!(
"Nothing to clean — no checkpoint files in {}",
checkpoints_dir.display()
);
return Ok(());
}
if !force {
println!(
"Would remove {} checkpoint file(s) (use --force to apply):",
plan.targets.len()
);
for path in &plan.targets {
println!(" · {}", path.display());
}
return Ok(());
}
let removed = execute_clean_plan(&plan)?;
println!("{}", "Cleaned checkpoints:".bold());
for path in &removed {
println!("{}", path.display());
}
Ok(())
}
/// Run system diagnostics
async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Option<&Path>) {
use crate::palette;
@@ -1152,6 +1530,48 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
println!(" Run `deepseek setup --skills` (or add --local for ./skills).");
}
// Tools directory
println!();
println!("{}", "Tools:".bold());
let tools_dir = default_tools_dir();
if tools_dir.exists() {
let count = count_dir_entries(&tools_dir);
println!(
" {} tools dir found at {} ({} items)",
"".truecolor(aqua_r, aqua_g, aqua_b),
tools_dir.display(),
count
);
} else {
println!(
" {} tools dir not found at {}",
"·".dimmed(),
tools_dir.display()
);
println!(" Run `deepseek-tui setup --tools` to scaffold a starter dir.");
}
// Plugins directory
println!();
println!("{}", "Plugins:".bold());
let plugins_dir = default_plugins_dir();
if plugins_dir.exists() {
let count = count_dir_entries(&plugins_dir);
println!(
" {} plugins dir found at {} ({} items)",
"".truecolor(aqua_r, aqua_g, aqua_b),
plugins_dir.display(),
count
);
} else {
println!(
" {} plugins dir not found at {}",
"·".dimmed(),
plugins_dir.display()
);
println!(" Run `deepseek-tui setup --plugins` to scaffold a starter dir.");
}
// Platform and sandbox checks
println!();
println!("{}", "Platform:".bold());
@@ -1181,6 +1601,145 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
);
}
/// Machine-readable counterpart to `run_doctor`. Skips the live API call so it
/// is safe to run in CI and from non-interactive scripts.
fn run_doctor_json(
config: &Config,
workspace: &Path,
config_path_override: Option<&Path>,
) -> Result<()> {
use serde_json::json;
let default_config_dir =
dirs::home_dir().map_or_else(|| PathBuf::from(".deepseek"), |h| h.join(".deepseek"));
let config_path = config_path_override
.map(PathBuf::from)
.or_else(|| {
std::env::var("DEEPSEEK_CONFIG_PATH")
.ok()
.map(PathBuf::from)
})
.unwrap_or_else(|| default_config_dir.join("config.toml"));
let api_key_state = match resolve_api_key_source(config) {
ApiKeySource::Env => "env",
ApiKeySource::Config => "config",
ApiKeySource::Missing => "missing",
};
let mcp_config_path = config.mcp_config_path();
let mcp_present = mcp_config_path.exists();
let mcp_summary = match load_mcp_config(&mcp_config_path) {
Ok(cfg) => {
let servers: Vec<serde_json::Value> = cfg
.servers
.iter()
.map(|(name, server)| {
let status = doctor_check_mcp_server(server);
let (kind, detail) = match &status {
McpServerDoctorStatus::Ok(d) => ("ok", d.clone()),
McpServerDoctorStatus::Warning(d) => ("warning", d.clone()),
McpServerDoctorStatus::Error(d) => ("error", d.clone()),
};
json!({
"name": name,
"enabled": server.enabled && !server.disabled,
"status": kind,
"detail": detail,
})
})
.collect();
json!({
"config_path": mcp_config_path.display().to_string(),
"present": mcp_present,
"servers": servers,
})
}
Err(err) => json!({
"config_path": mcp_config_path.display().to_string(),
"present": mcp_present,
"servers": [],
"error": err.to_string(),
}),
};
let global_skills_dir = config.skills_dir();
let agents_skills_dir = workspace.join(".agents").join("skills");
let local_skills_dir = workspace.join("skills");
let selected_skills_dir = if agents_skills_dir.exists() {
agents_skills_dir.clone()
} else if local_skills_dir.exists() {
local_skills_dir.clone()
} else {
global_skills_dir.clone()
};
let tools_dir = default_tools_dir();
let plugins_dir = default_plugins_dir();
let report = json!({
"version": env!("CARGO_PKG_VERSION"),
"config_path": config_path.display().to_string(),
"config_present": config_path.exists(),
"workspace": workspace.display().to_string(),
"api_key": {
"source": api_key_state,
},
"base_url": config
.base_url
.clone()
.unwrap_or_else(|| "https://api.deepseek.com".to_string()),
"default_text_model": config
.default_text_model
.clone()
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string()),
"mcp": mcp_summary,
"skills": {
"selected": selected_skills_dir.display().to_string(),
"global": {
"path": global_skills_dir.display().to_string(),
"present": global_skills_dir.exists(),
"count": skills_count_for(&global_skills_dir),
},
"agents": {
"path": agents_skills_dir.display().to_string(),
"present": agents_skills_dir.exists(),
"count": skills_count_for(&agents_skills_dir),
},
"local": {
"path": local_skills_dir.display().to_string(),
"present": local_skills_dir.exists(),
"count": skills_count_for(&local_skills_dir),
},
},
"tools": {
"path": tools_dir.display().to_string(),
"present": tools_dir.exists(),
"count": if tools_dir.exists() { count_dir_entries(&tools_dir) } else { 0 },
},
"plugins": {
"path": plugins_dir.display().to_string(),
"present": plugins_dir.exists(),
"count": if plugins_dir.exists() { count_dir_entries(&plugins_dir) } else { 0 },
},
"sandbox": match crate::sandbox::get_platform_sandbox() {
Some(kind) => json!({"available": true, "kind": kind.to_string()}),
None => json!({"available": false, "kind": null}),
},
"platform": {
"os": std::env::consts::OS,
"arch": std::env::consts::ARCH,
},
"api_connectivity": {
"checked": false,
"note": "Skipped in --json mode; run `deepseek-tui doctor` for a live check.",
},
});
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn run_execpolicy_command(command: ExecpolicyCommand) -> Result<()> {
match command.command {
ExecpolicySubcommand::Check(cmd) => cmd.run(),
@@ -2613,3 +3172,180 @@ mod doctor_mcp_tests {
));
}
}
#[cfg(test)]
mod setup_helper_tests {
use super::*;
use tempfile::TempDir;
#[test]
fn init_tools_dir_creates_readme_and_example() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("tools");
let (returned_dir, readme_status, example_status) =
init_tools_dir(&dir, false).expect("init_tools_dir should succeed");
assert_eq!(returned_dir, dir);
assert!(matches!(readme_status, WriteStatus::Created));
assert!(matches!(example_status, WriteStatus::Created));
assert!(dir.join("README.md").exists());
assert!(dir.join("example.sh").exists());
let readme = std::fs::read_to_string(dir.join("README.md")).unwrap();
assert!(
readme.contains("# name:"),
"README must show frontmatter convention"
);
let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
assert!(example.starts_with("#!/usr/bin/env sh"));
assert!(example.contains("# name: example"));
assert!(example.contains("# description:"));
}
#[test]
fn init_tools_dir_skips_existing_without_force() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("tools");
let _ = init_tools_dir(&dir, false).unwrap();
let (_, readme_status, example_status) = init_tools_dir(&dir, false).unwrap();
assert!(matches!(readme_status, WriteStatus::SkippedExists));
assert!(matches!(example_status, WriteStatus::SkippedExists));
}
#[test]
fn init_tools_dir_force_overwrites() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("tools");
let _ = init_tools_dir(&dir, false).unwrap();
std::fs::write(dir.join("example.sh"), "stale").unwrap();
let (_, _, example_status) = init_tools_dir(&dir, true).unwrap();
assert!(matches!(example_status, WriteStatus::Overwritten));
let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
assert_ne!(example, "stale");
}
#[test]
fn init_plugins_dir_creates_readme_and_example_layout() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("plugins");
let (readme_path, example_path, readme_status, example_status) =
init_plugins_dir(&dir, false).unwrap();
assert_eq!(readme_path, dir.join("README.md"));
assert_eq!(example_path, dir.join("example").join("PLUGIN.md"));
assert!(matches!(readme_status, WriteStatus::Created));
assert!(matches!(example_status, WriteStatus::Created));
assert!(readme_path.exists());
assert!(example_path.exists());
let plugin_md = std::fs::read_to_string(&example_path).unwrap();
assert!(plugin_md.contains("---"));
assert!(plugin_md.contains("name: example"));
}
#[test]
fn collect_clean_targets_finds_only_known_files() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("latest.json"), "{}").unwrap();
std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
std::fs::write(dir.join("unrelated.json"), "{}").unwrap();
let plan = collect_clean_targets(dir);
assert_eq!(plan.targets.len(), 2);
assert!(plan.targets.iter().any(|p| p.ends_with("latest.json")));
assert!(
plan.targets
.iter()
.any(|p| p.ends_with("offline_queue.json"))
);
assert!(!plan.targets.iter().any(|p| p.ends_with("unrelated.json")));
}
#[test]
fn execute_clean_plan_removes_files_and_returns_them() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let latest = dir.join("latest.json");
let queue = dir.join("offline_queue.json");
std::fs::write(&latest, "{}").unwrap();
std::fs::write(&queue, "[]").unwrap();
let plan = collect_clean_targets(dir);
let removed = execute_clean_plan(&plan).unwrap();
assert_eq!(removed.len(), 2);
assert!(!latest.exists());
assert!(!queue.exists());
}
#[test]
fn run_setup_clean_dry_run_lists_targets_without_force() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("latest.json"), "{}").unwrap();
run_setup_clean(dir, false).unwrap();
// Without --force, files must remain on disk.
assert!(dir.join("latest.json").exists());
}
#[test]
fn run_setup_clean_force_removes_files() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("latest.json"), "{}").unwrap();
std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
run_setup_clean(dir, true).unwrap();
assert!(!dir.join("latest.json").exists());
assert!(!dir.join("offline_queue.json").exists());
}
#[test]
fn run_setup_clean_handles_missing_dir() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("does-not-exist");
// Should print and return Ok without error.
run_setup_clean(&dir, true).unwrap();
assert!(!dir.exists());
}
#[test]
fn resolve_api_key_source_reports_env_when_set() {
// Snapshot env so we can restore it.
let prev = std::env::var("DEEPSEEK_API_KEY").ok();
// SAFETY: tests in this binary may run in parallel; use a marker that
// is unmistakably a test value so concurrent reads can detect it.
// To avoid clobbering CI keys we save/restore around the assertion.
unsafe {
std::env::set_var("DEEPSEEK_API_KEY", "test-helper-value");
}
let cfg = Config::default();
let source = resolve_api_key_source(&cfg);
match prev {
Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
}
assert_eq!(source, ApiKeySource::Env);
}
#[test]
fn skills_count_for_returns_zero_for_missing_dir() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("nope");
assert_eq!(skills_count_for(&dir), 0);
}
#[test]
fn skills_count_for_counts_valid_skill_dirs() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("skills");
let skill_dir = dir.join("getting-started");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: getting-started\ndescription: hi\n---\nbody",
)
.unwrap();
assert_eq!(skills_count_for(&dir), 1);
}
}
+152
View File
@@ -0,0 +1,152 @@
//! Protocol-recovery contract tests.
//!
//! These tests exist to keep the engine hostile to fake tool-call wrappers
//! (XML/Replit/markdown pseudo-calls in assistant text). Their job is to make
//! sure that:
//!
//! 1. The known wrapper markers are still present in `core/engine.rs` so the
//! streaming filter has something to scrub.
//! 2. The legacy text-based `tool_parser` does NOT treat the newer
//! `<function_calls>` wrapper as a real tool call — only the legacy
//! `[TOOL_CALL]` and `<invoke>` shapes ever produced structured calls, and
//! nothing should silently re-enable text-based execution.
//! 3. The closing-marker list stays the same length as the start-marker list,
//! so filter logic cannot get stuck in tool-call mode forever.
//!
//! The point is that protocol drift in the model output should be visible (we
//! still strip it and emit a status notice), not silently turned into tool
//! execution.
use std::fs;
#[path = "../src/core/tool_parser.rs"]
#[allow(dead_code)]
mod tool_parser;
const ENGINE_SRC: &str = include_str!("../src/core/engine.rs");
const EXPECTED_START_MARKERS: &[&str] = &[
"[TOOL_CALL]",
"<deepseek:tool_call",
"<tool_call",
"<invoke ",
"<function_calls>",
];
const EXPECTED_END_MARKERS: &[&str] = &[
"[/TOOL_CALL]",
"</deepseek:tool_call>",
"</tool_call>",
"</invoke>",
"</function_calls>",
];
#[test]
fn engine_keeps_known_fake_wrapper_start_markers() {
for marker in EXPECTED_START_MARKERS {
let needle = format!("\"{marker}\"");
assert!(
ENGINE_SRC.contains(&needle),
"engine.rs no longer mentions start marker `{marker}` — protocol \
scrubbing may have regressed. Searched for {needle:?}."
);
}
}
#[test]
fn engine_keeps_known_fake_wrapper_end_markers() {
for marker in EXPECTED_END_MARKERS {
let needle = format!("\"{marker}\"");
assert!(
ENGINE_SRC.contains(&needle),
"engine.rs no longer mentions end marker `{marker}` — protocol \
scrubbing may have regressed. Searched for {needle:?}."
);
}
}
#[test]
fn engine_marker_counts_stay_paired() {
// A future contributor could quietly drop a closing marker and leave the
// filter able to enter tool-call mode without ever leaving it. Lock the
// count to whatever the constants currently declare.
assert_eq!(EXPECTED_START_MARKERS.len(), EXPECTED_END_MARKERS.len());
assert!(ENGINE_SRC.contains("TOOL_CALL_START_MARKERS"));
assert!(ENGINE_SRC.contains("TOOL_CALL_END_MARKERS"));
}
#[test]
fn engine_emits_compact_fake_wrapper_notice() {
assert!(
ENGINE_SRC.contains("FAKE_WRAPPER_NOTICE"),
"engine.rs no longer references the protocol-recovery notice constant"
);
assert!(
ENGINE_SRC.contains("API tool channel"),
"the protocol-recovery notice should mention the API tool channel"
);
}
#[test]
fn legacy_parser_extracts_bracket_tool_call() {
let result = tool_parser::parse_tool_calls(
"intro [TOOL_CALL]\n{\"tool\":\"x\",\"args\":{}}\n[/TOOL_CALL]",
);
assert_eq!(result.tool_calls.len(), 1);
assert_eq!(result.tool_calls[0].name, "x");
assert_eq!(result.clean_text, "intro");
}
#[test]
fn legacy_parser_extracts_invoke_block() {
let result = tool_parser::parse_tool_calls(
"before <invoke name=\"do_thing\"><parameter name=\"k\">v</parameter></invoke> after",
);
assert_eq!(result.tool_calls.len(), 1);
assert_eq!(result.tool_calls[0].name, "do_thing");
}
#[test]
fn legacy_parser_does_not_execute_function_calls_wrapper() {
// The newer `<function_calls>` wrapper is the kind of forged shape that
// shows up in non-DeepSeek tool-call leakage. The legacy text parser must
// NOT turn it into a structured tool call (the engine's filter still
// strips it from visible text and the model is expected to use the API
// tool channel instead).
let raw = "narrative <function_calls>\n{\"name\":\"x\",\"input\":{}}\n</function_calls> end";
let result = tool_parser::parse_tool_calls(raw);
assert!(
result.tool_calls.is_empty(),
"function_calls wrapper must not be parsed as a real tool call: {:?}",
result.tool_calls
);
}
#[test]
fn legacy_parser_has_marker_helper_for_legacy_shapes_only() {
// The legacy parser's `has_tool_call_markers` is documentation of which
// shapes it ever knew how to handle. If it ever starts returning true for
// `<function_calls>`, the parser may also have started producing fake
// tool calls — we want to fail loudly in that case.
assert!(tool_parser::has_tool_call_markers(
"noise [TOOL_CALL]x[/TOOL_CALL]"
));
assert!(tool_parser::has_tool_call_markers(
"noise <invoke name=\"x\"></invoke>"
));
assert!(!tool_parser::has_tool_call_markers(
"noise <function_calls>{}</function_calls>"
));
}
#[test]
fn engine_source_file_still_exists_and_is_non_trivial() {
// Sanity check so the `include_str!` above is meaningful — if the engine
// module ever moves, this test must be updated alongside it.
let metadata = fs::metadata("src/core/engine.rs").expect("engine.rs must exist next to tests");
assert!(
metadata.len() > 10_000,
"engine.rs is unexpectedly small ({} bytes); did the file move?",
metadata.len()
);
}
+45
View File
@@ -232,3 +232,48 @@ checks use the resolved `mcp_config_path` / `skills_dir` (including env override
To bootstrap missing MCP/skills paths, run `deepseek-tui setup --all`. You can
also run `deepseek-tui setup --skills --local` to create a workspace-local
`./skills` dir.
`deepseek-tui doctor --json` prints a machine-readable report that skips the
live API connectivity probe. Top-level keys: `version`, `config_path`,
`config_present`, `workspace`, `api_key.source`, `base_url`,
`default_text_model`, `mcp`, `skills`, `tools`, `plugins`, `sandbox`,
`platform`, `api_connectivity`. CI consumers should rely on `api_key.source`
(`env`/`config`/`missing`) rather than parsing the human-readable `doctor`
text.
## Setup status, clean, and extension dirs
`deepseek-tui setup` accepts a few flags beyond the existing `--mcp`,
`--skills`, `--local`, `--all`, and `--force`:
- `--status` — print a compact one-screen status (api key, base URL, model,
MCP/skills/tools/plugins counts, sandbox, `.env` presence). Read-only and
network-free; safe to run in CI.
- `--tools` — scaffold `~/.deepseek/tools/` with a `README.md` describing the
self-describing frontmatter convention (`# name:` / `# description:` /
`# usage:`) and an `example.sh` that follows it. The directory is
intentionally not auto-loaded; wire individual scripts into the agent via
MCP, hooks, or skills.
- `--plugins` — scaffold `~/.deepseek/plugins/` with a `README.md` and an
`example/PLUGIN.md` placeholder using the same frontmatter shape as
`SKILL.md`. Plugins are not loaded automatically either; reference them
from a skill or MCP wrapper when you want them active.
- `--all` now scaffolds MCP + skills + tools + plugins together.
- `--clean` — list `~/.deepseek/sessions/checkpoints/latest.json` and
`offline_queue.json` if they exist. Pass `--force` to actually remove them.
This never touches real session history or the task queue.
`--status` and `--clean` are mutually exclusive with the scaffold flags.
## Why the engine strips XML/`[TOOL_CALL]` text
DeepSeek TUI sends and receives tool calls only over the API tool channel
(structured `tool_use` / `tool_call` items). The streaming loop in
`crates/tui/src/core/engine.rs` recognizes a fixed set of fake-wrapper start
markers — `[TOOL_CALL]`, `<deepseek:tool_call`, `<tool_call`, `<invoke `,
`<function_calls>` — and scrubs them from visible assistant text without ever
turning them into structured tool calls. When a wrapper is stripped, the loop
emits one compact `status` notice per turn so the user can see why their
visible text shrank. Treat any change that re-enables text-based tool
execution as a regression; the protocol-recovery tests in
`crates/tui/tests/protocol_recovery.rs` lock the contract.