fix: Found and fix review mistakes

This commit is contained in:
Petr Mironychev
2026-07-02 22:26:07 +02:00
parent c070d65366
commit 35bbaa1af0
139 changed files with 2032 additions and 1573 deletions

View File

@@ -6,6 +6,7 @@
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QTemporaryDir>
#include <AgentConfig.hpp>
@@ -25,13 +26,14 @@ void writeFile(const QString &dir, const QString &name, const QByteArray &conten
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";
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)
@@ -58,11 +60,14 @@ 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"));
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();
@@ -124,23 +129,29 @@ TEST(AgentLoaderTest, UserAgentExtendsBundledProviderBase)
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");
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();
@@ -163,7 +174,8 @@ TEST(AgentLoaderTest, ExtendsUnknownParentErrorNamesChild)
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_TRUE(
anyContains(result.errors, QStringLiteral("'Child' extends unknown agent 'NoSuchBase'")));
EXPECT_EQ(findConfig(result, QStringLiteral("Child")), nullptr);
}
@@ -182,3 +194,91 @@ TEST(AgentLoaderTest, ParseFileReportsWarningsForOwnFileOnly)
EXPECT_TRUE(anyContains(warnings, QStringLiteral("another_bogus")));
EXPECT_FALSE(anyContains(warnings, QStringLiteral("bogus_key")));
}
TEST(AgentLoaderTest, CyclicExtendsRejectsAllInvolvedAgents)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
writeFile(dir.path(), "a.toml", minimalAgent("A", "extends = \"B\"\n"));
writeFile(dir.path(), "b.toml", minimalAgent("B", "extends = \"A\"\n"));
const auto result = AgentLoader::load(QString(), dir.path());
EXPECT_TRUE(anyContains(result.errors, QStringLiteral("Cyclic 'extends'")));
EXPECT_EQ(findConfig(result, QStringLiteral("A")), nullptr);
EXPECT_EQ(findConfig(result, QStringLiteral("B")), nullptr);
}
TEST(AgentLoaderTest, SelfExtendsIsRejected)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
writeFile(dir.path(), "a.toml", minimalAgent("A", "extends = \"A\"\n"));
const auto result = AgentLoader::load(QString(), dir.path());
EXPECT_TRUE(anyContains(result.errors, QStringLiteral("Cyclic 'extends'")));
EXPECT_EQ(findConfig(result, QStringLiteral("A")), nullptr);
}
TEST(AgentLoaderTest, ExtendsChainTooDeepRejectsChain)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const int chainLength = 40;
writeFile(dir.path(), "agent0.toml", minimalAgent("Agent0"));
for (int i = 1; i <= chainLength; ++i) {
writeFile(
dir.path(),
QStringLiteral("agent%1.toml").arg(i),
minimalAgent(
QStringLiteral("Agent%1").arg(i).toUtf8(),
QStringLiteral("extends = \"Agent%1\"\n").arg(i - 1).toUtf8()));
}
const auto result = AgentLoader::load(QString(), dir.path());
EXPECT_TRUE(anyContains(result.errors, QStringLiteral("too deep")));
EXPECT_EQ(findConfig(result, QStringLiteral("Agent%1").arg(chainLength)), nullptr);
EXPECT_NE(findConfig(result, QStringLiteral("Agent0")), nullptr);
}
TEST(AgentLoaderTest, ParseFileRejectsBundledAgentShadow)
{
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(), "shadow.toml", minimalAgent("A", "description = \"mine\"\n"));
QString error;
const auto cfg = AgentLoader::parseFile(
user.path() + QStringLiteral("/shadow.toml"), bundled.path(), &error);
EXPECT_FALSE(cfg.has_value());
EXPECT_TRUE(error.contains(QStringLiteral("cannot be replaced"))) << error.toStdString();
}
TEST(AgentLoaderTest, ChildArrayReplacesParentArray)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
writeFile(
dir.path(),
"parent.toml",
minimalAgent("Parent", "tags = [\"a\", \"b\"]\n") + "stop = [\"one\", \"two\"]\n");
writeFile(
dir.path(),
"child.toml",
"name = \"Child\"\n"
"extends = \"Parent\"\n"
"tags = [\"c\"]\n"
"[body]\n"
"stop = [\"three\"]\n");
const auto result = AgentLoader::load(QString(), dir.path());
EXPECT_TRUE(result.errors.isEmpty()) << result.errors.join("; ").toStdString();
const AgentConfig *child = findConfig(result, QStringLiteral("Child"));
ASSERT_NE(child, nullptr);
EXPECT_EQ(child->tags, QStringList{QStringLiteral("c")});
const auto stops = child->body.value(QStringLiteral("stop")).toArray();
ASSERT_EQ(stops.size(), 1);
EXPECT_EQ(stops.first().toString(), QStringLiteral("three"));
}

