mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-06-30 01:59:11 -04:00
refactor: Move to agent architecture
This commit is contained in:
115
test/AgentConfigTest.cpp
Normal file
115
test/AgentConfigTest.cpp
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <QJsonObject>
|
||||
|
||||
#include <AgentConfig.hpp>
|
||||
|
||||
using QodeAssist::AgentConfig;
|
||||
|
||||
namespace {
|
||||
|
||||
AgentConfig makeValid()
|
||||
{
|
||||
AgentConfig cfg;
|
||||
cfg.name = QStringLiteral("Agent");
|
||||
cfg.providerInstance = QStringLiteral("P");
|
||||
cfg.model = QStringLiteral("m");
|
||||
cfg.endpoint = QStringLiteral("/e");
|
||||
cfg.body = QJsonObject{{"stream", true}};
|
||||
return cfg;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(AgentConfigTest, ValidConfigReturnsNoError)
|
||||
{
|
||||
EXPECT_TRUE(AgentConfig::validate(makeValid()).isEmpty());
|
||||
}
|
||||
|
||||
TEST(AgentConfigTest, MissingNameRejected)
|
||||
{
|
||||
AgentConfig cfg = makeValid();
|
||||
cfg.name.clear();
|
||||
EXPECT_TRUE(AgentConfig::validate(cfg).contains(QStringLiteral("no name")));
|
||||
}
|
||||
|
||||
TEST(AgentConfigTest, MissingProviderInstanceRejected)
|
||||
{
|
||||
AgentConfig cfg = makeValid();
|
||||
cfg.providerInstance.clear();
|
||||
EXPECT_TRUE(AgentConfig::validate(cfg).contains(QStringLiteral("provider_instance")));
|
||||
}
|
||||
|
||||
TEST(AgentConfigTest, MissingModelRejected)
|
||||
{
|
||||
AgentConfig cfg = makeValid();
|
||||
cfg.model.clear();
|
||||
EXPECT_TRUE(AgentConfig::validate(cfg).contains(QStringLiteral("no model")));
|
||||
}
|
||||
|
||||
TEST(AgentConfigTest, MissingEndpointRejected)
|
||||
{
|
||||
AgentConfig cfg = makeValid();
|
||||
cfg.endpoint.clear();
|
||||
EXPECT_TRUE(AgentConfig::validate(cfg).contains(QStringLiteral("no endpoint")));
|
||||
}
|
||||
|
||||
TEST(AgentConfigTest, MissingBodyRejected)
|
||||
{
|
||||
AgentConfig cfg = makeValid();
|
||||
cfg.body = QJsonObject{};
|
||||
EXPECT_TRUE(AgentConfig::validate(cfg).contains(QStringLiteral("[body]")));
|
||||
}
|
||||
|
||||
TEST(AgentConfigTest, FutureSchemaVersionRejected)
|
||||
{
|
||||
AgentConfig cfg = makeValid();
|
||||
cfg.schemaVersion = AgentConfig::kSupportedSchemaVersion + 1;
|
||||
EXPECT_TRUE(AgentConfig::validate(cfg).contains(QStringLiteral("schema_version")));
|
||||
}
|
||||
|
||||
TEST(AgentConfigTest, SupportedSchemaVersionAccepted)
|
||||
{
|
||||
AgentConfig cfg = makeValid();
|
||||
cfg.schemaVersion = AgentConfig::kSupportedSchemaVersion;
|
||||
EXPECT_TRUE(AgentConfig::validate(cfg).isEmpty());
|
||||
}
|
||||
|
||||
TEST(AgentConfigTest, MatchIsEmptyWhenAllDimensionsEmpty)
|
||||
{
|
||||
AgentConfig::Match m;
|
||||
EXPECT_TRUE(m.isEmpty());
|
||||
}
|
||||
|
||||
TEST(AgentConfigTest, MatchIsNotEmptyWhenAnyDimensionSet)
|
||||
{
|
||||
AgentConfig::Match files;
|
||||
files.filePatterns = {QStringLiteral("*.cpp")};
|
||||
EXPECT_FALSE(files.isEmpty());
|
||||
|
||||
AgentConfig::Match paths;
|
||||
paths.pathPatterns = {QStringLiteral("*/src/*")};
|
||||
EXPECT_FALSE(paths.isEmpty());
|
||||
|
||||
AgentConfig::Match projects;
|
||||
projects.projectNames = {QStringLiteral("P")};
|
||||
EXPECT_FALSE(projects.isEmpty());
|
||||
}
|
||||
|
||||
TEST(AgentConfigTest, IsUserSourceFalseForBundledResourcePath)
|
||||
{
|
||||
AgentConfig cfg = makeValid();
|
||||
cfg.sourcePath = QStringLiteral(":/agents/claude_chat.toml");
|
||||
EXPECT_FALSE(cfg.isUserSource());
|
||||
}
|
||||
|
||||
TEST(AgentConfigTest, IsUserSourceTrueForFilesystemPath)
|
||||
{
|
||||
AgentConfig cfg = makeValid();
|
||||
cfg.sourcePath = QStringLiteral("/home/me/.config/agents/mine.toml");
|
||||
EXPECT_TRUE(cfg.isUserSource());
|
||||
}
|
||||
184
test/AgentLoaderTest.cpp
Normal file
184
test/AgentLoaderTest.cpp
Normal file
@@ -0,0 +1,184 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
#include <AgentConfig.hpp>
|
||||
#include <AgentLoader.hpp>
|
||||
|
||||
using QodeAssist::AgentConfig;
|
||||
using QodeAssist::Agents::AgentLoader;
|
||||
|
||||
namespace {
|
||||
|
||||
void writeFile(const QString &dir, const QString &name, const QByteArray &contents)
|
||||
{
|
||||
QFile f(dir + QLatin1Char('/') + name);
|
||||
ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text));
|
||||
f.write(contents);
|
||||
}
|
||||
|
||||
QByteArray minimalAgent(const QByteArray &name, const QByteArray &extra = {})
|
||||
{
|
||||
return "name = \"" + name + "\"\n"
|
||||
"provider_instance = \"P\"\n"
|
||||
"model = \"m\"\n"
|
||||
"endpoint = \"/e\"\n"
|
||||
+ extra +
|
||||
"[body]\n"
|
||||
"stream = true\n";
|
||||
}
|
||||
|
||||
const AgentConfig *findConfig(const AgentLoader::LoadResult &result, const QString &name)
|
||||
{
|
||||
for (const auto &cfg : result.configs) {
|
||||
if (cfg.name == name)
|
||||
return &cfg;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool anyContains(const QStringList &list, const QString &needle)
|
||||
{
|
||||
for (const QString &s : list) {
|
||||
if (s.contains(needle))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(AgentLoaderTest, WarnsOnUnknownTopLevelAndMatchKeys)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
writeFile(dir.path(), "a.toml",
|
||||
minimalAgent("A",
|
||||
"enable_thinkin = true\n"
|
||||
"[match]\n"
|
||||
"file_pattern = [\"*.cpp\"]\n"));
|
||||
|
||||
const auto result = AgentLoader::load(QString(), dir.path());
|
||||
EXPECT_TRUE(result.errors.isEmpty()) << result.errors.join("; ").toStdString();
|
||||
EXPECT_TRUE(anyContains(result.warnings, QStringLiteral("enable_thinkin")));
|
||||
EXPECT_TRUE(anyContains(result.warnings, QStringLiteral("match.file_pattern")));
|
||||
}
|
||||
|
||||
TEST(AgentLoaderTest, WarnsOnDuplicateNameInSameLayer)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
writeFile(dir.path(), "first.toml", minimalAgent("Dup"));
|
||||
writeFile(dir.path(), "second.toml", minimalAgent("Dup"));
|
||||
|
||||
const auto result = AgentLoader::load(QString(), dir.path());
|
||||
EXPECT_TRUE(anyContains(result.warnings, QStringLiteral("defined in both")));
|
||||
const AgentConfig *cfg = findConfig(result, QStringLiteral("Dup"));
|
||||
ASSERT_NE(cfg, nullptr);
|
||||
EXPECT_TRUE(cfg->sourcePath.endsWith(QStringLiteral("second.toml")));
|
||||
}
|
||||
|
||||
TEST(AgentLoaderTest, UserAgentCollidingWithBundledNameIsRejected)
|
||||
{
|
||||
QTemporaryDir bundled;
|
||||
QTemporaryDir user;
|
||||
ASSERT_TRUE(bundled.isValid());
|
||||
ASSERT_TRUE(user.isValid());
|
||||
writeFile(bundled.path(), "a.toml", minimalAgent("A", "description = \"base\"\n"));
|
||||
writeFile(user.path(), "a.toml", minimalAgent("A", "description = \"mine\"\n"));
|
||||
|
||||
const auto result = AgentLoader::load(bundled.path(), user.path());
|
||||
EXPECT_TRUE(anyContains(result.errors, QStringLiteral("cannot be replaced")));
|
||||
const AgentConfig *cfg = findConfig(result, QStringLiteral("A"));
|
||||
ASSERT_NE(cfg, nullptr);
|
||||
EXPECT_EQ(cfg->description, QStringLiteral("base"));
|
||||
EXPECT_FALSE(cfg->isUserSource());
|
||||
}
|
||||
|
||||
TEST(AgentLoaderTest, HiddenIsNotInherited)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
writeFile(dir.path(), "parent.toml", minimalAgent("Parent", "hidden = true\n"));
|
||||
writeFile(dir.path(), "child.toml", minimalAgent("Child", "extends = \"Parent\"\n"));
|
||||
|
||||
const auto result = AgentLoader::load(QString(), dir.path());
|
||||
EXPECT_TRUE(result.errors.isEmpty()) << result.errors.join("; ").toStdString();
|
||||
const AgentConfig *parent = findConfig(result, QStringLiteral("Parent"));
|
||||
const AgentConfig *child = findConfig(result, QStringLiteral("Child"));
|
||||
ASSERT_NE(parent, nullptr);
|
||||
ASSERT_NE(child, nullptr);
|
||||
EXPECT_TRUE(parent->hidden);
|
||||
EXPECT_FALSE(child->hidden);
|
||||
}
|
||||
|
||||
TEST(AgentLoaderTest, UserAgentExtendsBundledProviderBase)
|
||||
{
|
||||
QTemporaryDir bundled;
|
||||
QTemporaryDir user;
|
||||
ASSERT_TRUE(bundled.isValid());
|
||||
ASSERT_TRUE(user.isValid());
|
||||
writeFile(bundled.path(), "base.toml",
|
||||
"name = \"Provider Base\"\n"
|
||||
"abstract = true\n"
|
||||
"provider_instance = \"P\"\n"
|
||||
"endpoint = \"/e\"\n"
|
||||
"[body]\n"
|
||||
"stream = true\n");
|
||||
writeFile(bundled.path(), "a.toml",
|
||||
"name = \"A\"\n"
|
||||
"extends = \"Provider Base\"\n"
|
||||
"model = \"stock-model\"\n");
|
||||
writeFile(user.path(), "mine.toml",
|
||||
"name = \"My A\"\n"
|
||||
"extends = \"Provider Base\"\n"
|
||||
"model = \"my-model\"\n"
|
||||
"[body]\n"
|
||||
"temperature = 0.2\n");
|
||||
|
||||
const auto result = AgentLoader::load(bundled.path(), user.path());
|
||||
EXPECT_TRUE(result.errors.isEmpty()) << result.errors.join("; ").toStdString();
|
||||
const AgentConfig *stock = findConfig(result, QStringLiteral("A"));
|
||||
ASSERT_NE(stock, nullptr);
|
||||
EXPECT_EQ(stock->model, QStringLiteral("stock-model"));
|
||||
const AgentConfig *mine = findConfig(result, QStringLiteral("My A"));
|
||||
ASSERT_NE(mine, nullptr);
|
||||
EXPECT_EQ(mine->model, QStringLiteral("my-model"));
|
||||
EXPECT_EQ(mine->providerInstance, QStringLiteral("P"));
|
||||
EXPECT_TRUE(mine->body.contains(QStringLiteral("stream")));
|
||||
EXPECT_TRUE(mine->body.contains(QStringLiteral("temperature")));
|
||||
EXPECT_TRUE(mine->isUserSource());
|
||||
}
|
||||
|
||||
TEST(AgentLoaderTest, ExtendsUnknownParentErrorNamesChild)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
writeFile(dir.path(), "child.toml", minimalAgent("Child", "extends = \"NoSuchBase\"\n"));
|
||||
|
||||
const auto result = AgentLoader::load(QString(), dir.path());
|
||||
EXPECT_TRUE(anyContains(result.errors, QStringLiteral("'Child' extends unknown agent 'NoSuchBase'")));
|
||||
EXPECT_EQ(findConfig(result, QStringLiteral("Child")), nullptr);
|
||||
}
|
||||
|
||||
TEST(AgentLoaderTest, ParseFileReportsWarningsForOwnFileOnly)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
writeFile(dir.path(), "other.toml", minimalAgent("Other", "bogus_key = 1\n"));
|
||||
writeFile(dir.path(), "target.toml", minimalAgent("Target", "another_bogus = 2\n"));
|
||||
|
||||
QString error;
|
||||
QStringList warnings;
|
||||
const auto cfg = AgentLoader::parseFile(
|
||||
dir.path() + QStringLiteral("/target.toml"), QString(), &error, &warnings);
|
||||
ASSERT_TRUE(cfg.has_value()) << error.toStdString();
|
||||
EXPECT_TRUE(anyContains(warnings, QStringLiteral("another_bogus")));
|
||||
EXPECT_FALSE(anyContains(warnings, QStringLiteral("bogus_key")));
|
||||
}
|
||||
104
test/AgentRouterTest.cpp
Normal file
104
test/AgentRouterTest.cpp
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <AgentConfig.hpp>
|
||||
#include <AgentRouter.hpp>
|
||||
|
||||
using QodeAssist::AgentConfig;
|
||||
using QodeAssist::AgentRouter::Context;
|
||||
using QodeAssist::AgentRouter::matches;
|
||||
|
||||
namespace {
|
||||
|
||||
Context ctx(const QString &filePath, const QString &projectName = QString())
|
||||
{
|
||||
return Context{filePath, projectName};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(AgentRouterTest, EmptyMatchIsCatchAll)
|
||||
{
|
||||
AgentConfig::Match m;
|
||||
EXPECT_TRUE(m.isEmpty());
|
||||
EXPECT_TRUE(matches(m, ctx(QStringLiteral("/any/file.cpp"))));
|
||||
EXPECT_TRUE(matches(m, ctx(QString())));
|
||||
}
|
||||
|
||||
TEST(AgentRouterTest, FilePatternMatchesByFileName)
|
||||
{
|
||||
AgentConfig::Match m;
|
||||
m.filePatterns = {QStringLiteral("*.cpp")};
|
||||
|
||||
EXPECT_TRUE(matches(m, ctx(QStringLiteral("/proj/src/foo.cpp"))));
|
||||
EXPECT_TRUE(matches(m, ctx(QStringLiteral("foo.cpp"))));
|
||||
EXPECT_FALSE(matches(m, ctx(QStringLiteral("/proj/src/foo.h"))));
|
||||
}
|
||||
|
||||
TEST(AgentRouterTest, FilePatternIsCaseInsensitive)
|
||||
{
|
||||
AgentConfig::Match m;
|
||||
m.filePatterns = {QStringLiteral("*.cpp")};
|
||||
|
||||
EXPECT_TRUE(matches(m, ctx(QStringLiteral("/proj/FOO.CPP"))));
|
||||
}
|
||||
|
||||
TEST(AgentRouterTest, FilePatternWithEmptyPathDoesNotMatch)
|
||||
{
|
||||
AgentConfig::Match m;
|
||||
m.filePatterns = {QStringLiteral("*.cpp")};
|
||||
|
||||
EXPECT_FALSE(matches(m, ctx(QString())));
|
||||
}
|
||||
|
||||
TEST(AgentRouterTest, MultipleFilePatternsAreOred)
|
||||
{
|
||||
AgentConfig::Match m;
|
||||
m.filePatterns = {QStringLiteral("*.cpp"), QStringLiteral("*.qml")};
|
||||
|
||||
EXPECT_TRUE(matches(m, ctx(QStringLiteral("main.qml"))));
|
||||
EXPECT_TRUE(matches(m, ctx(QStringLiteral("main.cpp"))));
|
||||
EXPECT_FALSE(matches(m, ctx(QStringLiteral("main.py"))));
|
||||
}
|
||||
|
||||
TEST(AgentRouterTest, PathPatternMatchesAcrossSeparators)
|
||||
{
|
||||
AgentConfig::Match m;
|
||||
m.pathPatterns = {QStringLiteral("*/tests/*")};
|
||||
|
||||
EXPECT_TRUE(matches(m, ctx(QStringLiteral("/home/me/tests/x.cpp"))));
|
||||
EXPECT_FALSE(matches(m, ctx(QStringLiteral("/home/me/src/x.cpp"))));
|
||||
}
|
||||
|
||||
TEST(AgentRouterTest, ProjectNameMatchIsCaseSensitive)
|
||||
{
|
||||
AgentConfig::Match m;
|
||||
m.projectNames = {QStringLiteral("MyProj")};
|
||||
|
||||
EXPECT_TRUE(matches(m, ctx(QStringLiteral("a.cpp"), QStringLiteral("MyProj"))));
|
||||
EXPECT_FALSE(matches(m, ctx(QStringLiteral("a.cpp"), QStringLiteral("myproj"))));
|
||||
EXPECT_FALSE(matches(m, ctx(QStringLiteral("a.cpp"), QString())));
|
||||
}
|
||||
|
||||
TEST(AgentRouterTest, DimensionsAreAndedTogether)
|
||||
{
|
||||
AgentConfig::Match m;
|
||||
m.filePatterns = {QStringLiteral("*.cpp")};
|
||||
m.projectNames = {QStringLiteral("P")};
|
||||
|
||||
EXPECT_TRUE(matches(m, ctx(QStringLiteral("a.cpp"), QStringLiteral("P"))));
|
||||
EXPECT_FALSE(matches(m, ctx(QStringLiteral("a.cpp"), QStringLiteral("Q"))));
|
||||
EXPECT_FALSE(matches(m, ctx(QStringLiteral("a.h"), QStringLiteral("P"))));
|
||||
}
|
||||
|
||||
TEST(AgentRouterTest, UnconstrainedDimensionDoesNotBlock)
|
||||
{
|
||||
AgentConfig::Match m;
|
||||
m.projectNames = {QStringLiteral("P")};
|
||||
|
||||
// file path is irrelevant because no file/path patterns are set
|
||||
EXPECT_TRUE(matches(m, ctx(QString(), QStringLiteral("P"))));
|
||||
}
|
||||
77
test/BundledAgentsTest.cpp
Normal file
77
test/BundledAgentsTest.cpp
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <QString>
|
||||
|
||||
#include <AgentConfig.hpp>
|
||||
#include <AgentLoader.hpp>
|
||||
#include <ContextRenderer.hpp>
|
||||
#include <JsonPromptTemplate.hpp>
|
||||
|
||||
using QodeAssist::Agents::AgentLoader;
|
||||
using QodeAssist::Templates::JsonPromptTemplate;
|
||||
using QodeAssist::Templates::ContextRenderer::Bindings;
|
||||
using QodeAssist::Templates::ContextRenderer::render;
|
||||
|
||||
TEST(BundledAgentsTest, AllBundledAgentsLoadResolveAndRender)
|
||||
{
|
||||
Q_INIT_RESOURCE(agents);
|
||||
|
||||
const AgentLoader::LoadResult result = AgentLoader::load(QStringLiteral(":/agents"), QString());
|
||||
|
||||
EXPECT_TRUE(result.errors.isEmpty())
|
||||
<< "bundled agent load errors: "
|
||||
<< result.errors.join(QStringLiteral("; ")).toStdString();
|
||||
|
||||
EXPECT_TRUE(result.warnings.isEmpty())
|
||||
<< "bundled agent load warnings: "
|
||||
<< result.warnings.join(QStringLiteral("; ")).toStdString();
|
||||
|
||||
ASSERT_FALSE(result.configs.empty()) << "no bundled agents were loaded from :/agents";
|
||||
|
||||
for (const auto &cfg : result.configs) {
|
||||
QString error;
|
||||
const auto tmpl = JsonPromptTemplate::fromConfig(cfg, &error);
|
||||
EXPECT_NE(tmpl, nullptr) << "bundled agent '" << cfg.name.toStdString()
|
||||
<< "' body failed to render: " << error.toStdString();
|
||||
}
|
||||
}
|
||||
|
||||
TEST(BundledAgentsTest, AllBundledSystemPromptsResolveQrcResources)
|
||||
{
|
||||
Q_INIT_RESOURCE(agents);
|
||||
|
||||
const AgentLoader::LoadResult result = AgentLoader::load(QStringLiteral(":/agents"), QString());
|
||||
ASSERT_TRUE(result.errors.isEmpty())
|
||||
<< result.errors.join(QStringLiteral("; ")).toStdString();
|
||||
ASSERT_FALSE(result.configs.empty());
|
||||
|
||||
const QStringList languages = {QString(), QStringLiteral("qml"), QStringLiteral("c-like")};
|
||||
|
||||
int withSystemPrompt = 0;
|
||||
for (const auto &cfg : result.configs) {
|
||||
if (cfg.systemPrompt.isEmpty())
|
||||
continue;
|
||||
++withSystemPrompt;
|
||||
for (const QString &lang : languages) {
|
||||
Bindings bindings;
|
||||
bindings.language = lang;
|
||||
|
||||
QString error;
|
||||
const QString rendered = render(cfg.systemPrompt, bindings, &error);
|
||||
|
||||
EXPECT_TRUE(error.isEmpty())
|
||||
<< "agent '" << cfg.name.toStdString() << "' (language='"
|
||||
<< lang.toStdString() << "') system_prompt render error: " << error.toStdString();
|
||||
EXPECT_FALSE(rendered.trimmed().isEmpty())
|
||||
<< "agent '" << cfg.name.toStdString() << "' (language='" << lang.toStdString()
|
||||
<< "') system_prompt rendered empty — a read_file(\":/...\") path is likely broken";
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_GT(withSystemPrompt, 0)
|
||||
<< "no bundled agent carried a system_prompt — this test would be vacuous";
|
||||
}
|
||||
@@ -1,11 +1,23 @@
|
||||
add_executable(QodeAssistTest
|
||||
../CodeHandler.cpp
|
||||
../LLMClientInterface.cpp
|
||||
../LLMSuggestion.cpp
|
||||
CodeHandlerTest.cpp
|
||||
ClaudeCacheControlTest.cpp
|
||||
DocumentContextReaderTest.cpp
|
||||
EnvBlockFormatterTest.cpp
|
||||
LLMSuggestionTest.cpp
|
||||
JsonPromptTemplateTest.cpp
|
||||
ContextAssemblerTest.cpp
|
||||
ResponseRouterTest.cpp
|
||||
BundledAgentsTest.cpp
|
||||
AgentLoaderTest.cpp
|
||||
AgentConfigTest.cpp
|
||||
AgentRouterTest.cpp
|
||||
ClaudeCacheControlTest.cpp
|
||||
ContextRendererTest.cpp
|
||||
ErrorInfoTest.cpp
|
||||
MessageSerializerTest.cpp
|
||||
ResponseCleanerTest.cpp
|
||||
SystemPromptBuilderTest.cpp
|
||||
# LLMClientInterfaceTests.cpp
|
||||
unittest_main.cpp
|
||||
)
|
||||
@@ -18,8 +30,11 @@ target_link_libraries(QodeAssistTest PRIVATE
|
||||
GTest::Main
|
||||
QtCreator::LanguageClient
|
||||
Context
|
||||
PluginLLMCore
|
||||
Common
|
||||
LLMQore
|
||||
Templates
|
||||
Agents
|
||||
Session
|
||||
)
|
||||
|
||||
target_include_directories(QodeAssistTest PRIVATE ${CMAKE_SOURCE_DIR})
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
|
||||
#include "providers/ClaudeCacheControl.hpp"
|
||||
#include <sources/providers/ClaudeCacheControl.hpp>
|
||||
|
||||
using namespace QodeAssist::Providers::ClaudeCacheControl;
|
||||
|
||||
@@ -21,6 +21,11 @@ QJsonObject expectedEphemeral(bool extendedTtl)
|
||||
return obj;
|
||||
}
|
||||
|
||||
void applyAll(QJsonObject &request, bool extendedTtl)
|
||||
{
|
||||
apply(request, extendedTtl, QStringList());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(ClaudeCacheControlTest, BreakpointWithoutExtendedTTL)
|
||||
@@ -42,7 +47,7 @@ TEST(ClaudeCacheControlTest, SystemAsStringWrappedIntoArray)
|
||||
QJsonObject request;
|
||||
request["system"] = "you are a helpful agent";
|
||||
|
||||
apply(request, false);
|
||||
applyAll(request, false);
|
||||
|
||||
ASSERT_TRUE(request.value("system").isArray());
|
||||
const QJsonArray sys = request.value("system").toArray();
|
||||
@@ -59,7 +64,7 @@ TEST(ClaudeCacheControlTest, EmptySystemStringIsNotWrapped)
|
||||
QJsonObject request;
|
||||
request["system"] = "";
|
||||
|
||||
apply(request, false);
|
||||
applyAll(request, false);
|
||||
|
||||
EXPECT_TRUE(request.value("system").isString());
|
||||
}
|
||||
@@ -71,7 +76,7 @@ TEST(ClaudeCacheControlTest, SystemAsArrayMarksLastBlock)
|
||||
QJsonObject{{"type", "text"}, {"text", "a"}},
|
||||
QJsonObject{{"type", "text"}, {"text", "b"}}};
|
||||
|
||||
apply(request, false);
|
||||
applyAll(request, false);
|
||||
|
||||
const QJsonArray sys = request.value("system").toArray();
|
||||
ASSERT_EQ(sys.size(), 2);
|
||||
@@ -87,7 +92,7 @@ TEST(ClaudeCacheControlTest, ToolsLastEntryGetsCacheControl)
|
||||
QJsonObject{{"name", "edit_file"}},
|
||||
QJsonObject{{"name", "search"}}};
|
||||
|
||||
apply(request, true);
|
||||
applyAll(request, true);
|
||||
|
||||
const QJsonArray tools = request.value("tools").toArray();
|
||||
ASSERT_EQ(tools.size(), 3);
|
||||
@@ -102,7 +107,7 @@ TEST(ClaudeCacheControlTest, SingleMessageHistorySkipped)
|
||||
request["messages"]
|
||||
= QJsonArray{QJsonObject{{"role", "user"}, {"content", "first message"}}};
|
||||
|
||||
apply(request, false);
|
||||
applyAll(request, false);
|
||||
|
||||
const QJsonArray msgs = request.value("messages").toArray();
|
||||
ASSERT_EQ(msgs.size(), 1);
|
||||
@@ -117,7 +122,7 @@ TEST(ClaudeCacheControlTest, HistoryBreakpointOnSecondToLastMessage)
|
||||
QJsonObject{{"role", "assistant"}, {"content", "a1"}},
|
||||
QJsonObject{{"role", "user"}, {"content", "u2-current"}}};
|
||||
|
||||
apply(request, false);
|
||||
applyAll(request, false);
|
||||
|
||||
const QJsonArray msgs = request.value("messages").toArray();
|
||||
ASSERT_EQ(msgs.size(), 3);
|
||||
@@ -146,7 +151,7 @@ TEST(ClaudeCacheControlTest, HistoryArrayContentMarksLastBlock)
|
||||
QJsonObject{{"type", "image"}}}}},
|
||||
QJsonObject{{"role", "assistant"}, {"content", "ok"}}};
|
||||
|
||||
apply(request, false);
|
||||
applyAll(request, false);
|
||||
|
||||
const QJsonArray msgs = request.value("messages").toArray();
|
||||
const QJsonArray content = msgs[0].toObject().value("content").toArray();
|
||||
@@ -161,7 +166,7 @@ TEST(ClaudeCacheControlTest, NoSystemNoToolsNoMessagesIsNoop)
|
||||
request["model"] = "claude-sonnet-4-5";
|
||||
request["max_tokens"] = 1024;
|
||||
|
||||
apply(request, false);
|
||||
applyAll(request, false);
|
||||
|
||||
EXPECT_EQ(request.value("model").toString(), "claude-sonnet-4-5");
|
||||
EXPECT_EQ(request.value("max_tokens").toInt(), 1024);
|
||||
@@ -175,8 +180,54 @@ TEST(ClaudeCacheControlTest, EmptyToolsArrayIsNoop)
|
||||
QJsonObject request;
|
||||
request["tools"] = QJsonArray{};
|
||||
|
||||
apply(request, false);
|
||||
applyAll(request, false);
|
||||
|
||||
EXPECT_TRUE(request.value("tools").isArray());
|
||||
EXPECT_TRUE(request.value("tools").toArray().isEmpty());
|
||||
}
|
||||
|
||||
TEST(ClaudeCacheControlTest, SelectiveBreakpointMarksOnlySystem)
|
||||
{
|
||||
QJsonObject request;
|
||||
request["system"] = "sys";
|
||||
request["tools"] = QJsonArray{QJsonObject{{"name", "read_file"}}};
|
||||
|
||||
apply(request, false, QStringList{QStringLiteral("system")});
|
||||
|
||||
EXPECT_TRUE(request.value("system").isArray());
|
||||
const QJsonArray tools = request.value("tools").toArray();
|
||||
ASSERT_EQ(tools.size(), 1);
|
||||
EXPECT_FALSE(tools[0].toObject().contains("cache_control"));
|
||||
}
|
||||
|
||||
TEST(ClaudeCacheControlTest, SelectiveBreakpointMarksOnlyTools)
|
||||
{
|
||||
QJsonObject request;
|
||||
request["system"] = "sys";
|
||||
request["tools"] = QJsonArray{QJsonObject{{"name", "read_file"}}};
|
||||
|
||||
apply(request, false, QStringList{QStringLiteral("tools")});
|
||||
|
||||
EXPECT_TRUE(request.value("system").isString());
|
||||
const QJsonArray tools = request.value("tools").toArray();
|
||||
ASSERT_EQ(tools.size(), 1);
|
||||
EXPECT_EQ(tools[0].toObject().value("cache_control").toObject(), expectedEphemeral(false));
|
||||
}
|
||||
|
||||
TEST(ClaudeCacheControlTest, EmptyBreakpointListMarksEveryDimension)
|
||||
{
|
||||
QJsonObject request;
|
||||
request["system"] = "sys";
|
||||
request["tools"] = QJsonArray{QJsonObject{{"name", "read_file"}}};
|
||||
request["messages"] = QJsonArray{
|
||||
QJsonObject{{"role", "user"}, {"content", "u1"}},
|
||||
QJsonObject{{"role", "assistant"}, {"content", "a1"}}};
|
||||
|
||||
apply(request, false, QStringList());
|
||||
|
||||
EXPECT_TRUE(request.value("system").isArray());
|
||||
EXPECT_TRUE(
|
||||
request.value("tools").toArray().last().toObject().contains("cache_control"));
|
||||
const QJsonArray msgs = request.value("messages").toArray();
|
||||
EXPECT_TRUE(msgs[0].toObject().value("content").isArray());
|
||||
}
|
||||
|
||||
418
test/ContextAssemblerTest.cpp
Normal file
418
test/ContextAssemblerTest.cpp
Normal file
@@ -0,0 +1,418 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
#include <LLMQore/ContentBlocks.hpp>
|
||||
|
||||
#include <ContextAssembler.hpp>
|
||||
#include <Message.hpp>
|
||||
#include <PluginBlocks.hpp>
|
||||
|
||||
using namespace QodeAssist;
|
||||
using Templates::ContentBlockEntry;
|
||||
|
||||
namespace {
|
||||
|
||||
Message textMessage(Message::Role role, const QString &text)
|
||||
{
|
||||
Message m(role);
|
||||
m.appendBlock(std::make_unique<LLMQore::TextContent>(text));
|
||||
return m;
|
||||
}
|
||||
|
||||
ContextAssembler::ContentLoader base64Loader(const QString &content)
|
||||
{
|
||||
return [content](const QString &) {
|
||||
return QString::fromUtf8(content.toUtf8().toBase64());
|
||||
};
|
||||
}
|
||||
|
||||
ContextAssembler::ContentLoader emptyLoader()
|
||||
{
|
||||
return [](const QString &) { return QString(); };
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(ContextAssemblerTest, SystemPromptAndUserTextProduceWireContext)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
history.push_back(textMessage(Message::Role::User, "hello"));
|
||||
|
||||
ContextAssembler::Manifest manifest;
|
||||
const auto ctx = ContextAssembler::assemble(history, "be helpful", nullptr, {}, &manifest);
|
||||
|
||||
ASSERT_TRUE(ctx.systemPrompt.has_value());
|
||||
EXPECT_EQ(*ctx.systemPrompt, "be helpful");
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
ASSERT_EQ(ctx.history->size(), 1);
|
||||
EXPECT_EQ(ctx.history->first().role, "user");
|
||||
ASSERT_EQ(ctx.history->first().blocks.size(), 1);
|
||||
EXPECT_EQ(ctx.history->first().blocks.first().kind, ContentBlockEntry::Kind::Text);
|
||||
EXPECT_EQ(ctx.history->first().blocks.first().text, "hello");
|
||||
EXPECT_EQ(manifest.historyMessages, 1);
|
||||
EXPECT_EQ(manifest.wireMessages, 1);
|
||||
EXPECT_EQ(manifest.systemChars, 10);
|
||||
EXPECT_EQ(manifest.textChars, 5);
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, EmptySystemPromptIsOmitted)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
history.push_back(textMessage(Message::Role::User, "hi"));
|
||||
|
||||
const auto ctx = ContextAssembler::assemble(history, QString(), nullptr);
|
||||
|
||||
EXPECT_FALSE(ctx.systemPrompt.has_value());
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, SystemRoleMessagesAreSkipped)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
history.push_back(textMessage(Message::Role::System, "legacy system"));
|
||||
history.push_back(textMessage(Message::Role::User, "hi"));
|
||||
|
||||
ContextAssembler::Manifest manifest;
|
||||
const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, {}, &manifest);
|
||||
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
EXPECT_EQ(ctx.history->size(), 1);
|
||||
EXPECT_EQ(ctx.history->first().role, "user");
|
||||
EXPECT_FALSE(manifest.elided.isEmpty());
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, CompletionContentBecomesPrefixSuffix)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
Message m(Message::Role::User);
|
||||
m.appendBlock(std::make_unique<CompletionContent>("int ma", "()\n"));
|
||||
history.push_back(std::move(m));
|
||||
|
||||
ContextAssembler::Manifest manifest;
|
||||
const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, {}, &manifest);
|
||||
|
||||
ASSERT_TRUE(ctx.prefix.has_value());
|
||||
EXPECT_EQ(*ctx.prefix, "int ma");
|
||||
ASSERT_TRUE(ctx.suffix.has_value());
|
||||
EXPECT_EQ(*ctx.suffix, "()\n");
|
||||
EXPECT_FALSE(ctx.history.has_value());
|
||||
EXPECT_TRUE(manifest.hasCompletionContext);
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, UnsignedThinkingDroppedSignedKept)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
Message m(Message::Role::Assistant);
|
||||
m.appendBlock(std::make_unique<LLMQore::ThinkingContent>("draft", QString()));
|
||||
m.appendBlock(std::make_unique<LLMQore::ThinkingContent>("signed", "sig"));
|
||||
m.appendBlock(std::make_unique<LLMQore::TextContent>("answer"));
|
||||
history.push_back(std::move(m));
|
||||
|
||||
ContextAssembler::Manifest manifest;
|
||||
const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, {}, &manifest);
|
||||
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
const auto &blocks = ctx.history->first().blocks;
|
||||
ASSERT_EQ(blocks.size(), 2);
|
||||
EXPECT_EQ(blocks[0].kind, ContentBlockEntry::Kind::Thinking);
|
||||
EXPECT_EQ(blocks[0].signature, "sig");
|
||||
EXPECT_EQ(blocks[1].kind, ContentBlockEntry::Kind::Text);
|
||||
EXPECT_EQ(manifest.elided.size(), 1);
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, ThinkingOnlyMessageIsDropped)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
Message m(Message::Role::Assistant);
|
||||
m.appendBlock(std::make_unique<LLMQore::ThinkingContent>("signed", "sig"));
|
||||
history.push_back(std::move(m));
|
||||
history.push_back(textMessage(Message::Role::User, "hi"));
|
||||
|
||||
const auto ctx = ContextAssembler::assemble(history, QString(), nullptr);
|
||||
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
EXPECT_EQ(ctx.history->size(), 1);
|
||||
EXPECT_EQ(ctx.history->first().role, "user");
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, OrphanToolUseIsDropped)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
Message m(Message::Role::Assistant);
|
||||
m.appendBlock(std::make_unique<LLMQore::TextContent>("calling"));
|
||||
m.appendBlock(
|
||||
std::make_unique<LLMQore::ToolUseContent>("tu1", "read_file", QJsonObject()));
|
||||
history.push_back(std::move(m));
|
||||
|
||||
ContextAssembler::Manifest manifest;
|
||||
const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, {}, &manifest);
|
||||
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
ASSERT_EQ(ctx.history->first().blocks.size(), 1);
|
||||
EXPECT_EQ(ctx.history->first().blocks.first().kind, ContentBlockEntry::Kind::Text);
|
||||
EXPECT_EQ(manifest.toolUseBlocks, 0);
|
||||
EXPECT_EQ(manifest.elided.size(), 1);
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, OrphanToolResultIsDropped)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
Message m(Message::Role::User);
|
||||
m.appendBlock(std::make_unique<LLMQore::ToolResultContent>("unknown", "data"));
|
||||
history.push_back(std::move(m));
|
||||
history.push_back(textMessage(Message::Role::User, "hi"));
|
||||
|
||||
ContextAssembler::Manifest manifest;
|
||||
const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, {}, &manifest);
|
||||
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
EXPECT_EQ(ctx.history->size(), 1);
|
||||
EXPECT_EQ(manifest.toolResultBlocks, 0);
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, PairedToolUseAndResultAreKept)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
Message use(Message::Role::Assistant);
|
||||
use.appendBlock(std::make_unique<LLMQore::ToolUseContent>(
|
||||
"tu1", "read_file", QJsonObject{{"path", "a.cpp"}}));
|
||||
history.push_back(std::move(use));
|
||||
Message result(Message::Role::User);
|
||||
result.appendBlock(std::make_unique<LLMQore::ToolResultContent>("tu1", "contents"));
|
||||
history.push_back(std::move(result));
|
||||
|
||||
ContextAssembler::Manifest manifest;
|
||||
const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, {}, &manifest);
|
||||
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
ASSERT_EQ(ctx.history->size(), 2);
|
||||
EXPECT_EQ(ctx.history->at(0).blocks.first().kind, ContentBlockEntry::Kind::ToolUse);
|
||||
EXPECT_EQ(ctx.history->at(1).blocks.first().kind, ContentBlockEntry::Kind::ToolResult);
|
||||
EXPECT_EQ(manifest.toolUseBlocks, 1);
|
||||
EXPECT_EQ(manifest.toolResultBlocks, 1);
|
||||
EXPECT_TRUE(manifest.elided.isEmpty());
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, AttachmentMaterializedThroughLoader)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
Message m(Message::Role::User);
|
||||
m.appendBlock(std::make_unique<StoredAttachmentContent>("notes.txt", "stored/notes"));
|
||||
history.push_back(std::move(m));
|
||||
|
||||
const auto ctx
|
||||
= ContextAssembler::assemble(history, QString(), base64Loader("file body"));
|
||||
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
const auto &block = ctx.history->first().blocks.first();
|
||||
EXPECT_EQ(block.kind, ContentBlockEntry::Kind::Text);
|
||||
EXPECT_EQ(block.text, "File: notes.txt\n```\nfile body\n```");
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, MissingAttachmentGetsPlaceholder)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
Message m(Message::Role::User);
|
||||
m.appendBlock(std::make_unique<StoredAttachmentContent>("notes.txt", "stored/notes"));
|
||||
history.push_back(std::move(m));
|
||||
|
||||
ContextAssembler::Manifest manifest;
|
||||
const auto ctx = ContextAssembler::assemble(history, QString(), emptyLoader(), {}, &manifest);
|
||||
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
const auto &block = ctx.history->first().blocks.first();
|
||||
EXPECT_EQ(block.kind, ContentBlockEntry::Kind::Text);
|
||||
EXPECT_EQ(block.text, "[Attachment unavailable: notes.txt]");
|
||||
EXPECT_EQ(manifest.elided.size(), 1);
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, StoredImageMaterializedThroughLoader)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
Message m(Message::Role::User);
|
||||
m.appendBlock(
|
||||
std::make_unique<StoredImageContent>("shot.png", "stored/shot", "image/png"));
|
||||
history.push_back(std::move(m));
|
||||
|
||||
ContextAssembler::Manifest manifest;
|
||||
const auto ctx
|
||||
= ContextAssembler::assemble(history, QString(), base64Loader("png"), {}, &manifest);
|
||||
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
const auto &block = ctx.history->first().blocks.first();
|
||||
EXPECT_EQ(block.kind, ContentBlockEntry::Kind::Image);
|
||||
EXPECT_EQ(block.mediaType, "image/png");
|
||||
EXPECT_FALSE(block.isImageUrl);
|
||||
EXPECT_EQ(manifest.imageBlocks, 1);
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, MissingImageWithNullLoaderGetsPlaceholder)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
Message m(Message::Role::User);
|
||||
m.appendBlock(
|
||||
std::make_unique<StoredImageContent>("shot.png", "stored/shot", "image/png"));
|
||||
history.push_back(std::move(m));
|
||||
|
||||
ContextAssembler::Manifest manifest;
|
||||
const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, {}, &manifest);
|
||||
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
const auto &block = ctx.history->first().blocks.first();
|
||||
EXPECT_EQ(block.kind, ContentBlockEntry::Kind::Text);
|
||||
EXPECT_EQ(block.text, "[Image unavailable: shot.png]");
|
||||
EXPECT_EQ(manifest.imageBlocks, 0);
|
||||
EXPECT_EQ(manifest.elided.size(), 1);
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, PinnedBlocksPrependedToLastUserMessage)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
history.push_back(textMessage(Message::Role::User, "first"));
|
||||
history.push_back(textMessage(Message::Role::Assistant, "reply"));
|
||||
history.push_back(textMessage(Message::Role::User, "second"));
|
||||
|
||||
ContextAssembler::Manifest manifest;
|
||||
const QVector<ContextAssembler::PinnedBlock> pinned{
|
||||
{"chat.files", "Linked files for reference:\nFile: a.cpp"}};
|
||||
const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, pinned, &manifest);
|
||||
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
ASSERT_EQ(ctx.history->size(), 3);
|
||||
EXPECT_EQ(ctx.history->at(0).blocks.size(), 1);
|
||||
const auto &last = ctx.history->at(2);
|
||||
EXPECT_EQ(last.role, "user");
|
||||
ASSERT_EQ(last.blocks.size(), 2);
|
||||
EXPECT_EQ(last.blocks[0].text, "Linked files for reference:\nFile: a.cpp");
|
||||
EXPECT_EQ(last.blocks[1].text, "second");
|
||||
EXPECT_EQ(manifest.pinnedBlocks, 1);
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, PinnedSkipsTrailingAssistantMessage)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
history.push_back(textMessage(Message::Role::User, "ask"));
|
||||
history.push_back(textMessage(Message::Role::Assistant, "answer"));
|
||||
|
||||
const QVector<ContextAssembler::PinnedBlock> pinned{{"chat.files", "files"}};
|
||||
const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, pinned);
|
||||
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
ASSERT_EQ(ctx.history->size(), 2);
|
||||
const auto &user = ctx.history->at(0);
|
||||
ASSERT_EQ(user.blocks.size(), 2);
|
||||
EXPECT_EQ(user.blocks[0].text, "files");
|
||||
EXPECT_EQ(user.blocks[1].text, "ask");
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, PinnedWithoutUserMessageCreatesSyntheticOne)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
|
||||
const QVector<ContextAssembler::PinnedBlock> pinned{{"chat.files", "files"}};
|
||||
const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, pinned);
|
||||
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
ASSERT_EQ(ctx.history->size(), 1);
|
||||
EXPECT_EQ(ctx.history->first().role, "user");
|
||||
ASSERT_EQ(ctx.history->first().blocks.size(), 1);
|
||||
EXPECT_EQ(ctx.history->first().blocks.first().text, "files");
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, EmptyPinnedTextIsIgnored)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
history.push_back(textMessage(Message::Role::User, "hi"));
|
||||
|
||||
ContextAssembler::Manifest manifest;
|
||||
const QVector<ContextAssembler::PinnedBlock> pinned{{"chat.files", QString()}};
|
||||
const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, pinned, &manifest);
|
||||
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
EXPECT_EQ(ctx.history->first().blocks.size(), 1);
|
||||
EXPECT_EQ(manifest.pinnedBlocks, 0);
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, PinnedAnchorsToTypedMessageNotToolResults)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
history.push_back(textMessage(Message::Role::User, "fix the bug"));
|
||||
Message use(Message::Role::Assistant);
|
||||
use.appendBlock(
|
||||
std::make_unique<LLMQore::ToolUseContent>("tu1", "edit_file", QJsonObject()));
|
||||
history.push_back(std::move(use));
|
||||
Message result(Message::Role::User);
|
||||
result.appendBlock(std::make_unique<LLMQore::ToolResultContent>("tu1", "edited"));
|
||||
history.push_back(std::move(result));
|
||||
|
||||
const QVector<ContextAssembler::PinnedBlock> pinned{{"chat.files", "files"}};
|
||||
const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, pinned);
|
||||
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
ASSERT_EQ(ctx.history->size(), 3);
|
||||
const auto &typed = ctx.history->at(0);
|
||||
ASSERT_EQ(typed.blocks.size(), 2);
|
||||
EXPECT_EQ(typed.blocks[0].text, "files");
|
||||
EXPECT_EQ(typed.blocks[1].text, "fix the bug");
|
||||
EXPECT_EQ(ctx.history->at(2).blocks.size(), 1);
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, PinnedInsertedAfterLeadingToolResults)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
Message use(Message::Role::Assistant);
|
||||
use.appendBlock(
|
||||
std::make_unique<LLMQore::ToolUseContent>("tu1", "edit_file", QJsonObject()));
|
||||
history.push_back(std::move(use));
|
||||
Message result(Message::Role::User);
|
||||
result.appendBlock(std::make_unique<LLMQore::ToolResultContent>("tu1", "edited"));
|
||||
history.push_back(std::move(result));
|
||||
|
||||
const QVector<ContextAssembler::PinnedBlock> pinned{{"chat.files", "files"}};
|
||||
const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, pinned);
|
||||
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
const auto &last = ctx.history->at(1);
|
||||
EXPECT_EQ(last.role, "user");
|
||||
ASSERT_EQ(last.blocks.size(), 2);
|
||||
EXPECT_EQ(last.blocks[0].kind, ContentBlockEntry::Kind::ToolResult);
|
||||
EXPECT_EQ(last.blocks[1].text, "files");
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, SkillInvocationBecomesTextEntry)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
Message m(Message::Role::User);
|
||||
m.appendBlock(std::make_unique<LLMQore::TextContent>("/review this"));
|
||||
m.appendBlock(std::make_unique<SkillInvocationContent>("review", "Review the code."));
|
||||
history.push_back(std::move(m));
|
||||
|
||||
const auto ctx = ContextAssembler::assemble(history, QString(), nullptr);
|
||||
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
const auto &blocks = ctx.history->first().blocks;
|
||||
ASSERT_EQ(blocks.size(), 2);
|
||||
EXPECT_EQ(blocks[1].kind, ContentBlockEntry::Kind::Text);
|
||||
EXPECT_EQ(blocks[1].text, "# Invoked Skill: review\n\nReview the code.");
|
||||
}
|
||||
|
||||
TEST(ContextAssemblerTest, UnsupportedBlocksAreCounted)
|
||||
{
|
||||
std::vector<Message> history;
|
||||
Message m(Message::Role::Assistant);
|
||||
m.appendBlock(std::make_unique<LLMQore::TextContent>("done"));
|
||||
m.appendBlock(std::make_unique<FileEditContent>("e1", "a.cpp", "old", "new"));
|
||||
history.push_back(std::move(m));
|
||||
|
||||
ContextAssembler::Manifest manifest;
|
||||
const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, {}, &manifest);
|
||||
|
||||
ASSERT_TRUE(ctx.history.has_value());
|
||||
EXPECT_EQ(ctx.history->first().blocks.size(), 1);
|
||||
EXPECT_EQ(manifest.unsupportedBlocks, 1);
|
||||
}
|
||||
206
test/ContextRendererTest.cpp
Normal file
206
test/ContextRendererTest.cpp
Normal file
@@ -0,0 +1,206 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
#include <ContextRenderer.hpp>
|
||||
|
||||
using QodeAssist::Templates::ContextRenderer::Bindings;
|
||||
using QodeAssist::Templates::ContextRenderer::render;
|
||||
|
||||
namespace {
|
||||
|
||||
void writeFile(const QString &path, const QByteArray &contents)
|
||||
{
|
||||
QFile f(path);
|
||||
ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text));
|
||||
f.write(contents);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(ContextRendererTest, EmptyTemplateRendersEmpty)
|
||||
{
|
||||
EXPECT_TRUE(render(QString(), Bindings{}).isEmpty());
|
||||
}
|
||||
|
||||
TEST(ContextRendererTest, SubstitutesProjectDirAndConfigVariables)
|
||||
{
|
||||
const QString out = render(
|
||||
QStringLiteral("P=${PROJECT_DIR};C=${CONFIG_DIR}"),
|
||||
Bindings{QStringLiteral("/proj"), QStringLiteral("/cfg")});
|
||||
EXPECT_EQ(out, QStringLiteral("P=/proj;C=/cfg"));
|
||||
}
|
||||
|
||||
TEST(ContextRendererTest, ReadsFileWithinProjectDir)
|
||||
{
|
||||
QTemporaryDir proj;
|
||||
ASSERT_TRUE(proj.isValid());
|
||||
writeFile(proj.filePath(QStringLiteral("notes.txt")), "hello body");
|
||||
|
||||
const QString out = render(
|
||||
QStringLiteral("{{ read_file(\"${PROJECT_DIR}/notes.txt\") }}"),
|
||||
Bindings{proj.path(), QString()});
|
||||
EXPECT_EQ(out, QStringLiteral("hello body"));
|
||||
}
|
||||
|
||||
TEST(ContextRendererTest, ReadsFileUnderConfigDir)
|
||||
{
|
||||
QTemporaryDir config;
|
||||
ASSERT_TRUE(config.isValid());
|
||||
writeFile(config.filePath(QStringLiteral("persona.md")), "be terse");
|
||||
|
||||
const QString out = render(
|
||||
QStringLiteral("{{ read_file(\"${CONFIG_DIR}/persona.md\") }}"),
|
||||
Bindings{QStringLiteral("/unrelated/project"), config.path()});
|
||||
EXPECT_EQ(out, QStringLiteral("be terse"));
|
||||
}
|
||||
|
||||
TEST(ContextRendererTest, ReadFileOutsideAllowedRootsThrowsLoudly)
|
||||
{
|
||||
QTemporaryDir proj;
|
||||
QTemporaryDir outside;
|
||||
ASSERT_TRUE(proj.isValid());
|
||||
ASSERT_TRUE(outside.isValid());
|
||||
writeFile(outside.filePath(QStringLiteral("secret.txt")), "TOP SECRET");
|
||||
|
||||
QString error;
|
||||
const QString out = render(
|
||||
QStringLiteral("{{ read_file(\"%1/secret.txt\") }}").arg(outside.path()),
|
||||
Bindings{proj.path(), QString()},
|
||||
&error);
|
||||
|
||||
EXPECT_TRUE(out.isEmpty());
|
||||
EXPECT_FALSE(error.isEmpty());
|
||||
EXPECT_TRUE(error.contains(QStringLiteral("read_file")));
|
||||
EXPECT_TRUE(error.contains(QStringLiteral("outside the allowed read roots")));
|
||||
}
|
||||
|
||||
TEST(ContextRendererTest, ReadFileMissingButAllowedThrowsLoudly)
|
||||
{
|
||||
QTemporaryDir proj;
|
||||
ASSERT_TRUE(proj.isValid());
|
||||
|
||||
QString error;
|
||||
const QString out = render(
|
||||
QStringLiteral("{{ read_file(\"${PROJECT_DIR}/nope.txt\") }}"),
|
||||
Bindings{proj.path(), QString()},
|
||||
&error);
|
||||
|
||||
EXPECT_TRUE(out.isEmpty());
|
||||
EXPECT_FALSE(error.isEmpty());
|
||||
EXPECT_TRUE(error.contains(QStringLiteral("cannot open")));
|
||||
}
|
||||
|
||||
TEST(ContextRendererTest, FileExistsTrueForPresentAllowedFileAndFalseWhenAbsent)
|
||||
{
|
||||
QTemporaryDir proj;
|
||||
ASSERT_TRUE(proj.isValid());
|
||||
writeFile(proj.filePath(QStringLiteral("present.txt")), "x");
|
||||
|
||||
EXPECT_EQ(
|
||||
render(
|
||||
QStringLiteral("{{ file_exists(\"${PROJECT_DIR}/present.txt\") }}"),
|
||||
Bindings{proj.path(), QString()}),
|
||||
QStringLiteral("true"));
|
||||
|
||||
QString error;
|
||||
EXPECT_EQ(
|
||||
render(
|
||||
QStringLiteral("{{ file_exists(\"${PROJECT_DIR}/missing.txt\") }}"),
|
||||
Bindings{proj.path(), QString()},
|
||||
&error),
|
||||
QStringLiteral("false"));
|
||||
EXPECT_TRUE(error.isEmpty());
|
||||
}
|
||||
|
||||
TEST(ContextRendererTest, FileExistsOutsideAllowedRootsThrowsLoudly)
|
||||
{
|
||||
QTemporaryDir proj;
|
||||
QTemporaryDir outside;
|
||||
ASSERT_TRUE(proj.isValid());
|
||||
ASSERT_TRUE(outside.isValid());
|
||||
writeFile(outside.filePath(QStringLiteral("present.txt")), "x");
|
||||
|
||||
QString error;
|
||||
const QString out = render(
|
||||
QStringLiteral("{{ file_exists(\"%1/present.txt\") }}").arg(outside.path()),
|
||||
Bindings{proj.path(), QString()},
|
||||
&error);
|
||||
|
||||
EXPECT_TRUE(out.isEmpty());
|
||||
EXPECT_FALSE(error.isEmpty());
|
||||
EXPECT_TRUE(error.contains(QStringLiteral("file_exists")));
|
||||
}
|
||||
|
||||
TEST(ContextRendererTest, HeadLinesTakesLeadingLines)
|
||||
{
|
||||
QTemporaryDir proj;
|
||||
ASSERT_TRUE(proj.isValid());
|
||||
writeFile(proj.filePath(QStringLiteral("multi.txt")), "l1\nl2\nl3\n");
|
||||
|
||||
const QString out = render(
|
||||
QStringLiteral("{{ head_lines(read_file(\"${PROJECT_DIR}/multi.txt\"), 2) }}"),
|
||||
Bindings{proj.path(), QString()});
|
||||
EXPECT_EQ(out, QStringLiteral("l1\nl2"));
|
||||
}
|
||||
|
||||
TEST(ContextRendererTest, StringHelpers)
|
||||
{
|
||||
const Bindings none{};
|
||||
EXPECT_EQ(
|
||||
render(QStringLiteral("{{ basename(\"/a/b/c.txt\") }}"), none), QStringLiteral("c.txt"));
|
||||
EXPECT_EQ(render(QStringLiteral("{{ ext(\"/a/b/c.txt\") }}"), none), QStringLiteral("txt"));
|
||||
EXPECT_EQ(render(QStringLiteral("{{ dirname(\"/a/b/c.txt\") }}"), none), QStringLiteral("/a/b"));
|
||||
EXPECT_EQ(render(QStringLiteral("{{ lower(\"ABC\") }}"), none), QStringLiteral("abc"));
|
||||
EXPECT_EQ(render(QStringLiteral("{{ upper(\"abc\") }}"), none), QStringLiteral("ABC"));
|
||||
}
|
||||
|
||||
TEST(ContextRendererTest, ParseErrorReturnsEmptyAndReportsError)
|
||||
{
|
||||
QString error;
|
||||
const QString out = render(QStringLiteral("{{ "), Bindings{}, &error);
|
||||
EXPECT_TRUE(out.isEmpty());
|
||||
EXPECT_FALSE(error.isEmpty());
|
||||
}
|
||||
|
||||
TEST(ContextRendererTest, ReadsBundledQrcResource)
|
||||
{
|
||||
Q_INIT_RESOURCE(agents);
|
||||
|
||||
QString error;
|
||||
const QString out = render(
|
||||
QStringLiteral("{{ read_file(\":/roles/qt-cpp-developer.md\") }}"), Bindings{}, &error);
|
||||
|
||||
EXPECT_TRUE(error.isEmpty()) << error.toStdString();
|
||||
EXPECT_FALSE(out.trimmed().isEmpty())
|
||||
<< "read_file(\":/roles/qt-cpp-developer.md\") returned empty — qrc alias broken?";
|
||||
EXPECT_TRUE(out.contains(QStringLiteral("Qt/C++ developer")));
|
||||
}
|
||||
|
||||
TEST(ContextRendererTest, SelectsCompletionRoleByLanguageFromQrc)
|
||||
{
|
||||
Q_INIT_RESOURCE(agents);
|
||||
|
||||
const QString tpl = QStringLiteral(
|
||||
"{%- if language == \"qml\" %}{{ read_file(\":/roles/code-completion-qml.md\") }}"
|
||||
"{%- else if language == \"c-like\" %}{{ read_file(\":/roles/code-completion-c-like.md\") }}"
|
||||
"{%- else %}{{ read_file(\":/roles/code-completion.md\") }}"
|
||||
"{%- endif %}");
|
||||
|
||||
Bindings qml;
|
||||
qml.language = QStringLiteral("qml");
|
||||
Bindings clike;
|
||||
clike.language = QStringLiteral("c-like");
|
||||
Bindings other;
|
||||
other.language = QStringLiteral("python");
|
||||
|
||||
EXPECT_TRUE(render(tpl, qml).contains(QStringLiteral("QML and Qt Quick")));
|
||||
EXPECT_TRUE(render(tpl, clike).contains(QStringLiteral("C++, Qt, and QML")));
|
||||
EXPECT_TRUE(render(tpl, other).contains(QStringLiteral("expert code completion assistant")));
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
#include <QSharedPointer>
|
||||
#include <QTextDocument>
|
||||
|
||||
namespace QodeAssist::PluginLLMCore {
|
||||
namespace QodeAssist::Templates {
|
||||
|
||||
void PrintTo(const ContextData &data, std::ostream *os)
|
||||
{
|
||||
@@ -20,10 +20,10 @@ void PrintTo(const ContextData &data, std::ostream *os)
|
||||
<< "}";
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::PluginLLMCore
|
||||
} // namespace QodeAssist::Templates
|
||||
|
||||
using namespace QodeAssist::Context;
|
||||
using namespace QodeAssist::PluginLLMCore;
|
||||
using namespace QodeAssist::Templates;
|
||||
using namespace QodeAssist::Settings;
|
||||
|
||||
class DocumentContextReaderTest : public QObject, public testing::Test
|
||||
@@ -367,7 +367,7 @@ TEST_F(DocumentContextReaderTest, testPrepareContext)
|
||||
|
||||
EXPECT_EQ(
|
||||
reader.prepareContext(2, 3, *createSettingsForWholeFile()),
|
||||
(QodeAssist::PluginLLMCore::ContextData{
|
||||
(QodeAssist::Templates::ContextData{
|
||||
.prefix = "Line 0\nLine 1\nLin",
|
||||
.suffix = "e 2\nLine 3\nLine 4",
|
||||
.fileContext = "\n Language: (MIME: text/python) filepath: /path/to/file()\n\n"
|
||||
@@ -375,7 +375,7 @@ TEST_F(DocumentContextReaderTest, testPrepareContext)
|
||||
|
||||
EXPECT_EQ(
|
||||
reader.prepareContext(2, 3, *createSettingsForLines(1, 1)),
|
||||
(QodeAssist::PluginLLMCore::ContextData{
|
||||
(QodeAssist::Templates::ContextData{
|
||||
.prefix = "Line 1\nLin",
|
||||
.suffix = "e 2\nLine 3",
|
||||
.fileContext = "\n Language: (MIME: text/python) filepath: /path/to/file()\n\n"
|
||||
@@ -383,7 +383,7 @@ TEST_F(DocumentContextReaderTest, testPrepareContext)
|
||||
|
||||
EXPECT_EQ(
|
||||
reader.prepareContext(2, 3, *createSettingsForLines(2, 2)),
|
||||
(QodeAssist::PluginLLMCore::ContextData{
|
||||
(QodeAssist::Templates::ContextData{
|
||||
.prefix = "Line 0\nLine 1\nLin",
|
||||
.suffix = "e 2\nLine 3\nLine 4",
|
||||
.fileContext = "\n Language: (MIME: text/python) filepath: /path/to/file()\n\n"
|
||||
|
||||
53
test/EnvBlockFormatterTest.cpp
Normal file
53
test/EnvBlockFormatterTest.cpp
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <context/EnvBlockFormatter.hpp>
|
||||
|
||||
using namespace QodeAssist::Context;
|
||||
|
||||
TEST(EnvBlockFormatterTest, FormatProjectWithBuildDir)
|
||||
{
|
||||
const QString out = EnvBlockFormatter::formatProject(
|
||||
{"MyApp", "/home/dev/myapp", "/home/dev/build-myapp"});
|
||||
|
||||
EXPECT_TRUE(out.startsWith("# Active project: MyApp"));
|
||||
EXPECT_TRUE(out.contains("# Project source root: /home/dev/myapp"));
|
||||
EXPECT_TRUE(out.contains("# Build output directory"));
|
||||
EXPECT_TRUE(out.contains("/home/dev/build-myapp"));
|
||||
}
|
||||
|
||||
TEST(EnvBlockFormatterTest, FormatProjectWithoutBuildDir)
|
||||
{
|
||||
const QString out = EnvBlockFormatter::formatProject({"MyApp", "/home/dev/myapp", {}});
|
||||
|
||||
EXPECT_TRUE(out.contains("# Project source root: /home/dev/myapp"));
|
||||
EXPECT_FALSE(out.contains("# Build output directory"));
|
||||
}
|
||||
|
||||
TEST(EnvBlockFormatterTest, FormatProjectEmptyEnv)
|
||||
{
|
||||
EXPECT_EQ(EnvBlockFormatter::formatProject({}), "# No active project in IDE");
|
||||
}
|
||||
|
||||
TEST(EnvBlockFormatterTest, FormatFileWithKnownMime)
|
||||
{
|
||||
const QString out
|
||||
= EnvBlockFormatter::formatFile({"/home/dev/myapp/main.cpp", "text/x-c++src"});
|
||||
|
||||
EXPECT_TRUE(out.startsWith("File information:"));
|
||||
EXPECT_TRUE(out.contains("Language:"));
|
||||
EXPECT_TRUE(out.contains("text/x-c++src"));
|
||||
EXPECT_TRUE(out.contains("File path: /home/dev/myapp/main.cpp"));
|
||||
}
|
||||
|
||||
TEST(EnvBlockFormatterTest, FormatFileWithoutMime)
|
||||
{
|
||||
const QString out = EnvBlockFormatter::formatFile({"/home/dev/myapp/data.bin", {}});
|
||||
|
||||
EXPECT_FALSE(out.contains("Language"));
|
||||
EXPECT_FALSE(out.contains("MIME"));
|
||||
EXPECT_TRUE(out.contains("File path: /home/dev/myapp/data.bin"));
|
||||
}
|
||||
84
test/ErrorInfoTest.cpp
Normal file
84
test/ErrorInfoTest.cpp
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <ErrorInfo.hpp>
|
||||
|
||||
using namespace QodeAssist;
|
||||
|
||||
TEST(ErrorInfoTest, MakeErrorPopulatesFields)
|
||||
{
|
||||
const ErrorInfo e = makeError(ErrorCategory::Tool, QStringLiteral("boom"), QStringLiteral("detail"));
|
||||
EXPECT_EQ(e.category, ErrorCategory::Tool);
|
||||
EXPECT_EQ(e.message, QStringLiteral("boom"));
|
||||
EXPECT_EQ(e.providerDetail, QStringLiteral("detail"));
|
||||
EXPECT_FALSE(e.isEmpty());
|
||||
}
|
||||
|
||||
TEST(ErrorInfoTest, DefaultIsEmpty)
|
||||
{
|
||||
ErrorInfo e;
|
||||
EXPECT_TRUE(e.isEmpty());
|
||||
EXPECT_EQ(e.category, ErrorCategory::Provider);
|
||||
}
|
||||
|
||||
TEST(ErrorInfoTest, EmptyMessageIsEmptyRegardlessOfCategory)
|
||||
{
|
||||
const ErrorInfo e = makeError(ErrorCategory::Auth, QString());
|
||||
EXPECT_TRUE(e.isEmpty());
|
||||
}
|
||||
|
||||
TEST(ErrorInfoTest, CategorizesHttp401AsAuth)
|
||||
{
|
||||
EXPECT_EQ(categorizeProviderError(QStringLiteral("HTTP 401 Unauthorized")), ErrorCategory::Auth);
|
||||
}
|
||||
|
||||
TEST(ErrorInfoTest, CategorizesForbiddenAsAuth)
|
||||
{
|
||||
EXPECT_EQ(categorizeProviderError(QStringLiteral("403 Forbidden")), ErrorCategory::Auth);
|
||||
}
|
||||
|
||||
TEST(ErrorInfoTest, CategorizesApiKeyAsAuth)
|
||||
{
|
||||
EXPECT_EQ(categorizeProviderError(QStringLiteral("invalid api key supplied")), ErrorCategory::Auth);
|
||||
}
|
||||
|
||||
TEST(ErrorInfoTest, CategorizesAuthenticationAsAuth)
|
||||
{
|
||||
EXPECT_EQ(categorizeProviderError(QStringLiteral("Authentication failed")), ErrorCategory::Auth);
|
||||
}
|
||||
|
||||
TEST(ErrorInfoTest, AuthMatchIsCaseInsensitive)
|
||||
{
|
||||
EXPECT_EQ(categorizeProviderError(QStringLiteral("UNAUTHORIZED")), ErrorCategory::Auth);
|
||||
}
|
||||
|
||||
TEST(ErrorInfoTest, CategorizesTimeoutAsNetwork)
|
||||
{
|
||||
EXPECT_EQ(categorizeProviderError(QStringLiteral("request timed out")), ErrorCategory::Network);
|
||||
}
|
||||
|
||||
TEST(ErrorInfoTest, CategorizesConnectionRefusedAsNetwork)
|
||||
{
|
||||
EXPECT_EQ(categorizeProviderError(QStringLiteral("Connection refused")), ErrorCategory::Network);
|
||||
}
|
||||
|
||||
TEST(ErrorInfoTest, CategorizesDnsFailureAsNetwork)
|
||||
{
|
||||
EXPECT_EQ(
|
||||
categorizeProviderError(QStringLiteral("could not resolve host")), ErrorCategory::Network);
|
||||
}
|
||||
|
||||
TEST(ErrorInfoTest, CategorizesSslAsNetwork)
|
||||
{
|
||||
EXPECT_EQ(categorizeProviderError(QStringLiteral("SSL handshake error")), ErrorCategory::Network);
|
||||
}
|
||||
|
||||
TEST(ErrorInfoTest, UnrecognizedErrorFallsBackToProvider)
|
||||
{
|
||||
EXPECT_EQ(
|
||||
categorizeProviderError(QStringLiteral("model produced an internal error")),
|
||||
ErrorCategory::Provider);
|
||||
}
|
||||
129
test/JsonPromptTemplateTest.cpp
Normal file
129
test/JsonPromptTemplateTest.cpp
Normal file
@@ -0,0 +1,129 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
|
||||
#include <AgentConfig.hpp>
|
||||
#include <ContextData.hpp>
|
||||
#include <JsonPromptTemplate.hpp>
|
||||
|
||||
using QodeAssist::AgentConfig;
|
||||
using QodeAssist::Templates::ContextData;
|
||||
using QodeAssist::Templates::JsonPromptTemplate;
|
||||
|
||||
namespace {
|
||||
|
||||
AgentConfig makeConfig(const QJsonObject &body)
|
||||
{
|
||||
AgentConfig cfg;
|
||||
cfg.name = QStringLiteral("test-agent");
|
||||
cfg.body = body;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
const QString kUserMessages
|
||||
= QStringLiteral("[ { \"role\": \"user\", \"content\": {{ tojson(ctx.prefix) }} } ]");
|
||||
|
||||
const QString kSystemField = QStringLiteral(
|
||||
"{% if existsIn(ctx, \"system_prompt\") %}{{ tojson(ctx.system_prompt) }}{% endif %}");
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(JsonPromptTemplateTest, RendersJinjaSplicesAndKeepsLiterals)
|
||||
{
|
||||
auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(QJsonObject{
|
||||
{"max_tokens", 128},
|
||||
{"temperature", 0.5},
|
||||
{"stream", true},
|
||||
{"messages", kUserMessages},
|
||||
}));
|
||||
ASSERT_NE(tmpl, nullptr);
|
||||
|
||||
ContextData ctx;
|
||||
ctx.prefix = QStringLiteral("hello world");
|
||||
|
||||
QJsonObject request;
|
||||
ASSERT_TRUE(tmpl->buildFullRequest(request, ctx));
|
||||
|
||||
EXPECT_EQ(request.value("max_tokens").toInt(), 128);
|
||||
EXPECT_DOUBLE_EQ(request.value("temperature").toDouble(), 0.5);
|
||||
EXPECT_TRUE(request.value("stream").toBool());
|
||||
|
||||
const QJsonArray messages = request.value("messages").toArray();
|
||||
ASSERT_EQ(messages.size(), 1);
|
||||
EXPECT_EQ(
|
||||
messages.at(0).toObject().value("content").toString(), QStringLiteral("hello world"));
|
||||
}
|
||||
|
||||
TEST(JsonPromptTemplateTest, DropsKeyWhenJinjaRendersEmpty)
|
||||
{
|
||||
auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(QJsonObject{
|
||||
{"system", kSystemField},
|
||||
{"messages", kUserMessages},
|
||||
}));
|
||||
ASSERT_NE(tmpl, nullptr);
|
||||
|
||||
ContextData ctx;
|
||||
ctx.prefix = QStringLiteral("hi");
|
||||
|
||||
QJsonObject request;
|
||||
ASSERT_TRUE(tmpl->buildFullRequest(request, ctx));
|
||||
|
||||
EXPECT_FALSE(request.contains(QStringLiteral("system")));
|
||||
EXPECT_TRUE(request.contains(QStringLiteral("messages")));
|
||||
}
|
||||
|
||||
TEST(JsonPromptTemplateTest, RendersSystemPromptWhenPresent)
|
||||
{
|
||||
auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(QJsonObject{
|
||||
{"system", kSystemField},
|
||||
{"messages", kUserMessages},
|
||||
}));
|
||||
ASSERT_NE(tmpl, nullptr);
|
||||
|
||||
ContextData ctx;
|
||||
ctx.prefix = QStringLiteral("hi");
|
||||
ctx.systemPrompt = QStringLiteral("You are a helpful assistant.");
|
||||
|
||||
QJsonObject request;
|
||||
ASSERT_TRUE(tmpl->buildFullRequest(request, ctx));
|
||||
|
||||
EXPECT_EQ(
|
||||
request.value("system").toString(), QStringLiteral("You are a helpful assistant."));
|
||||
}
|
||||
|
||||
TEST(JsonPromptTemplateTest, PreservesNestedLiteralObjects)
|
||||
{
|
||||
auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(QJsonObject{
|
||||
{"thinking", QJsonObject{{"type", "adaptive"}, {"budget", 8192}}},
|
||||
{"messages", kUserMessages},
|
||||
}));
|
||||
ASSERT_NE(tmpl, nullptr);
|
||||
|
||||
ContextData ctx;
|
||||
ctx.prefix = QStringLiteral("x");
|
||||
|
||||
QJsonObject request;
|
||||
ASSERT_TRUE(tmpl->buildFullRequest(request, ctx));
|
||||
|
||||
const QJsonObject thinking = request.value("thinking").toObject();
|
||||
EXPECT_EQ(thinking.value("type").toString(), QStringLiteral("adaptive"));
|
||||
EXPECT_EQ(thinking.value("budget").toInt(), 8192);
|
||||
}
|
||||
|
||||
TEST(JsonPromptTemplateTest, RejectsBodyThatRendersInvalidJsonAtLoad)
|
||||
{
|
||||
QString error;
|
||||
auto tmpl = JsonPromptTemplate::fromConfig(
|
||||
makeConfig(QJsonObject{
|
||||
{"messages", QStringLiteral("[ {{ tojson(ctx.prefix) }}")},
|
||||
}),
|
||||
&error);
|
||||
|
||||
EXPECT_EQ(tmpl, nullptr);
|
||||
EXPECT_FALSE(error.isEmpty());
|
||||
}
|
||||
306
test/MessageSerializerTest.cpp
Normal file
306
test/MessageSerializerTest.cpp
Normal file
@@ -0,0 +1,306 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
|
||||
#include <LLMQore/ContentBlocks.hpp>
|
||||
|
||||
#include <Message.hpp>
|
||||
#include <MessageSerializer.hpp>
|
||||
#include <PluginBlocks.hpp>
|
||||
|
||||
using namespace QodeAssist;
|
||||
|
||||
namespace {
|
||||
|
||||
// Round-trips a message through JSON and back, returning the re-serialized
|
||||
// form so it can be compared against the original serialization. Any field
|
||||
// dropped or mangled by fromJson/toJson surfaces as a JSON mismatch.
|
||||
QJsonObject reserialize(const Message &message)
|
||||
{
|
||||
bool ok = false;
|
||||
const QJsonObject json = MessageSerializer::toJson(message);
|
||||
Message restored = MessageSerializer::fromJson(json, &ok);
|
||||
EXPECT_TRUE(ok);
|
||||
return MessageSerializer::toJson(restored);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(MessageSerializerTest, RoleAndIdRoundtrip)
|
||||
{
|
||||
Message m(Message::Role::Assistant, QStringLiteral("msg-7"));
|
||||
m.appendBlock(std::make_unique<LLMQore::TextContent>(QStringLiteral("hi")));
|
||||
|
||||
const QJsonObject json = MessageSerializer::toJson(m);
|
||||
EXPECT_EQ(json.value("role").toString(), QStringLiteral("assistant"));
|
||||
EXPECT_EQ(json.value("id").toString(), QStringLiteral("msg-7"));
|
||||
|
||||
EXPECT_EQ(reserialize(m), json);
|
||||
}
|
||||
|
||||
TEST(MessageSerializerTest, EmptyIdIsOmitted)
|
||||
{
|
||||
Message m(Message::Role::User);
|
||||
m.appendBlock(std::make_unique<LLMQore::TextContent>(QStringLiteral("x")));
|
||||
|
||||
const QJsonObject json = MessageSerializer::toJson(m);
|
||||
EXPECT_FALSE(json.contains(QStringLiteral("id")));
|
||||
EXPECT_EQ(json.value("role").toString(), QStringLiteral("user"));
|
||||
}
|
||||
|
||||
TEST(MessageSerializerTest, SystemRoleRoundtrip)
|
||||
{
|
||||
Message m(Message::Role::System);
|
||||
m.appendBlock(std::make_unique<LLMQore::TextContent>(QStringLiteral("rules")));
|
||||
EXPECT_EQ(MessageSerializer::toJson(m).value("role").toString(), QStringLiteral("system"));
|
||||
EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
|
||||
}
|
||||
|
||||
TEST(MessageSerializerTest, ThinkingBlockPreservesSignature)
|
||||
{
|
||||
Message m(Message::Role::Assistant);
|
||||
m.appendBlock(
|
||||
std::make_unique<LLMQore::ThinkingContent>(QStringLiteral("draft"), QStringLiteral("sig")));
|
||||
|
||||
const QJsonObject block
|
||||
= MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
|
||||
EXPECT_EQ(block.value("type").toString(), QStringLiteral("thinking"));
|
||||
EXPECT_EQ(block.value("thinking").toString(), QStringLiteral("draft"));
|
||||
EXPECT_EQ(block.value("signature").toString(), QStringLiteral("sig"));
|
||||
|
||||
EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
|
||||
}
|
||||
|
||||
TEST(MessageSerializerTest, RedactedThinkingRoundtrip)
|
||||
{
|
||||
Message m(Message::Role::Assistant);
|
||||
m.appendBlock(std::make_unique<LLMQore::RedactedThinkingContent>(QStringLiteral("blob")));
|
||||
|
||||
const QJsonObject block
|
||||
= MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
|
||||
EXPECT_EQ(block.value("type").toString(), QStringLiteral("redacted_thinking"));
|
||||
EXPECT_EQ(block.value("signature").toString(), QStringLiteral("blob"));
|
||||
|
||||
EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
|
||||
}
|
||||
|
||||
TEST(MessageSerializerTest, ImageBase64Roundtrip)
|
||||
{
|
||||
Message m(Message::Role::User);
|
||||
m.appendBlock(std::make_unique<LLMQore::ImageContent>(
|
||||
QStringLiteral("ZGF0YQ=="),
|
||||
QStringLiteral("image/png"),
|
||||
LLMQore::ImageContent::ImageSourceType::Base64));
|
||||
|
||||
const QJsonObject block
|
||||
= MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
|
||||
EXPECT_EQ(block.value("type").toString(), QStringLiteral("image"));
|
||||
EXPECT_EQ(block.value("sourceType").toString(), QStringLiteral("base64"));
|
||||
EXPECT_EQ(block.value("mediaType").toString(), QStringLiteral("image/png"));
|
||||
|
||||
EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
|
||||
}
|
||||
|
||||
TEST(MessageSerializerTest, ImageUrlSourceTypeRoundtrip)
|
||||
{
|
||||
Message m(Message::Role::User);
|
||||
m.appendBlock(std::make_unique<LLMQore::ImageContent>(
|
||||
QStringLiteral("https://example.com/a.png"),
|
||||
QStringLiteral("image/png"),
|
||||
LLMQore::ImageContent::ImageSourceType::Url));
|
||||
|
||||
const QJsonObject block
|
||||
= MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
|
||||
EXPECT_EQ(block.value("sourceType").toString(), QStringLiteral("url"));
|
||||
|
||||
EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
|
||||
}
|
||||
|
||||
TEST(MessageSerializerTest, ToolUseRoundtrip)
|
||||
{
|
||||
Message m(Message::Role::Assistant);
|
||||
m.appendBlock(std::make_unique<LLMQore::ToolUseContent>(
|
||||
QStringLiteral("tu1"),
|
||||
QStringLiteral("read_file"),
|
||||
QJsonObject{{"path", "a.cpp"}}));
|
||||
|
||||
const QJsonObject block
|
||||
= MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
|
||||
EXPECT_EQ(block.value("type").toString(), QStringLiteral("tool_use"));
|
||||
EXPECT_EQ(block.value("id").toString(), QStringLiteral("tu1"));
|
||||
EXPECT_EQ(block.value("name").toString(), QStringLiteral("read_file"));
|
||||
EXPECT_EQ(block.value("input").toObject().value("path").toString(), QStringLiteral("a.cpp"));
|
||||
|
||||
EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
|
||||
}
|
||||
|
||||
TEST(MessageSerializerTest, ToolResultRoundtrip)
|
||||
{
|
||||
Message m(Message::Role::User);
|
||||
m.appendBlock(
|
||||
std::make_unique<LLMQore::ToolResultContent>(QStringLiteral("tu1"), QStringLiteral("body")));
|
||||
|
||||
const QJsonObject block
|
||||
= MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
|
||||
EXPECT_EQ(block.value("type").toString(), QStringLiteral("tool_result"));
|
||||
EXPECT_EQ(block.value("toolUseId").toString(), QStringLiteral("tu1"));
|
||||
EXPECT_EQ(block.value("result").toString(), QStringLiteral("body"));
|
||||
|
||||
EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
|
||||
}
|
||||
|
||||
TEST(MessageSerializerTest, StoredImageRoundtrip)
|
||||
{
|
||||
Message m(Message::Role::User);
|
||||
m.appendBlock(std::make_unique<StoredImageContent>(
|
||||
QStringLiteral("shot.png"), QStringLiteral("stored/shot"), QStringLiteral("image/png")));
|
||||
|
||||
const QJsonObject block
|
||||
= MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
|
||||
EXPECT_EQ(block.value("type").toString(), QStringLiteral("stored_image"));
|
||||
EXPECT_EQ(block.value("fileName").toString(), QStringLiteral("shot.png"));
|
||||
EXPECT_EQ(block.value("storedPath").toString(), QStringLiteral("stored/shot"));
|
||||
EXPECT_EQ(block.value("mediaType").toString(), QStringLiteral("image/png"));
|
||||
|
||||
EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
|
||||
}
|
||||
|
||||
TEST(MessageSerializerTest, StoredAttachmentRoundtrip)
|
||||
{
|
||||
Message m(Message::Role::User);
|
||||
m.appendBlock(std::make_unique<StoredAttachmentContent>(
|
||||
QStringLiteral("notes.txt"), QStringLiteral("stored/notes")));
|
||||
|
||||
const QJsonObject block
|
||||
= MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
|
||||
EXPECT_EQ(block.value("type").toString(), QStringLiteral("stored_attachment"));
|
||||
EXPECT_EQ(block.value("fileName").toString(), QStringLiteral("notes.txt"));
|
||||
EXPECT_EQ(block.value("storedPath").toString(), QStringLiteral("stored/notes"));
|
||||
|
||||
EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
|
||||
}
|
||||
|
||||
TEST(MessageSerializerTest, SkillInvocationRoundtrip)
|
||||
{
|
||||
Message m(Message::Role::User);
|
||||
m.appendBlock(std::make_unique<SkillInvocationContent>(
|
||||
QStringLiteral("review"), QStringLiteral("Review the code.")));
|
||||
|
||||
const QJsonObject block
|
||||
= MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
|
||||
EXPECT_EQ(block.value("type").toString(), QStringLiteral("skill_invocation"));
|
||||
EXPECT_EQ(block.value("skillName").toString(), QStringLiteral("review"));
|
||||
EXPECT_EQ(block.value("body").toString(), QStringLiteral("Review the code."));
|
||||
|
||||
EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
|
||||
}
|
||||
|
||||
TEST(MessageSerializerTest, FileEditRoundtripWithStatusAndMessage)
|
||||
{
|
||||
Message m(Message::Role::Assistant);
|
||||
m.appendBlock(std::make_unique<FileEditContent>(
|
||||
QStringLiteral("e1"),
|
||||
QStringLiteral("a.cpp"),
|
||||
QStringLiteral("old"),
|
||||
QStringLiteral("new"),
|
||||
FileEditContent::Status::Applied,
|
||||
QStringLiteral("done")));
|
||||
|
||||
const QJsonObject block
|
||||
= MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
|
||||
EXPECT_EQ(block.value("type").toString(), QStringLiteral("file_edit"));
|
||||
EXPECT_EQ(block.value("editId").toString(), QStringLiteral("e1"));
|
||||
EXPECT_EQ(block.value("filePath").toString(), QStringLiteral("a.cpp"));
|
||||
EXPECT_EQ(block.value("oldContent").toString(), QStringLiteral("old"));
|
||||
EXPECT_EQ(block.value("newContent").toString(), QStringLiteral("new"));
|
||||
EXPECT_EQ(block.value("status").toString(), QStringLiteral("applied"));
|
||||
EXPECT_EQ(block.value("statusMessage").toString(), QStringLiteral("done"));
|
||||
|
||||
EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
|
||||
}
|
||||
|
||||
TEST(MessageSerializerTest, FileEditOmitsEmptyStatusMessageAndDefaultsToPending)
|
||||
{
|
||||
Message m(Message::Role::Assistant);
|
||||
m.appendBlock(std::make_unique<FileEditContent>(
|
||||
QStringLiteral("e1"),
|
||||
QStringLiteral("a.cpp"),
|
||||
QStringLiteral("old"),
|
||||
QStringLiteral("new")));
|
||||
|
||||
const QJsonObject block
|
||||
= MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
|
||||
EXPECT_EQ(block.value("status").toString(), QStringLiteral("pending"));
|
||||
EXPECT_FALSE(block.contains(QStringLiteral("statusMessage")));
|
||||
}
|
||||
|
||||
TEST(MessageSerializerTest, MultipleBlocksPreserveOrder)
|
||||
{
|
||||
Message m(Message::Role::Assistant);
|
||||
m.appendBlock(std::make_unique<LLMQore::TextContent>(QStringLiteral("calling")));
|
||||
m.appendBlock(std::make_unique<LLMQore::ToolUseContent>(
|
||||
QStringLiteral("tu1"), QStringLiteral("read_file"), QJsonObject()));
|
||||
|
||||
const QJsonArray blocks = MessageSerializer::toJson(m).value("blocks").toArray();
|
||||
ASSERT_EQ(blocks.size(), 2);
|
||||
EXPECT_EQ(blocks[0].toObject().value("type").toString(), QStringLiteral("text"));
|
||||
EXPECT_EQ(blocks[1].toObject().value("type").toString(), QStringLiteral("tool_use"));
|
||||
|
||||
EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
|
||||
}
|
||||
|
||||
TEST(MessageSerializerTest, UnknownRoleFailsDeserialization)
|
||||
{
|
||||
QJsonObject json;
|
||||
json["role"] = QStringLiteral("operator");
|
||||
json["blocks"] = QJsonArray{};
|
||||
|
||||
bool ok = true;
|
||||
const Message m = MessageSerializer::fromJson(json, &ok);
|
||||
EXPECT_FALSE(ok);
|
||||
EXPECT_TRUE(m.blocks().empty());
|
||||
}
|
||||
|
||||
TEST(MessageSerializerTest, EmptyBlocksDeserializeOk)
|
||||
{
|
||||
QJsonObject json;
|
||||
json["role"] = QStringLiteral("user");
|
||||
json["blocks"] = QJsonArray{};
|
||||
|
||||
bool ok = false;
|
||||
const Message m = MessageSerializer::fromJson(json, &ok);
|
||||
EXPECT_TRUE(ok);
|
||||
EXPECT_TRUE(m.blocks().empty());
|
||||
}
|
||||
|
||||
TEST(MessageSerializerTest, AllUnknownBlocksFailDeserialization)
|
||||
{
|
||||
QJsonObject json;
|
||||
json["role"] = QStringLiteral("assistant");
|
||||
json["blocks"] = QJsonArray{QJsonObject{{"type", "future_block"}}};
|
||||
|
||||
bool ok = true;
|
||||
const Message m = MessageSerializer::fromJson(json, &ok);
|
||||
EXPECT_FALSE(ok);
|
||||
EXPECT_TRUE(m.blocks().empty());
|
||||
}
|
||||
|
||||
TEST(MessageSerializerTest, UnknownBlocksSkippedButKnownKept)
|
||||
{
|
||||
QJsonObject json;
|
||||
json["role"] = QStringLiteral("assistant");
|
||||
json["blocks"] = QJsonArray{
|
||||
QJsonObject{{"type", "future_block"}},
|
||||
QJsonObject{{"type", "text"}, {"text", "kept"}}};
|
||||
|
||||
bool ok = false;
|
||||
const Message m = MessageSerializer::fromJson(json, &ok);
|
||||
EXPECT_TRUE(ok);
|
||||
ASSERT_EQ(m.blocks().size(), 1u);
|
||||
EXPECT_EQ(m.text(), QStringLiteral("kept"));
|
||||
}
|
||||
64
test/ResponseCleanerTest.cpp
Normal file
64
test/ResponseCleanerTest.cpp
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <ResponseCleaner.hpp>
|
||||
|
||||
using QodeAssist::ResponseCleaner;
|
||||
|
||||
TEST(ResponseCleanerTest, EmptyInputStaysEmpty)
|
||||
{
|
||||
EXPECT_EQ(ResponseCleaner::clean(QString()), QString());
|
||||
}
|
||||
|
||||
TEST(ResponseCleanerTest, PlainCodeIsUnchanged)
|
||||
{
|
||||
const QString code = QStringLiteral("int main() {\n return 0;\n}");
|
||||
EXPECT_EQ(ResponseCleaner::clean(code), code);
|
||||
}
|
||||
|
||||
TEST(ResponseCleanerTest, ExtractsFencedCodeWithLanguage)
|
||||
{
|
||||
const QString input = QStringLiteral("```cpp\nint main() {}\n```");
|
||||
EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("int main() {}"));
|
||||
}
|
||||
|
||||
TEST(ResponseCleanerTest, ExtractsFencedCodeWithoutLanguage)
|
||||
{
|
||||
const QString input = QStringLiteral("```\nfoo\nbar\n```");
|
||||
EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("foo\nbar"));
|
||||
}
|
||||
|
||||
TEST(ResponseCleanerTest, StripsHeresTheExplanationPrefix)
|
||||
{
|
||||
const QString input = QStringLiteral("Here's the refactored code:\nint x = 1;");
|
||||
EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("int x = 1;"));
|
||||
}
|
||||
|
||||
TEST(ResponseCleanerTest, StripsHereIsTheExplanationPrefix)
|
||||
{
|
||||
const QString input = QStringLiteral("Here is the code:\nint y = 2;");
|
||||
EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("int y = 2;"));
|
||||
}
|
||||
|
||||
TEST(ResponseCleanerTest, StripsBareCodeColonPrefix)
|
||||
{
|
||||
const QString input = QStringLiteral("code:\nfoo();");
|
||||
EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("foo();"));
|
||||
}
|
||||
|
||||
TEST(ResponseCleanerTest, TrimsLeadingAndTrailingNewlines)
|
||||
{
|
||||
const QString input = QStringLiteral("\n\nhello\n\n");
|
||||
EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("hello"));
|
||||
}
|
||||
|
||||
TEST(ResponseCleanerTest, FencedCodeWithExplanationLineInsideIsExtractedVerbatim)
|
||||
{
|
||||
// The fence body is returned verbatim; explanation stripping only inspects
|
||||
// the first lines of the *extracted* code, which here is real code.
|
||||
const QString input = QStringLiteral("```python\nx = 1\ny = 2\n```");
|
||||
EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("x = 1\ny = 2"));
|
||||
}
|
||||
159
test/ResponseRouterTest.cpp
Normal file
159
test/ResponseRouterTest.cpp
Normal file
@@ -0,0 +1,159 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <QFuture>
|
||||
#include <QHash>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkRequest>
|
||||
#include <QVector>
|
||||
|
||||
#include <LLMQore/BaseClient.hpp>
|
||||
#include <LLMQore/ToolResult.hpp>
|
||||
|
||||
#include <ConversationHistory.hpp>
|
||||
#include <ErrorInfo.hpp>
|
||||
#include <Message.hpp>
|
||||
#include <ResponseEvent.hpp>
|
||||
#include <ResponseRouter.hpp>
|
||||
|
||||
using namespace QodeAssist;
|
||||
|
||||
namespace {
|
||||
|
||||
class FakeClient : public LLMQore::BaseClient
|
||||
{
|
||||
public:
|
||||
using LLMQore::BaseClient::BaseClient;
|
||||
|
||||
void fireChunk(const QString &id, const QString &chunk) { emit chunkReceived(id, chunk); }
|
||||
void fireThinking(const QString &id, const QString &thinking, const QString &signature)
|
||||
{
|
||||
emit thinkingBlockReceived(id, thinking, signature);
|
||||
}
|
||||
void fireToolStarted(
|
||||
const QString &id, const QString &toolId, const QString &name, const QJsonObject &args)
|
||||
{
|
||||
emit toolStarted(id, toolId, name, args);
|
||||
}
|
||||
void fireToolResult(
|
||||
const QString &id, const QString &toolId, const QString &name, const QString &result)
|
||||
{
|
||||
emit toolResultReady(id, toolId, name, result);
|
||||
}
|
||||
void fireFinalized(const QString &id, const LLMQore::CompletionInfo &info)
|
||||
{
|
||||
emit requestFinalized(id, info);
|
||||
}
|
||||
void fireFailed(const QString &id, const QString &error) { emit requestFailed(id, error); }
|
||||
|
||||
protected:
|
||||
LLMQore::RequestID sendMessage(
|
||||
const QJsonObject &, const QString &, LLMQore::RequestMode) override
|
||||
{
|
||||
return {};
|
||||
}
|
||||
LLMQore::RequestID ask(const QString &, LLMQore::RequestMode) override { return {}; }
|
||||
QFuture<QList<QString>> listModels() override { return {}; }
|
||||
LLMQore::ToolSchemaFormat toolSchemaFormat() const override
|
||||
{
|
||||
return LLMQore::ToolSchemaFormat::Claude;
|
||||
}
|
||||
void processData(const LLMQore::RequestID &, const QByteArray &) override {}
|
||||
void processBufferedResponse(const LLMQore::RequestID &, const QByteArray &) override {}
|
||||
QNetworkRequest prepareNetworkRequest(const QUrl &) const override { return {}; }
|
||||
LLMQore::BaseMessage *messageForRequest(const LLMQore::RequestID &) const override
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
void cleanupDerivedData(const LLMQore::RequestID &) override {}
|
||||
QJsonObject buildContinuationPayload(
|
||||
const QJsonObject &,
|
||||
LLMQore::BaseMessage *,
|
||||
const QHash<QString, LLMQore::ToolResult> &) override
|
||||
{
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(ResponseRouterTest, BuildsAssistantTurnAndEmitsEvents)
|
||||
{
|
||||
FakeClient client;
|
||||
ConversationHistory history;
|
||||
ResponseRouter router(&client, &history);
|
||||
|
||||
QVector<ResponseEvent::Kind> kinds;
|
||||
QObject::connect(&router, &ResponseRouter::event, &router, [&kinds](const ResponseEvent &ev) {
|
||||
kinds.append(ev.kind());
|
||||
});
|
||||
|
||||
const QString id = QStringLiteral("req-1");
|
||||
router.beginRequest(id);
|
||||
client.fireThinking(id, QStringLiteral("pondering"), QStringLiteral("sig"));
|
||||
client.fireChunk(id, QStringLiteral("Hello"));
|
||||
client.fireChunk(id, QStringLiteral(" world"));
|
||||
client.fireToolStarted(
|
||||
id, QStringLiteral("t1"), QStringLiteral("read_file"), QJsonObject{{"path", "a.txt"}});
|
||||
client.fireToolResult(
|
||||
id, QStringLiteral("t1"), QStringLiteral("read_file"), QStringLiteral("contents"));
|
||||
|
||||
LLMQore::CompletionInfo info;
|
||||
info.stopReason = QStringLiteral("end_turn");
|
||||
info.usage = LLMQore::TokenUsage{12, 34, 0, 0};
|
||||
client.fireFinalized(id, info);
|
||||
|
||||
ASSERT_EQ(history.size(), 2);
|
||||
|
||||
const Message &assistant = history.messages()[0];
|
||||
EXPECT_EQ(assistant.role(), Message::Role::Assistant);
|
||||
EXPECT_EQ(assistant.id(), id);
|
||||
EXPECT_EQ(assistant.text(), QStringLiteral("Hello world"));
|
||||
EXPECT_TRUE(assistant.hasToolUse());
|
||||
|
||||
const Message &toolResult = history.messages()[1];
|
||||
EXPECT_EQ(toolResult.role(), Message::Role::User);
|
||||
|
||||
EXPECT_TRUE(kinds.contains(ResponseEvent::Kind::ThinkingDelta));
|
||||
EXPECT_TRUE(kinds.contains(ResponseEvent::Kind::TextDelta));
|
||||
EXPECT_TRUE(kinds.contains(ResponseEvent::Kind::ToolResult));
|
||||
EXPECT_TRUE(kinds.contains(ResponseEvent::Kind::Usage));
|
||||
EXPECT_TRUE(kinds.contains(ResponseEvent::Kind::MessageStop));
|
||||
}
|
||||
|
||||
TEST(ResponseRouterTest, CategorizesAuthError)
|
||||
{
|
||||
FakeClient client;
|
||||
ConversationHistory history;
|
||||
ResponseRouter router(&client, &history);
|
||||
|
||||
std::optional<ResponseEvents::Error> captured;
|
||||
QObject::connect(&router, &ResponseRouter::event, &router, [&captured](const ResponseEvent &ev) {
|
||||
if (ev.kind() == ResponseEvent::Kind::Error)
|
||||
captured = *ev.as<ResponseEvents::Error>();
|
||||
});
|
||||
|
||||
router.beginRequest(QStringLiteral("req-2"));
|
||||
client.fireFailed(
|
||||
QStringLiteral("req-2"), QStringLiteral("HTTP 401 Unauthorized: invalid api key"));
|
||||
|
||||
ASSERT_TRUE(captured.has_value());
|
||||
EXPECT_EQ(captured->category, ErrorCategory::Auth);
|
||||
}
|
||||
|
||||
TEST(ResponseRouterTest, IgnoresEventsForInactiveRequest)
|
||||
{
|
||||
FakeClient client;
|
||||
ConversationHistory history;
|
||||
ResponseRouter router(&client, &history);
|
||||
|
||||
router.beginRequest(QStringLiteral("req-3"));
|
||||
client.fireChunk(QStringLiteral("OTHER"), QStringLiteral("ignored"));
|
||||
|
||||
EXPECT_TRUE(history.isEmpty());
|
||||
}
|
||||
154
test/SystemPromptBuilderTest.cpp
Normal file
154
test/SystemPromptBuilderTest.cpp
Normal file
@@ -0,0 +1,154 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <QSignalSpy>
|
||||
|
||||
#include <SystemPromptBuilder.hpp>
|
||||
|
||||
using QodeAssist::SystemPromptBuilder;
|
||||
|
||||
TEST(SystemPromptBuilderTest, StartsEmpty)
|
||||
{
|
||||
SystemPromptBuilder builder;
|
||||
EXPECT_TRUE(builder.isEmpty());
|
||||
EXPECT_TRUE(builder.compose().isEmpty());
|
||||
EXPECT_TRUE(builder.layerNames().isEmpty());
|
||||
}
|
||||
|
||||
TEST(SystemPromptBuilderTest, SetLayerStoresTextAndName)
|
||||
{
|
||||
SystemPromptBuilder builder;
|
||||
builder.setLayer(QStringLiteral("agent"), QStringLiteral("you are helpful"));
|
||||
|
||||
EXPECT_FALSE(builder.isEmpty());
|
||||
EXPECT_EQ(builder.layer(QStringLiteral("agent")), QStringLiteral("you are helpful"));
|
||||
EXPECT_EQ(builder.layerNames(), QStringList{QStringLiteral("agent")});
|
||||
}
|
||||
|
||||
TEST(SystemPromptBuilderTest, ComposeOrdersByPriorityAscending)
|
||||
{
|
||||
SystemPromptBuilder builder;
|
||||
builder.setLayer(QStringLiteral("b"), QStringLiteral("B"), 100);
|
||||
builder.setLayer(QStringLiteral("a"), QStringLiteral("A"), 50);
|
||||
|
||||
EXPECT_EQ(builder.compose(), QStringLiteral("A\n\nB"));
|
||||
}
|
||||
|
||||
TEST(SystemPromptBuilderTest, EqualPriorityKeepsInsertionOrder)
|
||||
{
|
||||
SystemPromptBuilder builder;
|
||||
builder.setLayer(QStringLiteral("first"), QStringLiteral("F"), 100);
|
||||
builder.setLayer(QStringLiteral("second"), QStringLiteral("S"), 100);
|
||||
|
||||
EXPECT_EQ(builder.compose(), QStringLiteral("F\n\nS"));
|
||||
}
|
||||
|
||||
TEST(SystemPromptBuilderTest, AgentPriorityComposesBeforeDefault)
|
||||
{
|
||||
SystemPromptBuilder builder;
|
||||
builder.setLayer(QStringLiteral("env"), QStringLiteral("ENV"), SystemPromptBuilder::kDefaultPriority);
|
||||
builder.setLayer(QStringLiteral("agent"), QStringLiteral("SYS"), SystemPromptBuilder::kAgentPriority);
|
||||
|
||||
EXPECT_EQ(builder.compose(), QStringLiteral("SYS\n\nENV"));
|
||||
}
|
||||
|
||||
TEST(SystemPromptBuilderTest, ComposeUsesCustomSeparator)
|
||||
{
|
||||
SystemPromptBuilder builder;
|
||||
builder.setLayer(QStringLiteral("a"), QStringLiteral("A"), 1);
|
||||
builder.setLayer(QStringLiteral("b"), QStringLiteral("B"), 2);
|
||||
|
||||
EXPECT_EQ(builder.compose(QStringLiteral(" | ")), QStringLiteral("A | B"));
|
||||
}
|
||||
|
||||
TEST(SystemPromptBuilderTest, ComposeSkipsEmptyTextButLayerStaysNamed)
|
||||
{
|
||||
SystemPromptBuilder builder;
|
||||
builder.setLayer(QStringLiteral("a"), QStringLiteral("A"));
|
||||
builder.setLayer(QStringLiteral("blank"), QString());
|
||||
|
||||
EXPECT_EQ(builder.compose(), QStringLiteral("A"));
|
||||
EXPECT_TRUE(builder.layerNames().contains(QStringLiteral("blank")));
|
||||
}
|
||||
|
||||
TEST(SystemPromptBuilderTest, SetLayerUpdatesExistingInPlace)
|
||||
{
|
||||
SystemPromptBuilder builder;
|
||||
builder.setLayer(QStringLiteral("a"), QStringLiteral("old"));
|
||||
builder.setLayer(QStringLiteral("a"), QStringLiteral("new"));
|
||||
|
||||
EXPECT_EQ(builder.layerNames().size(), 1);
|
||||
EXPECT_EQ(builder.layer(QStringLiteral("a")), QStringLiteral("new"));
|
||||
}
|
||||
|
||||
TEST(SystemPromptBuilderTest, IdenticalSetLayerEmitsNoSignal)
|
||||
{
|
||||
SystemPromptBuilder builder;
|
||||
builder.setLayer(QStringLiteral("a"), QStringLiteral("A"), 10);
|
||||
|
||||
QSignalSpy spy(&builder, &SystemPromptBuilder::layersChanged);
|
||||
builder.setLayer(QStringLiteral("a"), QStringLiteral("A"), 10);
|
||||
|
||||
EXPECT_EQ(spy.count(), 0);
|
||||
}
|
||||
|
||||
TEST(SystemPromptBuilderTest, ChangingSetLayerEmitsSignal)
|
||||
{
|
||||
SystemPromptBuilder builder;
|
||||
builder.setLayer(QStringLiteral("a"), QStringLiteral("A"), 10);
|
||||
|
||||
QSignalSpy spy(&builder, &SystemPromptBuilder::layersChanged);
|
||||
builder.setLayer(QStringLiteral("a"), QStringLiteral("A"), 20);
|
||||
|
||||
EXPECT_EQ(spy.count(), 1);
|
||||
}
|
||||
|
||||
TEST(SystemPromptBuilderTest, ClearLayerRemovesAndSignals)
|
||||
{
|
||||
SystemPromptBuilder builder;
|
||||
builder.setLayer(QStringLiteral("a"), QStringLiteral("A"));
|
||||
|
||||
QSignalSpy spy(&builder, &SystemPromptBuilder::layersChanged);
|
||||
builder.clearLayer(QStringLiteral("a"));
|
||||
|
||||
EXPECT_TRUE(builder.isEmpty());
|
||||
EXPECT_EQ(spy.count(), 1);
|
||||
}
|
||||
|
||||
TEST(SystemPromptBuilderTest, ClearMissingLayerEmitsNoSignal)
|
||||
{
|
||||
SystemPromptBuilder builder;
|
||||
builder.setLayer(QStringLiteral("a"), QStringLiteral("A"));
|
||||
|
||||
QSignalSpy spy(&builder, &SystemPromptBuilder::layersChanged);
|
||||
builder.clearLayer(QStringLiteral("nope"));
|
||||
|
||||
EXPECT_FALSE(builder.isEmpty());
|
||||
EXPECT_EQ(spy.count(), 0);
|
||||
}
|
||||
|
||||
TEST(SystemPromptBuilderTest, ClearEmptiesAndSignals)
|
||||
{
|
||||
SystemPromptBuilder builder;
|
||||
builder.setLayer(QStringLiteral("a"), QStringLiteral("A"));
|
||||
builder.setLayer(QStringLiteral("b"), QStringLiteral("B"));
|
||||
|
||||
QSignalSpy spy(&builder, &SystemPromptBuilder::layersChanged);
|
||||
builder.clear();
|
||||
|
||||
EXPECT_TRUE(builder.isEmpty());
|
||||
EXPECT_EQ(spy.count(), 1);
|
||||
}
|
||||
|
||||
TEST(SystemPromptBuilderTest, ClearWhenAlreadyEmptyEmitsNoSignal)
|
||||
{
|
||||
SystemPromptBuilder builder;
|
||||
|
||||
QSignalSpy spy(&builder, &SystemPromptBuilder::layersChanged);
|
||||
builder.clear();
|
||||
|
||||
EXPECT_EQ(spy.count(), 0);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
|
||||
|
||||
#include <iostream>
|
||||
#include <pluginllmcore/ContextData.hpp>
|
||||
#include <sources/common/ContextData.hpp>
|
||||
#include <QString>
|
||||
|
||||
QT_BEGIN_NAMESPACE
|
||||
@@ -44,12 +44,11 @@ std::ostream &operator<<(std::ostream &out, const std::optional<T> &value)
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace QodeAssist::PluginLLMCore {
|
||||
namespace QodeAssist::Templates {
|
||||
|
||||
inline std::ostream &operator<<(std::ostream &out, const Message &value)
|
||||
{
|
||||
out << "Message{"
|
||||
<< "role=" << value.role << "content=" << value.content << "}";
|
||||
out << "Message{role=" << value.role << ", blocks=" << value.blocks.size() << "}";
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -62,4 +61,4 @@ inline std::ostream &operator<<(std::ostream &out, const ContextData &value)
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::PluginLLMCore
|
||||
} // namespace QodeAssist::Templates
|
||||
|
||||
Reference in New Issue
Block a user