feat: Add LSP plugin unit tests and frontend common initialization library
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / Sanitizer (ASan+UBSan) / ubuntu-24.04 (push) Has been cancelled
CI / Coverage (gcovr) / ubuntu-24.04 (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled

- Introduced `dstalk_lsp_plugin_test` for testing LSP plugin functionalities including `lsp_trim`, `lsp_frame_message`, and `lsp_parse_content_length`.
- Created `dstalk_frontend_common` static library to encapsulate shared initialization logic for frontend components (CLI, GUI, Web).
- Implemented configuration file discovery and service querying in `dstalk_frontend_init`.
- Added internal headers for LSP and Anthropic plugins to facilitate unit testing.
- Established a mailroom system for asynchronous message passing between stateless agents, enhancing coordination and context management.
This commit is contained in:
2026-06-01 08:51:40 +08:00
parent 8faa02c3d5
commit c0af9c65c7
17 changed files with 1235 additions and 69 deletions

View File

@@ -15,3 +15,9 @@ find_package(Boost REQUIRED CONFIG)
target_link_libraries(dstalk_cli
PRIVATE dstalk boost::boost dstalk_boost_config
)
# POSIX 平台需要 pthread (用于 std::thread spinner)
if(NOT WIN32)
find_package(Threads REQUIRED)
target_link_libraries(dstalk_cli PRIVATE Threads::Threads)
endif()

View File

@@ -7,12 +7,15 @@
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <iostream>
#include <string>
#include <system_error>
#include <thread>
#include <vector>
#include <boost/json.hpp>
@@ -64,6 +67,8 @@ static const dstalk_tools_service_t* g_tools = nullptr;
static std::string g_current_model;
static std::atomic<bool> g_quit_requested{false};
static std::atomic<bool> g_quit_via_signal{false};
static std::atomic<bool> g_spinning{false};
static std::thread g_spinner_thread;
// ---- Ctrl+C 信号处理 / Ctrl+C signal handlers ----
// Windows console event handler (CTRL_C_EVENT / CTRL_BREAK_EVENT).
@@ -90,6 +95,138 @@ static void on_signal(int /*sig*/)
// ---- 工具函数 / Utility functions ----
// ---- 进度指示器 (spinner) / Progress indicator (spinner) ----
// 在等待 AI 响应时在 stderr 显示旋转字符,通过 atomic flag 控制启停。
// Displays a rotating character on stderr while waiting for AI responses, controlled via atomic flag.
static void spinner_run()
{
const char chars[] = "|/-\\";
int i = 0;
while (g_spinning.load(std::memory_order_relaxed)) {
std::fprintf(stderr, "\r%c", chars[i % 4]);
std::fflush(stderr);
i++;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
// 光标归位(不擦除,由下一条 stdout 输出覆盖) / Return cursor (don't erase, let next stdout output overwrite)
std::fprintf(stderr, "\r");
std::fflush(stderr);
}
static void spinner_start()
{
if (g_spinner_thread.joinable()) {
g_spinner_thread.join();
}
g_spinning = true;
g_spinner_thread = std::thread(spinner_run);
}
static void spinner_stop()
{
g_spinning = false;
}
static void spinner_join()
{
if (g_spinner_thread.joinable()) {
g_spinner_thread.join();
}
}
// ---- 错误分类与友好提示 / Error classification and user-friendly messages ----
// 根据 HTTP 状态码和错误消息字符串匹配,将常见错误归类为认证/频率限制/网络/配额问题,并给出中文建议。
// Classifies common errors into auth/rate-limit/network/quota categories based on HTTP status and string matching, with Chinese suggestions.
static void print_error(const char* error_msg, int http_status)
{
std::string msg(error_msg ? error_msg : "unknown error");
const char* category = nullptr;
const char* suggestion = nullptr;
// 先按 HTTP 状态码分类(最可靠) / First classify by HTTP status code (most reliable)
switch (http_status) {
case 401:
case 403:
category = "认证失败";
suggestion = "请检查 API key 是否正确(输入 /status 查看当前配置)";
break;
case 429:
category = "请求频率限制";
suggestion = "API 调用太频繁,请稍后重试或降低请求频率";
break;
case 400:
category = "请求参数错误";
suggestion = "请求格式不正确,可能是模型名或参数有误(输入 /status 查看)";
break;
case 502:
case 503:
case 504:
category = "服务器错误";
suggestion = "API 服务器暂时不可用,请稍后重试";
break;
default:
break;
}
// http_status 未覆盖 → 字符串模式匹配 / HTTP status not covered → string pattern matching
if (!category) {
if (msg.find("401") != std::string::npos ||
msg.find("403") != std::string::npos ||
msg.find("Unauthorized") != std::string::npos ||
msg.find("Forbidden") != std::string::npos ||
msg.find("authentication") != std::string::npos ||
msg.find("invalid api key") != std::string::npos ||
msg.find("Incorrect API key") != std::string::npos) {
category = "认证失败";
suggestion = "请检查 API key 是否正确(输入 /status 查看当前配置)";
} else if (msg.find("429") != std::string::npos ||
msg.find("rate") != std::string::npos ||
msg.find("Rate limit") != std::string::npos ||
msg.find("too many requests") != std::string::npos) {
category = "请求频率限制";
suggestion = "API 调用太频繁,请稍后重试或降低请求频率";
} else if (msg.find("connection refused") != std::string::npos ||
msg.find("Connection refused") != std::string::npos ||
msg.find("connection reset") != std::string::npos ||
msg.find("Connection reset") != std::string::npos ||
msg.find("timed out") != std::string::npos ||
msg.find("Timeout") != std::string::npos ||
msg.find("network") != std::string::npos ||
msg.find("Network") != std::string::npos ||
msg.find("resolve") != std::string::npos ||
msg.find("Name or service not known") != std::string::npos ||
msg.find("Couldn't resolve") != std::string::npos ||
msg.find("Failed to connect") != std::string::npos ||
msg.find("Could not connect") != std::string::npos ||
msg.find("could not connect") != std::string::npos ||
msg.find("connect error") != std::string::npos ||
msg.find("Connect error") != std::string::npos ||
msg.find("connect failed") != std::string::npos ||
msg.find("Connect failed") != std::string::npos) {
category = "网络错误";
suggestion = "无法连接到 API 服务器,请检查网络连接和 base_url输入 /status 查看)";
} else if (msg.find("400") != std::string::npos ||
msg.find("Bad Request") != std::string::npos) {
category = "请求参数错误";
suggestion = "请求格式不正确,可能是模型名或参数有误(输入 /status 查看)";
} else if (msg.find("insufficient") != std::string::npos ||
msg.find("quota") != std::string::npos ||
msg.find("billing") != std::string::npos) {
category = "配额不足";
suggestion = "API 配额已用完或账户余额不足,请检查账户状态";
}
}
if (category && suggestion) {
std::fprintf(stderr, CLR_RED "[ERROR] %s\n" CLR_RESET, category);
std::fprintf(stderr, CLR_YELLOW " -> %s\n" CLR_RESET, suggestion);
std::fprintf(stderr, CLR_DIM " (原始错误: %s)\n" CLR_RESET, msg.c_str());
} else {
std::fprintf(stderr, CLR_RED "[ERROR] AI 调用失败: %s\n" CLR_RESET, msg.c_str());
}
}
// 打印启动横幅 / Print the dstalk CLI banner with version, AI indicator, and quick command hints.
static void print_banner()
{
@@ -391,12 +528,15 @@ static void handle_command(const char* line)
}
// ---- 流式回调 / Streaming callback ----
// 流式输出回调:每收到一个 token 打印到 stdout 并刷新 / Callback invoked for each token during streaming chat; prints the token to stdout and flushes.
// 流式输出回调:每收到一个 token 打印到 stdout 并刷新
// 第一个 token 到达时停止 spinner 并用 \r 覆盖旋转字符。
// Callback invoked for each token during streaming chat; stops spinner on first token and overwrites the spinner character with \r.
static int on_stream_token(const char* token, void* userdata)
{
bool* first = static_cast<bool*>(userdata);
if (*first) {
std::printf(CLR_GREEN);
spinner_stop();
std::printf("\r" CLR_GREEN);
*first = false;
}
std::printf("%s", token);
@@ -404,6 +544,18 @@ static int on_stream_token(const char* token, void* userdata)
return 0;
}
// ---- 管道 / --prompt 共用:从 stdin 读入全部内容 / Read all stdin content (shared by pipe and --prompt modes) ----
static std::string read_all_stdin()
{
std::string result;
std::string line;
while (std::getline(std::cin, line)) {
if (!result.empty()) result += '\n';
result += line;
}
return result;
}
// ---- 主程序 / Main entry point ----
// 入口:初始化 dstalk host查询插件服务处理 batch/pipe/交互模式。
// Entry point: initializes dstalk host, queries plugin services, handles batch/pipe/interactive modes.
@@ -520,11 +672,7 @@ int main(int argc, char* argv[])
// ---- B3: 管道输入模式 (非交互) / Pipe input mode (non-interactive) ----
if (pipe_mode) {
std::string input;
char buf[4096];
while (std::fgets(buf, sizeof(buf), stdin)) {
input += buf;
}
std::string input = read_all_stdin();
if (input.empty()) {
std::fprintf(stderr, "empty prompt\n");
dstalk_shutdown();
@@ -544,8 +692,7 @@ int main(int argc, char* argv[])
dstalk_shutdown();
return EXIT_OK;
} else {
std::fprintf(stderr, CLR_RED "[ERROR] AI error: %s\n" CLR_RESET,
result.error ? result.error : "unknown");
print_error(result.error, result.http_status);
g_ai->free_result(&result);
dstalk_shutdown();
return EXIT_FATAL;
@@ -557,10 +704,7 @@ int main(int argc, char* argv[])
std::string prompt_text;
if (std::strcmp(prompt_arg, "-") == 0) {
// --prompt - or --prompt (no arg): read prompt from stdin / --prompt - 或 --prompt无参数从 stdin 读取提示
char buf[4096];
while (std::fgets(buf, sizeof(buf), stdin)) {
prompt_text += buf;
}
prompt_text = read_all_stdin();
if (prompt_text.empty()) {
std::fprintf(stderr, "empty prompt\n");
dstalk_shutdown();
@@ -588,15 +732,15 @@ int main(int argc, char* argv[])
dstalk_shutdown();
return EXIT_OK;
} else {
std::fprintf(stderr, CLR_RED "[ERROR] AI error: %s\n" CLR_RESET,
result.error ? result.error : "unknown");
print_error(result.error, result.http_status);
g_ai->free_result(&result);
dstalk_shutdown();
return EXIT_FATAL;
}
}
char buffer[8192];
// ---- 交互模式主循环 / Interactive mode main loop ----
std::string line;
while (true) {
// B1: 检查退出标志 / Check quit flag
if (g_quit_requested) {
@@ -611,26 +755,17 @@ int main(int argc, char* argv[])
std::fflush(stdout);
}
if (!std::fgets(buffer, sizeof(buffer), stdin)) break;
// 动态读取一行,无大小限制 / Read one line dynamically, no size limit
if (!std::getline(std::cin, line)) break;
// C3: fgets 截断检测 / fgets truncation detection
if (!std::strchr(buffer, '\n') && !feof(stdin)) {
std::fprintf(stderr, CLR_RED "[ERROR] 输入超过 8KB已截断。建议用文件方式dstalk --batch < file.txt\n" CLR_RESET);
int c;
while ((c = std::fgetc(stdin)) != '\n' && c != EOF) {}
}
// 去除末尾的 \rWindows / Strip trailing \r (Windows)
if (!line.empty() && line.back() == '\r') line.pop_back();
// 去除末尾换行 / Strip trailing newline
size_t len = std::strlen(buffer);
while (len > 0 && (buffer[len-1] == '\n' || buffer[len-1] == '\r')) {
buffer[--len] = '\0';
}
if (len == 0) continue;
if (line.empty()) continue;
// 命令处理 / Command dispatch
if (buffer[0] == '/') {
handle_command(buffer);
if (line[0] == '/') {
handle_command(line.c_str());
continue;
}
@@ -644,14 +779,19 @@ int main(int argc, char* argv[])
int history_count = 0;
const dstalk_message_t* history = g_session->history(&history_count);
// 启动 spinner等待 AI 响应 / Start spinner while waiting for AI response
spinner_start();
bool first = true;
dstalk_chat_result_t result = g_ai->chat_stream(
history, history_count, buffer, on_stream_token, &first);
history, history_count, line.c_str(), on_stream_token, &first);
// 确保 spinner 已停止(处理无流式输出的情况) / Ensure spinner is stopped (handles no-stream-output case)
spinner_stop();
if (result.ok) {
std::printf(CLR_RESET "\n\n");
// 将用户消息和 AI 回复添加到会话 / Add user message and AI reply to session
dstalk_message_t user_msg = {"user", buffer, nullptr, nullptr};
dstalk_message_t user_msg = {"user", line.c_str(), nullptr, nullptr};
g_session->add(&user_msg);
dstalk_message_t ai_msg = {"assistant", result.content, nullptr, result.tool_calls_json};
g_session->add(&ai_msg);
@@ -727,8 +867,10 @@ int main(int argc, char* argv[])
history = g_session->history(&history_count);
g_ai->free_result(&result);
spinner_start();
bool tool_stream_first = true;
result = g_ai->chat_stream(history, history_count, nullptr, on_stream_token, &tool_stream_first);
spinner_stop();
if (result.ok) {
std::printf(CLR_RESET "\n");
@@ -741,8 +883,7 @@ int main(int argc, char* argv[])
g_session->add(&ai_followup);
has_tool_calls = (result.tool_calls_json && result.tool_calls_json[0] != '\0');
} else {
std::printf(CLR_RED "[ERROR] AI 调用失败: %s\n" CLR_RESET,
result.error ? result.error : "unknown error");
print_error(result.error, result.http_status);
break;
}
}
@@ -751,14 +892,17 @@ int main(int argc, char* argv[])
std::fprintf(stderr, CLR_YELLOW "[WARN] 已达最大工具调用轮次(%d),停止\n" CLR_RESET, MAX_TOOL_ROUNDS);
}
} else {
// A3: error 路径下需 NULL 保护;当前只取 result.errorcontent 未涉及 / Error path needs NULL guard; currently only reads result.error, content not involved
std::printf(CLR_RESET "\n" CLR_RED "[ERROR] AI 调用失败: %s\n" CLR_RESET,
result.error ? result.error : "unknown error");
// AI 调用失败reset 颜色,输出分类错误信息 / AI call failed: reset color, output classified error info
std::printf(CLR_RESET "\n");
print_error(result.error, result.http_status);
}
g_ai->free_result(&result);
}
// B2: 单一退出点dstalk_shutdown 只在此调用(交互模式下) / Single exit point, dstalk_shutdown only called here (in interactive mode)
// 确保 spinner 线程已结束——先发信号停止,再 join 等待线程真正退出 / Ensure spinner thread has ended: signal stop first, then join to wait for thread exit
spinner_stop();
spinner_join();
dstalk_shutdown();
return g_quit_via_signal ? EXIT_INTERRUPT : EXIT_OK;
}