View File

@@ -99,6 +99,5 @@ 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

@@ -23,8 +23,7 @@ TEST(BundledAgentsTest, AllBundledAgentsLoadResolveAndRender)
const AgentLoader::LoadResult result = AgentLoader::load(QStringLiteral(":/agents"), QString());
EXPECT_TRUE(result.errors.isEmpty())
<< "bundled agent load errors: "
<< result.errors.join(QStringLiteral("; ")).toStdString();
<< "bundled agent load errors: " << result.errors.join(QStringLiteral("; ")).toStdString();
EXPECT_TRUE(result.warnings.isEmpty())
<< "bundled agent load warnings: "
@@ -45,8 +44,7 @@ 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_TRUE(result.errors.isEmpty()) << result.errors.join(QStringLiteral("; ")).toStdString();
ASSERT_FALSE(result.configs.empty());
const QStringList languages = {QString(), QStringLiteral("qml"), QStringLiteral("c-like")};
@@ -64,8 +62,8 @@ TEST(BundledAgentsTest, AllBundledSystemPromptsResolveQrcResources)
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();
<< "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";

View File

@@ -1,6 +1,7 @@
add_executable(QodeAssistTest
../CodeHandler.cpp
../LLMSuggestion.cpp
../sources/settings/PipelinesConfig.cpp
CodeHandlerTest.cpp
DocumentContextReaderTest.cpp
EnvBlockFormatterTest.cpp
@@ -16,9 +17,9 @@ add_executable(QodeAssistTest
ContextRendererTest.cpp
ErrorInfoTest.cpp
MessageSerializerTest.cpp
PipelinesConfigTest.cpp
ResponseCleanerTest.cpp
SystemPromptBuilderTest.cpp
# LLMClientInterfaceTests.cpp
unittest_main.cpp
)
@@ -35,9 +36,17 @@ target_link_libraries(QodeAssistTest PRIVATE
Templates
Agents
Session
TomlSerializer
tomlplusplus::tomlplusplus
QodeAssistLogger
)
target_include_directories(QodeAssistTest PRIVATE ${CMAKE_SOURCE_DIR})
target_include_directories(QodeAssistTest PRIVATE
${CMAKE_SOURCE_DIR}
${CMAKE_SOURCE_DIR}/sources/settings
${CMAKE_SOURCE_DIR}/sources/tomlSerializer
${CMAKE_SOURCE_DIR}/logger
)
target_compile_definitions(QodeAssistTest PRIVATE CMAKE_CURRENT_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}")

View File

@@ -226,8 +226,7 @@ TEST(ClaudeCacheControlTest, EmptyBreakpointListMarksEveryDimension)
apply(request, false, QStringList());
EXPECT_TRUE(request.value("system").isArray());
EXPECT_TRUE(
request.value("tools").toArray().last().toObject().contains("cache_control"));
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

@@ -27,9 +27,7 @@ Message textMessage(Message::Role role, const QString &text)
ContextAssembler::ContentLoader base64Loader(const QString &content)
{
return [content](const QString &) {
return QString::fromUtf8(content.toUtf8().toBase64());
};
return [content](const QString &) { return QString::fromUtf8(content.toUtf8().toBase64()); };
}
ContextAssembler::ContentLoader emptyLoader()
@@ -145,8 +143,7 @@ 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()));
m.appendBlock(std::make_unique<LLMQore::ToolUseContent>("tu1", "read_file", QJsonObject()));
history.push_back(std::move(m));
ContextAssembler::Manifest manifest;
@@ -179,8 +176,8 @@ 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"}}));
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"));
@@ -205,8 +202,7 @@ TEST(ContextAssemblerTest, AttachmentMaterializedThroughLoader)
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"));
const auto ctx = ContextAssembler::assemble(history, QString(), base64Loader("file body"));
ASSERT_TRUE(ctx.history.has_value());
const auto &block = ctx.history->first().blocks.first();
@@ -235,8 +231,7 @@ TEST(ContextAssemblerTest, StoredImageMaterializedThroughLoader)
{
std::vector<Message> history;
Message m(Message::Role::User);
m.appendBlock(
std::make_unique<StoredImageContent>("shot.png", "stored/shot", "image/png"));
m.appendBlock(std::make_unique<StoredImageContent>("shot.png", "stored/shot", "image/png"));
history.push_back(std::move(m));
ContextAssembler::Manifest manifest;
@@ -255,8 +250,7 @@ TEST(ContextAssemblerTest, MissingImageWithNullLoaderGetsPlaceholder)
{
std::vector<Message> history;
Message m(Message::Role::User);
m.appendBlock(
std::make_unique<StoredImageContent>("shot.png", "stored/shot", "image/png"));
m.appendBlock(std::make_unique<StoredImageContent>("shot.png", "stored/shot", "image/png"));
history.push_back(std::move(m));
ContextAssembler::Manifest manifest;
@@ -343,8 +337,7 @@ 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()));
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"));
@@ -366,8 +359,7 @@ TEST(ContextAssemblerTest, PinnedInsertedAfterLeadingToolResults)
{
std::vector<Message> history;
Message use(Message::Role::Assistant);
use.appendBlock(
std::make_unique<LLMQore::ToolUseContent>("tu1", "edit_file", QJsonObject()));
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"));

View File

@@ -138,6 +138,30 @@ TEST(ContextRendererTest, FileExistsOutsideAllowedRootsThrowsLoudly)
EXPECT_TRUE(error.contains(QStringLiteral("file_exists")));
}
TEST(ContextRendererTest, FileExistsWithUnresolvedProjectDirReturnsFalse)
{
QString error;
EXPECT_EQ(
render(
QStringLiteral(
"{% if file_exists(\"${PROJECT_DIR}/x.md\") %}yes{% else %}no{% endif %}"),
Bindings{QString(), QString()},
&error),
QStringLiteral("no"));
EXPECT_TRUE(error.isEmpty());
}
TEST(ContextRendererTest, ReadFileWithUnresolvedProjectDirThrowsLoudly)
{
QString error;
const QString out = render(
QStringLiteral("{{ read_file(\"${PROJECT_DIR}/x.md\") }}"),
Bindings{QString(), QString()},
&error);
EXPECT_TRUE(out.isEmpty());
EXPECT_TRUE(error.contains(QStringLiteral("read_file")));
}
TEST(ContextRendererTest, HeadLinesTakesLeadingLines)
{
QTemporaryDir proj;
@@ -153,8 +177,7 @@ TEST(ContextRendererTest, HeadLinesTakesLeadingLines)
TEST(ContextRendererTest, StringHelpers)
{
const Bindings none{};
EXPECT_EQ(
render(QStringLiteral("{{ basename(\"/a/b/c.txt\") }}"), none), QStringLiteral("c.txt"));
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"));
@@ -189,7 +212,8 @@ TEST(ContextRendererTest, SelectsCompletionRoleByLanguageFromQrc)
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 if language == \"c-like\" %}{{ read_file(\":/roles/code-completion-c-like.md\") "
"}}"
"{%- else %}{{ read_file(\":/roles/code-completion.md\") }}"
"{%- endif %}");

View File

@@ -370,24 +370,27 @@ TEST_F(DocumentContextReaderTest, testPrepareContext)
(QodeAssist::Templates::ContextData{
.prefix = "Line 0\nLine 1\nLin",
.suffix = "e 2\nLine 3\nLine 4",
.fileContext = "\nFile information:\nMIME type: text/python\nFile path: /path/to/file\n\n"
"Recent Project Changes Context:\n "}));
.fileContext
= "\nFile information:\nMIME type: text/python\nFile path: /path/to/file\n\n"
"Recent Project Changes Context:\n "}));
EXPECT_EQ(
reader.prepareContext(2, 3, *createSettingsForLines(1, 1)),
(QodeAssist::Templates::ContextData{
.prefix = "Line 1\nLin",
.suffix = "e 2\nLine 3",
.fileContext = "\nFile information:\nMIME type: text/python\nFile path: /path/to/file\n\n"
"Recent Project Changes Context:\n "}));
.fileContext
= "\nFile information:\nMIME type: text/python\nFile path: /path/to/file\n\n"
"Recent Project Changes Context:\n "}));
EXPECT_EQ(
reader.prepareContext(2, 3, *createSettingsForLines(2, 2)),
(QodeAssist::Templates::ContextData{
.prefix = "Line 0\nLine 1\nLin",
.suffix = "e 2\nLine 3\nLine 4",
.fileContext = "\nFile information:\nMIME type: text/python\nFile path: /path/to/file\n\n"
"Recent Project Changes Context:\n "}));
.fileContext
= "\nFile information:\nMIME type: text/python\nFile path: /path/to/file\n\n"
"Recent Project Changes Context:\n "}));
}
#include "DocumentContextReaderTest.moc"

