Files
QodeAssist/tests/QodeAssistTest.cpp
Petr Mironychev af85efb554 refactor: Decoupling chat architecture (#367)
* refactor: Migrate tests to Qt Creator plugin framework and update llmqore transports

* refactor: Move conversation history into IDE-free session library

* refactor: Extract turn context assembly behind injected ports

* build: Add RunQodeAssistTests target for the plugin test suite

* refactor: Route the chat through a Session and ChatBackend seam
2026-07-19 22:45:04 +02:00

1747 lines
63 KiB
C++

// Copyright (C) 2026 Petr Mironychev
// Copyright (C) 2025 Povilas Kanapickas <povilas@radix.lt>
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
#include "QodeAssistTest.hpp"
#include "ChatView/ChatHistoryBridge.hpp"
#include "ChatView/ChatModel.hpp"
#include "completion/CodeHandler.hpp"
#include "completion/LLMSuggestion.hpp"
#include "llmcore/ContextData.hpp"
#include "providers/ClaudeCacheControl.hpp"
#include "session/HistoryProjection.hpp"
#include "session/HistorySerializer.hpp"
#include "session/Session.hpp"
#include "session/TurnContextBuilder.hpp"
#include <QJsonArray>
#include <QTest>
#include <QTextDocument>
namespace QodeAssist {
namespace {
constexpr char kTestMimeType[] = "text/python";
constexpr char kTestFilePath[] = "/path/to/file";
constexpr char kTestFileContext[]
= "\n Language: (MIME: text/python) filepath: /path/to/file()\n\n";
Session::Message userTurn()
{
Session::Message message;
message.role = Session::MessageRole::User;
message.id = "u1";
message.blocks
= {Session::TextBlock{"explain this"},
Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"},
Session::ImageBlock{"shot.png", "shot_cd34.png", "image/png"}};
return message;
}
Session::Message assistantTurn()
{
Session::Message message;
message.role = Session::MessageRole::Assistant;
message.id = "r1";
message.blocks
= {Session::ThinkingBlock{"weighing options", "sig-1", false},
Session::ToolCallBlock{
"t1", "read_file", QJsonObject{{"path", "main.cpp"}}, "int main() {}"},
Session::TextBlock{"here is the answer"}};
message.usage = Session::Usage{120, 40, 80, 12};
return message;
}
Session::ConversationHistory sampleHistory()
{
Session::Message assistant = assistantTurn();
assistant.blocks.append(
Session::FileEditBlock{"e1", "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\"}"});
Session::ConversationHistory history;
history.append(userTurn());
history.append(assistant);
return history;
}
Session::ConversationHistory historyWithoutFileEdits()
{
Session::ConversationHistory history;
history.append(userTurn());
history.append(assistantTurn());
return history;
}
class FakeChatBackend : public Session::ChatBackend
{
public:
void sendTurn(const Session::TurnRequest &request) override
{
++m_turnCount;
m_lastOptions = request.options;
m_lastUserBlocks = request.userBlocks;
m_historySizeAtSend = request.history ? request.history->size() : -1;
m_lastContext = request.context;
}
void cancel() override { ++m_cancelCount; }
void script(const QList<Session::SessionEvent> &events)
{
for (const Session::SessionEvent &scripted : events)
emit sessionEvent(scripted);
}
int turnCount() const { return m_turnCount; }
int cancelCount() const { return m_cancelCount; }
qsizetype historySizeAtSend() const { return m_historySizeAtSend; }
const QList<Session::ContentBlock> &lastUserBlocks() const { return m_lastUserBlocks; }
const std::optional<Session::TurnContext> &lastContext() const { return m_lastContext; }
Session::TurnOptions lastOptions() const { return m_lastOptions; }
private:
int m_turnCount = 0;
int m_cancelCount = 0;
qsizetype m_historySizeAtSend = -1;
QList<Session::ContentBlock> m_lastUserBlocks;
std::optional<Session::TurnContext> m_lastContext;
Session::TurnOptions m_lastOptions;
};
QList<Session::SessionEvent> scriptedTurn(const QString &turnId)
{
return {
Session::TurnStarted{turnId},
Session::ThinkingReceived{turnId, "weighing options", "sig-1", false},
Session::TextDelta{turnId, "let me "},
Session::TextDelta{turnId, "check"},
Session::ToolCallStarted{turnId, "t1", "read_file", QJsonObject{{"path", "main.cpp"}}, false},
Session::ToolCallCompleted{turnId, "t1", "read_file", "int main() {}"},
Session::TextDelta{turnId, "here is the answer"},
Session::UsageReported{turnId, Session::Usage{120, 40, 80, 12}},
Session::TurnCompleted{turnId}};
}
class FakeProjectContext : public Session::IProjectContextPort
{
public:
FakeProjectContext(Session::ProjectInfo info, QString rules)
: m_info(std::move(info))
, m_rules(std::move(rules))
{}
Session::ProjectInfo projectInfo() const override { return m_info; }
QString projectRules() const override { return m_rules; }
private:
Session::ProjectInfo m_info;
QString m_rules;
};
class FakeSkillsContext : public Session::ISkillsContextPort
{
public:
FakeSkillsContext(QString alwaysOn, QString catalog, QList<Session::InvokedSkill> skills)
: m_alwaysOn(std::move(alwaysOn))
, m_catalog(std::move(catalog))
, m_skills(std::move(skills))
{}
QString alwaysOnBodies() const override { return m_alwaysOn; }
QString catalogText() const override { return m_catalog; }
std::optional<Session::InvokedSkill> findSkill(const QString &name) const override
{
for (const Session::InvokedSkill &skill : m_skills) {
if (skill.name == name)
return skill;
}
return std::nullopt;
}
private:
QString m_alwaysOn;
QString m_catalog;
QList<Session::InvokedSkill> m_skills;
};
class FakeLinkedFiles : public Session::ILinkedFilesPort
{
public:
explicit FakeLinkedFiles(QList<Session::LinkedFile> files)
: m_files(std::move(files))
{}
QList<Session::LinkedFile> readFiles(const QList<QString> &paths) const override
{
m_requestedPaths = paths;
return m_files;
}
QList<QString> requestedPaths() const { return m_requestedPaths; }
private:
QList<Session::LinkedFile> m_files;
mutable QList<QString> m_requestedPaths;
};
Session::ProjectInfo sampleProject()
{
Session::ProjectInfo info;
info.available = true;
info.displayName = "QodeAssist";
info.sourceRoot = "/src/QodeAssist";
info.buildDirectory = "/src/QodeAssist/build";
return info;
}
} // namespace
Context::DocumentContextReader QodeAssistTest::createReader(const QString &text)
{
auto *document = new QTextDocument(this);
document->setPlainText(text);
return Context::DocumentContextReader(document, kTestMimeType, kTestFilePath);
}
QSharedPointer<Settings::CodeCompletionSettings> QodeAssistTest::createSettingsForWholeFile()
{
auto settings = QSharedPointer<Settings::CodeCompletionSettings>::create();
settings->readFullFile.setValue(true, Utils::BaseAspect::BeQuiet);
settings->useProjectChangesCache.setValue(false, Utils::BaseAspect::BeQuiet);
return settings;
}
QSharedPointer<Settings::CodeCompletionSettings> QodeAssistTest::createSettingsForLines(
int linesBefore, int linesAfter)
{
auto settings = QSharedPointer<Settings::CodeCompletionSettings>::create();
settings->readFullFile.setValue(false, Utils::BaseAspect::BeQuiet);
settings->readStringsBeforeCursor.setValue(linesBefore, Utils::BaseAspect::BeQuiet);
settings->readStringsAfterCursor.setValue(linesAfter, Utils::BaseAspect::BeQuiet);
settings->useProjectChangesCache.setValue(false, Utils::BaseAspect::BeQuiet);
return settings;
}
QJsonObject QodeAssistTest::expectedEphemeral(bool extendedTtl)
{
QJsonObject cacheControl{{"type", "ephemeral"}};
if (extendedTtl)
cacheControl["ttl"] = "1h";
return cacheControl;
}
void QodeAssistTest::testProcessTextEmpty()
{
QCOMPARE(CodeHandler::processText("", "/file.py"), QString("\n\n"));
}
void QodeAssistTest::testProcessTextCommentsAroundCodeBlock()
{
const QString input = "This is a comment\n"
"```python\nprint('Hello, world!')\n```\n"
"Another comment";
QCOMPARE(
CodeHandler::processText(input, "/file.py"),
QString("# This is a comment\n\nprint('Hello, world!')\n# Another comment\n\n"));
}
void QodeAssistTest::testProcessTextWithPlainCodeBlockNoNewline()
{
const QString input = "This is a comment\n"
"```print('Hello, world!')\n```\n"
"Another comment";
QCOMPARE(
CodeHandler::processText(input, "/file.py"),
QString("# This is a comment\n\nprint('Hello, world!')\n# Another comment\n\n"));
}
void QodeAssistTest::testProcessTextWithPlainCodeBlockWithNewline()
{
const QString input = "This is a comment\n"
"```\nprint('Hello, world!')\n```\n"
"Another comment";
QCOMPARE(
CodeHandler::processText(input, "/file.py"),
QString("# This is a comment\n\n\nprint('Hello, world!')\n# Another comment\n\n"));
}
void QodeAssistTest::testProcessTextNoCommentsWithLanguageCodeBlock()
{
const QString input = "```python\nprint('Hello, world!')\n```";
QCOMPARE(CodeHandler::processText(input, "/file.py"), QString("print('Hello, world!')\n"));
}
void QodeAssistTest::testProcessTextNoCommentsWithPlainCodeBlockNoNewline()
{
const QString input = "```print('Hello, world!')\n```";
QCOMPARE(CodeHandler::processText(input, "/file.py"), QString("print('Hello, world!')\n"));
}
void QodeAssistTest::testProcessTextNoCommentsWithPlainCodeBlockWithNewline()
{
const QString input = "```\nprint('Hello, world!')\n```";
QCOMPARE(CodeHandler::processText(input, "/file.py"), QString("\nprint('Hello, world!')\n"));
}
void QodeAssistTest::testProcessTextWithMultipleCodeBlocksDifferentLanguages()
{
const QString input = "First comment\n```python\nprint('Block 1')\n"
"```\nMiddle comment\n"
"```cpp\ncout << \"Block 2\";\n```\n"
"Last comment";
QCOMPARE(
CodeHandler::processText(input, "/file.py"),
QString(
"# First comment\n\n"
"print('Block 1')\n"
"// Middle comment\n\n"
"cout << \"Block 2\";\n"
"// Last comment\n\n"));
}
void QodeAssistTest::testProcessTextWithMultipleCodeBlocksSameLanguage()
{
const QString input = "First comment\n```python\nprint('Block 1')\n"
"```\nMiddle comment\n"
"```python\nprint('Block 2')\n```\n"
"Last comment";
QCOMPARE(
CodeHandler::processText(input, "/file.py"),
QString(
"# First comment\n\n"
"print('Block 1')\n"
"# Middle comment\n\n"
"print('Block 2')\n"
"# Last comment\n\n"));
}
void QodeAssistTest::testProcessTextWithMultiplePlainCodeBlocksWithNewline()
{
const QString input = "First comment\n```\nprint('Block 1')\n"
"```\nMiddle comment\n"
"```\ncout << \"Block 2\";\n```\n"
"Last comment";
QCOMPARE(
CodeHandler::processText(input, "/file.py"),
QString(
"# First comment\n\n\n"
"print('Block 1')\n"
"# Middle comment\n\n\n"
"cout << \"Block 2\";\n"
"# Last comment\n\n"));
}
void QodeAssistTest::testProcessTextWithMultiplePlainCodeBlocksWithoutNewline()
{
const QString input = "First comment\n```print('Block 1')\n"
"```\nMiddle comment\n"
"```cout << \"Block 2\";\n```\n"
"Last comment";
QCOMPARE(
CodeHandler::processText(input, "/file.py"),
QString(
"# First comment\n\n"
"print('Block 1')\n"
"# Middle comment\n\n"
"cout << \"Block 2\";\n"
"# Last comment\n\n"));
}
void QodeAssistTest::testProcessTextWithEmptyLines()
{
const QString input
= "Comment with empty line\n\n```python\nprint('Hello')\n```\n\nAnother comment";
QCOMPARE(
CodeHandler::processText(input, "/file.py"),
QString(
"# Comment with empty line\n\n\n"
"print('Hello')\n\n"
"# Another comment\n\n"));
}
void QodeAssistTest::testProcessTextPlainCodeBlockWithNewlineWithEmptyLines()
{
const QString input = "Comment with empty line\n\n```\nprint('Hello')\n```\n\nAnother comment";
QCOMPARE(
CodeHandler::processText(input, "/file.py"),
QString(
"# Comment with empty line\n\n\n\n"
"print('Hello')\n\n"
"# Another comment\n\n"));
}
void QodeAssistTest::testProcessTextWithoutCodeBlock()
{
const QString input = "This is just a comment\nwith multiple lines";
QCOMPARE(
CodeHandler::processText(input, "/file.py"),
QString("# This is just a comment\n# with multiple lines\n\n"));
}
void QodeAssistTest::testDetectLanguageFromLine()
{
QCOMPARE(CodeHandler::detectLanguageFromLine("```python"), QString("python"));
QCOMPARE(CodeHandler::detectLanguageFromLine("```javascript"), QString("js"));
QCOMPARE(CodeHandler::detectLanguageFromLine("```cpp"), QString("c-like"));
QCOMPARE(CodeHandler::detectLanguageFromLine("``` ruby "), QString("ruby"));
QCOMPARE(CodeHandler::detectLanguageFromLine("```"), QString());
QCOMPARE(CodeHandler::detectLanguageFromLine("``` "), QString());
}
void QodeAssistTest::testDetectLanguageFromExtension()
{
QCOMPARE(CodeHandler::detectLanguageFromExtension("py"), QString("python"));
QCOMPARE(CodeHandler::detectLanguageFromExtension("js"), QString("js"));
QCOMPARE(CodeHandler::detectLanguageFromExtension("cpp"), QString("c-like"));
QCOMPARE(CodeHandler::detectLanguageFromExtension("hpp"), QString("c-like"));
QCOMPARE(CodeHandler::detectLanguageFromExtension("rb"), QString("ruby"));
QCOMPARE(CodeHandler::detectLanguageFromExtension("sh"), QString("shell"));
QCOMPARE(CodeHandler::detectLanguageFromExtension("unknown"), QString());
QCOMPARE(CodeHandler::detectLanguageFromExtension(""), QString());
}
void QodeAssistTest::testCommentPrefixForDifferentLanguages()
{
struct Case
{
QString input;
QString expected;
};
const QList<Case> cases{
{"Comment\n```python\ncode\n```", "# Comment\n\ncode\n"},
{"Comment\n```cpp\ncode\n```", "// Comment\n\ncode\n"},
{"Comment\n```ruby\ncode\n```", "# Comment\n\ncode\n"},
{"Comment\n```lua\ncode\n```", "-- Comment\n\ncode\n"}};
for (const auto &testCase : cases)
QCOMPARE(CodeHandler::processText(testCase.input, ""), testCase.expected);
}
void QodeAssistTest::testHasCodeBlocks()
{
QVERIFY(CodeHandler::hasCodeBlocks("text\n```python\ncode\n```"));
QVERIFY(!CodeHandler::hasCodeBlocks("just a plain comment\nwith two lines"));
}
void QodeAssistTest::testReplaceLengthEmptyRight()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("foo", ""), 0);
}
void QodeAssistTest::testReplaceLengthNoOverlap()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("foo", "bar"), 0);
}
void QodeAssistTest::testReplaceLengthSingleCharNoOverlap()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("a", "b"), 0);
}
void QodeAssistTest::testReplaceLengthLcpExtendsRight()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("myVar", "myVariable"), 5);
}
void QodeAssistTest::testReplaceLengthLcpPartialEngineCall()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("engine.load()", "engine.rootContext()"), 7);
}
void QodeAssistTest::testReplaceLengthLcpThenClosingTailExtendsFull()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("colors << \"red\"", "colors{};"), 9);
}
void QodeAssistTest::testReplaceLengthClosingTailReplaceSemicolon()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("x;", ";"), 1);
}
void QodeAssistTest::testReplaceLengthClosingTailReplaceParen()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("arg1, arg2)", ")"), 1);
}
void QodeAssistTest::testReplaceLengthClosingTailReplaceBrackets()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("[0]", "];"), 2);
}
void QodeAssistTest::testReplaceLengthClosingTailReplaceBracesAndSemi()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("= {\"red\"}", "{};"), 3);
}
void QodeAssistTest::testReplaceLengthClosingTailWithLeadingSpace()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength(" = {\"red\"}", "{};"), 3);
}
void QodeAssistTest::testReplaceLengthClosingTailEqualsSemi()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("= 5;", ";"), 1);
}
void QodeAssistTest::testReplaceLengthClosingTailNoMatchingClose()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("\"red\", \"green\"", "};"), 0);
}
void QodeAssistTest::testReplaceLengthRealCodeRightNoLcp()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("myVar", " = 5;"), 0);
}
void QodeAssistTest::testReplaceLengthTrailingWhitespaceOnlyLeftAlone()
{
QCOMPARE(LLMSuggestion::calculateReplaceLength("code", " "), 0);
}
void QodeAssistTest::testCacheControlBreakpointWithoutExtendedTtl()
{
const QJsonObject cacheControl = Providers::ClaudeCacheControl::buildBreakpoint(false);
QCOMPARE(cacheControl.value("type").toString(), QString("ephemeral"));
QVERIFY(!cacheControl.contains("ttl"));
}
void QodeAssistTest::testCacheControlBreakpointWithExtendedTtl()
{
const QJsonObject cacheControl = Providers::ClaudeCacheControl::buildBreakpoint(true);
QCOMPARE(cacheControl.value("type").toString(), QString("ephemeral"));
QCOMPARE(cacheControl.value("ttl").toString(), QString("1h"));
}
void QodeAssistTest::testCacheControlSystemAsStringWrappedIntoArray()
{
QJsonObject request;
request["system"] = "you are a helpful agent";
Providers::ClaudeCacheControl::apply(request, false);
QVERIFY(request.value("system").isArray());
const QJsonArray system = request.value("system").toArray();
QCOMPARE(system.size(), 1);
const QJsonObject block = system.first().toObject();
QCOMPARE(block.value("type").toString(), QString("text"));
QCOMPARE(block.value("text").toString(), QString("you are a helpful agent"));
QCOMPARE(block.value("cache_control").toObject(), expectedEphemeral(false));
}
void QodeAssistTest::testCacheControlEmptySystemStringIsNotWrapped()
{
QJsonObject request;
request["system"] = "";
Providers::ClaudeCacheControl::apply(request, false);
QVERIFY(request.value("system").isString());
}
void QodeAssistTest::testCacheControlSystemAsArrayMarksLastBlock()
{
QJsonObject request;
request["system"] = QJsonArray{
QJsonObject{{"type", "text"}, {"text", "a"}}, QJsonObject{{"type", "text"}, {"text", "b"}}};
Providers::ClaudeCacheControl::apply(request, false);
const QJsonArray system = request.value("system").toArray();
QCOMPARE(system.size(), 2);
QVERIFY(!system[0].toObject().contains("cache_control"));
QCOMPARE(system[1].toObject().value("cache_control").toObject(), expectedEphemeral(false));
}
void QodeAssistTest::testCacheControlToolsLastEntryGetsCacheControl()
{
QJsonObject request;
request["tools"] = QJsonArray{
QJsonObject{{"name", "read_file"}},
QJsonObject{{"name", "edit_file"}},
QJsonObject{{"name", "search"}}};
Providers::ClaudeCacheControl::apply(request, true);
const QJsonArray tools = request.value("tools").toArray();
QCOMPARE(tools.size(), 3);
QVERIFY(!tools[0].toObject().contains("cache_control"));
QVERIFY(!tools[1].toObject().contains("cache_control"));
QCOMPARE(tools[2].toObject().value("cache_control").toObject(), expectedEphemeral(true));
}
void QodeAssistTest::testCacheControlSingleMessageHistorySkipped()
{
QJsonObject request;
request["messages"] = QJsonArray{QJsonObject{{"role", "user"}, {"content", "first message"}}};
Providers::ClaudeCacheControl::apply(request, false);
const QJsonArray messages = request.value("messages").toArray();
QCOMPARE(messages.size(), 1);
QVERIFY(messages[0].toObject().value("content").isString());
}
void QodeAssistTest::testCacheControlHistoryBreakpointOnSecondToLastMessage()
{
QJsonObject request;
request["messages"] = QJsonArray{
QJsonObject{{"role", "user"}, {"content", "u1"}},
QJsonObject{{"role", "assistant"}, {"content", "a1"}},
QJsonObject{{"role", "user"}, {"content", "u2-current"}}};
Providers::ClaudeCacheControl::apply(request, false);
const QJsonArray messages = request.value("messages").toArray();
QCOMPARE(messages.size(), 3);
QVERIFY(messages[0].toObject().value("content").isString());
const QJsonArray assistantContent = messages[1].toObject().value("content").toArray();
QCOMPARE(assistantContent.size(), 1);
QCOMPARE(assistantContent.first().toObject().value("text").toString(), QString("a1"));
QCOMPARE(
assistantContent.first().toObject().value("cache_control").toObject(),
expectedEphemeral(false));
QVERIFY(messages[2].toObject().value("content").isString());
}
void QodeAssistTest::testCacheControlHistoryArrayContentMarksLastBlock()
{
QJsonObject request;
request["messages"] = QJsonArray{
QJsonObject{
{"role", "user"},
{"content",
QJsonArray{
QJsonObject{{"type", "text"}, {"text", "describe this"}},
QJsonObject{{"type", "image"}}}}},
QJsonObject{{"role", "assistant"}, {"content", "ok"}}};
Providers::ClaudeCacheControl::apply(request, false);
const QJsonArray messages = request.value("messages").toArray();
const QJsonArray content = messages[0].toObject().value("content").toArray();
QCOMPARE(content.size(), 2);
QVERIFY(!content[0].toObject().contains("cache_control"));
QCOMPARE(content[1].toObject().value("cache_control").toObject(), expectedEphemeral(false));
}
void QodeAssistTest::testCacheControlNoSystemNoToolsNoMessagesIsNoop()
{
QJsonObject request;
request["model"] = "claude-sonnet-4-5";
request["max_tokens"] = 1024;
Providers::ClaudeCacheControl::apply(request, false);
QCOMPARE(request.value("model").toString(), QString("claude-sonnet-4-5"));
QCOMPARE(request.value("max_tokens").toInt(), 1024);
QVERIFY(!request.contains("system"));
QVERIFY(!request.contains("tools"));
QVERIFY(!request.contains("messages"));
}
void QodeAssistTest::testCacheControlEmptyToolsArrayIsNoop()
{
QJsonObject request;
request["tools"] = QJsonArray{};
Providers::ClaudeCacheControl::apply(request, false);
QVERIFY(request.value("tools").isArray());
QVERIFY(request.value("tools").toArray().isEmpty());
}
void QodeAssistTest::testGetLineText()
{
auto reader = createReader("Line 0\nLine 1\nLine 2");
QCOMPARE(reader.getLineText(0), QString("Line 0"));
QCOMPARE(reader.getLineText(1), QString("Line 1"));
QCOMPARE(reader.getLineText(2), QString("Line 2"));
QCOMPARE(reader.getLineText(0, 4), QString("Line"));
}
void QodeAssistTest::testGetContext()
{
auto reader = createReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4");
QCOMPARE(reader.getContextBefore(0, -1, 2), QString("Line 0"));
QCOMPARE(reader.getContextAfter(0, -1, 2), QString("Line 0\nLine 1"));
QCOMPARE(reader.getContextBefore(1, -1, 2), QString("Line 0\nLine 1"));
QCOMPARE(reader.getContextAfter(1, -1, 2), QString("Line 1\nLine 2"));
QCOMPARE(reader.getContextBefore(2, -1, 2), QString("Line 1\nLine 2"));
QCOMPARE(reader.getContextAfter(2, -1, 2), QString("Line 2\nLine 3"));
QCOMPARE(reader.getContextBefore(3, -1, 2), QString("Line 2\nLine 3"));
QCOMPARE(reader.getContextAfter(3, -1, 2), QString("Line 3\nLine 4"));
QCOMPARE(reader.getContextBefore(0, 1, 2), QString("L"));
QCOMPARE(reader.getContextAfter(0, 1, 2), QString("ine 0\nLine 1"));
QCOMPARE(reader.getContextBefore(1, 1, 2), QString("Line 0\nL"));
QCOMPARE(reader.getContextAfter(1, 1, 2), QString("ine 1\nLine 2"));
QCOMPARE(reader.getContextBefore(2, 1, 2), QString("Line 1\nL"));
QCOMPARE(reader.getContextAfter(2, 1, 2), QString("ine 2\nLine 3"));
QCOMPARE(reader.getContextBefore(3, 1, 2), QString("Line 2\nL"));
QCOMPARE(reader.getContextAfter(3, 1, 2), QString("ine 3\nLine 4"));
}
void QodeAssistTest::testGetContextWithCopyright()
{
auto reader = createReader("/* Copyright (C) 2024 */\nLine 0\nLine 1\nLine 2\nLine 3");
QCOMPARE(reader.getContextBefore(0, -1, 2), QString());
QCOMPARE(reader.getContextAfter(0, -1, 2), QString("Line 0"));
QCOMPARE(reader.getContextBefore(1, -1, 2), QString("Line 0"));
QCOMPARE(reader.getContextAfter(1, -1, 2), QString("Line 0\nLine 1"));
QCOMPARE(reader.getContextBefore(2, -1, 2), QString("Line 0\nLine 1"));
QCOMPARE(reader.getContextAfter(2, -1, 2), QString("Line 1\nLine 2"));
QCOMPARE(reader.getContextBefore(3, -1, 2), QString("Line 1\nLine 2"));
QCOMPARE(reader.getContextAfter(3, -1, 2), QString("Line 2\nLine 3"));
QCOMPARE(reader.getContextBefore(0, 1, 2), QString());
QCOMPARE(reader.getContextAfter(0, 1, 2), QString("Line 0"));
QCOMPARE(reader.getContextBefore(1, 1, 2), QString("L"));
QCOMPARE(reader.getContextAfter(1, 1, 2), QString("ine 0\nLine 1"));
QCOMPARE(reader.getContextBefore(2, 1, 2), QString("Line 0\nL"));
QCOMPARE(reader.getContextAfter(2, 1, 2), QString("ine 1\nLine 2"));
QCOMPARE(reader.getContextBefore(3, 1, 2), QString("Line 1\nL"));
QCOMPARE(reader.getContextAfter(3, 1, 2), QString("ine 2\nLine 3"));
}
void QodeAssistTest::testReadWholeFile()
{
auto reader = createReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4");
QCOMPARE(reader.readWholeFileBefore(0, -1), QString("Line 0"));
QCOMPARE(reader.readWholeFileAfter(0, -1), QString("Line 0\nLine 1\nLine 2\nLine 3\nLine 4"));
QCOMPARE(reader.readWholeFileBefore(1, -1), QString("Line 0\nLine 1"));
QCOMPARE(reader.readWholeFileAfter(1, -1), QString("Line 1\nLine 2\nLine 3\nLine 4"));
QCOMPARE(reader.readWholeFileBefore(2, -1), QString("Line 0\nLine 1\nLine 2"));
QCOMPARE(reader.readWholeFileAfter(2, -1), QString("Line 2\nLine 3\nLine 4"));
QCOMPARE(reader.readWholeFileBefore(3, -1), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileAfter(3, -1), QString("Line 3\nLine 4"));
QCOMPARE(reader.readWholeFileBefore(4, -1), QString("Line 0\nLine 1\nLine 2\nLine 3\nLine 4"));
QCOMPARE(reader.readWholeFileAfter(4, -1), QString("Line 4"));
QCOMPARE(reader.readWholeFileBefore(0, 1), QString("L"));
QCOMPARE(reader.readWholeFileAfter(0, 1), QString("ine 0\nLine 1\nLine 2\nLine 3\nLine 4"));
QCOMPARE(reader.readWholeFileBefore(1, 1), QString("Line 0\nL"));
QCOMPARE(reader.readWholeFileAfter(1, 1), QString("ine 1\nLine 2\nLine 3\nLine 4"));
QCOMPARE(reader.readWholeFileBefore(2, 1), QString("Line 0\nLine 1\nL"));
QCOMPARE(reader.readWholeFileAfter(2, 1), QString("ine 2\nLine 3\nLine 4"));
QCOMPARE(reader.readWholeFileBefore(3, 1), QString("Line 0\nLine 1\nLine 2\nL"));
QCOMPARE(reader.readWholeFileAfter(3, 1), QString("ine 3\nLine 4"));
QCOMPARE(reader.readWholeFileBefore(4, 1), QString("Line 0\nLine 1\nLine 2\nLine 3\nL"));
QCOMPARE(reader.readWholeFileAfter(4, 1), QString("ine 4"));
}
void QodeAssistTest::testReadWholeFileWithCopyright()
{
auto reader = createReader("/* Copyright (C) 2024 */\nLine 0\nLine 1\nLine 2\nLine 3");
QCOMPARE(reader.readWholeFileBefore(0, -1), QString());
QCOMPARE(reader.readWholeFileAfter(0, -1), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(1, -1), QString("Line 0"));
QCOMPARE(reader.readWholeFileAfter(1, -1), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(2, -1), QString("Line 0\nLine 1"));
QCOMPARE(reader.readWholeFileAfter(2, -1), QString("Line 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(3, -1), QString("Line 0\nLine 1\nLine 2"));
QCOMPARE(reader.readWholeFileAfter(3, -1), QString("Line 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(0, 0), QString());
QCOMPARE(reader.readWholeFileAfter(0, 0), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(1, 0), QString());
QCOMPARE(reader.readWholeFileAfter(1, 0), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(2, 0), QString("Line 0\n"));
QCOMPARE(reader.readWholeFileAfter(2, 0), QString("Line 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(3, 0), QString("Line 0\nLine 1\n"));
QCOMPARE(reader.readWholeFileAfter(3, 0), QString("Line 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(0, 1), QString());
QCOMPARE(reader.readWholeFileAfter(0, 1), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(1, 1), QString("L"));
QCOMPARE(reader.readWholeFileAfter(1, 1), QString("ine 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(2, 1), QString("Line 0\nL"));
QCOMPARE(reader.readWholeFileAfter(2, 1), QString("ine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(3, 1), QString("Line 0\nLine 1\nL"));
QCOMPARE(reader.readWholeFileAfter(3, 1), QString("ine 2\nLine 3"));
}
void QodeAssistTest::testReadWholeFileWithMultilineCopyright()
{
auto reader = createReader(
"/*\n * Copyright (C) 2024\n * \n * This file is part of QodeAssist.\n */\n"
"Line 0\nLine 1\nLine 2\nLine 3");
QCOMPARE(reader.readWholeFileBefore(0, -1), QString());
QCOMPARE(reader.readWholeFileAfter(0, -1), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(1, -1), QString());
QCOMPARE(reader.readWholeFileAfter(1, -1), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(2, -1), QString());
QCOMPARE(reader.readWholeFileAfter(2, -1), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(3, -1), QString());
QCOMPARE(reader.readWholeFileAfter(3, -1), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(4, -1), QString());
QCOMPARE(reader.readWholeFileAfter(4, -1), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(5, -1), QString("Line 0"));
QCOMPARE(reader.readWholeFileAfter(5, -1), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(6, -1), QString("Line 0\nLine 1"));
QCOMPARE(reader.readWholeFileAfter(6, -1), QString("Line 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(7, -1), QString("Line 0\nLine 1\nLine 2"));
QCOMPARE(reader.readWholeFileAfter(7, -1), QString("Line 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(8, -1), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileAfter(8, -1), QString("Line 3"));
QCOMPARE(reader.readWholeFileBefore(0, 0), QString());
QCOMPARE(reader.readWholeFileAfter(0, 0), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(1, 0), QString());
QCOMPARE(reader.readWholeFileAfter(1, 0), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(2, 0), QString());
QCOMPARE(reader.readWholeFileAfter(2, 0), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(3, 0), QString());
QCOMPARE(reader.readWholeFileAfter(3, 0), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(4, 0), QString());
QCOMPARE(reader.readWholeFileAfter(4, 0), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(5, 0), QString());
QCOMPARE(reader.readWholeFileAfter(5, 0), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(6, 0), QString("Line 0\n"));
QCOMPARE(reader.readWholeFileAfter(6, 0), QString("Line 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(7, 0), QString("Line 0\nLine 1\n"));
QCOMPARE(reader.readWholeFileAfter(7, 0), QString("Line 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(8, 0), QString("Line 0\nLine 1\nLine 2\n"));
QCOMPARE(reader.readWholeFileAfter(8, 0), QString("Line 3"));
QCOMPARE(reader.readWholeFileBefore(0, 1), QString());
QCOMPARE(reader.readWholeFileAfter(0, 1), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(1, 1), QString());
QCOMPARE(reader.readWholeFileAfter(1, 1), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(2, 1), QString());
QCOMPARE(reader.readWholeFileAfter(2, 1), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(3, 1), QString());
QCOMPARE(reader.readWholeFileAfter(3, 1), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(4, 1), QString());
QCOMPARE(reader.readWholeFileAfter(4, 1), QString("Line 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(5, 1), QString("L"));
QCOMPARE(reader.readWholeFileAfter(5, 1), QString("ine 0\nLine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(6, 1), QString("Line 0\nL"));
QCOMPARE(reader.readWholeFileAfter(6, 1), QString("ine 1\nLine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(7, 1), QString("Line 0\nLine 1\nL"));
QCOMPARE(reader.readWholeFileAfter(7, 1), QString("ine 2\nLine 3"));
QCOMPARE(reader.readWholeFileBefore(8, 1), QString("Line 0\nLine 1\nLine 2\nL"));
QCOMPARE(reader.readWholeFileAfter(8, 1), QString("ine 3"));
}
void QodeAssistTest::testFindCopyrightSingleLine()
{
auto reader = createReader("/* Copyright (C) 2024 */\nCode line 0\nCode line 1");
const auto info = reader.findCopyright();
QVERIFY(info.found);
QCOMPARE(info.startLine, 0);
QCOMPARE(info.endLine, 0);
}
void QodeAssistTest::testFindCopyrightMultiLine()
{
auto reader = createReader(
"/*\n * Copyright (C) 2024\n * \n * This file is part of QodeAssist.\n */\nCode line 0");
const auto info = reader.findCopyright();
QVERIFY(info.found);
QCOMPARE(info.startLine, 0);
QCOMPARE(info.endLine, 4);
}
void QodeAssistTest::testFindCopyrightMultipleBlocks()
{
auto reader = createReader("/* Copyright 2023 */\n\n/* Copyright 2024 */\nCode");
const auto info = reader.findCopyright();
QVERIFY(info.found);
QCOMPARE(info.startLine, 0);
QCOMPARE(info.endLine, 0);
}
void QodeAssistTest::testFindCopyrightNoCopyright()
{
auto reader = createReader("/* Just a comment */\nCode line 0");
const auto info = reader.findCopyright();
QVERIFY(!info.found);
QCOMPARE(info.startLine, -1);
QCOMPARE(info.endLine, -1);
}
void QodeAssistTest::testGetContextBetween()
{
auto reader = createReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4");
QCOMPARE(reader.getContextBetween(2, -1, 0, -1), QString());
QCOMPARE(reader.getContextBetween(0, -1, 0, -1), QString("Line 0"));
QCOMPARE(reader.getContextBetween(1, -1, 1, -1), QString("Line 1"));
QCOMPARE(reader.getContextBetween(1, 3, 1, 1), QString());
QCOMPARE(reader.getContextBetween(1, 3, 1, 3), QString());
QCOMPARE(reader.getContextBetween(1, 3, 1, 4), QString("e"));
QCOMPARE(reader.getContextBetween(1, -1, 3, -1), QString("Line 1\nLine 2\nLine 3"));
QCOMPARE(reader.getContextBetween(1, 2, 3, -1), QString("ne 1\nLine 2\nLine 3"));
QCOMPARE(reader.getContextBetween(1, -1, 3, 2), QString("Line 1\nLine 2\nLi"));
QCOMPARE(reader.getContextBetween(1, 2, 3, 2), QString("ne 1\nLine 2\nLi"));
}
void QodeAssistTest::testPrepareContext()
{
auto reader = createReader("Line 0\nLine 1\nLine 2\nLine 3\nLine 4");
QCOMPARE(
reader.prepareContext(2, 3, *createSettingsForWholeFile()),
(LLMCore::ContextData{
.prefix = "Line 0\nLine 1\nLin",
.suffix = "e 2\nLine 3\nLine 4",
.fileContext = kTestFileContext}));
QCOMPARE(
reader.prepareContext(2, 3, *createSettingsForLines(1, 1)),
(LLMCore::ContextData{
.prefix = "Line 1\nLin", .suffix = "e 2\nLine 3", .fileContext = kTestFileContext}));
QCOMPARE(
reader.prepareContext(2, 3, *createSettingsForLines(2, 2)),
(LLMCore::ContextData{
.prefix = "Line 0\nLine 1\nLin",
.suffix = "e 2\nLine 3\nLine 4",
.fileContext = kTestFileContext}));
}
void QodeAssistTest::testHistorySnapshotUsesCurrentVersion()
{
const QJsonObject root = Session::HistorySerializer::toJson(sampleHistory());
QCOMPARE(root.value("version").toString(), Session::HistorySerializer::currentVersion());
QCOMPARE(root.value("messages").toArray().size(), 2);
}
void QodeAssistTest::testHistorySnapshotRoundTrip()
{
const Session::ConversationHistory history = sampleHistory();
const auto restored = Session::HistorySerializer::fromJson(
Session::HistorySerializer::toJson(history));
QVERIFY(restored.has_value());
QCOMPARE(restored->size(), history.size());
QCOMPARE(restored->at(1).blocks.size(), history.at(1).blocks.size());
QCOMPARE(*restored, history);
}
void QodeAssistTest::testUnsupportedChatVersionIsRejected()
{
QJsonObject root;
root["version"] = "9.9";
root["messages"] = QJsonArray{};
QVERIFY(!Session::HistorySerializer::fromJson(root).has_value());
QVERIFY(!Session::HistorySerializer::isSupportedVersion("9.9"));
QVERIFY(Session::HistorySerializer::isSupportedVersion("0.1"));
QVERIFY(Session::HistorySerializer::isSupportedVersion("0.2"));
}
void QodeAssistTest::testChatFileWithoutMessagesArrayIsRejected()
{
QJsonObject missing;
missing["version"] = Session::HistorySerializer::currentVersion();
QVERIFY(!Session::HistorySerializer::fromJson(missing).has_value());
QJsonObject wrongType;
wrongType["version"] = Session::HistorySerializer::currentVersion();
wrongType["messages"] = "not an array";
QVERIFY(!Session::HistorySerializer::fromJson(wrongType).has_value());
QJsonObject empty;
empty["version"] = Session::HistorySerializer::currentVersion();
empty["messages"] = QJsonArray{};
const auto history = Session::HistorySerializer::fromJson(empty);
QVERIFY(history.has_value());
QCOMPARE(history->size(), 0);
}
void QodeAssistTest::testLegacyChatFileConvertsToHistory()
{
QJsonObject root;
root["version"] = "0.2";
root["messages"] = QJsonArray{
QJsonObject{{"role", 1}, {"content", "explain this"}, {"id", "u1"}},
QJsonObject{
{"role", 5},
{"content", "weighing options\n[Signature: sig-1...]"},
{"id", "r1"},
{"signature", "sig-1"},
{"usage", QJsonObject{{"promptTokens", 120}, {"completionTokens", 40}}}},
QJsonObject{
{"role", 3},
{"content", "read_file\nint main() {}"},
{"id", "t1"},
{"toolName", "read_file"},
{"toolResult", "int main() {}"}},
QJsonObject{{"role", 2}, {"content", "here is the answer"}, {"id", "r1"}}};
const auto history = Session::HistorySerializer::fromJson(root);
QVERIFY(history.has_value());
QCOMPARE(history->size(), 2);
const Session::Message &user = history->at(0);
QCOMPARE(user.role, Session::MessageRole::User);
QCOMPARE(user.blocks.size(), 1);
QCOMPARE(std::get<Session::TextBlock>(user.blocks.at(0)), (Session::TextBlock{"explain this"}));
const Session::Message &assistant = history->at(1);
QCOMPARE(assistant.role, Session::MessageRole::Assistant);
QCOMPARE(assistant.id, QString("r1"));
QCOMPARE(assistant.blocks.size(), 3);
QCOMPARE(
std::get<Session::ThinkingBlock>(assistant.blocks.at(0)),
(Session::ThinkingBlock{"weighing options", "sig-1", false}));
QCOMPARE(
std::get<Session::ToolCallBlock>(assistant.blocks.at(1)),
(Session::ToolCallBlock{"t1", "read_file", QJsonObject{}, "int main() {}"}));
QCOMPARE(
std::get<Session::TextBlock>(assistant.blocks.at(2)),
(Session::TextBlock{"here is the answer"}));
QCOMPARE(assistant.usage.promptTokens, 120);
QCOMPARE(assistant.usage.completionTokens, 40);
}
void QodeAssistTest::testLegacyToolRowWithoutMetadataKeepsItsText()
{
QJsonObject root;
root["version"] = "0.2";
root["messages"] = QJsonArray{
QJsonObject{{"role", 1}, {"content", "list the files"}, {"id", "u1"}},
QJsonObject{{"role", 3}, {"content", "main.cpp\nmain.qml"}, {"id", "t1"}}};
const auto history = Session::HistorySerializer::fromJson(root);
QVERIFY(history.has_value());
const QList<Session::MessageRow> rows = Session::projectToRows(*history);
QCOMPARE(rows.size(), 2);
QCOMPARE(rows.at(1).kind, Session::RowKind::Tool);
QCOMPARE(rows.at(1).id, QString("t1"));
QCOMPARE(rows.at(1).content, QString("main.cpp\nmain.qml"));
}
void QodeAssistTest::testChatRowsRoundTripThroughHistory()
{
Session::MessageRow user;
user.kind = Session::RowKind::User;
user.id = "u1";
user.content = "explain this";
user.attachments = {Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}};
user.images = {Session::ImageBlock{"shot.png", "shot_cd34.png", "image/png"}};
Session::MessageRow thinking;
thinking.kind = Session::RowKind::Thinking;
thinking.id = "r1";
thinking.content = "weighing options\n[Signature: sig-1...]";
thinking.signature = "sig-1";
thinking.usage = Session::Usage{120, 40, 80, 12};
Session::MessageRow tool;
tool.kind = Session::RowKind::Tool;
tool.id = "t1";
tool.content = "read_file\nint main() {}";
tool.toolName = "read_file";
tool.toolArguments = QJsonObject{{"path", "main.cpp"}};
tool.toolResult = "int main() {}";
Session::MessageRow assistant;
assistant.kind = Session::RowKind::Assistant;
assistant.id = "r1";
assistant.content = "here is the answer";
Session::MessageRow fileEdit;
fileEdit.kind = Session::RowKind::FileEdit;
fileEdit.id = "e1";
fileEdit.content = "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\"}";
const QList<Session::MessageRow> rows{user, thinking, tool, assistant, fileEdit};
const Session::ConversationHistory history = Session::buildFromRows(rows);
QCOMPARE(history.size(), 2);
const QList<Session::MessageRow> projected = Session::projectToRows(history);
QCOMPARE(projected, rows);
}
void QodeAssistTest::testCompressedChatShapeReloadsAsOneAssistantRow()
{
Session::Message summary;
summary.role = Session::MessageRole::Assistant;
summary.id = "c1";
summary.blocks = {Session::TextBlock{"# Chat Summary\n\nwe discussed the parser"}};
Session::ConversationHistory compressed;
compressed.append(summary);
const auto reloaded = Session::HistorySerializer::fromJson(
Session::HistorySerializer::toJson(compressed));
QVERIFY(reloaded.has_value());
const QList<Session::MessageRow> rows = Session::projectToRows(*reloaded);
QCOMPARE(rows.size(), 1);
QCOMPARE(rows.at(0).kind, Session::RowKind::Assistant);
QCOMPARE(rows.at(0).content, QString("# Chat Summary\n\nwe discussed the parser"));
}
void QodeAssistTest::testHistoryAppliesToChatModel()
{
Chat::ChatModel model;
Session::Session session;
Chat::ChatHistoryBridge bridge(&session, &model);
session.setHistory(historyWithoutFileEdits());
QCOMPARE(model.rowCount(), 4);
QCOMPARE(
model.data(model.index(0), Chat::ChatModel::RoleType).value<Chat::ChatModel::ChatRole>(),
Chat::ChatModel::ChatRole::User);
QCOMPARE(model.data(model.index(0), Chat::ChatModel::Content).toString(), QString("explain this"));
QCOMPARE(model.data(model.index(0), Chat::ChatModel::Attachments).toList().size(), 1);
QCOMPARE(model.data(model.index(0), Chat::ChatModel::Images).toList().size(), 1);
QCOMPARE(
model.data(model.index(1), Chat::ChatModel::RoleType).value<Chat::ChatModel::ChatRole>(),
Chat::ChatModel::ChatRole::Thinking);
QCOMPARE(
model.data(model.index(1), Chat::ChatModel::Content).toString(),
QString("weighing options\n[Signature: sig-1...]"));
QCOMPARE(
model.data(model.index(2), Chat::ChatModel::RoleType).value<Chat::ChatModel::ChatRole>(),
Chat::ChatModel::ChatRole::Tool);
QCOMPARE(
model.data(model.index(2), Chat::ChatModel::Content).toString(),
QString("read_file\nint main() {}"));
QCOMPARE(
model.data(model.index(3), Chat::ChatModel::RoleType).value<Chat::ChatModel::ChatRole>(),
Chat::ChatModel::ChatRole::Assistant);
QCOMPARE(
model.data(model.index(3), Chat::ChatModel::Content).toString(),
QString("here is the answer"));
QCOMPARE(model.sessionPromptTokens(), 120);
QCOMPARE(model.sessionCompletionTokens(), 40);
QCOMPARE(model.sessionCachedPromptTokens(), 80);
}
void QodeAssistTest::testAdjacentThinkingBlocksSurviveReload()
{
Session::Message assistant;
assistant.role = Session::MessageRole::Assistant;
assistant.id = "r1";
assistant.blocks
= {Session::ThinkingBlock{"first", "sig-a", true},
Session::ThinkingBlock{"second", "sig-b", true},
Session::TextBlock{"the answer"}};
Session::ConversationHistory history;
history.append(assistant);
const QList<Session::MessageRow> rows = Session::projectToRows(history);
QCOMPARE(rows.size(), 3);
QCOMPARE(rows.at(0).content, QString("first\n[Signature: sig-a...]"));
QCOMPARE(rows.at(1).content, QString("second\n[Signature: sig-b...]"));
QCOMPARE(Session::buildFromRows(rows), history);
}
void QodeAssistTest::testSessionAppendsUserTurnBeforeSending()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn(
{Session::TextBlock{"explain this"}, Session::AttachmentBlock{"notes.txt", "notes_ab.txt"}},
std::nullopt,
Session::TurnOptions{true, false});
QCOMPARE(backend.turnCount(), 1);
QCOMPARE(backend.historySizeAtSend(), qsizetype(1));
QCOMPARE(backend.lastUserBlocks().size(), 2);
QCOMPARE(backend.lastOptions(), (Session::TurnOptions{true, false}));
QVERIFY(!backend.lastContext().has_value());
QCOMPARE(session.history().size(), 1);
QCOMPARE(session.history().at(0).role, Session::MessageRole::User);
QCOMPARE(session.rows().size(), 1);
QCOMPARE(session.rows().at(0).content, QString("explain this"));
QCOMPARE(session.rows().at(0).attachments.size(), 1);
}
void QodeAssistTest::testSessionStreamsTextThinkingAndTools()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"explain this"}}, std::nullopt, {});
backend.script(scriptedTurn("r1"));
QCOMPARE(session.history().size(), 2);
const Session::Message &assistant = session.history().at(1);
QCOMPARE(assistant.role, Session::MessageRole::Assistant);
QCOMPARE(assistant.id, QString("r1"));
QCOMPARE(assistant.usage, (Session::Usage{120, 40, 80, 12}));
QCOMPARE(assistant.blocks.size(), 4);
QCOMPARE(
assistant.blocks.at(0),
(Session::ContentBlock{Session::ThinkingBlock{"weighing options", "sig-1", false}}));
QCOMPARE(assistant.blocks.at(1), (Session::ContentBlock{Session::TextBlock{"let me check"}}));
QCOMPARE(
assistant.blocks.at(2),
(Session::ContentBlock{Session::ToolCallBlock{
"t1", "read_file", QJsonObject{{"path", "main.cpp"}}, "int main() {}"}}));
QCOMPARE(
assistant.blocks.at(3), (Session::ContentBlock{Session::TextBlock{"here is the answer"}}));
}
void QodeAssistTest::testSessionTrimsStreamedText()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {});
backend.script(
{Session::TurnStarted{"r1"},
Session::TextDelta{"r1", "\n\n"},
Session::TextDelta{"r1", " here is"},
Session::TextDelta{"r1", " the answer \n"}});
QCOMPARE(session.history().at(1).blocks.size(), 1);
QCOMPARE(
session.history().at(1).blocks.at(0),
(Session::ContentBlock{Session::TextBlock{"here is the answer"}}));
}
void QodeAssistTest::testSessionAbandoningTheTurnCancelsTheBackend()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {});
backend.script({Session::TurnStarted{"r1"}, Session::TextDelta{"r1", "partial"}});
const int cancelsBeforeTruncate = backend.cancelCount();
session.truncateRows(0);
QCOMPARE(backend.cancelCount(), cancelsBeforeTruncate + 1);
const int cancelsBeforeLoad = backend.cancelCount();
session.setHistory(Session::ConversationHistory{});
QCOMPARE(backend.cancelCount(), cancelsBeforeLoad + 1);
}
void QodeAssistTest::testSessionProjectionMatchesHistory()
{
Chat::ChatModel model;
FakeChatBackend backend;
Session::Session session;
Chat::ChatHistoryBridge bridge(&session, &model);
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"explain this"}}, std::nullopt, {});
backend.script(scriptedTurn("r1"));
const QList<Session::MessageRow> expected = Session::projectToRows(session.history());
QCOMPARE(session.rows(), expected);
QCOMPARE(model.rowCount(), int(expected.size()));
for (int row = 0; row < expected.size(); ++row) {
QCOMPARE(
model.data(model.index(row), Chat::ChatModel::Content).toString(),
expected.at(row).content);
}
QCOMPARE(model.sessionPromptTokens(), 120);
QCOMPARE(model.sessionCompletionTokens(), 40);
}
void QodeAssistTest::testSessionAccumulatesStreamedThinking()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {});
backend.script(
{Session::TurnStarted{"r1"},
Session::ThinkingReceived{"r1", "step", QString(), false},
Session::ThinkingReceived{"r1", "step one", QString(), false},
Session::ThinkingReceived{"r1", "step one and two", QString(), false},
Session::TextDelta{"r1", "done"}});
const Session::Message &assistant = session.history().at(1);
QCOMPARE(assistant.blocks.size(), 2);
QCOMPARE(
assistant.blocks.at(0),
(Session::ContentBlock{Session::ThinkingBlock{"step one and two", QString(), false}}));
QCOMPARE(session.rows().size(), 3);
}
void QodeAssistTest::testSessionKeepsThinkingAfterToolContinuation()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {});
backend.script(
{Session::TurnStarted{"r1"},
Session::ThinkingReceived{"r1", "first thought", "sig-a", false},
Session::ToolCallStarted{"r1", "t1", "read_file", QJsonObject{}, false},
Session::ToolCallCompleted{"r1", "t1", "read_file", "contents"},
Session::ThinkingReceived{"r1", "second thought", "sig-b", false},
Session::TextDelta{"r1", "done"}});
const Session::Message &assistant = session.history().at(1);
QCOMPARE(assistant.blocks.size(), 4);
QCOMPARE(
assistant.blocks.at(0),
(Session::ContentBlock{Session::ThinkingBlock{"first thought", "sig-a", false}}));
QCOMPARE(
assistant.blocks.at(2),
(Session::ContentBlock{Session::ThinkingBlock{"second thought", "sig-b", false}}));
}
void QodeAssistTest::testSessionDropsPreToolTextWhenAsked()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {});
backend.script(
{Session::TurnStarted{"r1"},
Session::TextDelta{"r1", "leaked tool preamble"},
Session::ToolCallStarted{"r1", "t1", "read_file", QJsonObject{}, true},
Session::ToolCallCompleted{"r1", "t1", "read_file", "contents"},
Session::TextDelta{"r1", "the answer"}});
const Session::Message &assistant = session.history().at(1);
QCOMPARE(assistant.blocks.size(), 2);
QCOMPARE(
assistant.blocks.at(0),
(Session::ContentBlock{
Session::ToolCallBlock{"t1", "read_file", QJsonObject{}, "contents"}}));
QCOMPARE(assistant.blocks.at(1), (Session::ContentBlock{Session::TextBlock{"the answer"}}));
QCOMPARE(session.rows(), Session::projectToRows(session.history()));
}
void QodeAssistTest::testSessionToolResultAppendsFileEditRow()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
const QString result
= "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\",\"file\":\"main.cpp\",\"status\":\"pending\"}";
session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {});
backend.script(
{Session::TurnStarted{"r1"},
Session::ToolCallStarted{"r1", "t1", "edit_file", QJsonObject{}, false},
Session::ToolCallCompleted{"r1", "t1", "edit_file", result}});
const Session::Message &assistant = session.history().at(1);
QCOMPARE(assistant.blocks.size(), 2);
QCOMPARE(assistant.blocks.at(1), (Session::ContentBlock{Session::FileEditBlock{"e1", result}}));
QVERIFY(session.updateFileEditStatus("e1", "applied", "Successfully applied"));
const Session::MessageRow &editRow = session.rows().last();
QCOMPARE(editRow.kind, Session::RowKind::FileEdit);
QVERIFY(editRow.content.contains("\"status\":\"applied\""));
QVERIFY(editRow.content.contains("\"status_message\":\"Successfully applied\""));
}
void QodeAssistTest::testSessionIgnoresEchoedFileEditMarker()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
const QString echoed
= "Pre-compression history (/src): 1 matching message(s)\n\n[#3 assistant]\n"
"QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\",\"file\":\"main.cpp\"}";
session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {});
backend.script(
{Session::TurnStarted{"r1"},
Session::ToolCallStarted{"r1", "t1", "read_original_history", QJsonObject{}, false},
Session::ToolCallCompleted{"r1", "t1", "read_original_history", echoed}});
QCOMPARE(session.history().at(1).blocks.size(), 1);
QCOMPARE(session.rows().size(), 2);
}
void QodeAssistTest::testSessionTruncationKeepsBlockOrder()
{
Session::Message user;
user.role = Session::MessageRole::User;
user.blocks
= {Session::TextBlock{"look at this"},
Session::AttachmentBlock{"notes.txt", "notes_ab.txt"},
Session::ImageBlock{"shot.png", "shot_cd.png", "image/png"}};
Session::Message assistant;
assistant.role = Session::MessageRole::Assistant;
assistant.id = "r1";
assistant.blocks = {Session::TextBlock{"sure"}};
Session::ConversationHistory history;
history.append(user);
history.append(assistant);
Session::Session session;
session.setHistory(history);
session.truncateRows(1);
QCOMPARE(session.history().size(), 1);
QCOMPARE(session.history().at(0).blocks, user.blocks);
QCOMPARE(session.rows(), Session::projectToRows(session.history()));
}
void QodeAssistTest::testHistorySnapshotReportsUnreadableBlocks()
{
QJsonObject block;
block["type"] = "from_a_newer_build";
QJsonObject message;
message["role"] = "assistant";
message["blocks"] = QJsonArray{block, QJsonObject{{"type", "text"}, {"text", "kept"}}};
QJsonObject root;
root["version"] = Session::HistorySerializer::currentVersion();
root["messages"] = QJsonArray{message};
int dropped = -1;
const auto history = Session::HistorySerializer::fromJson(root, &dropped);
QVERIFY(history.has_value());
QCOMPARE(dropped, 1);
QCOMPARE(history->at(0).blocks.size(), 1);
}
void QodeAssistTest::testSessionUsageLandsOnTheTurn()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {});
backend.script(
{Session::TurnStarted{"r1"},
Session::ThinkingReceived{"r1", "hmm", "sig", false},
Session::TextDelta{"r1", "done"},
Session::UsageReported{"r1", Session::Usage{10, 5, 2, 1}},
Session::TurnCompleted{"r1"}});
QCOMPARE(session.history().at(1).usage, (Session::Usage{10, 5, 2, 1}));
QCOMPARE(session.rows().at(1).usage, (Session::Usage{10, 5, 2, 1}));
QCOMPARE(session.rows().at(2).usage, Session::Usage{});
}
void QodeAssistTest::testSessionIgnoresEventsFromOtherTurns()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {});
backend.script(
{Session::TurnStarted{"r1"},
Session::TextDelta{"r1", "mine"},
Session::TextDelta{"other", "not mine"},
Session::ToolCallStarted{"other", "t9", "read_file", QJsonObject{}, false}});
QCOMPARE(session.history().size(), 2);
QCOMPARE(session.history().at(1).blocks.size(), 1);
QCOMPARE(
session.history().at(1).blocks.at(0), (Session::ContentBlock{Session::TextBlock{"mine"}}));
}
void QodeAssistTest::testSessionReportsFailureWithoutStartedTurn()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
QString reported;
QObject::connect(
&session, &Session::Session::turnFailed, &session, [&reported](const QString &error) {
reported = error;
});
backend.script({Session::TurnFailed{QString(), "no provider configured"}});
QCOMPARE(reported, QString("no provider configured"));
}
void QodeAssistTest::testSessionIgnoresFailureFromAbandonedTurn()
{
FakeChatBackend backend;
Session::Session session;
session.setBackend(&backend);
int reported = 0;
QObject::connect(&session, &Session::Session::turnFailed, &session, [&reported](const QString &) {
++reported;
});
session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {});
backend.script({Session::TurnStarted{"r1"}, Session::TextDelta{"r1", "partial"}});
session.truncateRows(0);
backend.script({Session::TurnFailed{"r1", "stale timeout"}});
QCOMPARE(reported, 0);
}
void QodeAssistTest::testSessionTruncateRowsRewritesHistory()
{
Chat::ChatModel model;
FakeChatBackend backend;
Session::Session session;
Chat::ChatHistoryBridge bridge(&session, &model);
session.setBackend(&backend);
session.sendTurn({Session::TextBlock{"explain this"}}, std::nullopt, {});
backend.script(scriptedTurn("r1"));
const qsizetype before = session.rows().size();
QVERIFY(before > 1);
session.truncateRows(1);
QCOMPARE(session.rows().size(), qsizetype(1));
QCOMPARE(model.rowCount(), 1);
QCOMPARE(session.history().size(), 1);
QCOMPARE(session.history().at(0).role, Session::MessageRole::User);
QCOMPARE(session.rows(), Session::projectToRows(session.history()));
}
void QodeAssistTest::testTurnContextCollectsPartsFromPorts()
{
FakeProjectContext projectPort(sampleProject(), "Always use tabs.");
FakeSkillsContext skillsPort(
"# Always on", "# Skills catalog", {Session::InvokedSkill{"review", "Review body"}});
FakeLinkedFiles filesPort({Session::LinkedFile{"main.cpp", "int main() {}"}});
Session::TurnContextRequest request;
request.message = "please /review this";
request.basePrompt = "You are a helpful assistant.";
request.rolePrompt = "Act as a reviewer.";
request.linkedFilePaths = QList<QString>{"/src/main.cpp"};
const Session::TurnContext context
= Session::TurnContextBuilder(projectPort, &skillsPort, filesPort).build(request);
QCOMPARE(context.basePrompt, QString("You are a helpful assistant."));
QVERIFY(context.rolePrompt.has_value());
QCOMPARE(*context.rolePrompt, QString("Act as a reviewer."));
QVERIFY(context.project.available);
QCOMPARE(context.project.displayName, QString("QodeAssist"));
QCOMPARE(context.projectRules, QString("Always use tabs."));
QCOMPARE(context.alwaysOnSkills, QString("# Always on"));
QCOMPARE(context.skillsCatalog, QString("# Skills catalog"));
QCOMPARE(context.invokedSkills.size(), 1);
QCOMPARE(context.invokedSkills.at(0).name, QString("review"));
QCOMPARE(context.linkedFilePaths, (QList<QString>{"/src/main.cpp"}));
QCOMPARE(context.linkedFiles.size(), 1);
QCOMPARE(context.linkedFiles.at(0).fileName, QString("main.cpp"));
QCOMPARE(filesPort.requestedPaths(), (QList<QString>{"/src/main.cpp"}));
}
void QodeAssistTest::testTurnContextSkillCommandScanning()
{
FakeProjectContext projectPort({}, QString());
FakeSkillsContext skillsPort(
QString(),
QString(),
{Session::InvokedSkill{"review", "Review body"},
Session::InvokedSkill{"docs", "Docs body"},
Session::InvokedSkill{"blank", QString()}});
FakeLinkedFiles filesPort({});
Session::TurnContextRequest request;
request.message = "/review then /docs then /review again /blank /unknown and mid/word";
const Session::TurnContext context
= Session::TurnContextBuilder(projectPort, &skillsPort, filesPort).build(request);
QCOMPARE(context.invokedSkills.size(), 2);
QCOMPARE(context.invokedSkills.at(0).name, QString("review"));
QCOMPARE(context.invokedSkills.at(1).name, QString("docs"));
}
void QodeAssistTest::testTurnContextWithoutSkillsPort()
{
FakeProjectContext projectPort(sampleProject(), QString());
FakeLinkedFiles filesPort({Session::LinkedFile{"main.cpp", "int main() {}"}});
Session::TurnContextRequest request;
request.message = "/review this";
request.basePrompt = "base";
const Session::TurnContext context
= Session::TurnContextBuilder(projectPort, nullptr, filesPort).build(request);
QVERIFY(context.alwaysOnSkills.isEmpty());
QVERIFY(context.skillsCatalog.isEmpty());
QVERIFY(context.invokedSkills.isEmpty());
QVERIFY(context.linkedFiles.isEmpty());
QVERIFY(filesPort.requestedPaths().isEmpty());
}
void QodeAssistTest::testLinkedFilesHeaderSurvivesUnreadablePaths()
{
FakeProjectContext projectPort({}, QString());
FakeLinkedFiles filesPort({});
Session::TurnContextRequest request;
request.basePrompt = "base";
request.linkedFilePaths = QList<QString>{"/src/ignored.cpp"};
const Session::TurnContext context
= Session::TurnContextBuilder(projectPort, nullptr, filesPort).build(request);
QVERIFY(context.linkedFiles.isEmpty());
QCOMPARE(context.linkedFilePaths, (QList<QString>{"/src/ignored.cpp"}));
QCOMPARE(
Session::renderSystemPrompt(context),
QString("base\n# No active project in IDE\n\nLinked files for reference:\n"));
}
void QodeAssistTest::testSystemPromptRenderingWithProject()
{
Session::TurnContext context;
context.basePrompt = "You are helpful.";
context.rolePrompt = "Be terse.";
context.project = sampleProject();
context.projectRules = "Use tabs.";
context.alwaysOnSkills = "# Always on";
context.skillsCatalog = "# Catalog";
context.invokedSkills = {Session::InvokedSkill{"review", "Review body"}};
context.linkedFilePaths = QList<QString>{"/src/main.cpp"};
context.linkedFiles = {Session::LinkedFile{"main.cpp", "int main() {}"}};
const QString expected
= "You are helpful."
"\n\nBe terse."
"\n# Active project: QodeAssist"
"\n# Project source root: /src/QodeAssist"
"\n# All new source files, headers, QML and CMake edits MUST be created or modified "
"under this directory. Use absolute paths rooted here, or project-relative paths."
"\n# Build output directory (compiler artifacts only — do NOT create or edit source "
"files here): /src/QodeAssist/build"
"\n# Project Rules\n\nUse tabs."
"\n\n# Always on"
"\n\n# Catalog"
"\n\n# Invoked Skill: review\n\nReview body"
"\n\nLinked files for reference:\n"
"\nFile: main.cpp\nContent:\nint main() {}\n";
QCOMPARE(Session::renderSystemPrompt(context), expected);
}
void QodeAssistTest::testSystemPromptRenderingWithoutProject()
{
Session::TurnContext context;
context.basePrompt = "You are helpful.";
QCOMPARE(
Session::renderSystemPrompt(context),
QString("You are helpful.\n# No active project in IDE"));
}
} // namespace QodeAssist