diff --git a/CMakeLists.txt b/CMakeLists.txt index c0c8940..6fa1fb3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,6 +74,17 @@ add_executable(gxde-ai-see-selftest target_include_directories(gxde-ai-see-selftest PRIVATE src ${ICU_INCLUDE_DIRS}) target_link_libraries(gxde-ai-see-selftest PRIVATE Qt6::Core Qt6::Gui ${ICU_LIBRARIES}) +add_executable(gxde-ai-see-chattest + tests/chattest.cpp + src/chat/chatclient.cpp + src/chat/mcpclient.cpp + src/config.cpp +) +target_include_directories(gxde-ai-see-chattest PRIVATE src) +target_link_libraries(gxde-ai-see-chattest PRIVATE Qt6::Core Qt6::Network) +target_compile_definitions(gxde-ai-see-chattest PRIVATE + TESTS_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests") + add_executable(gxde-ai-see-mcptest tests/mcptest.cpp src/chat/mcpclient.cpp diff --git a/src/chat/mcpclient.cpp b/src/chat/mcpclient.cpp index d87e68b..908dfd9 100644 --- a/src/chat/mcpclient.cpp +++ b/src/chat/mcpclient.cpp @@ -72,10 +72,7 @@ void McpServer::start() const int toolsId = m_nextId++; m_callbacks.insert(toolsId, [this](const QJsonObject &response) { - m_tools = response.value(QStringLiteral("result")) - .toObject() - .value(QStringLiteral("tools")) - .toArray(); + m_tools = response.value(QStringLiteral("tools")).toArray(); m_ready = true; Q_EMIT readyChanged(); }); @@ -119,8 +116,16 @@ void McpServer::handleLine(const QByteArray &line) if (object.contains(QStringLiteral("id"))) { const int id = object.value(QStringLiteral("id")).toInt(-1); auto callback = m_callbacks.take(id); - if (callback) { - callback(object); + if (!callback) { + return; + } + if (object.contains(QStringLiteral("error"))) { + const QJsonObject error = object.value(QStringLiteral("error")).toObject(); + callback(QJsonObject{ { QStringLiteral("error"), + error.value(QStringLiteral("message")) + .toString(QStringLiteral("MCP 调用失败")) } }); + } else { + callback(object.value(QStringLiteral("result")).toObject()); } } } diff --git a/tests/api/fake_openai.py b/tests/api/fake_openai.py new file mode 100644 index 0000000..c03c233 --- /dev/null +++ b/tests/api/fake_openai.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 GXDE OS Team +# SPDX-License-Identifier: GPL-3.0-or-later +"""Fake OpenAI compatible streaming endpoint used by tests/chattest.cpp. + +First request streams a tool call for the ``echo`` MCP tool; once the request +contains a ``tool`` message it streams the final answer. +""" + +import json +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length) or b"{}") + messages = body.get("messages", []) + has_tool_result = any(message.get("role") == "tool" for message in messages) + + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + + if has_tool_result: + delta = {"choices": [{"index": 0, "delta": {"content": "工具返回:echo: hello"}, + "finish_reason": None}]} + else: + delta = {"choices": [{ + "index": 0, + "delta": { + "role": "assistant", + "tool_calls": [{ + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "echo", + "arguments": json.dumps({"text": "hello"})}, + }], + }, + "finish_reason": None, + }]} + + self.wfile.write(("data: " + json.dumps(delta) + "\n\n").encode()) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + + def log_message(self, *args): + pass + + +server = HTTPServer(("127.0.0.1", 0), Handler) +print("PORT", server.server_port, flush=True) +server.serve_forever() diff --git a/tests/chattest.cpp b/tests/chattest.cpp new file mode 100644 index 0000000..53a5330 --- /dev/null +++ b/tests/chattest.cpp @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later +// +// End-to-end chat + MCP test against a fake OpenAI compatible server: +// request -> streaming tool call -> MCP tools/call -> follow-up request -> text. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "chat/chatclient.h" +#include "chat/mcpclient.h" +#include "config.h" + +static QTextStream out(stdout); +static int exitCode = 1; + +static void finish(QCoreApplication *app, bool ok, const QString &message) +{ + out << (ok ? "PASS " : "FAIL ") << message << "\n"; + out.flush(); + exitCode = ok ? 0 : 1; + app->quit(); +} + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + app.setOrganizationName(QStringLiteral("GXDE")); + app.setApplicationName(QStringLiteral("gxde-ai-see")); + + QTemporaryDir configDir; + if (!configDir.isValid()) { + finish(&app, false, QStringLiteral("cannot create temp config dir")); + return exitCode; + } + qputenv("XDG_CONFIG_HOME", configDir.path().toUtf8()); + qputenv("XDG_DATA_HOME", configDir.path().toUtf8()); + qputenv("XDG_CACHE_HOME", configDir.path().toUtf8()); + + const QString testsDir = QStringLiteral(TESTS_DIR); + + QProcess server; + server.setProcessChannelMode(QProcess::MergedChannels); + server.start(QStringLiteral("python3"), + { testsDir + QStringLiteral("/api/fake_openai.py") }); + if (!server.waitForStarted(5000)) { + finish(&app, false, QStringLiteral("fake API server did not start")); + return exitCode; + } + + QByteArray output; + int port = 0; + while (port == 0) { + if (!server.waitForReadyRead(5000)) { + break; + } + output += server.readAll(); + const int index = output.indexOf("PORT "); + if (index >= 0) { + const int end = output.indexOf('\n', index); + if (end > index) { + port = output.mid(index + 5, end - index - 5).trimmed().toInt(); + } + } + } + if (port == 0) { + finish(&app, false, QStringLiteral("cannot read fake API port")); + return exitCode; + } + out << "fake API port " << port << "\n"; + + Config::instance()->init(); + Config::instance()->set(QStringLiteral("chat/apiKey"), QStringLiteral("test")); + Config::instance()->set(QStringLiteral("chat/baseUrl"), + QStringLiteral("http://127.0.0.1:%1").arg(port)); + Config::instance()->set(QStringLiteral("chat/model"), QStringLiteral("test-model")); + Config::instance()->set( + QStringLiteral("chat/mcpServers"), + QStringLiteral("[{\"name\":\"test\",\"command\":\"python3\",\"args\":[\"%1/mcp/test_server.py\"]}]") + .arg(testsDir)); + Config::instance()->sync(); + + McpManager::instance()->reload(); + ChatClient client; + + QObject::connect(&client, &ChatClient::failed, [&](const QString &error) { + finish(&app, false, QStringLiteral("chat failed: %1").arg(error)); + }); + + QObject::connect(&client, &ChatClient::toolCallsReady, + [&](const QVector &calls) { + if (calls.isEmpty()) { + finish(&app, false, QStringLiteral("empty tool call")); + return; + } + out << "tool call " << calls.first().name << " " + << calls.first().arguments << "\n"; + const ToolCall call = calls.first(); + McpManager::instance()->callTool( + call.name, + QJsonObject{ { QStringLiteral("text"), QStringLiteral("hello") } }, + [&, call](const QJsonObject &result) { + QString text; + for (const QJsonValue &value : + result.value(QStringLiteral("content")).toArray()) { + text += value.toObject() + .value(QStringLiteral("text")) + .toString(); + } + out << "tool result " << text << "\n"; + if (!text.contains(QStringLiteral("echo: hello"))) { + finish(&app, false, QStringLiteral("tool result mismatch")); + return; + } + + QJsonObject assistant; + assistant.insert(QStringLiteral("role"), + QStringLiteral("assistant")); + assistant.insert(QStringLiteral("content"), QJsonValue::Null); + QJsonObject function; + function.insert(QStringLiteral("name"), call.name); + function.insert(QStringLiteral("arguments"), call.arguments); + assistant.insert( + QStringLiteral("tool_calls"), + QJsonArray{ QJsonObject{ + { QStringLiteral("id"), call.id }, + { QStringLiteral("type"), QStringLiteral("function") }, + { QStringLiteral("function"), function } } }); + QJsonObject toolMessage{ + { QStringLiteral("role"), QStringLiteral("tool") }, + { QStringLiteral("tool_call_id"), call.id }, + { QStringLiteral("content"), text }, + }; + client.send( + { ChatMessage{ QStringLiteral("user"), + QStringLiteral("hi") } }, + QJsonArray{ assistant, toolMessage }); + }); + }); + + QObject::connect(&client, &ChatClient::finished, [&](const QString &text) { + out << "answer " << text << "\n"; + finish(&app, text.contains(QStringLiteral("echo: hello")), + QStringLiteral("tool loop end to end")); + }); + + QTimer::singleShot(20000, &app, [&]() { + finish(&app, false, QStringLiteral("timeout")); + }); + + auto *poller = new QTimer(&app); + poller->setInterval(100); + QObject::connect(poller, &QTimer::timeout, [&]() { + if (!McpManager::instance()->hasTools()) { + return; + } + poller->stop(); + out << "mcp tools ready\n"; + client.setTools(McpManager::instance()->toolDefinitions()); + client.send({ ChatMessage{ QStringLiteral("user"), QStringLiteral("hi") } }); + }); + poller->start(); + + const int rc = app.exec(); + if (server.state() != QProcess::NotRunning) { + server.kill(); + server.waitForFinished(1000); + } + return exitCode != 0 ? exitCode : rc; +}