View File

@@ -34,8 +34,7 @@ TEST(EnvBlockFormatterTest, FormatProjectEmptyEnv)
TEST(EnvBlockFormatterTest, FormatFileWithKnownMime)
{
const QString out
= EnvBlockFormatter::formatFile({"/home/dev/myapp/main.cpp", "text/x-c++src"});
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:"));

View File

@@ -10,7 +10,8 @@ using namespace QodeAssist;
TEST(ErrorInfoTest, MakeErrorPopulatesFields)
{
const ErrorInfo e = makeError(ErrorCategory::Tool, QStringLiteral("boom"), QStringLiteral("detail"));
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"));
@@ -42,7 +43,8 @@ TEST(ErrorInfoTest, CategorizesForbiddenAsAuth)
TEST(ErrorInfoTest, CategorizesApiKeyAsAuth)
{
EXPECT_EQ(categorizeProviderError(QStringLiteral("invalid api key supplied")), ErrorCategory::Auth);
EXPECT_EQ(
categorizeProviderError(QStringLiteral("invalid api key supplied")), ErrorCategory::Auth);
}
TEST(ErrorInfoTest, CategorizesAuthenticationAsAuth)

View File

@@ -25,8 +25,8 @@ AgentConfig makeConfig(const QJsonObject &body)
return cfg;
}
const QString kUserMessages
= QStringLiteral("[ { \"role\": \"user\", \"content\": {{ tojson(ctx.prefix) }} } ]");
const QString kUserMessages = QStringLiteral(
"[ { \"role\": \"user\", \"content\": {{ tojson(ctx.prefix) }} } ]");
const QString kSystemField = QStringLiteral(
"{% if existsIn(ctx, \"system_prompt\") %}{{ tojson(ctx.system_prompt) }}{% endif %}");
@@ -35,12 +35,13 @@ const QString kSystemField = QStringLiteral(
TEST(JsonPromptTemplateTest, RendersJinjaSplicesAndKeepsLiterals)
{
auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(QJsonObject{
{"max_tokens", 128},
{"temperature", 0.5},
{"stream", true},
{"messages", kUserMessages},
}));
auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(
QJsonObject{
{"max_tokens", 128},
{"temperature", 0.5},
{"stream", true},
{"messages", kUserMessages},
}));
ASSERT_NE(tmpl, nullptr);
ContextData ctx;
@@ -55,16 +56,16 @@ TEST(JsonPromptTemplateTest, RendersJinjaSplicesAndKeepsLiterals)
const QJsonArray messages = request.value("messages").toArray();
ASSERT_EQ(messages.size(), 1);
EXPECT_EQ(
messages.at(0).toObject().value("content").toString(), QStringLiteral("hello world"));
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},
}));
auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(
QJsonObject{
{"system", kSystemField},
{"messages", kUserMessages},
}));
ASSERT_NE(tmpl, nullptr);
ContextData ctx;
@@ -79,10 +80,11 @@ TEST(JsonPromptTemplateTest, DropsKeyWhenJinjaRendersEmpty)
TEST(JsonPromptTemplateTest, RendersSystemPromptWhenPresent)
{
auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(QJsonObject{
{"system", kSystemField},
{"messages", kUserMessages},
}));
auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(
QJsonObject{
{"system", kSystemField},
{"messages", kUserMessages},
}));
ASSERT_NE(tmpl, nullptr);
ContextData ctx;
@@ -92,16 +94,16 @@ TEST(JsonPromptTemplateTest, RendersSystemPromptWhenPresent)
QJsonObject request;
ASSERT_TRUE(tmpl->buildFullRequest(request, ctx));
EXPECT_EQ(
request.value("system").toString(), QStringLiteral("You are a helpful assistant."));
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},
}));
auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(
QJsonObject{
{"thinking", QJsonObject{{"type", "adaptive"}, {"budget", 8192}}},
{"messages", kUserMessages},
}));
ASSERT_NE(tmpl, nullptr);
ContextData ctx;
@@ -119,11 +121,45 @@ TEST(JsonPromptTemplateTest, RejectsBodyThatRendersInvalidJsonAtLoad)
{
QString error;
auto tmpl = JsonPromptTemplate::fromConfig(
makeConfig(QJsonObject{
{"messages", QStringLiteral("[ {{ tojson(ctx.prefix) }}")},
}),
makeConfig(
QJsonObject{
{"messages", QStringLiteral("[ {{ tojson(ctx.prefix) }}")},
}),
&error);
EXPECT_EQ(tmpl, nullptr);
EXPECT_FALSE(error.isEmpty());
}
TEST(JsonPromptTemplateTest, RejectsHandQuotedInterpolationAtLoad)
{
QString error;
auto tmpl = JsonPromptTemplate::fromConfig(
makeConfig(
QJsonObject{
{"messages",
QStringLiteral("[ { \"role\": \"user\", \"content\": \"{{ ctx.prefix }}\" } ]")},
}),
&error);
EXPECT_EQ(tmpl, nullptr);
EXPECT_FALSE(error.isEmpty());
}
TEST(JsonPromptTemplateTest, RoundTripsQuotesBackslashesAndNewlinesViaTojson)
{
auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(
QJsonObject{
{"messages", kUserMessages},
}));
ASSERT_NE(tmpl, nullptr);
ContextData ctx;
ctx.prefix = QStringLiteral("a \"quoted\" back\\slash\nnewline");
QJsonObject request;
ASSERT_TRUE(tmpl->buildFullRequest(request, ctx));
const QJsonArray messages = request.value("messages").toArray();
ASSERT_EQ(messages.size(), 1);
EXPECT_EQ(messages.at(0).toObject().value("content").toString(), *ctx.prefix);
}

