refactor: Move to agent architecture

This commit is contained in:
Petr Mironychev
2026-05-30 14:50:49 +02:00
parent 34ce787320
commit ccc2ec2e80
364 changed files with 10801 additions and 19020 deletions

115
test/AgentConfigTest.cpp Normal file
View 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
View 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
View 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"))));
}

View 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";
}

View File

@@ -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})

View File

@@ -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());
}

View 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);
}

View 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")));
}

View File

@@ -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"

View 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
View 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);
}

View 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());
}

View 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"));
}

View 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
View 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());
}

View 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);
}

View File

@@ -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