Merge branch 'claude/improve-deepseek-v4-harness-NxBpS' into main
This commit is contained in:
@@ -125,6 +125,14 @@ the default model is `deepseek-ai/deepseek-v4-pro` and the default base URL is
|
||||
`https://integrate.api.nvidia.com/v1`. With `--provider nvidia-nim`,
|
||||
`--model deepseek-v4-flash` maps to `deepseek-ai/deepseek-v4-flash`.
|
||||
|
||||
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
|
||||
|
||||
@@ -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(¤t_text_raw, &mut in_tool_call_block);
|
||||
if !fake_wrapper_notice_emitted
|
||||
&& filtered.len() < current_text_raw.len()
|
||||
&& contains_fake_tool_wrapper(¤t_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
|
||||
|
||||
@@ -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
@@ -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)?;
|
||||
@@ -790,19 +813,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!(
|
||||
"{}",
|
||||
@@ -860,6 +1015,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}");
|
||||
@@ -870,6 +1044,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;
|
||||
@@ -1149,6 +1527,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());
|
||||
@@ -1178,6 +1598,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(),
|
||||
@@ -2604,3 +3163,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -256,3 +256,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.
|
||||
|
||||
Reference in New Issue
Block a user