View File

@@ -17,9 +17,6 @@ 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;
@@ -92,10 +89,11 @@ TEST(MessageSerializerTest, RedactedThinkingRoundtrip)
TEST(MessageSerializerTest, ImageBase64Roundtrip)
{
Message m(Message::Role::User);
m.appendBlock(std::make_unique<LLMQore::ImageContent>(
QStringLiteral("ZGF0YQ=="),
QStringLiteral("image/png"),
LLMQore::ImageContent::ImageSourceType::Base64));
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();
@@ -109,10 +107,11 @@ TEST(MessageSerializerTest, ImageBase64Roundtrip)
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));
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();
@@ -124,10 +123,9 @@ TEST(MessageSerializerTest, ImageUrlSourceTypeRoundtrip)
TEST(MessageSerializerTest, ToolUseRoundtrip)
{
Message m(Message::Role::Assistant);
m.appendBlock(std::make_unique<LLMQore::ToolUseContent>(
QStringLiteral("tu1"),
QStringLiteral("read_file"),
QJsonObject{{"path", "a.cpp"}}));
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();
@@ -157,8 +155,9 @@ TEST(MessageSerializerTest, ToolResultRoundtrip)
TEST(MessageSerializerTest, StoredImageRoundtrip)
{
Message m(Message::Role::User);
m.appendBlock(std::make_unique<StoredImageContent>(
QStringLiteral("shot.png"), QStringLiteral("stored/shot"), QStringLiteral("image/png")));
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();
@@ -173,8 +172,9 @@ TEST(MessageSerializerTest, StoredImageRoundtrip)
TEST(MessageSerializerTest, StoredAttachmentRoundtrip)
{
Message m(Message::Role::User);
m.appendBlock(std::make_unique<StoredAttachmentContent>(
QStringLiteral("notes.txt"), QStringLiteral("stored/notes")));
m.appendBlock(
std::make_unique<StoredAttachmentContent>(
QStringLiteral("notes.txt"), QStringLiteral("stored/notes")));
const QJsonObject block
= MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
@@ -188,8 +188,9 @@ TEST(MessageSerializerTest, StoredAttachmentRoundtrip)
TEST(MessageSerializerTest, SkillInvocationRoundtrip)
{
Message m(Message::Role::User);
m.appendBlock(std::make_unique<SkillInvocationContent>(
QStringLiteral("review"), QStringLiteral("Review the code.")));
m.appendBlock(
std::make_unique<SkillInvocationContent>(
QStringLiteral("review"), QStringLiteral("Review the code.")));
const QJsonObject block
= MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
@@ -203,13 +204,14 @@ TEST(MessageSerializerTest, SkillInvocationRoundtrip)
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")));
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();
@@ -227,11 +229,12 @@ TEST(MessageSerializerTest, FileEditRoundtripWithStatusAndMessage)
TEST(MessageSerializerTest, FileEditOmitsEmptyStatusMessageAndDefaultsToPending)
{
Message m(Message::Role::Assistant);
m.appendBlock(std::make_unique<FileEditContent>(
QStringLiteral("e1"),
QStringLiteral("a.cpp"),
QStringLiteral("old"),
QStringLiteral("new")));
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();
@@ -243,8 +246,9 @@ 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()));
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);
@@ -295,8 +299,7 @@ TEST(MessageSerializerTest, UnknownBlocksSkippedButKnownKept)
QJsonObject json;
json["role"] = QStringLiteral("assistant");
json["blocks"] = QJsonArray{
QJsonObject{{"type", "future_block"}},
QJsonObject{{"type", "text"}, {"text", "kept"}}};
QJsonObject{{"type", "future_block"}}, QJsonObject{{"type", "text"}, {"text", "kept"}}};
bool ok = false;
const Message m = MessageSerializer::fromJson(json, &ok);

View File

@@ -0,0 +1,139 @@
// 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 <QFile>
#include <QTemporaryDir>
#include <AgentLoader.hpp>
#include <PipelinesConfig.hpp>
using QodeAssist::Agents::AgentLoader;
using QodeAssist::Settings::PipelineRosters;
using QodeAssist::Settings::PipelinesConfig;
using QodeAssist::Settings::PipelinesLoadStatus;
namespace {
class PipelinesConfigTest : public ::testing::Test
{
protected:
void SetUp() override
{
ASSERT_TRUE(m_dir.isValid());
m_path = m_dir.filePath(QStringLiteral("pipelines.toml"));
PipelinesConfig::setFilePathForTests(m_path);
}
void TearDown() override { PipelinesConfig::setFilePathForTests(QString()); }
QTemporaryDir m_dir;
QString m_path;
};
void expectEqualRosters(const PipelineRosters &a, const PipelineRosters &b)
{
EXPECT_EQ(a.codeCompletion, b.codeCompletion);
EXPECT_EQ(a.chatAssistant, b.chatAssistant);
EXPECT_EQ(a.chatCompression, b.chatCompression);
EXPECT_EQ(a.quickRefactor, b.quickRefactor);
}
} // namespace
TEST_F(PipelinesConfigTest, DefaultsFillEverySlot)
{
const PipelineRosters defs = PipelineRosters::defaults();
EXPECT_FALSE(defs.codeCompletion.isEmpty());
EXPECT_FALSE(defs.chatAssistant.isEmpty());
EXPECT_FALSE(defs.chatCompression.isEmpty());
EXPECT_FALSE(defs.quickRefactor.isEmpty());
}
TEST_F(PipelinesConfigTest, DefaultsReferenceBundledAgents)
{
Q_INIT_RESOURCE(agents);
const AgentLoader::LoadResult result = AgentLoader::load(QStringLiteral(":/agents"), QString());
ASSERT_FALSE(result.configs.empty());
QStringList bundledNames;
for (const auto &cfg : result.configs)
bundledNames.append(cfg.name);
const PipelineRosters defs = PipelineRosters::defaults();
QStringList referenced = defs.codeCompletion + defs.chatAssistant;
referenced << defs.chatCompression << defs.quickRefactor;
for (const QString &name : std::as_const(referenced)) {
EXPECT_TRUE(bundledNames.contains(name))
<< "default roster references unknown agent '" << name.toStdString() << "'";
}
}
TEST_F(PipelinesConfigTest, MissingFileYieldsDefaults)
{
const auto result = PipelinesConfig::load();
EXPECT_EQ(result.status, PipelinesLoadStatus::FileMissing);
expectEqualRosters(result.rosters, PipelineRosters::defaults());
}
TEST_F(PipelinesConfigTest, SaveLoadRoundTrip)
{
PipelineRosters rosters;
rosters.codeCompletion = {QStringLiteral("Agent A"), QStringLiteral("Agent B")};
rosters.chatAssistant = {QStringLiteral("Agent C")};
rosters.chatCompression = QStringLiteral("Agent D");
rosters.quickRefactor = QString();
ASSERT_TRUE(PipelinesConfig::save(rosters));
const auto result = PipelinesConfig::load();
EXPECT_EQ(result.status, PipelinesLoadStatus::Ok);
expectEqualRosters(result.rosters, rosters);
}
TEST_F(PipelinesConfigTest, ExplicitlyEmptySlotStaysEmpty)
{
PipelineRosters rosters;
ASSERT_TRUE(PipelinesConfig::save(rosters));
const auto result = PipelinesConfig::load();
EXPECT_EQ(result.status, PipelinesLoadStatus::Ok);
EXPECT_TRUE(result.rosters.codeCompletion.isEmpty());
EXPECT_TRUE(result.rosters.chatAssistant.isEmpty());
EXPECT_TRUE(result.rosters.chatCompression.isEmpty());
EXPECT_TRUE(result.rosters.quickRefactor.isEmpty());
}
TEST_F(PipelinesConfigTest, LegacyArrayCollapsesToFirstName)
{
QFile f(m_path);
ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text));
f.write(
"[pipelines]\n"
"code_completion = [\"CC\"]\n"
"chat_assistant = [\"CA\"]\n"
"chat_compression = [\"First\", \"Second\"]\n"
"quick_refactor = [\"Only\"]\n");
f.close();
const auto result = PipelinesConfig::load();
EXPECT_EQ(result.status, PipelinesLoadStatus::Ok);
EXPECT_EQ(result.rosters.chatCompression, QStringLiteral("First"));
EXPECT_EQ(result.rosters.quickRefactor, QStringLiteral("Only"));
}
TEST_F(PipelinesConfigTest, SaveInvalidatesCache)
{
PipelineRosters first;
first.chatCompression = QStringLiteral("Before");
ASSERT_TRUE(PipelinesConfig::save(first));
EXPECT_EQ(PipelinesConfig::loadCached().rosters.chatCompression, QStringLiteral("Before"));
PipelineRosters second;
second.chatCompression = QStringLiteral("AfterX");
ASSERT_TRUE(PipelinesConfig::save(second));
EXPECT_EQ(PipelinesConfig::loadCached().rosters.chatCompression, QStringLiteral("AfterX"));
}

View File

@@ -57,8 +57,36 @@ TEST(ResponseCleanerTest, TrimsLeadingAndTrailingNewlines)
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"));
}
TEST(ResponseCleanerTest, FencedPythonDefLineIsPreserved)
{
const QString input = QStringLiteral("```python\ndef foo():\n return 1\n```");
EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("def foo():\n return 1"));
}
TEST(ResponseCleanerTest, FencedAccessSpecifierLineIsPreserved)
{
const QString input = QStringLiteral("```cpp\npublic:\n void foo();\n```");
EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("public:\n void foo();"));
}
TEST(ResponseCleanerTest, UnfencedCodeStartingWithColonLineIsPreserved)
{
const QString input = QStringLiteral("def foo():\n return 1");
EXPECT_EQ(ResponseCleaner::clean(input), input);
}
TEST(ResponseCleanerTest, UnfencedCaseLabelIsPreserved)
{
const QString input = QStringLiteral("case 1:\n break;");
EXPECT_EQ(ResponseCleaner::clean(input), input);
}
TEST(ResponseCleanerTest, UnfencedProseHeaderIsStripped)
{
const QString input = QStringLiteral("Refactored version:\nint x = 1;");
EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("int x = 1;"));
}

View File

@@ -52,8 +52,7 @@ public:
void fireFailed(const QString &id, const QString &error) { emit requestFailed(id, error); }
protected:
LLMQore::RequestID sendMessage(
const QJsonObject &, const QString &, LLMQore::RequestMode) override
LLMQore::RequestID sendMessage(const QJsonObject &, const QString &, LLMQore::RequestMode) override
{
return {};
}
@@ -157,3 +156,91 @@ TEST(ResponseRouterTest, IgnoresEventsForInactiveRequest)
EXPECT_TRUE(history.isEmpty());
}
TEST(ResponseRouterTest, DistinctThinkingBlocksStayDistinctWithOwnSignatures)
{
FakeClient client;
ConversationHistory history;
ResponseRouter router(&client, &history);
const QString id = QStringLiteral("req-4");
router.beginRequest(id);
client.fireThinking(id, QStringLiteral("first"), QStringLiteral("sig1"));
client.fireThinking(id, QStringLiteral("second"), QStringLiteral("sig2"));
ASSERT_EQ(history.size(), 1);
const auto &blocks = history.messages()[0].blocks();
ASSERT_EQ(blocks.size(), 2u);
const auto *first = dynamic_cast<const LLMQore::ThinkingContent *>(blocks[0].get());
const auto *second = dynamic_cast<const LLMQore::ThinkingContent *>(blocks[1].get());
ASSERT_NE(first, nullptr);
ASSERT_NE(second, nullptr);
EXPECT_EQ(first->thinking(), QStringLiteral("first"));
EXPECT_EQ(first->signature(), QStringLiteral("sig1"));
EXPECT_EQ(second->thinking(), QStringLiteral("second"));
EXPECT_EQ(second->signature(), QStringLiteral("sig2"));
}
TEST(ResponseRouterTest, SignatureOnlyEmissionBecomesRedactedThinking)
{
FakeClient client;
ConversationHistory history;
ResponseRouter router(&client, &history);
const QString id = QStringLiteral("req-5");
router.beginRequest(id);
client.fireThinking(id, QStringLiteral("visible"), QStringLiteral("sig1"));
client.fireThinking(id, QString(), QStringLiteral("redacted-payload"));
ASSERT_EQ(history.size(), 1);
const auto &blocks = history.messages()[0].blocks();
ASSERT_EQ(blocks.size(), 2u);
const auto *visible = dynamic_cast<const LLMQore::ThinkingContent *>(blocks[0].get());
const auto *redacted = dynamic_cast<const LLMQore::RedactedThinkingContent *>(blocks[1].get());
ASSERT_NE(visible, nullptr);
ASSERT_NE(redacted, nullptr);
EXPECT_EQ(visible->signature(), QStringLiteral("sig1"));
EXPECT_EQ(redacted->signature(), QStringLiteral("redacted-payload"));
}
TEST(ResponseRouterTest, TextAfterToolUseStaysAfterToolUse)
{
FakeClient client;
ConversationHistory history;
ResponseRouter router(&client, &history);
const QString id = QStringLiteral("req-6");
router.beginRequest(id);
client.fireChunk(id, QStringLiteral("before"));
client.fireToolStarted(id, QStringLiteral("t1"), QStringLiteral("tool"), QJsonObject{});
client.fireChunk(id, QStringLiteral("after"));
ASSERT_EQ(history.size(), 1);
const auto &blocks = history.messages()[0].blocks();
ASSERT_EQ(blocks.size(), 3u);
EXPECT_NE(dynamic_cast<const LLMQore::TextContent *>(blocks[0].get()), nullptr);
EXPECT_NE(dynamic_cast<const LLMQore::ToolUseContent *>(blocks[1].get()), nullptr);
const auto *after = dynamic_cast<const LLMQore::TextContent *>(blocks[2].get());
ASSERT_NE(after, nullptr);
EXPECT_EQ(after->text(), QStringLiteral("after"));
}
TEST(ResponseRouterTest, BatchedToolResultsMergeIntoOneUserMessage)
{
FakeClient client;
ConversationHistory history;
ResponseRouter router(&client, &history);
const QString id = QStringLiteral("req-7");
router.beginRequest(id);
client.fireChunk(id, QStringLiteral("calling tools"));
client.fireToolResult(id, QStringLiteral("t1"), QStringLiteral("tool_a"), QStringLiteral("r1"));
client.fireToolResult(id, QStringLiteral("t2"), QStringLiteral("tool_b"), QStringLiteral("r2"));
ASSERT_EQ(history.size(), 2);
const Message &results = history.messages()[1];
EXPECT_EQ(results.role(), Message::Role::User);
EXPECT_EQ(results.blocks().size(), 2u);
}

View File

@@ -49,8 +49,10 @@ TEST(SystemPromptBuilderTest, EqualPriorityKeepsInsertionOrder)
TEST(SystemPromptBuilderTest, AgentPriorityComposesBeforeDefault)
{
SystemPromptBuilder builder;
builder.setLayer(QStringLiteral("env"), QStringLiteral("ENV"), SystemPromptBuilder::kDefaultPriority);
builder.setLayer(QStringLiteral("agent"), QStringLiteral("SYS"), SystemPromptBuilder::kAgentPriority);
builder.setLayer(
QStringLiteral("env"), QStringLiteral("ENV"), SystemPromptBuilder::kDefaultPriority);
builder.setLayer(
QStringLiteral("agent"), QStringLiteral("SYS"), SystemPromptBuilder::kAgentPriority);
EXPECT_EQ(builder.compose(), QStringLiteral("SYS\n\nENV"));
}